# SMS（ショートメッセージ）配信 設計・運用マニュアル

v1.0 · 2026-08-03 · 対象: Mail Studio v4（SMSチャネル）

> - 利用者向けのやさしい手順書 → `public/manual/mail-studio/index.html` の「6. SMS（ショートメッセージ）を送る」
> - 設計判断の記録 → `docs/SDD.md` §2 / §3 / ADR-006〜009
> - プロバイダ選定の根拠 → `reports/SMS導入調査レポート_2026-07-27.html`、`reports/2026-08-03_SMS契約サービス最終回答.html`
> - 実装計画と進捗 → `docs/SMS_IMPLEMENTATION_PLAN_v2_20260727.md`

---

## 1. このチャネルの位置づけ

Mail Studio は「メール配信システム」だが、**ステップ配信の1ステップとしてSMSを混在**できる。用途は次のとおり。

| 使う場面 | 理由 |
|---|---|
| セミナー・締切の直前リマインド | SMSは開封率が圧倒的に高い。当日連絡はメールより確実 |
| メールが届かない読者への到達手段 | ドメイン拒否・メールアドレス変更で連絡不能になった層に届く |
| 重要な事務連絡（入金・発送・変更） | 見落とされにくい |

**使わない場面**: 通常のメルマガ・長文の教育コンテンツ。SMSは1通あたり約10円かかり、70文字ごとに課金が増える。メールで足りるものはメールで送る。

### コスト感（1セグメント10円・自動付与フッター込みの実測ベース → §9 参照）
現行設定では**フッターだけで2セグメント消費する**ため、1人あたり最低20円で見積もる。

| 配信規模 | 概算費用（1人2セグメント想定） |
|---|---|
| 100人 × 1ステップ | 約2,000円 |
| 100人 × 3ステップのシナリオ | 約6,000円 |
| 1,000人 × 1ステップ | 約20,000円 |

> メール1万通が実質無料であることと比較すること。**SMSは「送る前に金額を確認するチャネル」**として扱う。
> §9 のコスト削減レバーを適用すれば1人あたり10円程度下げられる。

---

## 2. 推奨プロバイダ: NTT C-Path

| 項目 | 内容 |
|---|---|
| サービス名 | **NTT C-Path**（旧 Karaden SMS API → NTT CPaaS → NTT C-Path） |
| 運営 | NTTドコモビジネスX株式会社（旧 NTTコム オンライン・マーケティング・ソリューション） |
| 管理ポータル | https://ibportal.nttcpaas.com/homepage/ |
| 初期費用 / 月額 | 0円 / 0円（前払い式のため購入額以上に課金されない） |
| 単価 | 契約画面の表示が正（調査時点の公開情報では10円/通前後）。**申込時に必ず実額を確認する** |
| 課金単位 | 70文字までが1通、以降66文字ごとに1通加算（公式サイトで確認済み） |
| 回線 | 国内キャリア直収 |
| 申込 | Web完結・クレジットカード可 |

### なぜこれを推奨するか（他候補との比較の要点）
1. **固定費ゼロ**: 小規模運用（月100〜1000通）では月額固定費が総コストを支配する。UTAGE等の統合型は月2万円級の固定費がかかり、同じ通数で20倍以上の差になる。
2. **国内直収**: Twilio/AWS等の海外CPaaSは国際網経由で、キャリアの迷惑SMSフィルタ（ドコモ・au・ソフトバンクとも自動適用）に掛かりやすい。特にURLを含む販促SMSは不利。ステップ配信では配信停止URLが必ず入るため、この差は致命的。
3. **セルフサーブ**: 法人審査や営業折衝を待たずに開通できる。

### 乗り換え候補（将来）
- **KDDI Message Cast**: 9.35円/通・月額0円・無料トライアル2ヶ月3,000通。法人審査あり。単価面で有利なので並行申込を推奨。
- **クラコールSMS / SMSLINK**: 5〜6円/通〜。月500通超が定着したら相見積もり。

いずれも `SmsSender` 実装の追加とSecretsの変更だけで切り替わる（ADR-008）。

---

## 2-2. プロバイダAPI仕様（実装の根拠・2026-08-03 公式OpenAPI仕様で確認）

現行のNTT C-Path / NTT CPaaS は **Infobip のホワイトラベル**（ベースURLの `ibapi` = Infobip API）。
**旧 Karaden SMS API（`prg.karaden.jp`）とは互換性のない別API**なので、旧SDK・旧記事の情報を流用しないこと。

