# SMS（ショートメッセージ）チャネル追加 実装計画 v2 — 確定版（2026-07-27）

> ## ✅ 最終ステータス（2026-08-03 夕方 更新）
> **P1完了 + プロバイダ接続を実物仕様で確定。全145テスト green。** 残りは P0（契約・キー登録・実機テスト）のみ。
> - 契約先が確定: **NTT C-Path / NTT CPaaS**（ポータル `ibportal.nttcpaas.com`）
> - **重要**: 現行APIは **Infobipホワイトラベル**で、旧 Karaden SMS API とは非互換。当初の想定実装は破棄し、
>   公式OpenAPI仕様（`docs.nttcpaas.com/openapi_sms.json` v3.169.0）に基づいて `NttCPaaSSmsSender` を実装し直した。
> - **重大な発見**: API側の `tracking.shortenUrl` / `trackClicks` の既定が `true`。そのままだと本文URLが
>   業者の短縮URLに書き換えられ、自前のクリック計測が壊れ、かつキャリアのフィルタに掛かりやすくなる → 明示的に無効化した。
> - **コストの再評価**: 自動付与フッターだけで本番URLだと122文字＝**2セグメント（20円/人）**。
>   「1通10円」で見積もると実コストを半分に見誤る。`SMS_SENDER_INFO` / `SMS_BASE_URL` の削減レバーを追加。
> - **上限をセグメント単位に変更**: メッセージ数で数えると長文で上限金額を数倍超えるため。
> - 詳細は `docs/SMS_OPERATION_MANUAL.md` §2-2 / §9。
>
> ## 🟢 実装ステータス（2026-08-03 更新）
> **P1 は実装完了・全133テスト green・typecheck 通過。** 残るのは P0（プロバイダ契約）のみ。
> - 完了: §1 スキーマ / §2 抽象 / §3 Secrets / §4 中核ロジック / §5 API / §6 ダッシュボード / §7 テスト（32件追加）
> - 未了: **P0（Karaden 申込 → APIキー登録 → 実機テスト送信）**、P2、P3
> - 実装時の変更点:
>   - `subject` は SMSステップでも必須（管理画面上のステップ名として使用）。`body_html` はSMS時に空文字。
>   - 二重送信防止のため step_sends を流用（新テーブルなし）。
>   - **Karaden SMS API は「NTT CPaaS SMS API」へ移行済み**（運営: NTTドコモビジネスX）。
>     申込URL: `https://mypage.nttcpaas.com/tenants/request` / 製品: `https://www.nttcpaas.com/products/sms`
>     単価は公式ページで非公開化されており、**申込画面で実際の単価を要確認**（レポート記載の10円/通は移行前のサポート記事ベース）。
>     `src/sms.ts` の `KaradenSmsSender` はエンドポイント・フィールド名が**未検証**。公式ドキュメント入手後に確定させること（`KARADEN_API_BASE` で上書き可能にしてある）。
>   - 70文字/66文字ごとの課金単位は NTT CPaaS 公式サイトでも確認済み（実装の `smsSegments()` と一致）。

> 対象: Mail Studio（Cloudflare Workers / Hono / D1 / Resend）のステップ配信に SMS チャネルを追加する。
> v1 からの変更: 調査完了に伴いプロバイダ推奨を確定、同意記録カラムを追加、キャリアフィルタ実務対応を反映。
> 調査レポート: `reports/SMS導入調査レポート_2026-07-27.html`（競合調査・プロバイダ比較・コスト試算・出典）

---

## 0. 方針サマリ（確定）

- **導入パターン**: 自前実装＋国内直収SMS API（調査レポート §6 パターンB）。統合サービス移行（UTAGE等）は月額固定が重く不採用。
- **プロバイダ戦略**（アダプタで抽象化し Secrets 変更のみで乗り換え可能にする）:
  1. **Karaden SMS API**（NTT系）で開始 — 月額0円・10円/通（税込）・Web申込+クレカで即日開通・REST API公開。
  2. **KDDI Message Cast** のトライアル（2ヶ月/3,000通無料）を並行申込 — 審査が通れば 9.35円/通（税込）で本採用。
  3. 月500通超が定着したら クラコールSMS（5円〜）/ SMSLINK（6円〜）へ相見積もり。
  - 参考実装として **Twilio** アダプタも用意（即時開設・$15無料枠で技術検証に有用。ただし国際網のため本番販促には非推奨）。
