commit 5f749af2982ebb5def340fd18725db3387a6c717 Author: warrenchen Date: Tue Feb 10 16:08:04 2026 +0900 Initail project diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f1846e1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +.vscode +.idea +.dotnet +.nuget +**/bin +**/obj +**/*.user +**/*.suo +**/*.swp +**/*.log +.env +.env.* +!/.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1630969 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +ASPNETCORE_ENVIRONMENT=Development +ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres;Password=postgres +ESP__Provider=ses +ESP__ApiKey=change_me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f55ab05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# Build results +bin/ +obj/ +[Bb]uild/ +[Bb]uilds/ + +# Visual Studio +.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates +*.vcxproj.user +*.vcxproj.filters +*.vcxproj.user + +# Rider / JetBrains +.idea/ +*.sln.iml + +# VS Code +.vscode/ + +# Visual Studio Code local history +.history/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# NuGet +*.nupkg +*.snupkg +# Local NuGet/global package caches in repo +.nuget/ +# The packages folder can be ignored because of PackageReference +# Uncomment if using packages.config +#packages/ + +# Logs +*.log + +# User-specific files +*.userprefs + +# Resharper / DotCover +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# Test results +TestResults/ +*.trx + +# Coverage +*.coverage +*.coveragexml +coverage/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# Secrets +secrets.json +appsettings.*.json +!appsettings.json +.env +.env.* +!.env.example + +# OS +.DS_Store +Thumbs.db + +# Others +*.swp +*.tmp +*.pid +*.pid.lock +.dotnet/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..55672f0 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + false + false + + diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..e41d7ef --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ba5066 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Send Engine(發送引擎) + +此專案為獨立的發送引擎,負責寄送、節流、退信處理與追蹤。文件統一放在 `docs/`。 + +## 目標(MVP) +- 接收 Member Center 的訂閱事件(activated / unsubscribed / preferences.updated) +- 多租戶名單快照(依 tenant/list)且僅增量更新 +- 管理 Campaign / Send Job 與排程 +- 對接 ESP(SES / SendGrid / Mailgun) +- 記錄投遞結果與退信(必要時回寫 Member Center) + +## 既定條件 +- 與 Member Center 相同框架(C# .NET Core) +- Send engine 不提供跨租戶名單查詢 +- 事件同步採 event/queue 或 webhook(二擇一即可) +- DB:PostgreSQL + +## 範圍(第一階段) +- 事件 ingest 與名單增量同步 +- Campaign / Send Job 基本流程 +- Sender Adapter(至少一種 ESP) +- 投遞與退信記錄 + +## 非目標(暫不處理) +- 自建 SMTP server +- 跨租戶名單查詢 +- 行銷自動化或行為追蹤 + +## 文件 +- `docs/DESIGN.md`:系統設計總覽、核心模組與資料流 +- `docs/FLOWS.md`:主要業務流程(同步、寄送、退信) +- `docs/OPENAPI.md`:API 規格說明與補充規則 +- `docs/SCHEMA.sql`:資料庫 schema(PostgreSQL) +- `docs/TECH_STACK.md`:技術棧與選型 +- `docs/INSTALL.md`:安裝、初始化與維運指令 +- `docs/USE_CASES.md`:需求用例(角色、情境、驗收) + +## 專案結構 +```text +mass_mail_engine/ +├── src/ +│ ├── SendEngine.Api/ # REST API(事件 ingest、送信、管理 API) +│ ├── SendEngine.Installer/ # 安裝與初始化 CLI(migrate/init/admin) +│ ├── SendEngine.Application/ # 應用層介面與 DTO +│ ├── SendEngine.Infrastructure/# EF Core、ESP Adapter、服務實作 +│ └── SendEngine.Domain/ # 領域實體與常數 +├── docs/ # 專案文件 +├── .vscode/ # 本機開發啟動與工作設定 +├── SendEngine.sln # Solution +└── README.md +``` + +## 待確認事項 +- 事件系統選擇(Kafka/RabbitMQ/SNS+SQS / Webhook) +- ESP 優先順序(SES / SendGrid / Mailgun) +- 退信回寫的規則(hard bounce / soft bounce) +- 追蹤事件範圍(open / click / unsubscribe) diff --git a/SendEngine.sln b/SendEngine.sln new file mode 100644 index 0000000..03a3aa0 --- /dev/null +++ b/SendEngine.sln @@ -0,0 +1,4 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..cee4568 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,53 @@ +# Design + +本文件描述 Send Engine 的核心模組、資料流、模組邊界與 Auth/Scope 設計。 +本引擎只負責事件控制與執行,不提供 UI。 +ESP 介接暫定為 Amazon SES。 + +## 核心模組 +- Event Ingest +- List Store(多租戶) +- Campaign / Send Job +- Sender Adapter +- Delivery & Bounce Handling + +## 信任邊界與 Auth 模型 +### 外部角色 +- Member Center:事件來源與名單權威來源(authority) +- 各租戶網站:內容來源(campaign/content producer) +- Send Engine:執行與狀態記錄(executor) + +### 信任邊界 +- Send Engine 不自行發放 tenant 身分;一律以 Member Center 代表的 tenant scope 為準 +- 所有 API 必須攜帶 tenant_id,且在驗證後固定於 request context + +### 驗證方式(建議) +1. **Member Center → Send Engine** + - 使用簽名 Webhook(主推),或 OAuth2 Client Credentials + - token 內含 `tenant_id` 與 scopes(例如 `newsletter:events.write`) +2. **租戶網站 → Send Engine** + - 使用 OAuth2 Client Credentials 或 JWT(由 Member Center 簽發) + - token 內含 `tenant_id` 與 scopes(例如 `newsletter:send.write`) +3. **Send Engine → Member Center** + - 使用 OAuth2 Client Credentials(Send Engine 作為 client) + - scopes 只允許 `newsletter:list.read` / `newsletter:events.read` / `newsletter:events.write` + +### Tenant Scope 取得與約束 +- **事件 ingest**:以 token 中的 `tenant_id` 為唯一來源,不接受 body 覆寫 +- **Send Job 建立**:tenant_id 從 token 取得;list_id 必須屬於該 tenant +- **名單同步**:由 Member Center 主動推送(全量/增量),Send Engine 不主動拉取名單 +- **回寫事件**:回寫時攜帶同一 tenant_id,Member Center 做二次驗證 + +### 防護與審計 +- 所有跨服務呼叫需記錄 `tenant_id`、`client_id`、`scope`、`request_id` +- API 層進行 scope 檢查與 tenant 一致性檢查 +- Event Ingest 與 Webhook 需具備重放防護(timestamp + nonce) + +### 名單外流風險控制 +- Send Engine 不提供「名單查詢 API」 +- 全量同步由 Member Center 發動並推送 +- 如需測試/診斷,僅回傳計數與版本資訊,不回傳名單內容 + +## 資料模型原則 +- 事件為 append-only,快照表僅反映最新狀態 +- 任何狀態變更必須可追溯(含來源與租戶) diff --git a/docs/FLOWS.md b/docs/FLOWS.md new file mode 100644 index 0000000..484fe93 --- /dev/null +++ b/docs/FLOWS.md @@ -0,0 +1,93 @@ +# Flows + +本文件描述 Send Engine 的三條核心流程:訂閱事件同步、發送排程、退信處理。 +本引擎只負責事件控制與執行,不提供 UI。 +ESP 介接暫定為 Amazon SES。 +Member Center 為多租戶架構,信件內容由各租戶網站產生後送入 Send Engine。 + +## 1. 訂閱事件同步流程 +目的:以事件為主,建立/更新本地名單快照(tenant/list scope),不做跨租戶查詢。 + +流程: +1. Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`)。 +2. Send Engine 的 Event Ingest 接收事件(Webhook 或 Queue 擇一)。 +3. 進行驗證(tenant scope、簽章/Token、重放保護)。 +4. 事件落地為 Inbox(append-only),標記 `received_at`。 +5. Consumer 依 `event_id` 去重並處理: + - activated → upsert 訂閱者、掛到 tenant/list + - unsubscribed → 標記狀態為 unsubscribed(保留歷史) + - preferences.updated → 更新偏好欄位與訂閱狀態 +6. 寫入 List Store 快照(只增量更新,不拉全量)。 +7. 記錄處理結果與版本號(供重播與對帳)。 + +錯誤與重試: +- 驗證失敗 → 直接拒絕(401/403),不寫入 Inbox。 +- DB 暫時性錯誤 → 重試(含指數退避),仍失敗則進 DLQ。 +- 事件格式錯誤 → 標記為 invalid,記錄原因。 + +## 1b. 全量名單同步流程(由 Member Center 主動推送) +目的:避免 Send Engine 透過 API 拉取名單,降低名單外流風險。 + +流程: +1. Member Center 內部管理介面或排程觸發「名單重建」。 +2. Member Center 以 webhook 推送全量名單或分批名單事件(推薦分批)。 +3. Send Engine 驗證來源與 tenant scope。 +4. 事件寫入 Inbox,Consumer 以批次方式重建 List Store。 +5. 重建完成後標記同步版本(sync_version)。 + +注意事項: +- 建議以批次/游標推送,避免單筆 payload 過大 +- 可於 Send Engine 端提供 `sync_received` 回應與進度回報 + +## 2. 發送排程流程 +目的:從租戶網站送入的內容建立 Send Job,切分送信任務並控速。 + +流程: +1. 租戶網站以 Member Center 簽發的 token 呼叫 Send Engine API 建立 Campaign/Send Job: + - 必填:tenant_id、list_id、內容(subject/body/template 其一或組合) + - 選填:排程時間、發送窗口、追蹤設定(open/click) +2. Send Engine 驗證 tenant scope 與內容完整性,建立 Send Job。 + - tenant_id 以 token 為準,body 的 tenant_id 僅作一致性檢查 + - list_id 必須屬於 tenant scope +3. Scheduler 在排程時間點啟動 Send Job: + - 讀取 List Store 快照 + - 依規則過濾(已退訂、bounced、黑名單) +4. 切分成可控批次(batch),寫入 Outbox。 +5. Sender Worker 取出 batch,轉成 SES API 請求。 +6. SES 回應 message_id → 記錄 delivery log。 +7. 更新 Send Job 進度(成功/失敗/重試)。 + +控速策略(範例): +- 全域 TPS 上限 + tenant TPS 上限 +- SES provider-specific rate limit +- 超限則延後批次並回寫排程 + +內容管理注意事項: +- Send Engine 不負責內容產生與編輯,僅接收已渲染或可渲染內容 +- 若採模板渲染,模板需由租戶網站提供並由 Send Engine 以 tenant scope 渲染 + +## 3. 退信處理流程 +目的:處理 ESP 回報的 bounce/complaint,並回寫本地名單狀態。 + +流程: +1. SES 透過 SNS/Webhook 回報事件(bounce/complaint/delivery/open/click)。 +2. Webhook 驗證簽章與來源(SES/SNS 驗證)。 +3. 將事件寫入 Inbox(append-only)。 +4. Consumer 解析事件: + - hard bounce → 標記 bounced + 停用 + - soft bounce → 記錄次數,超過門檻停用 + - complaint → 立即停用並列入黑名單 +5. 更新 List Store 快照與投遞記錄。 +6. 回寫 Member Center 以停用訂閱(例如 hard bounce / complaint)。 + +回寫規則: +- Send Engine 僅回寫「停用原因」與必要欄位 +- Member Center 需提供可標註來源的欄位(例如 `disabled_by=send_engine`) + +資料一致性: +- 任何狀態改變需保留歷史(append-only events + current snapshot) +- 避免以 ESP 回報反向新增不存在的訂閱者(僅更新已存在者) + +多租戶安全性補充: +- 所有事件與 Send Job 必須攜帶 tenant_id,且不可跨租戶讀寫 +- API 僅允許 tenant scope 的操作(list/read/write) diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..19d1498 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,4 @@ +# Install + +- 需求:.NET SDK 8.x, PostgreSQL +- 設定:複製 `.env.example` → `.env` diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md new file mode 100644 index 0000000..009ee2e --- /dev/null +++ b/docs/OPENAPI.md @@ -0,0 +1,254 @@ +# OpenAPI Notes + +本文件描述 Send Engine 對外 API、Webhook 驗證與與 Member Center 的介接規則。 +目標是讓 Member Center 與租戶網站可以清楚交換資料與責任邊界。 + +## Auth 與驗證 +### 1. 租戶網站 → Send Engine API +使用 OAuth2 Client Credentials 或 JWT(由 Member Center 簽發)。 + +必要 claims: +- `tenant_id` +- `scope`(至少 `newsletter:send.write`) + +規則: +- `tenant_id` 只能取自 token,不接受 body 覆寫 +- `list_id` 必須屬於該 tenant + +### 2. Member Center → Send Engine Webhook +使用簽名 Webhook(HMAC)或 OAuth2 Client Credentials(建議簽名)。 + +Header 建議: +- `X-Signature`: `hex(hmac_sha256(secret, body))` +- `X-Timestamp`: Unix epoch seconds +- `X-Nonce`: UUID + +驗證規則: +- timestamp 在允許時間窗內(例如 ±5 分鐘) +- nonce 不可重複(重放防護) +- signature 必須匹配 + +### 3. Send Engine → Member Center 回寫 +使用 OAuth2 Client Credentials(Send Engine 作為 client) + +scope 最小化: +- `newsletter:events.write`(停用回寫) +- `newsletter:list.read`(若未來仍需查詢) + +## 通用欄位 +### Timestamp +- 欄位:`occurred_at` +- 格式:RFC3339,例如 `2026-02-10T09:30:00Z` + +### ID +- `tenant_id`、`list_id`、`subscriber_id`、`event_id` 皆為 UUID + +## 通用錯誤格式 +```json +{ + "error": "string_code", + "message": "human readable message", + "request_id": "uuid" +} +``` + +## Webhook:Member Center → Send Engine +### A. 訂閱事件(增量) +用途:同步新增/取消/偏好變更。 + +Endpoint: +- `POST /webhooks/subscriptions` + +Scope: +- `newsletter:events.write` + +事件型別: +- `subscription.activated` +- `subscription.unsubscribed` +- `preferences.updated` + +Request Body(示意): +```json +{ + "event_id": "uuid", + "event_type": "subscription.activated", + "tenant_id": "uuid", + "list_id": "uuid", + "subscriber": { + "id": "uuid", + "email": "user@example.com", + "status": "active", + "preferences": { "topic": "news" } + }, + "occurred_at": "2026-02-10T09:30:00Z" +} +``` + +Response: +- `200 OK`:accepted +- `401/403`:驗證失敗 +- `409`:event_id 重複或 nonce 重放 +- `422`:格式錯誤 + +### B. 全量名單同步 +用途:由 Member Center 主動推送全量或分批名單,避免 Send Engine 拉取名單。 + +Endpoint: +- `POST /webhooks/lists/full-sync` + +Scope: +- `newsletter:events.write` + +Request Body(分批示意): +```json +{ + "sync_id": "uuid", + "batch_no": 1, + "batch_total": 10, + "tenant_id": "uuid", + "list_id": "uuid", + "subscribers": [ + { "id": "uuid", "email": "a@example.com", "status": "active" }, + { "id": "uuid", "email": "b@example.com", "status": "unsubscribed" } + ], + "occurred_at": "2026-02-10T09:30:00Z" +} +``` + +Response: +- `200 OK`:accepted +- `401/403`:驗證失敗 +- `409`:sync_id + batch_no 重複 +- `422`:格式錯誤 + +## API:租戶網站 → Send Engine +### C. 建立 Send Job +用途:租戶網站送入內容,建立 Campaign/Send Job 並排程。 + +Endpoint: +- `POST /api/send-jobs` + +Scope: +- `newsletter:send.write` + +Request Body: +```json +{ + "tenant_id": "uuid", + "list_id": "uuid", + "name": "Weekly Update", + "subject": "Weekly Update", + "body_html": "

