# SDD — メール配信システム（IEEE 1016）

v1.1 · 2026-08-03（v4 SMSチャネル追加を反映） · 対応SRS: v1.0
> SMSの運用手順・プロバイダ設計は `docs/SMS_OPERATION_MANUAL.md`、導入判断の根拠は `reports/SMS導入調査レポート_2026-07-27.html` を参照。

## 1. 論理ビュー（コンポーネント）
```
[ Browser SPA (public/) ]  白基調ダッシュボード
        │ fetch /api/*
┌───────────────────────────────────────────────┐
│ Cloudflare Worker (Hono)  src/index.ts          │
│  ├─ auth.ts      パスワード+HMACセッション        │
│  ├─ api.ts       管理API(購読者/配信/ステップ/連携) │
│  ├─ public.ts    /f/:token /u/:token /api/hooks   │
│  ├─ core.ts      スケジューラ/冪等/上限(純粋ロジック) │
│  ├─ mailer.ts    Mailer IF: ResendMailer/DryRun    │
│  ├─ sms.ts       SmsSender IF: NTT C-Path/Twilio/Dry│
│  └─ store.ts     Store IF: D1Store/InMemoryStore    │
│  scheduled(): core.runScheduler(store, mailer, cfg,  │
│                                 now, smsSender)      │
└───────────────────────────────────────────────┘
        │ binding          │ HTTPS        │ HTTPS
   [ Cloudflare D1 ]  [ Resend API ]  [ SMS Provider ]
```
- `sms.ts` は `mailer.ts` と相似形。チャネル追加時もスケジューラの構造を変えずに済ませる設計。
- SMSプロバイダはアダプタ差し替え式（Secrets の `SMS_PROVIDER` のみで切替）。特定業者にロックインしない。

## 2. プロセスビュー（処理フロー・並行性）
### 一斉配信
draft →[予約]→ scheduled →[Cron: WHERE status='scheduled' を sending へ原子的UPDATE]→ sending →[対象購読者へpending行作成→上限内でbatch送信→成功で更新]→ 全完了で sent。失敗行は次ティック再開。
- 楽観ロック: `UPDATE broadcasts SET status='sending' WHERE id=? AND status='scheduled'` の影響行数=1 を取れたインスタンスのみ送信（多重起動安全 / FR-BC-004）。

### ステップメール
enroll →[next_send_at=now+遅延]→ Cron が next_send_at<=now のenrollを拾い current_step を送信→ next_send_at=次ステップ遅延 / 最終で completed。step_sends 一意で二重送信防止。

### ステップSMS（v4）
ステップは `step_emails.channel`（email|sms）でチャネルを持つ。スケジューラは1つのenrollmentループ内で分岐し、**メールとSMSを1シナリオ内に混在**できる（例: step1=email → step2=sms → step3=email）。
- 送信枠は**チャネルごとに独立**: `budget`（メール）と `smsBudget`（SMS）を別々に持ち、片方が枯渇しても他方は止まらない。`dueEnrollments(now, budget + smsBudget)` で両方分を取得し、枠切れのチャネルに当たった enrollment は `continue` で次ティックへ回す。
- 送信可否は購読者側でも判定: `canReceiveSms(sub) = sub.phone && sub.sms_status==='active'`。満たさない場合は `step_sends` に skipped 記録して**ステップは前進**させる（シナリオが詰まらない）。
- 二重送信防止は step_sends の `UNIQUE(enrollment_id, step_email_id)` をそのまま流用（channel は step 行の属性なので追加キー不要）。
- 冪等キー: `sms-step-{enrollment_id}-{step_email_id}`。失敗時は enrollment を進めず次ティック再試行（メールと同一方針）。

### 安全ゲート（送信1通の判定）
`canSend = (DRY_RUN===false) && broadcast.approved===true`。falseなら Resend を呼ばず send行を `skipped(dry)` 記録。日次/ティック上限を超える分は次ティックへ繰越（FR-SAFE-001）。
- SMS: `canSendSmsLive(cfg, sender) = (SMS_DRY_RUN===false) && sender.live===true`。**メールの DRY_RUN とは独立**したゲート。プロバイダ未設定時は `buildSmsSender()` が DryRunSmsSender を返すため実送信されない（設定不備で送ってしまう事故を構造的に防ぐ）。
- SMS上限は `MAX_SMS_PER_TICK`(20) / `MAX_SMS_PER_DAY`(100)。日次カウンタは settings の `smsDaily:YYYY-MM-DD` に保持し、メールの `sent:YYYY-MM-DD` と**別勘定**。SMSは1通≒10円でメールの数百倍のコストのため、上限超過はコスト事故に直結する。