```
POST https://{tenant}.ibapi.nttcpaas.com/sms/3/messages
Authorization: App {API_KEY}        ← Bearer でも Basic でもない。Basic認証は明示的に非対応
Content-Type: application/json
```
```json
{
  "messages": [{
    "sender": "ServiceSMS",
    "destinations": [{ "to": "819012345678", "messageId": "<冪等キー>" }],
    "content": { "text": "本文" }
  }],
  "options": {
    "includeSmsCountInResponse": true,
    "tracking": { "shortenUrl": false, "trackClicks": false }
  }
}
```

| 項目 | 内容 |
|---|---|
| 宛先形式 | **E.164から「+」を外した国際形式**（`819012345678`）。`+8190…` も `09012345678` も不可 |
| 送信元 | トライアルは `ServiceSMS` 固定。本番は `SD`+8桁等の払い出し番号。**国内宛では英字SenderIDは使用不可** |
| ⚠️ `tracking` | `shortenUrl` / `trackClicks` の**API既定は true**。有効のままだと本文URLが業者の短縮URLに書き換えられ、①自前のクリック計測が壊れる ②短縮ドメインはキャリアのフィルタでブロックされやすい。**必ず false を明示**（実装済み） |
| `flash` | NTT CPaaS では非対応。含めるとエラー |
| 冪等キー | **APIに存在しない**。Resend の Idempotency-Key に相当するものがないため、二重送信防止は step_sends の一意制約で担保する（実装済み）。`destinations[].messageId` には追跡用に自前キーを載せている |
| レスポンス | 200 = 「受付(PENDING)」であって到達ではない。`messages[0].messageId` / `status.groupName` / `details.messageCount`（課金通数） |
| エラー | `{ errorCode, description, action, violations, resources }`。DLRの `error.permanent` で恒久/一時エラーを判別できる |
| 連続送信制限 | **同一番号へ同一文面は1時間に6通まで／異なる文面は20通まで**（キャリアのスパム防止）。リトライは1時間以上あけることが推奨されている |
| トライアル制限 | 100通まで・**ポータル登録済みの自分の番号にしか送れない**・SenderIDは `ServiceSMS` 固定。サンドボックス用の別ホストは無い |
| 本番SenderID | 申請から開通まで3〜5営業日 |

### ドキュメント間の矛盾と、その対処
公式の日本語ガイドは v3 のパスに **v2 のボディ（`from` / `text`）** を載せた例を掲載しているが、
機械生成された正式なOpenAPI仕様（`docs.nttcpaas.com/openapi_sms.json`, v3.169.0）では v3 は **`sender` + `content.text`** で、`from`/`text` は v2（`/sms/2/text/advanced`）の定義。
実装では **v3仕様で送り、400が返ったときだけ v2 形式で1度だけ再試行**する（400は「1通も送信されていない」ことを意味するので二重送信にならない）。
どちらの形式で通ったかは戻り値の id 末尾（`:v3` / `:v2`）に記録されるので、初回の実送信後にログを見れば確定できる。

---

## 3. アーキテクチャ

```
runScheduler(store, mailer, cfg, now, smsSender)
   ├─ 一斉配信（メールのみ）
   └─ ステップ配信ループ
        step.channel === 'sms' ?
          ├─ yes → canReceiveSms(sub) チェック
          │         └─ buildSmsBody() → smsSender.send() → step_sends 記録
          └─ no  → 既存のメール送信パス
```

| ファイル | 役割 |
|---|---|
| `src/sms.ts` | `SmsSender` 抽象＋実装（DryRun / NTT C-Path / Twilio / Scripted）＋`normalizePhoneE164()` |
| `src/core.ts` | `buildSmsBody` / `smsCharCount` / `smsSegments` / `canSendSmsLive` / `canReceiveSms` / スケジューラ分岐 |
| `src/store.ts` `src/d1store.ts` | `setSubscriberPhone` / `setSmsStatus` / SMS日次カウンタ |
| `src/app.ts` | ステップCRUDの channel 対応、電話番号・同意API、SMSテスト送信、統計 |
| `src/index.ts` | `buildSmsSender()`（設定不備なら必ず DryRun に落とす） |
| `public/app.js` | チャネル切替UI・文字数/通数/概算コスト表示・購読者の番号/同意管理 |
| `schema_v4_sms.sql` | 既存DBへのマイグレーション（新規構築は `schema_full.sql` 1本） |

### 設計の核（不変条件）
1. **SMSの実送信ゲートはメールと独立**（`SMS_DRY_RUN`）。メールを本番運用しながらSMSだけ止められる。
2. **送信枠も独立**（`smsDaily:YYYY-MM-DD`）。片方の枯渇・事故が他方に波及しない。
3. **同意なしには送らない**。`sms_status='active'` は明示同意時のみ。番号の保有だけでは対象外。
4. **二重送信禁止**。`step_sends` の `UNIQUE(enrollment_id, step_email_id)`。
5. **法定表示を自動付与**。`buildSmsBody()` が送信者表示と停止URLを必ず付ける。

