11 KiB
Raw Permalink Blame History

OpenAPI Notes

本文件描述 Send Engine 對外 API、Webhook 驗證與與 Member Center 的介接規則。 目標是讓 Member Center 與租戶網站可以清楚交換資料與責任邊界。

Auth 與驗證

1. 租戶網站 → Send Engine API

使用 OAuth2 Client Credentialstoken 由 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

目前實作為簽名 WebhookHMAC

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 CredentialsSend Engine 作為 client

scope 最小化:

  • newsletter:events.write(停用回寫)
  • newsletter:list.read(若未來仍需查詢)

實作約定:

  • 優先走 token endpointclient credentials
  • ApiToken 僅作暫時 fallback建議逐步移除

通用欄位

Timestamp

  • 欄位:occurred_at
  • 格式RFC3339例如 2026-02-10T09:30:00Z

ID

  • tenant_idlist_idsubscriber_idevent_id 皆為 UUID

通用錯誤格式

{
  "error": "string_code"
}

補充:

  • 部分錯誤會附帶 reasonmessage(例如 webhook 驗證失敗)
  • messagerequest_id 目前非固定欄位

WebhookMember Center → Send Engine

A. 訂閱事件(增量)

用途:同步新增/取消/偏好變更。

Endpoint

  • POST /webhooks/subscriptions

Scope

  • newsletter:events.write

事件型別:

  • subscription.activated
  • subscription.unsubscribed
  • preferences.updated

Request Body示意

{
  "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 OKaccepted
  • 401/403:驗證失敗
  • 409event_id 重複或 nonce 重放
  • 422:格式錯誤

B. 全量名單同步

用途:由 Member Center 主動推送全量或分批名單,避免 Send Engine 拉取名單。

Endpoint

  • POST /webhooks/lists/full-sync

Scope

  • newsletter:events.write

Request Body分批示意

{
  "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 OKaccepted
  • 401/403:驗證失敗
  • 422:格式錯誤

API租戶網站 → Send Engine

C. 建立 Send Job

用途:租戶網站送入內容,建立 Campaign/Send Job 並排程。

Endpoint

  • POST /api/send-jobs

Scope

  • newsletter:send.write

Request Body

{
  "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

{
  "send_job_id": "uuid",
  "sendJobId": "uuid",
  "status": "pending"
}

說明:回應同時提供 snake_casecamelCase(向後相容不同語言客戶端)。

C-1. Tenant Site Integration已實作

用途:內容網站以 Member Center 發出的 JWT 呼叫 Send Engine 建立發送工作。

步驟:

  1. 取得 access tokenscope 至少 newsletter:send.write
  2. 呼叫 POST /api/send-jobs
  3. 查詢 GET /api/send-jobs/{id}(需 newsletter:send.read

token 需包含:

  • tenant_idUUID
  • scope(空白分隔字串)

範例(建立 send job

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"
    }
  }'

回應:

{
  "send_job_id": "uuid",
  "sendJobId": "uuid",
  "status": "pending"
}

說明:

  • ESP__Provider=mock(非 ses會由 Dev Sender 產生 send.preview 事件供你檢查替換結果
  • ESP__Provider=mock 時,也會把每位收件人的模擬發送內容輸出到 console logMOCK 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_templateSES 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

{
  "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

{
  "id": "uuid",
  "status": "cancelled"
}

WebhookSES → 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 簽章(SigningCertURLSignatureVersionSignature、canonical string
  • 可加強:在環境設定 Ses__AllowedTopicArnsSes__AllowedCertHosts 做來源白名單

Request Body示意

{
  "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"
}

相容:仍接受舊版扁平 payloadevent_type/tenant_id/email)。

Response

  • 200 OK
  • 422payload 解析錯誤或缺少必要欄位
  • 500處理時發生未預期錯誤SQS poller 會視為 transient保留訊息重試

事件對應規則(固定):

  • hard_bounced:立即設為黑名單(suppressed
  • soft_bounced:累計達門檻後設為黑名單(suppressed
  • complaint:設為黑名單(suppressed)並回寫 Member Centerreason=complaint
  • suppression:設為黑名單(suppressed

SES Bounce 對應:

  • bounce.bounceType=Permanenthard_bounced
  • bounce.bounceType=Transientsoft_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

外部 APISend Engine → Member Center

以下為 Member Center 端提供的 API非 Send Engine 的 OpenAPI 規格範圍。

G. 停用訂閱回寫

用途:因 hard bounce / complaint 停用訂閱,並在 Member Center 註記來源。

EndpointMember Center 提供):

  • POST /subscriptions/disable

Scope

  • newsletter:events.write

Request Body示意

{
  "tenant_id": "uuid",
  "subscriber_id": "uuid",
  "list_id": "uuid",
  "reason": "hard_bounce",
  "disabled_by": "send_engine",
  "occurred_at": "2026-02-10T09:45:00Z"
}

狀態碼與錯誤

通用錯誤:

  • 401/403Auth 或 scope 不符
  • 409重放或事件重複nonce / event_id
  • 422:資料格式錯誤
  • 500:伺服器內部錯誤

Retry 策略(整合規格)

  • Throttle指數退避重試
  • Temporary network error重試
  • Hard failure不重試
  • Retry 上限可設定(例如 5 次)

相關環境參數

  • Bounce__SoftBounceThresholdsoft bounce 轉黑名單門檻(預設 5