- **アーキテクチャは既存の Mailer 抽象と完全に相似形**: `SmsSender` インターフェイス + DryRun 実装 + プロバイダ実装。
- **ステップ単位で channel（email | sms）を選択**（UTAGE/エキスパ型のメール×SMS混在シナリオ）。
- 既存の不変条件（CLAUDE.md §5）を SMS にもそのまま適用:
  1. 送信は安全側（`SMS_DRY_RUN` 既定 true）
  2. 二重送信禁止（step_sends の一意制約を流用）
  3. 上限（`MAX_SMS_PER_DAY` 別枠。SMSは1通約10円でメールの数百倍のコストのため**必須**）
  4. 法令（特電法はSMSにも適用 → オプトイン管理 + 同意記録 + 送信者表示 + 停止URL自動付与）
  5. テスト容易性（DryRunSmsSender + InMemoryStore）

### 想定ランニングコスト（Karaden 10円/通・1通=1セグメント時）
| 月間通数 | 従量 | 固定費 | 合計 |
|---|---|---|---|
| 100通 | 1,000円 | 0円 | **約1,000円** |
| 500通 | 5,000円 | 0円 | 約5,000円 |
| 1,000通 | 10,000円 | 0円 | 約10,000円（この規模でクラコール等へ相見積もり） |

※実際の販促文は「本文+送信者表示+停止URL」で2〜3セグメント/通になりやすい。UIのコスト表示はセグメント数込みで計算する（§6）。

---

## 1. スキーマ変更（`schema_v4_sms.sql` 新規作成 / `schema_full.sql` にも統合）

```sql
-- 購読者: 電話番号・SMS同意状態・同意記録（特電法/特商法の記録保存義務対応）
ALTER TABLE subscribers ADD COLUMN phone TEXT;               -- E.164 (+819012345678)
ALTER TABLE subscribers ADD COLUMN sms_status TEXT NOT NULL DEFAULT 'none';
--   none = 番号なし/同意なし | active = 同意あり | unsubscribed = SMS停止
ALTER TABLE subscribers ADD COLUMN sms_consent_at INTEGER;   -- 同意日時（epoch ms）
ALTER TABLE subscribers ADD COLUMN sms_consent_source TEXT;  -- 取得経路（form/webhook/manual等）
CREATE UNIQUE INDEX IF NOT EXISTS idx_sub_phone ON subscribers (phone) WHERE phone IS NOT NULL;

-- ステップ: チャネルと SMS 本文
ALTER TABLE step_emails ADD COLUMN channel TEXT NOT NULL DEFAULT 'email';  -- email | sms
ALTER TABLE step_emails ADD COLUMN body_text TEXT;                          -- SMS本文（プレーンテキスト）
```

- 既存行は `channel='email'` のまま**挙動不変**（v3 と同じ後方互換パターン）。
- 送信ログは **step_sends をそのまま流用**。`UNIQUE (enrollment_id, step_email_id)` が channel に関係なく二重送信を防ぐ。
- 日次カウンタは settings テーブルの既存方式に `smsDaily:<YYYY-MM-DD>` キーを追加（メールの日次カウンタと分離）。

## 2. 型・抽象（`src/types.ts` 追加 / `src/sms.ts` 新規）

```ts
// types.ts に追加
export type SmsStatus = "none" | "active" | "unsubscribed";
export type StepChannel = "email" | "sms";
export interface OutSms { to: string; body: string }  // to は E.164

// sms.ts（mailer.ts と相似形）
export interface SmsSender {
  send(msg: OutSms, idempotencyKey: string): Promise<SendResult>;
  readonly live: boolean;
}
export class DryRunSmsSender implements SmsSender { /* DryRunMailer と同型。sent[] に記録 */ }
export class KaradenSmsSender implements SmsSender { /* 本命。公式REST API仕様に準拠（実装時に最新Docs確認） */ }
export class TwilioSmsSender implements SmsSender { /* 検証用。下記の安定仕様 */ }
```

- **KaradenSmsSender**（本命）: REST API・前払いポイント制。エンドポイント/認証方式は公式ドキュメント（prg-support.karaden.jp / 開発者向けDocs）の最新仕様に準拠して実装する。※2026年1月にNTTドコモビジネスXへ社名変更・「NTT CPaaS」への移行が告知されており、**実装直前に必ず最新仕様を確認**。
- **TwilioSmsSender**（検証用・仕様が安定しているため参考実装として先行実装可）:
  - `POST https://api.twilio.com/2010-04-01/Accounts/{SID}/Messages.json`
  - Basic認証（`SID:AUTH_TOKEN` を base64）+ `application/x-www-form-urlencoded`（`To`/`From`/`Body`）。fetch のみで完結。