---

## 4. 設定（Secrets）

```
SMS_PROVIDER=none            # none | nttcpaas（=nttcpath）| twilio  ← none で全SMS停止（ロールバック手段）
SMS_DRY_RUN=true             # 実送信ゲート。メールの DRY_RUN とは独立
MAX_SMS_PER_TICK=20          # 1分あたりの上限（セグメント数）
MAX_SMS_PER_DAY=100          # 1日の上限 ← コスト上限そのもの（100セグメント≒1,000円/日）
                             #   ※ 単位はメッセージ数ではなくセグメント。1人2通なら50人分
SMS_COST_PER_SEGMENT=10      # 管理画面のコスト概算表示用（円）
SMS_SENDER_INFO=             # SMSに載せる短い送信者名（未設定なら SENDER_INFO / §9参照）
SMS_BASE_URL=                # SMSの停止URLに使う短いドメイン（未設定なら APP_BASE_URL / §9参照）

# NTT C-Path / NTT CPaaS
NTT_CPAAS_API_KEY=           # ポータルで発行。生成後2日間しか表示されない点に注意
NTT_CPAAS_API_BASE=          # 例: yourtenant.ibapi.nttcpaas.com（テナント固有サブドメイン・スキーム省略可）
NTT_CPAAS_NOTIFY_URL=        # 配信レポート(DLR)の通知先（任意・P2）
SMS_FROM=                    # 未指定なら ServiceSMS（トライアル用）。本番は払い出される送信元番号
```

### APIキーの登録手順（値を画面・履歴に残さない）
```bash
npx wrangler secret put NTT_CPAAS_API_KEY
# → プロンプトが出るので貼り付ける（画面に表示されない・シェル履歴にも残らない）
npx wrangler secret put NTT_CPAAS_API_BASE
npx wrangler secret put SMS_PROVIDER      # nttcpaas
```
- **APIキーをチャット・Git・メモに書かない**。SMSを勝手に送られる権限そのもの。
- ローカル開発時は `.dev.vars`（Git管理外）に置く。`.dev.vars.example` は雛形なので実値を書かない。
- ポータルの仕様上、**APIキーは作成から2日以内しかコピーできない**。取得したらすぐ登録すること。期限を過ぎたら再発行する。

---

## 5. 運用手順

### 5-1. 導入（初回のみ）
1. NTT C-Path に申込 → 単価を確認 → ポータルでAPIキーとAPIベースURLを取得
2. 上記の `wrangler secret put` で登録
3. `SMS_DRY_RUN=true` のまま `npm run deploy`
4. 管理画面でSMSステップを作り「✈ テスト送信」→ プレビューと通数・概算コストを確認
5. `SMS_DRY_RUN=false` に変更 → **自分の携帯へテスト送信1通** → 実機で着信・文面・停止URLを確認
6. 問題なければ読者への配信を開始（最初は少人数のリストから）

### 5-2. 日常運用
- ダッシュボードの「本日のSMS送信数 / 上限」で消費を確認する
- 新しいシナリオを作ったら、必ず**テスト送信で通数と金額を確認してから**有効化する
- 読者数 × 通数 × 単価 を事前に暗算する習慣をつける（100人 × 2通 = 約2,000円）

### 5-3. 緊急停止
```bash
npx wrangler secret put SMS_PROVIDER   # none を入力 → SMS送信が全て skipped になる
```
またはローカル/本番の `SMS_DRY_RUN` を `true` に戻す。**メール配信は影響を受けない**。

### 5-4. マイグレーション（既存DBにSMS機能を追加する場合）
```bash
npx wrangler d1 execute mail_db --local  --file=./schema_v4_sms.sql
npx wrangler d1 execute mail_db --remote --file=./schema_v4_sms.sql
```
既存行は `sms_status='none'` / `channel='email'` になるため**挙動は変わらない**。ロールバックは不要（列の追加のみ）。

---

## 6. 法令対応（実装済みの内容）

**特定電子メール法はSMSにも適用される。** 通販事業者の場合は特定商取引法の電子メール広告規制も二重適用される。

