# OpenAPI Notes 本文件描述 Send Engine 對外 API、Webhook 驗證與與 Member Center 的介接規則。 目標是讓 Member Center 與租戶網站可以清楚交換資料與責任邊界。 ## Auth 與驗證 ### 1. 租戶網站 → Send Engine API 使用 OAuth2 Client Credentials(token 由 Member Center 簽發,Send Engine 以 OIDC/JWKS 驗簽驗證)。 若未明確設定 JWT metadata/authority,會回退使用 `MemberCenter__BaseUrl + /.well-known/openid-configuration`。 必要 claims: - `tenant_id` - `scope`(至少 `newsletter:send.write`) - 必須包含 `aud`(需符合 `Jwt__Audience`) 規則: - `tenant_id` 只能取自 token,不接受 body 覆寫 - `list_id` 必須屬於該 tenant ### 2. Member Center → Send Engine Webhook 目前實作為簽名 Webhook(HMAC)。 Header 建議: - `X-Signature`: `hex(hmac_sha256(secret, body))` - `X-Timestamp`: Unix epoch seconds - `X-Nonce`: UUID - `X-Client-Id`: Auth client UUID(對應 `auth_clients.id`) 驗證規則: - timestamp 在允許時間窗內(例如 ±5 分鐘) - nonce 不可重複(重放防護) - signature 必須匹配 - client 必須存在且為 active - `auth_clients.tenant_id` 必須與 payload `tenant_id` 一致 - 不使用任何 `X-Client-Id` fallback;缺少 tenant 對應 client 時應由來源端跳過發送 - 預設拒絕 `auth_clients.tenant_id = NULL` 的通用 client(可透過 `Webhook__AllowNullTenantClient=true` 明確開啟) ### 3. Send Engine → Member Center 回寫 使用 OAuth2 Client Credentials(Send Engine 作為 client) scope 最小化: - `newsletter:events.write`(停用回寫) - `newsletter:list.read`(若未來仍需查詢) 實作約定: - 優先走 token endpoint(client credentials) - `ApiToken` 僅作暫時 fallback(建議逐步移除) ## 通用欄位 ### Timestamp - 欄位:`occurred_at` - 格式:RFC3339,例如 `2026-02-10T09:30:00Z` ### ID - `tenant_id`、`list_id`、`subscriber_id`、`event_id` 皆為 UUID ## 通用錯誤格式 ```json { "error": "string_code" } ``` 補充: - 部分錯誤會附帶 `reason` 或 `message`(例如 webhook 驗證失敗) - `message`、`request_id` 目前非固定欄位 ## 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`:驗證失敗 - `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`(若有提供) - `tenant_id` 必須已存在(不存在回 `422 tenant_not_found`) - `list_id` 若不存在,會在該 tenant 下自動建立 placeholder list 後建立 send job - `template` 可攜帶發信元資料(例如:`{"ses_template_name":"newsletter_default","list_unsubscribe_url_template":"https://member.example/unsubscribe?token={{unsubscribe_token}}","list_unsubscribe_mailto":"mailto:unsubscribe@member.example"}`) 替換合約(Mock/SES 一致): - `{{email}}` - `{{unsubscribe_token}}` - `{{tenant_id}}` - `{{list_id}}` - `{{campaign_id}}` - `{{send_job_id}}` Response: ```json { "send_job_id": "uuid", "sendJobId": "uuid", "status": "pending" } ``` 說明:回應同時提供 `snake_case` 與 `camelCase`(向後相容不同語言客戶端)。 ### C-1. Tenant Site Integration(已實作) 用途:內容網站以 Member Center 發出的 JWT 呼叫 Send Engine 建立發送工作。 步驟: 1. 取得 access token(scope 至少 `newsletter:send.write`) 2. 呼叫 `POST /api/send-jobs` 3. 查詢 `GET /api/send-jobs/{id}`(需 `newsletter:send.read`) token 需包含: - `tenant_id`(UUID) - `scope`(空白分隔字串) 範例(建立 send job): ```bash curl -X POST "http://localhost:6060/api/send-jobs" \ -H "Authorization: BearerHello {{email}}
", "body_text": "Hello {{email}} | unsubscribe: https://member.example/unsubscribe?token={{unsubscribe_token}}", "template": { "ses_template_name": "newsletter_default" } }' ``` 回應: ```json { "send_job_id": "uuid", "sendJobId": "uuid", "status": "pending" } ``` 說明: - `ESP__Provider=mock`(非 ses)時,會由 Dev Sender 產生 `send.preview` 事件供你檢查替換結果 - `ESP__Provider=mock` 時,也會把每位收件人的模擬發送內容輸出到 console log(`MOCK send preview`) - 若 `template.list_unsubscribe_url_template`(或 `template.list_unsubscribe_mailto`)有提供,sender 會加上: - `List-Unsubscribe` - `List-Unsubscribe-Post: List-Unsubscribe=One-Click` - `ESP__Provider=ses` 時,背景 sender 依 `Ses__SendMode` 發送: - `raw_bulk`(預設):SES `SendEmail`,依內容分組每次最多 50 位收件者 - `bulk_template`:SES `SendBulkEmail`(每批最多 50,需要 SES template) - 若已設定 Member Center one-click token endpoint,發送前會批次呼叫 `POST /newsletter/one-click-unsubscribe-tokens` - 僅 `status=issued` 的收件者會被送出,並把 `unsubscribe_token` 注入替換內容 ### D. 查詢 Send Job Endpoint: - `GET /api/send-jobs/{id}` Scope: - `newsletter:send.read` Response: ```json { "id": "uuid", "tenant_id": "uuid", "tenantId": "uuid", "list_id": "uuid", "listId": "uuid", "campaign_id": "uuid", "campaignId": "uuid", "status": "running", "scheduled_at": "2026-02-11T02:00:00Z", "scheduledAt": "2026-02-11T02:00:00Z", "window_start": "2026-02-11T02:00:00Z", "windowStart": "2026-02-11T02:00:00Z", "window_end": "2026-02-11T05:00:00Z", "windowEnd": "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/hard_bounced/soft_bounced/complaint/suppression/delivery/open/click 等事件。 推薦架構(正式): - `SES Configuration Set -> SNS -> SQS -> ECS Worker` - 由 Worker 消費事件,不要求對外公開 webhook - 目前實作:`SqsSesPollerWorker` 會從 SQS 取訊息並直接呼叫內部 SES processing service 相容模式(可選): - `POST /webhooks/ses` 驗證: - 目前實作:`Ses__SkipSignatureValidation=false` 時僅要求 `X-Amz-Sns-Signature` header 存在 - 正式建議:補上 SES/SNS 憑證鏈與簽章內容驗證 Request Body(示意): ```json { "Type": "Notification", "MessageId": "sns-message-id", "Message": "{\"eventType\":\"Bounce\",\"mail\":{\"messageId\":\"ses-message-id\",\"tags\":{\"tenant_id\":[\"...\"],\"list_id\":[\"...\"]}},\"bounce\":{\"bounceType\":\"Permanent\",\"bouncedRecipients\":[{\"emailAddress\":\"user@example.com\"}],\"timestamp\":\"2026-02-10T09:45:00Z\"}}", "Timestamp": "2026-02-10T09:45:01Z" } ``` 相容:仍接受舊版扁平 payload(`event_type`/`tenant_id`/`email`)。 Response: - `200 OK` - `422`:payload 解析錯誤或缺少必要欄位 - `500`:處理時發生未預期錯誤(SQS poller 會視為 transient,保留訊息重試) 事件對應規則(固定): - `hard_bounced`:立即設為黑名單(`suppressed`) - `soft_bounced`:累計達門檻後設為黑名單(`suppressed`) - `complaint`:設為黑名單(`suppressed`)並回寫 Member Center(reason=`complaint`) - `suppression`:設為黑名單(`suppressed`) SES `Bounce` 對應: - `bounce.bounceType=Permanent` → `hard_bounced` - `bounce.bounceType=Transient` → `soft_bounced` 事件優先序(規劃): - `complaint` > `hard_bounced`/`suppression` > `soft_bounced` > `delivery` > `open`/`click` - 目前實作尚未建立完整 recipient 狀態機覆蓋所有事件;實際會以 `suppressed` 作為最終不可發狀態 回寫 Member Center 條件: - `hard_bounced`:設黑名單後回寫 - `soft_bounced`:達門檻設黑名單後回寫 - `complaint`:設黑名單後立即回寫 - `suppression`:設黑名單後回寫 回寫原因碼(固定): - `hard_bounce` - `soft_bounce_threshold` - `complaint` - `suppression` ## 外部 API:Send Engine → Member Center 以下為 Member Center 端提供的 API,非 Send Engine 的 OpenAPI 規格範圍。 ### G. 停用訂閱回寫 用途:因 hard bounce / complaint 停用訂閱,並在 Member Center 註記來源。 Endpoint(Member Center 提供): - `POST /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`:伺服器內部錯誤 ## Retry 策略(整合規格) - Throttle:指數退避重試 - Temporary network error:重試 - Hard failure:不重試 - Retry 上限可設定(例如 5 次) ## 相關環境參數 - `Bounce__SoftBounceThreshold`:soft bounce 轉黑名單門檻(預設 `5`)