- プロバイダ側に Idempotency-Key 相当はない前提で設計: **二重送信防止は自前の step_sends 一意制約＋楽観ロックで担保**（現行メールと同じ。Resend の Idempotency-Key は元々「保険」で主防御はDB側）。

## 3. Secrets / 環境変数（`.dev.vars.example` / wrangler secrets に追加）

| 変数 | 既定 | 用途 |
|---|---|---|
| `SMS_PROVIDER` | `none` | `none` / `karaden` / `twilio`（将来 `kddi` / `clocall` 等を追加） |
| `SMS_DRY_RUN` | `true` | SMS実送信ゲート（メールのDRY_RUNとは独立） |
| `KARADEN_API_KEY`（等） | — | Karaden認証情報（公式仕様に応じて命名） |
| `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN` / `TWILIO_FROM` | — | Twilio検証用 |
| `MAX_SMS_PER_TICK` | `20` | ティックあたりSMS上限 |
| `MAX_SMS_PER_DAY` | `100` | 日次SMS上限（コスト暴走防止の生命線。100通=約1,000円/日が上限） |

`index.ts` の `Env` / `buildConfig` に相似の `buildSmsSender(env)` を追加。`RuntimeConfig` に `smsDryRun` / `maxSmsPerTick` / `maxSmsPerDay` を追加。`SMS_PROVIDER=none` なら常に DryRunSmsSender（= ロールバック手段）。

## 4. 中核ロジック（`src/core.ts`）

### 4.1 SMS本文の組み立て（法令対応・純粋関数）

```ts
/** SMS本文に送信者表示 + 配信停止URLを自動付与（特電法対応、buildEmailHtml と対）*/
export function buildSmsBody(bodyText: string, cfg: RuntimeConfig, unsubToken: string): string {
  const url = `${cfg.appBaseUrl}/u/${unsubToken}`;
  return `${bodyText}\n【${cfg.senderInfo}】\n配信停止 ${url}`;
}
```

- 停止URLは**自ドメイン**（既存 `/u/:token`）。汎用短縮URL（bit.ly等）はキャリアフィルタの主要ブロック要因のため**使用禁止**。
- 文字数と通数: 日本語SMSは **70文字=1セグメント、超過は66〜67文字ごとに+1**（改行=2文字）。上限660文字。**KDDI宛は5セグメント（330文字）超で遅延の可能性** → UIで330文字超に警告（§6）。

### 4.2 スケジューラ分岐（`runScheduler` ステップ処理部、core.ts:253 付近）

1. `step.channel === 'sms'` の送信条件: `sub.phone` があり `sub.sms_status === 'active'`。満たさなければ `recordStepSend(..., skipped)` して**ステップは前進**（シナリオが詰まらない）。
2. 送信ゲート: `canSendSmsLive(cfg) = !cfg.smsDryRun && SMS_PROVIDER設定済み`。
3. 予算: メール用 `budget` とは**別に** `smsBudget = min(maxSmsPerTick, maxSmsPerDay - smsDailySent)`。SMSがメールの日次枠を食わない・逆も然り。
4. 冪等キー: `sms-step-{enrollment_id}-{step_email_id}`。
5. 失敗時: メールと同じく enrollment を進めず次ティック再試行（既存 `ok=false` パス流用）。

`Store` インターフェイス追加: `getSmsDailyCount(day)` / `incrSmsDailyCount(day, n)`（settings 流用のため実装は小さい）。

## 5. API（`src/app.ts`）

- `POST/PUT /api/campaigns/:id/steps(/:sid)`: `channel` / `body_text` を受け付け。バリデーション: `channel='sms'` なら `body_text` 必須・660文字以内（フッター込み）。`subject`/`body_html` は email 時のみ必須。
- `POST /api/subscribers` / intake（webhook・フォーム・LINE/X連携）: `phone` を任意受け付け（E.164正規化。国内 `090...` → `+8190...` 変換）。**`sms_consent: true` が明示された場合のみ** `sms_status='active'` + `sms_consent_at=now` + `sms_consent_source` を記録（特電法のオプトイン原則。番号保有≠同意）。
- `POST /api/subscribers/:id/sms-status`: 管理画面からの SMS 停止/再開（再開時も同意日時を更新）。
- `GET /u/:token`: 停止処理で `status='unsubscribed'` に加えて `sms_status='unsubscribed'` も設定（P1は全チャネル一括停止。P2でチャネル選択UI）。
- テスト送信 `POST /api/campaigns/:id/steps/:sid/test`: SMSステップは管理者指定番号へ1通（`SMS_DRY_RUN=true` 時は組み立て済み本文とセグメント数を返すのみ）。