| 要件 | 実装 |
|---|---|
| 事前同意（オプトイン） | `sms_status='active'` は `sms_consent:true` を明示的に受け取ったときだけ設定。番号の保有では送信対象にならない |
| 同意記録の保存 | `sms_consent_at`（日時）/ `sms_consent_source`（経路）をDBに永続保存。特電法=最終送信から1か月、特商法該当時=3年の保存義務に対応 |
| 送信者の表示 | `buildSmsBody()` が全SMSに送信者名を自動付与。文字数節約のため `SMS_SENDER_INFO`（社名のみ）を使う場合、住所等の詳細は配信停止ページ `/u/:token` に表示される（`thanksPage(msg, cfg.senderInfo)`） |
| オプトアウト手段の明示 | 全SMSに停止URL（自ドメイン `/u/:token`）を自動付与 |
| 拒否者への送信停止 | `/u/:token` でメールとSMSを同時に停止。スケジューラが送信対象から自動除外 |

### キャリアの迷惑SMSフィルタ対策（実務）
- **汎用短縮URL（bit.ly等）を使わない** — ブロックの主要因。自ドメインURLのみ使用
- **国内直収プロバイダを使う** — 国際網経由はフィルタ強化の対象
- **330文字（5セグメント）を超えない** — au宛で遅延・制限の可能性。UIで警告を出す
- 無効番号への反復送信を避ける（配信結果の取得はP2で対応予定）

---

## 7. テスト

`tests/unit/sms.test.ts`（44件）。方針は既存と同じく DryRun + InMemoryStore。全体で145件 green。

| 観点 | 代表ケース |
|---|---|
| プロバイダAPI | v3仕様どおりのボディ組み立て / URL短縮の無効化 / 400時のv2フォールバック / 400以外は再試行しない |
| 電話番号の正規化 | 国内表記・全角・E.164・不正入力 |
| 課金計算 | 改行=2文字、70/71/136/137文字の境界 |
| 法定表示 | 送信者表示と停止URLが必ず付く／短縮URLを含まない |
| 送信ゲート | DRY_RUN時・プロバイダ未設定時は送らない |
| オプトイン | 番号のみ（同意なし）では送信されない |
| スケジューラ | 送信・スキップ時の前進・二重送信防止・失敗時の再試行 |
| 上限 | `MAX_SMS_PER_DAY` 到達で停止、メール枠と独立、セグメント単位で消費 |
| コスト削減 | `SMS_SENDER_INFO` / `SMS_BASE_URL` で課金通数が減る・未設定なら後方互換 |
| 混在シナリオ | email → sms → email が正順で進む |
| オプトアウト | 停止後はSMSも送られない |

---

## 8. 今後の拡張（未実装）

| フェーズ | 内容 |
|---|---|
| P2 | 一斉SMS配信（broadcasts にも channel）／停止ページでチャネル別停止（SMSのみ停止）／配信結果の取得と無効番号の自動除外 |
| P3 | メール未開封者への自動SMSフォロー（開封計測は実装済みのため、開封イベント不在者への条件分岐ステップとして実現）／SMS内リンクのクリック計測（既存 `/t/c` を流用） |

---

## 9. コスト最適化（2026-08-03 追記・重要）

### 実測: フッターだけで2セグメント消費する

`buildSmsBody()` が自動付与する「送信者表示＋配信停止URL」の実測値:

| 構成 | フッター文字数 | 本文0文字時 | 本文20文字時 | 本文50文字時 |
|---|---|---|---|---|
| 本番URL(workers.dev) + 住所つき送信者表示 | 122文字 | 2通=20円 | 3通=30円 | 3通=30円 |
| 本番URL + 社名のみ | 108文字 | 2通=20円 | 2通=20円 | 3通=30円 |
| 短い独自ドメイン + 社名のみ | 75文字 | 2通=20円 | 2通=20円 | 2通=20円 |

**「1通10円」という単価をそのまま信じると実コストを半分に見誤る。** 実際は1人あたり20〜30円が現実的な下限。

### 削減レバー（どちらも設定のみ）

| 設定 | 効果 | 法令上の注意 |
|---|---|---|
| `SMS_SENDER_INFO` に**社名だけ**を設定 | 住所・電話番号ぶん（約14文字〜）を削減 | 送信者の詳細（住所等）は `/u/:token` の配信停止ページに表示されるため、特電法の表示義務は満たす（`thanksPage(msg, cfg.senderInfo)`） |
| `SMS_BASE_URL` に**短い独自ドメイン**を設定 | workers.dev の53文字 → 20文字程度で約33文字削減 | 自ドメインであること（汎用短縮URLサービスは使わない） |

両方適用すると本文50文字でも2通に収まり、**100人配信で1,000円の差**になる。

### 上限はセグメント単位で消費される
`MAX_SMS_PER_DAY` は**メッセージ数ではなくセグメント（課金通数）**で消費する。
長文SMSでも「上限＝1日のコスト上限」が崩れないようにするため（`core.ts` の `smsBudget -= segments`）。
既定の 100 は「1日あたり最大約1,000円」を意味する。1人2通なら**50人分**が上限になる点に注意。
