From 5f749af2982ebb5def340fd18725db3387a6c717 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Tue, 10 Feb 2026 16:08:04 +0900 Subject: [PATCH] Initail project --- .dockerignore | 15 ++ .env.example | 4 + .gitignore | 81 ++++++++ Directory.Build.props | 6 + NuGet.Config | 9 + README.md | 57 ++++++ SendEngine.sln | 4 + docs/DESIGN.md | 53 +++++ docs/FLOWS.md | 93 +++++++++ docs/INSTALL.md | 4 + docs/OPENAPI.md | 254 +++++++++++++++++++++++ docs/SCHEMA.sql | 224 +++++++++++++++++++++ docs/TECH_STACK.md | 5 + docs/USE_CASES.md | 5 + docs/openapi.yaml | 458 ++++++++++++++++++++++++++++++++++++++++++ global.json | 6 + 16 files changed, 1278 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 NuGet.Config create mode 100644 README.md create mode 100644 SendEngine.sln create mode 100644 docs/DESIGN.md create mode 100644 docs/FLOWS.md create mode 100644 docs/INSTALL.md create mode 100644 docs/OPENAPI.md create mode 100644 docs/SCHEMA.sql create mode 100644 docs/TECH_STACK.md create mode 100644 docs/USE_CASES.md create mode 100644 docs/openapi.yaml create mode 100644 global.json 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" + } +}