## 3. データビュー（D1スキーマ要約 / 詳細 schema_full.sql）
- subscribers(id PK, email UNIQUE, name, status, source, attrs, created_at, unsub_token UNIQUE,
  **phone UNIQUE(部分索引), sms_status, sms_consent_at, sms_consent_source** ←v4)
- lists(id PK, name, created_at)
- list_subscribers(list_id, subscriber_id, PK複合)
- broadcasts(id PK, name, subject, body_html, list_id, status, scheduled_at, approved, dry_run, sent_at, created_at)
- broadcast_sends(id PK, broadcast_id, subscriber_id, status, resend_id, error, sent_at, UNIQUE(broadcast_id,subscriber_id))
- step_campaigns(id PK, name, list_id, trigger, status, created_at)
- step_emails(id PK, campaign_id, step_order, delay_minutes, subject, body_html, timing_type, delay_days, send_time,
  **channel, body_text** ←v4, UNIQUE(campaign_id,step_order))
- step_enrollments(id PK, campaign_id, subscriber_id, current_step, status, next_send_at, enrolled_at, UNIQUE(campaign_id,subscriber_id))
- step_sends(id PK, enrollment_id, step_email_id, status, resend_id, sent_at, UNIQUE(enrollment_id,step_email_id))
- hooks(id PK, token UNIQUE, list_id, campaign_id NULLABLE, name, created_at)
- audit_log(id PK, ts, action, detail)
- settings(key PK, value) — 日次カウンタ `sent:YYYY-MM-DD`（メール）/ **`smsDaily:YYYY-MM-DD`（SMS・別勘定）**

### v4 のデータ設計上の判断
- **phone と sms_status を分離**: 「番号を知っている」と「送ってよい」は別事実。特定電子メール法のオプトイン原則を型で表現し、番号だけの登録では送信対象にならないようにした。
- **sms_consent_at / sms_consent_source を保持**: 同意記録の保存義務（特電法=最終送信から1か月 / 特商法該当時=3年）への対応。
- **SMS送信ログ用のテーブルを新設しない**: step_sends を流用することで、既存の二重送信防止・進行管理のロジックをそのまま適用できる。
- **phone は部分UNIQUE索引**（`WHERE phone IS NOT NULL`）: 番号未登録の購読者が複数いても衝突しない。

## 4. 物理ビュー（デプロイ）
- `wrangler deploy` で Worker 配置。D1 binding=`DB`。Cron `*/1 * * * *`。
- 静的アセットは Worker assets (public/)。Secrets: ADMIN_PASSWORD, SESSION_SECRET, RESEND_API_KEY, MAIL_FROM, SENDER_INFO, DRY_RUN, MAX_SENDS_PER_TICK, MAX_SENDS_PER_DAY。
- ローカル: `wrangler dev`（D1ローカル）/ `npm test`（InMemoryStore, CF不要）。

## 5. モジュールIF（抜粋）
```ts
interface Store {
  // subscribers
  upsertSubscriber(email, name, source): Promise<Subscriber>  // 冪等
  getSubscriberByToken(token): Promise<Subscriber|null>
  setSubscriberStatus(id, status): Promise<void>
  // lists / hooks
  addToList(listId, subId), createHook(...), getHook(token)
  // broadcasts
  claimScheduledBroadcasts(now): Promise<Broadcast[]>  // 楽観ロックでsendingへ
  ensureBroadcastSends(bcId, subIds): Promise<void>     // 一意, pending作成
  pendingBroadcastSends(bcId, limit): Promise<Send[]>
  markBroadcastSend(id, status, resendId?, error?)
  // steps
  dueEnrollments(now, limit), advanceEnrollment(...), ensureStepSend(...)
  // settings/audit
  ...
}
interface Mailer { send(msgs: OutMsg[], idempotencyKey): Promise<Result[]> }

// v4: SMS（mailer.ts と相似形。実装差し替えでプロバイダを乗り換える）
interface SmsSender {
  send(msg: { to: string; body: string }, idempotencyKey: string): Promise<SendResult>
  readonly live: boolean   // false のとき canSendSmsLive() が必ず false になる
}
// 実装: DryRunSmsSender / NttCPathSmsSender(推奨) / TwilioSmsSender / ScriptedSmsSender(テスト)

// Store 追加分
setSubscriberPhone(id, phone|null, {consent?, source?, now}): Promise<{ok, error?}>
setSmsStatus(id, status, now): Promise<void>
getSmsDailySendCount(day) / incrSmsDailySendCount(day, n)

// core 追加分（純粋関数）
buildSmsBody(bodyText, cfg, unsubToken): string  // 送信者表示+停止URLを自動付与
smsCharCount(text): number                        // 改行=2文字
smsSegments(text): number                         // 70文字=1通、以降66文字ごとに+1
canSendSmsLive(cfg, sender): boolean / canReceiveSms(sub): boolean
```

