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

> 対象: Mail Studio（Cloudflare Workers / Hono / D1 / Resend）のステップ配信に SMS チャネルを追加する。
> 関連レポート: `reports/SMS導入調査レポート_2026-07-27.html`（競合調査・SMSプロバイダ比較・コスト試算）

---

## 0. 方針サマリ

- **アーキテクチャは既存の Mailer 抽象と完全に相似形**にする。`SmsSender` インターフェイス + DryRun 実装 + プロバイダ実装（アダプタ差し替え式）。プロバイダはSecretsで切替可能にし、**特定プロバイダにロックインしない**。
- **ステップ配信のステップ単位で channel（email | sms）を選べる**方式。エキスパ/UTAGEと同じ「シナリオ内にメールとSMSを混在」できる形。
- 既存の不変条件（CLAUDE.md §5）を SMS にもそのまま適用:
  1. 送信は安全側（`SMS_DRY_RUN` 既定 true + 承認済みのみ実送信）
  2. 二重送信禁止（step_sends の一意制約をそのまま流用）
  3. 上限（`MAX_SMS_PER_DAY` を別枠で追加。SMSは1通あたりのコストがメールの数百倍のため**必須**）
  4. 法令（特定電子メール法はSMSにも適用 → 送信者表示 + 配信停止導線を本文に自動付与、オプトイン管理）
  5. テスト容易性（DryRunSmsSender + InMemoryStore で純粋関数として検証）

---

## 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停止
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 に関係なく二重送信を防ぐ（step_email 行自体がチャネルを持つため追加の一意キー不要）。
- 日次カウンタは 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 { /* mailer.ts の DryRunMailer と同型 */ }
export class TwilioSmsSender implements SmsSender { /* fetch で REST 直叩き（下記） */ }
// 将来: KaradenSmsSender / SmsLinkSender 等を同じ interface で追加可能
```

- **Twilio 実装**（第一候補。調査レポートの推奨に応じて差し替え可）:
  - `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`。
  - SDK不要・fetch のみで完結 → Cloudflare Workers でそのまま動く。
  - Twilio 側に Idempotency-Key 相当はないため、**二重送信防止は自前の step_sends 一意制約＋楽観ロックで担保**（現行メールと同じ考え方。Resend の Idempotency-Key は「保険」であり主防御は元々DB側）。

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

| 変数 | 既定 | 用途 |
|---|---|---|
| `SMS_PROVIDER` | `none` | `none` / `twilio`（将来 `karaden` 等を追加） |
| `SMS_DRY_RUN` | `true` | SMS実送信ゲート（メールのDRY_RUNとは独立） |
| `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN` | — | Twilio認証 |
| `SMS_FROM` | — | 送信元番号（E.164） |
| `MAX_SMS_PER_TICK` | `20` | ティックあたりSMS上限 |
| `MAX_SMS_PER_DAY` | `100` | 日次SMS上限（コスト暴走防止の生命線） |

`index.ts` の `Env` / `buildConfig` / `buildMailer` に相似の `buildSmsSender` を追加。`RuntimeConfig` に `smsDryRun` / `smsFrom` / `maxSmsPerTick` / `maxSmsPerDay` を追加。

## 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}`;
}
```

- 配信停止は既存の `/u/:token` ページを流用（Phase 1）。ページ側に「メール/SMS両方停止」の文言を追加。
- 文字数と通数: 日本語SMSは **70文字=1通（1セグメント）**、以降66〜67文字ごとに課金加算（最大660文字）。UI に通数カウンタを付ける（§6）。

### 4.2 スケジューラ分岐（`runScheduler` ステップ処理部）

`core.ts:253` 付近（`const step = steps[e.current_step]`）で channel 分岐:

1. `step.channel === 'sms'` の場合の送信条件: `sub.phone` があり `sub.sms_status === 'active'`。満たさなければ `recordStepSend(..., skipped)` して**ステップは前進**（シナリオが詰まらない）。
2. 送信ゲート: `canSendSmsLive(cfg) = !cfg.smsDryRun && 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` を任意受け付け。**`sms_consent: true` が明示された場合のみ** `sms_status='active'`（特電法のオプトイン原則。番号があるだけでは送らない）。
- `POST /api/subscribers/:id/sms-status`: 管理画面からの SMS 停止/再開。
- `GET /u/:token`: 停止処理で `status='unsubscribed'` に加えて `sms_status='unsubscribed'` も設定。
- テスト送信 `POST /api/campaigns/:id/steps/:sid/test`: SMSステップの場合は管理者指定番号へ1通（SMS_DRY_RUN 時はプレビュー文字列を返すだけ）。

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

- ステップ編集モーダルに「チャネル: メール / SMS」トグル。SMS選択時は件名欄→本文テキストエリアに切替。
- **文字数・通数・概算コストのライブ表示**（例: 「142文字 = 3通 ≒ ¥30」※単価はプロバイダ設定から）。フッター自動付与分（送信者表示+停止URL 約40文字）を含めて計算。
- 購読者一覧・編集に電話番号と SMS同意状態の列/フィールドを追加。
- ダッシュボードのカウンタに「本日のSMS送信数 / 上限」を追加。

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

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

1. `buildSmsBody`: 停止URL・送信者表示が必ず付く / 通数境界（70/140文字）
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. フェーズ分割・工数目安

| Phase | 内容 | 目安 |
|---|---|---|
| **P0** | プロバイダ契約（アカウント開設・送信元番号取得・Secrets登録）。※コスト・手順はレポート参照 | 半日〜（審査待ち含む） |
| **P1** | 上記 §1〜§7 一式（SMSステップ配信、DRY_RUNで完結検証） | 実装1〜2日 |
| **P2** | 一斉SMS配信（broadcasts にも channel 追加）/ ポータルでSMSのみ停止 / 配信結果Webhook受信（bounce相当の無効番号検知） | 1日 |
| **P3** | 競合機能の取り込み: メール未開封者への自動SMSフォロー（開封計測は実装済みなので `open_events` 不在者への条件分岐ステップ）/ SMS内リンクのクリック計測（既存 `/t/c` 流用） | 1〜2日 |

- P1 完了時点で「DRY_RUNでの全機能検証 → 実キー投入 → 1件テスト送信 → 段階拡大」という既存の安全運用手順をそのまま踏襲。
- ロールバック: `SMS_PROVIDER=none` にすれば全SMS送信が skipped になり、スキーマ変更は後方互換のため戻し不要。

## 9. リスクと対策

| リスク | 対策 |
|---|---|
| コスト暴走（SMSは1通8〜10円級） | `MAX_SMS_PER_DAY` 既定100・ダッシュボード表示・日次カウンタ分離 |
| 特電法違反（同意なし送信） | `sms_status` の明示オプトイン必須設計。番号保有≠同意 |
| キャリアの迷惑SMSフィルタ | 停止URLは自ドメイン（短縮URLサービス不使用）、送信者表示を本文に固定 |
| プロバイダ障害 | Mailer と同じ「失敗→中断→次ティック再開」パターン。アダプタ差し替えで乗り換え可能 |
| 誤送信 | SMS_DRY_RUN 既定 true + テスト送信API + 承認フロー（既存 approved を流用） |

## 10. 未決事項（調査レポート確定後に更新）

- [ ] 採用プロバイダの最終決定（レポートの推奨: 本ドキュメント v2 で反映）
- [ ] 送信元番号の種別（共有番号/専用番号）と月額
- [ ] P2 の配信結果 Webhook のペイロード仕様（プロバイダ依存）