## 6. ダッシュボード（`public/`）

- ステップ編集モーダルに「チャネル: メール / SMS」トグル。SMS選択時は件名欄→本文テキストエリアに切替。
- **文字数・通数・概算コストのライブ表示**: フッター自動付与分（送信者表示+停止URL 約40〜50文字）込みで「◯文字 = ◯通 ≒ ◯円」を表示。**330文字（5セグメント）超で警告**（KDDI遅延）、660文字で入力上限。単価は設定値（既定10円）から計算。
- 購読者一覧・編集に電話番号・SMS同意状態（同意日時つき）の列/フィールドを追加。
- ダッシュボードのカウンタに「本日のSMS送信数 / 上限（概算コスト）」を追加。

## 7. テスト（`tests/`）

TC を既存方針（DryRun + InMemoryStore）で追加:

1. `buildSmsBody`: 停止URL・送信者表示が必ず付く / セグメント境界（70/140/330文字）
2. スケジューラ: SMSステップが `sms_status='active'` かつ phone ありのみ送信、それ以外 skipped で前進
3. 二重送信: 同一 enrollment×step で2ティック回しても1通のみ（既存一意制約の回帰）
4. 上限: `MAX_SMS_PER_DAY` 到達で以降スキップ・翌日再開、メール枠と独立
5. `SMS_DRY_RUN=true` で実送信ゼロ（DryRunSmsSender.sent 検証）
6. メール×SMS混在シナリオ: step1=email → step2=sms → step3=email が正順で進む
7. オプトアウト: `/u/:token` 後に SMS も止まる
8. オプトイン: `sms_consent` なしの intake では `sms_status='none'` のまま（送信されない）
9. E.164正規化: `090-1234-5678` → `+819012345678`

## 8. フェーズ分割・工数目安

| Phase | 内容 | 目安 |
|---|---|---|
| **P0** | Karaden Web申込（即日・クレカ・少額ポイント購入）＋ KDDI Message Cast 無料トライアル申込（審査待ち）。Secrets登録 | 半日〜（審査待ち含む） |
| **P1** | 上記 §1〜§7 一式（ステップSMS、DRY_RUNで完結検証 → 実キーで1件テスト → 段階拡大） | 実装1〜2日 |
| **P2** | 一斉SMS配信（broadcasts にも channel 追加）/ 停止ページのチャネル選択（SMSのみ停止= UTAGE %cancel_sms% 相当）/ 配信結果取得で無効番号を自動除外（bounced相当） | 1日 |
| **P3** | 競合差別化機能: **メール未開封者への自動SMSフォロー**（開封計測は実装済み → 開封イベント不在者への条件分岐ステップ）/ SMS内リンクのクリック計測（既存 `/t/c` 流用・ポイント付与も動く） | 1〜2日 |

- P1 は**プロバイダ契約前でも着手可能**（DRY_RUNで全機能検証できるため、P0の審査待ちと並行できる）。
- ロールバック: `SMS_PROVIDER=none` で全SMS送信が skipped。スキーマ変更は後方互換のため戻し不要。

## 9. リスクと対策

| リスク | 対策 |
|---|---|
| コスト暴走（1通約10円） | `MAX_SMS_PER_DAY` 既定100・SMS専用日次カウンタ・ダッシュボード表示・前払いポイント制（残高切れ=自然停止） |
| 特電法違反（同意なし送信） | 明示オプトイン必須設計 + 同意日時/経路のDB永続記録 |
| キャリアの迷惑SMSフィルタ | 国内直収プロバイダ採用・自ドメインURLのみ・送信者表示固定・330文字警告・無効番号の除外（P2） |
| Karaden の NTT CPaaS 移行 | 実装直前に最新仕様確認。SmsSender アダプタで乗り換えコスト最小化 |
| KDDI MC の審査落ち/遅延 | Karaden で先行運用（両者は独立。片方で開始可能） |
| プロバイダ障害 | Mailer と同じ「失敗→中断→次ティック再開」パターン |
| 誤送信 | SMS_DRY_RUN 既定 true + テスト送信API + 段階ロールアウト |

## 10. v1 からの変更点

- プロバイダ推奨を確定（Karaden → KDDI MC → 規模拡大でクラコール/SMSLINK。Twilioは検証用に降格）
- `sms_consent_at` / `sms_consent_source` を追加（同意記録の保存義務対応）
- キャリアフィルタ実務対応を明記（自ドメインURL・330文字警告・無効番号除外）
- 想定ランニングコスト表を追加
- E.164正規化・オプトイン境界のTCを追加