## 6. ADR
- ADR-001 Hono採用: Workers標準・Node互換でテスト容易（代替: 素のfetchルータ→保守性低）。
- ADR-002 Store/Mailer抽象: D1非依存テストのため（代替: miniflare→重い・CF前提）。
- ADR-003 楽観ロックで多重起動制御: Cron冪等性確保（代替: 分散ロック→D1で過剰）。
- ADR-004 UIは素のSPA: ビルド不要・依存最小（代替: React→ビルド/依存増、MVP過剰）。
- ADR-005 即時送信も「scheduled_at=now の予約」に統一: 経路一本化で冪等/承認を一律適用。
- **ADR-006 SMSはステップの属性（channel列）で表現**: 「SMS専用シナリオ」を別テーブルで作る案、MyASP型の「メール配信後フックでSMS発火」案も検討したが、channel列方式なら既存のステップ順序・進行管理・二重送信防止をそのまま再利用でき、UI上も1つのタイムラインでメールとSMSを並べられる（代替案はデータモデルとUIが二重化する）。
- **ADR-007 SMSの送信枠・DRY_RUNをメールと別勘定にする**: SMSは1通≒10円でメールの数百倍のコスト。共通の枠にするとメールの大量配信でSMS枠が消える／逆にSMSの事故がメールを止める、という相互汚染が起きる。ゲートも分離し、メールを本番運用しながらSMSだけ停止状態を保てるようにした。
- **ADR-008 SMSプロバイダはアダプタ抽象**: 国内直収系は料金・仕様の改定が頻繁（調査中もKaraden→NTT CPaaS→NTT C-Path と改称・移行があった）。`SmsSender` 実装の差し替えとSecrets変更だけで乗り換えられる構造にし、業者都合の変更をアプリ全体に波及させない。
- **ADR-009 SMSの本文に短縮URLサービスを使わない**: キャリアの迷惑SMSフィルタが汎用短縮ドメイン（bit.ly等）を高確率でブロックするため、既存のクリック計測と同じ自ドメインURL書き換え方式を用いる（到達率と計測を両立）。

## 7. トレーサビリティ（要件→モジュール→TC）
| 要件 | モジュール | TC |
|---|---|---|
| FR-AUTH-* | auth.ts | TC-FR-AUTH-* |
| FR-SUB-*, FR-HOOK-* | store.ts, public.ts, api.ts | TC-FR-SUB-*, TC-FR-HOOK-* |
| FR-BC-*, FR-SAFE-* | core.ts, store.ts, mailer.ts | TC-FR-BC-*, TC-FR-SAFE-* |
| FR-STEP-* | core.ts, store.ts | TC-FR-STEP-* |
| FR-DELIV-* | public.ts, mailer.ts, core.ts | TC-FR-DELIV-* |
| FR-UI-* | public/ | TC-FR-UI-* |
| SMS: チャネル混在ステップ | core.ts, store.ts, app.ts | tests/unit/sms.test.ts「スケジューラ: SMSステップ」 |
| SMS: オプトイン（同意なし送信の禁止） | store.ts(setSubscriberPhone), core.ts(canReceiveSms) | 同「送信ゲート」「API: 電話番号と同意」 |
| SMS: 送信上限・コスト暴走防止 | core.ts(smsBudget), index.ts(buildConfig) | 同「MAX_SMS_PER_DAY を超えない」「枠はメールと独立」 |
| SMS: 法定表示（送信者・停止導線） | core.ts(buildSmsBody), app.ts(/u/:token) | 同「buildSmsBody」「配信停止後はSMSも送られない」 |
| SMS: 二重送信・失敗再試行 | core.ts, store.ts(step_sends) | 同「2ティック回しても二重送信しない」「送信失敗なら前進せず再試行」 |
