378 lines
11 KiB
Markdown
378 lines
11 KiB
Markdown
# 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": "<p>Hello</p>",
|
||
"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: Bearer <ACCESS_TOKEN>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"tenant_id": "c9034414-43d6-404e-8d41-e80922420bf1",
|
||
"list_id": "22222222-2222-2222-2222-222222222222",
|
||
"name": "Weekly Update",
|
||
"subject": "Hi {{email}}",
|
||
"body_html": "<p>Hello {{email}}</p><p><a href=\"https://member.example/unsubscribe?token={{unsubscribe_token}}\">unsubscribe</a></p>",
|
||
"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` 時會驗 SNS envelope 簽章(`SigningCertURL`、`SignatureVersion`、`Signature`、canonical string)
|
||
- 可加強:在環境設定 `Ses__AllowedTopicArns` 與 `Ses__AllowedCertHosts` 做來源白名單
|
||
|
||
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`)
|