Hello

", + "body_text": "Hello", + "template": null, + "scheduled_at": "2026-02-11T02:00:00Z", + "window_start": "2026-02-11T02:00:00Z", + "window_end": "2026-02-11T05:00:00Z", + "tracking": { "open": true, "click": false } +} +``` + +欄位規則: +- `subject` 必填,最小長度 1 +- `body_html` / `body_text` / `template` 至少擇一 +- `window_start` 必須小於 `window_end`(若有提供) + +Response: +```json +{ + "send_job_id": "uuid", + "status": "pending" +} +``` + +### D. 查詢 Send Job +Endpoint: +- `GET /api/send-jobs/{id}` + +Scope: +- `newsletter:send.read` + +Response: +```json +{ + "id": "uuid", + "tenant_id": "uuid", + "list_id": "uuid", + "campaign_id": "uuid", + "status": "running", + "scheduled_at": "2026-02-11T02:00:00Z", + "window_start": "2026-02-11T02:00:00Z", + "window_end": "2026-02-11T05:00:00Z" +} +``` + +### E. 取消 Send Job +Endpoint: +- `POST /api/send-jobs/{id}/cancel` + +Scope: +- `newsletter:send.write` + +Response: +```json +{ + "id": "uuid", + "status": "cancelled" +} +``` + +## Webhook:SES → Send Engine +### F. SES 事件回報 +用途:接收 bounce/complaint/delivery/open/click 等事件。 + +Endpoint: +- `POST /webhooks/ses` + +驗證: +- 依 SES/SNS 規格驗簽 + +Request Body(示意): +```json +{ + "event_type": "bounce", + "message_id": "ses-id", + "tenant_id": "uuid", + "email": "user@example.com", + "bounce_type": "hard", + "occurred_at": "2026-02-10T09:45:00Z" +} +``` + +Response: +- `200 OK` + +## 外部 API:Send Engine → Member Center +以下為 Member Center 端提供的 API,非 Send Engine 的 OpenAPI 規格範圍。 + +### G. 停用訂閱回寫 +用途:因 hard bounce / complaint 停用訂閱,並在 Member Center 註記來源。 + +Endpoint(Member Center 提供): +- `POST /api/subscriptions/disable` + +Scope: +- `newsletter:events.write` + +Request Body(示意): +```json +{ + "tenant_id": "uuid", + "subscriber_id": "uuid", + "list_id": "uuid", + "reason": "hard_bounce", + "disabled_by": "send_engine", + "occurred_at": "2026-02-10T09:45:00Z" +} +``` + +## 狀態碼與錯誤 +通用錯誤: +- `401/403`:Auth 或 scope 不符 +- `409`:重放或事件重複(nonce / event_id) +- `422`:資料格式錯誤 +- `500`:伺服器內部錯誤 diff --git a/docs/SCHEMA.sql b/docs/SCHEMA.sql new file mode 100644 index 0000000..438fff4 --- /dev/null +++ b/docs/SCHEMA.sql @@ -0,0 +1,224 @@ +-- PostgreSQL schema (MVP) +-- Note: This schema is optimized for low volume and long-term retention. +-- Future expansion: add delivery_detail / tracking_detail tables if needed. + +-- Extensions +CREATE EXTENSION IF NOT EXISTS citext; + +-- Tenants (optional; can be sourced from Member Center if not stored locally) +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY, + name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Lists (per tenant) +CREATE TABLE IF NOT EXISTS lists ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_lists_tenant ON lists(tenant_id); + +ALTER TABLE lists + ADD CONSTRAINT fk_lists_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id); + +-- Subscribers (per tenant) +CREATE TABLE IF NOT EXISTS subscribers ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + email CITEXT NOT NULL, + status TEXT NOT NULL, -- active/unsubscribed/bounced/complaint + preferences JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, email) +); +CREATE INDEX IF NOT EXISTS idx_subscribers_tenant ON subscribers(tenant_id); + +ALTER TABLE subscribers + ADD CONSTRAINT fk_subscribers_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id); + +-- List membership snapshot +CREATE TABLE IF NOT EXISTS list_members ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + list_id UUID NOT NULL, + subscriber_id UUID NOT NULL, + status TEXT NOT NULL, -- active/unsubscribed/bounced/complaint + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, list_id, subscriber_id) +); +CREATE INDEX IF NOT EXISTS idx_list_members_tenant ON list_members(tenant_id); +CREATE INDEX IF NOT EXISTS idx_list_members_list ON list_members(list_id); + +ALTER TABLE list_members + ADD CONSTRAINT fk_list_members_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id); + +ALTER TABLE list_members + ADD CONSTRAINT fk_list_members_list + FOREIGN KEY (list_id) REFERENCES lists(id); + +ALTER TABLE list_members + ADD CONSTRAINT fk_list_members_subscriber + FOREIGN KEY (subscriber_id) REFERENCES subscribers(id); + +-- Event inbox (append-only) +CREATE TABLE IF NOT EXISTS events_inbox ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + event_type TEXT NOT NULL, + source TEXT NOT NULL, -- member_center / ses + payload JSONB NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'received', -- received/processed/invalid/failed + error TEXT +); +CREATE INDEX IF NOT EXISTS idx_events_inbox_tenant ON events_inbox(tenant_id); +CREATE INDEX IF NOT EXISTS idx_events_inbox_type ON events_inbox(event_type); +CREATE INDEX IF NOT EXISTS idx_events_inbox_status ON events_inbox(status); + +ALTER TABLE events_inbox + ADD CONSTRAINT fk_events_inbox_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id); + +-- Campaigns (content is provided by tenant sites) +CREATE TABLE IF NOT EXISTS campaigns ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + list_id UUID NOT NULL, + name TEXT, + subject TEXT, + body_html TEXT, + body_text TEXT, + template JSONB, -- optional template payload + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_campaigns_tenant ON campaigns(tenant_id); + +ALTER TABLE campaigns + ADD CONSTRAINT fk_campaigns_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id); + +ALTER TABLE campaigns + ADD CONSTRAINT fk_campaigns_list + FOREIGN KEY (list_id) REFERENCES lists(id); + +-- Send jobs +CREATE TABLE IF NOT EXISTS send_jobs ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + list_id UUID NOT NULL, + campaign_id UUID NOT NULL, + scheduled_at TIMESTAMPTZ, + window_start TIMESTAMPTZ, + window_end TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'pending', -- pending/running/completed/failed/cancelled + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_send_jobs_tenant ON send_jobs(tenant_id); +CREATE INDEX IF NOT EXISTS idx_send_jobs_status ON send_jobs(status); + +ALTER TABLE send_jobs + ADD CONSTRAINT fk_send_jobs_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id); + +ALTER TABLE send_jobs + ADD CONSTRAINT fk_send_jobs_list + FOREIGN KEY (list_id) REFERENCES lists(id); + +ALTER TABLE send_jobs + ADD CONSTRAINT fk_send_jobs_campaign + FOREIGN KEY (campaign_id) REFERENCES campaigns(id); + +-- Outbox (batches to send) +CREATE TABLE IF NOT EXISTS send_batches ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + send_job_id UUID NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', -- queued/sending/done/failed + size INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_send_batches_job ON send_batches(send_job_id); +CREATE INDEX IF NOT EXISTS idx_send_batches_status ON send_batches(status); + +ALTER TABLE send_batches + ADD CONSTRAINT fk_send_batches_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id); + +ALTER TABLE send_batches + ADD CONSTRAINT fk_send_batches_job + FOREIGN KEY (send_job_id) REFERENCES send_jobs(id); + +-- Delivery summary (no per-recipient details for MVP) +CREATE TABLE IF NOT EXISTS delivery_summary ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + send_job_id UUID NOT NULL, + total INT NOT NULL DEFAULT 0, + delivered INT NOT NULL DEFAULT 0, + bounced INT NOT NULL DEFAULT 0, + complained INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, send_job_id) +); +CREATE INDEX IF NOT EXISTS idx_delivery_summary_job ON delivery_summary(send_job_id); + +ALTER TABLE delivery_summary + ADD CONSTRAINT fk_delivery_summary_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id); + +ALTER TABLE delivery_summary + ADD CONSTRAINT fk_delivery_summary_job + FOREIGN KEY (send_job_id) REFERENCES send_jobs(id); + +-- Auth: trusted clients for this service (prevent abuse) +-- Member Center and tenant sites are registered here. +CREATE TABLE IF NOT EXISTS auth_clients ( + id UUID PRIMARY KEY, + tenant_id UUID, -- NULL for Member Center or global services + client_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + scopes TEXT[] NOT NULL, + status TEXT NOT NULL DEFAULT 'active', -- active/disabled + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_auth_clients_tenant ON auth_clients(tenant_id); + +-- API keys for HMAC or simple client auth (store hash only) +CREATE TABLE IF NOT EXISTS auth_client_keys ( + id UUID PRIMARY KEY, + client_id UUID NOT NULL, + key_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_auth_client_keys_client ON auth_client_keys(client_id); + +ALTER TABLE auth_client_keys + ADD CONSTRAINT fk_auth_client_keys_client + FOREIGN KEY (client_id) REFERENCES auth_clients(id); + +-- Replay protection for signed webhooks (optional) +CREATE TABLE IF NOT EXISTS webhook_nonces ( + id UUID PRIMARY KEY, + client_id UUID NOT NULL, + nonce TEXT NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (client_id, nonce) +); +CREATE INDEX IF NOT EXISTS idx_webhook_nonces_client ON webhook_nonces(client_id); + +ALTER TABLE webhook_nonces + ADD CONSTRAINT fk_webhook_nonces_client + FOREIGN KEY (client_id) REFERENCES auth_clients(id); diff --git a/docs/TECH_STACK.md b/docs/TECH_STACK.md new file mode 100644 index 0000000..05f3aac --- /dev/null +++ b/docs/TECH_STACK.md @@ -0,0 +1,5 @@ +# Tech Stack + +- C# .NET Core +- PostgreSQL +- ESP: SES / SendGrid / Mailgun diff --git a/docs/USE_CASES.md b/docs/USE_CASES.md new file mode 100644 index 0000000..90654ce --- /dev/null +++ b/docs/USE_CASES.md @@ -0,0 +1,5 @@ +# Use Cases + +- 管理者建立 Campaign +- 系統排程送信 +- 退信與投遞追蹤 diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..e6b6c4d --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,458 @@ +openapi: 3.1.0 +info: + title: Send Engine API + version: 0.1.0 + description: | + Send Engine external API and webhooks. +servers: + - url: https://send-engine.example.com + +security: + - bearerAuth: [] + +paths: + /api/send-jobs: + post: + summary: Create send job + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSendJobRequest' + responses: + '200': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSendJobResponse' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/send-jobs/{id}: + get: + summary: Get send job + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SendJob' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/send-jobs/{id}/cancel: + post: + summary: Cancel send job + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SendJobStatusResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /webhooks/subscriptions: + post: + summary: Member Center subscription events + security: + - webhookSignature: [] + webhookTimestamp: [] + webhookNonce: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionEvent' + responses: + '200': + description: Accepted + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Duplicate event + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /webhooks/lists/full-sync: + post: + summary: Member Center full list sync + security: + - webhookSignature: [] + webhookTimestamp: [] + webhookNonce: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FullSyncBatch' + responses: + '200': + description: Accepted + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Duplicate batch + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /webhooks/ses: + post: + summary: SES/SNS events + security: + - sesSignature: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SesEvent' + responses: + '200': + description: OK + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + webhookSignature: + type: apiKey + in: header + name: X-Signature + webhookTimestamp: + type: apiKey + in: header + name: X-Timestamp + webhookNonce: + type: apiKey + in: header + name: X-Nonce + sesSignature: + type: apiKey + in: header + name: X-Amz-Sns-Signature + + schemas: + CreateSendJobRequest: + type: object + required: [list_id, subject] + properties: + tenant_id: + type: string + format: uuid + list_id: + type: string + format: uuid + name: + type: string + subject: + type: string + minLength: 1 + body_html: + type: string + body_text: + type: string + template: + type: object + additionalProperties: true + scheduled_at: + type: string + format: date-time + window_start: + type: string + format: date-time + window_end: + type: string + format: date-time + tracking: + $ref: '#/components/schemas/TrackingOptions' + oneOf: + - required: [body_html] + - required: [body_text] + - required: [template] + + CreateSendJobResponse: + type: object + required: [send_job_id, status] + properties: + send_job_id: + type: string + format: uuid + status: + type: string + enum: [pending, running, completed, failed, cancelled] + + SendJob: + type: object + required: [id, tenant_id, list_id, campaign_id, status] + properties: + id: + type: string + format: uuid + tenant_id: + type: string + format: uuid + list_id: + type: string + format: uuid + campaign_id: + type: string + format: uuid + status: + type: string + enum: [pending, running, completed, failed, cancelled] + scheduled_at: + type: string + format: date-time + window_start: + type: string + format: date-time + window_end: + type: string + format: date-time + + SendJobStatusResponse: + type: object + required: [id, status] + properties: + id: + type: string + format: uuid + status: + type: string + enum: [pending, running, completed, failed, cancelled] + + TrackingOptions: + type: object + properties: + open: + type: boolean + click: + type: boolean + + SubscriptionEvent: + type: object + required: [event_id, event_type, tenant_id, list_id, subscriber, occurred_at] + properties: + event_id: + type: string + format: uuid + event_type: + type: string + enum: [subscription.activated, subscription.unsubscribed, preferences.updated] + tenant_id: + type: string + format: uuid + list_id: + type: string + format: uuid + subscriber: + $ref: '#/components/schemas/SubscriberPayload' + occurred_at: + type: string + format: date-time + + SubscriberPayload: + type: object + required: [id, email, status] + properties: + id: + type: string + format: uuid + email: + type: string + format: email + status: + type: string + enum: [active, unsubscribed, bounced, complaint] + preferences: + type: object + additionalProperties: true + + FullSyncBatch: + type: object + required: [sync_id, batch_no, batch_total, tenant_id, list_id, subscribers, occurred_at] + properties: + sync_id: + type: string + format: uuid + batch_no: + type: integer + minimum: 1 + batch_total: + type: integer + minimum: 1 + tenant_id: + type: string + format: uuid + list_id: + type: string + format: uuid + subscribers: + type: array + items: + $ref: '#/components/schemas/SubscriberPayload' + occurred_at: + type: string + format: date-time + + SesEvent: + type: object + required: [event_type, message_id, tenant_id, email, occurred_at] + properties: + event_type: + type: string + enum: [bounce, complaint, delivery, open, click] + message_id: + type: string + tenant_id: + type: string + format: uuid + email: + type: string + format: email + bounce_type: + type: string + enum: [hard, soft] + occurred_at: + type: string + format: date-time + + ErrorResponse: + type: object + required: [error, message, request_id] + properties: + error: + type: string + message: + type: string + request_id: + type: string diff --git a/global.json b/global.json new file mode 100644 index 0000000..1bdb496 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.417", + "rollForward": "latestFeature" + } +}