feat: Enhance Send Engine API with JWT and OIDC support, update OpenAPI documentation. Complete send flow with mock.
- Updated authentication to support OAuth2 Client Credentials and JWT with OIDC/JWKS verification. - Added necessary claims for JWT, including `aud` and fallback for JWT metadata. - Improved error response format and added additional claims for webhook events. - Introduced new request body structure for creating send jobs, supporting both snake_case and camelCase. - Implemented DevMockSenderWorker for simulating email sending during development. - Integrated Amazon SES for email delivery, with bulk sending capabilities. - Updated OpenAPI documentation to reflect changes in request and response schemas. - Enhanced error handling and logging for better traceability.
This commit is contained in:
parent
7647a8cb3b
commit
60a24ee7c0
18
.env.example
18
.env.example
@ -3,9 +3,12 @@ ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres
|
|||||||
ESP__Provider=ses
|
ESP__Provider=ses
|
||||||
ESP__ApiKey=change_me
|
ESP__ApiKey=change_me
|
||||||
Db__AutoMigrate=true
|
Db__AutoMigrate=true
|
||||||
Jwt__Issuer=member_center
|
Jwt__Issuer=http://localhost:7850/
|
||||||
Jwt__Audience=send_engine
|
Jwt__Audience=send_engine_api
|
||||||
Jwt__SigningKey=change_me_jwt_signing_key
|
Jwt__Authority=
|
||||||
|
Jwt__MetadataAddress=
|
||||||
|
Jwt__RequireHttpsMetadata=false
|
||||||
|
Jwt__SigningKey=
|
||||||
Webhook__Secrets__member_center=change_me_webhook_secret
|
Webhook__Secrets__member_center=change_me_webhook_secret
|
||||||
Webhook__TimestampSkewSeconds=300
|
Webhook__TimestampSkewSeconds=300
|
||||||
Webhook__AllowNullTenantClient=false
|
Webhook__AllowNullTenantClient=false
|
||||||
@ -14,10 +17,15 @@ Bounce__SoftBounceThreshold=5
|
|||||||
MemberCenter__BaseUrl=
|
MemberCenter__BaseUrl=
|
||||||
MemberCenter__DisableSubscriptionPath=/subscriptions/disable
|
MemberCenter__DisableSubscriptionPath=/subscriptions/disable
|
||||||
MemberCenter__TokenPath=/oauth/token
|
MemberCenter__TokenPath=/oauth/token
|
||||||
MemberCenter__DisableSubscriptionUrl=
|
MemberCenter__OneClickUnsubscribeTokensPath=/newsletter/one-click-unsubscribe-tokens
|
||||||
MemberCenter__TokenUrl=
|
|
||||||
MemberCenter__ClientId=
|
MemberCenter__ClientId=
|
||||||
MemberCenter__ClientSecret=
|
MemberCenter__ClientSecret=
|
||||||
MemberCenter__Scope=newsletter:events.write
|
MemberCenter__Scope=newsletter:events.write
|
||||||
MemberCenter__ApiToken=
|
MemberCenter__ApiToken=
|
||||||
TestFriendly__Enabled=false
|
TestFriendly__Enabled=false
|
||||||
|
DevSender__Enabled=false
|
||||||
|
DevSender__PollIntervalSeconds=5
|
||||||
|
Ses__Region=us-east-1
|
||||||
|
Ses__FromEmail=
|
||||||
|
Ses__ConfigurationSet=
|
||||||
|
Ses__TemplateName=
|
||||||
|
|||||||
@ -23,14 +23,19 @@ ESP 介接暫定為 Amazon SES。
|
|||||||
- List-Unsubscribe one-click endpoint 本身的服務實作
|
- List-Unsubscribe one-click endpoint 本身的服務實作
|
||||||
- 會員最終名單權威資料庫
|
- 會員最終名單權威資料庫
|
||||||
|
|
||||||
### 狀態機(規劃)
|
### 狀態機
|
||||||
Job 狀態:
|
Job 狀態(目前實作):
|
||||||
|
- pending
|
||||||
|
- running
|
||||||
|
- completed
|
||||||
|
- failed
|
||||||
|
- cancelled
|
||||||
|
|
||||||
|
Job 狀態(規劃擴充):
|
||||||
- queued
|
- queued
|
||||||
- sending
|
- sending
|
||||||
- sent
|
- sent
|
||||||
- partially_failed
|
- partially_failed
|
||||||
- failed
|
|
||||||
- completed
|
|
||||||
|
|
||||||
Recipient 狀態:
|
Recipient 狀態:
|
||||||
- pending
|
- pending
|
||||||
@ -49,6 +54,10 @@ Recipient 狀態:
|
|||||||
- 更新 DB 狀態
|
- 更新 DB 狀態
|
||||||
- 必要時呼叫 Member Center 停用/註記 API
|
- 必要時呼叫 Member Center 停用/註記 API
|
||||||
|
|
||||||
|
目前實作:
|
||||||
|
- 先以 `POST /webhooks/ses` 接收事件並更新資料
|
||||||
|
- `SNS -> SQS -> Worker` 尚未落地
|
||||||
|
|
||||||
## 信任邊界與 Auth 模型
|
## 信任邊界與 Auth 模型
|
||||||
### 外部角色
|
### 外部角色
|
||||||
- Member Center:事件來源與名單權威來源(authority)
|
- Member Center:事件來源與名單權威來源(authority)
|
||||||
|
|||||||
@ -40,9 +40,9 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
|||||||
- 可於 Send Engine 端提供 `sync_received` 回應與進度回報
|
- 可於 Send Engine 端提供 `sync_received` 回應與進度回報
|
||||||
|
|
||||||
## 2. 發送排程流程
|
## 2. 發送排程流程
|
||||||
目的:從租戶網站送入的內容建立 Send Job,切分送信任務並控速。
|
目的:從租戶網站送入的內容建立 Send Job,並由背景 worker 執行發送。
|
||||||
|
|
||||||
流程:
|
目前實作流程:
|
||||||
1. 租戶網站以 Member Center 簽發的 token 呼叫 Send Engine API 建立 Campaign/Send Job:
|
1. 租戶網站以 Member Center 簽發的 token 呼叫 Send Engine API 建立 Campaign/Send Job:
|
||||||
- 必填:tenant_id、list_id、內容(subject/body/template 其一或組合)
|
- 必填:tenant_id、list_id、內容(subject/body/template 其一或組合)
|
||||||
- 選填:排程時間、發送窗口、追蹤設定(open/click)
|
- 選填:排程時間、發送窗口、追蹤設定(open/click)
|
||||||
@ -50,17 +50,16 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
|||||||
- tenant_id 以 token 為準,body 的 tenant_id 僅作一致性檢查
|
- tenant_id 以 token 為準,body 的 tenant_id 僅作一致性檢查
|
||||||
- tenant 必須預先存在(建議由 Installer 建立)
|
- tenant 必須預先存在(建議由 Installer 建立)
|
||||||
- list_id 若不存在,Send Engine 會在該 tenant 下自動建立 list(placeholder)
|
- list_id 若不存在,Send Engine 會在該 tenant 下自動建立 list(placeholder)
|
||||||
3. Scheduler 在排程時間點啟動 Send Job:
|
3. `DevMockSenderWorker` 輪詢 `send_jobs(status=pending)`,執行時先改為 `running`。
|
||||||
- 讀取 List Store 快照
|
4. worker 讀取 `subscriptions(status=active)`,並過濾不可發送對象:
|
||||||
- 依規則過濾(已退訂、bounced、黑名單)
|
- unsubscribed / suppressed 不發送
|
||||||
4. 切分成可控批次(batch),寫入 Outbox。
|
- 若啟用 one-click token endpoint,僅發送 `status=issued` 的收件者
|
||||||
5. Sender Worker 取出 batch,轉成 SES API 請求。
|
5. 發送執行:
|
||||||
6. 發送時必帶:
|
- `ESP__Provider=mock`:僅模擬發送,寫入預覽事件並輸出 console log
|
||||||
- SES Configuration Set
|
- `ESP__Provider=ses`:使用 SES v2 `SendBulkEmail`(每批最多 50)
|
||||||
- Message Tags(至少含 campaign_id / site_id / list_id)
|
6. 更新 Send Job 狀態:
|
||||||
- Newsletter 類型需帶 `List-Unsubscribe` 與 `List-Unsubscribe-Post` headers
|
- 全部成功:`completed`
|
||||||
7. SES 回應 message_id → 記錄 delivery log。
|
- 例外或部分失敗:`failed`
|
||||||
8. 更新 Send Job 進度(成功/失敗/重試)。
|
|
||||||
|
|
||||||
控速策略(範例):
|
控速策略(範例):
|
||||||
- 全域 TPS 上限 + tenant TPS 上限
|
- 全域 TPS 上限 + tenant TPS 上限
|
||||||
@ -74,9 +73,9 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
|||||||
## 3. 退信處理流程
|
## 3. 退信處理流程
|
||||||
目的:處理 ESP 回報的 bounce/complaint,並回寫本地名單狀態。
|
目的:處理 ESP 回報的 bounce/complaint,並回寫本地名單狀態。
|
||||||
|
|
||||||
流程:
|
目前實作流程(Webhook):
|
||||||
1. SES 事件由 Configuration Set 發送至 SNS,再落到 SQS。
|
1. 由 `POST /webhooks/ses` 接收 SES 事件 payload。
|
||||||
2. ECS Worker 輪詢 SQS,解析 SNS envelope 與 SES payload。
|
2. 驗證(可透過 `Ses__SkipSignatureValidation` 控制是否要求簽章)。
|
||||||
3. 將事件寫入 Inbox(append-only)。
|
3. 將事件寫入 Inbox(append-only)。
|
||||||
4. Consumer 解析事件:
|
4. Consumer 解析事件:
|
||||||
- hard bounce → 立即標記 blacklisted(同義於 `suppressed`)
|
- hard bounce → 立即標記 blacklisted(同義於 `suppressed`)
|
||||||
@ -93,6 +92,7 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
|||||||
補充:
|
補充:
|
||||||
- Unknown event 不應使 worker crash,應記錄後送入 DLQ
|
- Unknown event 不應使 worker crash,應記錄後送入 DLQ
|
||||||
- Throttle/暫時性網路錯誤使用指數退避重試
|
- Throttle/暫時性網路錯誤使用指數退避重試
|
||||||
|
- `SNS -> SQS -> Worker` 架構為正式環境建議,尚未在目前程式碼中落地
|
||||||
|
|
||||||
回寫規則:
|
回寫規則:
|
||||||
- Send Engine 僅回寫「停用原因」與必要欄位
|
- Send Engine 僅回寫「停用原因」與必要欄位
|
||||||
|
|||||||
@ -27,13 +27,35 @@
|
|||||||
- `MemberCenter__BaseUrl`(建議)
|
- `MemberCenter__BaseUrl`(建議)
|
||||||
- `MemberCenter__DisableSubscriptionPath`(預設 `/subscriptions/disable`)
|
- `MemberCenter__DisableSubscriptionPath`(預設 `/subscriptions/disable`)
|
||||||
- `MemberCenter__TokenPath`(預設 `/oauth/token`)
|
- `MemberCenter__TokenPath`(預設 `/oauth/token`)
|
||||||
|
- `MemberCenter__OneClickUnsubscribeTokensPath`(預設 `/newsletter/one-click-unsubscribe-tokens`)
|
||||||
- `MemberCenter__ClientId`
|
- `MemberCenter__ClientId`
|
||||||
- `MemberCenter__ClientSecret`
|
- `MemberCenter__ClientSecret`
|
||||||
- `MemberCenter__Scope=newsletter:events.write`
|
- `MemberCenter__Scope=newsletter:events.write`
|
||||||
- `MemberCenter__DisableSubscriptionUrl` 與 `MemberCenter__TokenUrl` 可用完整 URL 覆蓋(fallback)
|
|
||||||
- `MemberCenter__ApiToken` 僅作暫時 fallback(非首選)
|
- `MemberCenter__ApiToken` 僅作暫時 fallback(非首選)
|
||||||
|
- Send Job API 驗證(JWT):
|
||||||
|
- `Jwt__Issuer`
|
||||||
|
- `Jwt__Audience`
|
||||||
|
- 建議(Member Center OIDC/JWKS):
|
||||||
|
- `Jwt__Authority`(例如 `http://member-center`)
|
||||||
|
- 或 `Jwt__MetadataAddress`(例如 `http://member-center/.well-known/openid-configuration`)
|
||||||
|
- 若兩者都未設定,會自動回退使用 `MemberCenter__BaseUrl + /.well-known/openid-configuration`
|
||||||
|
- `Jwt__RequireHttpsMetadata`(本機可設 `false`)
|
||||||
|
- 相容舊模式(不建議):`Jwt__SigningKey`(HS 對稱驗簽)
|
||||||
- 本機測試輔助(臨時):
|
- 本機測試輔助(臨時):
|
||||||
- `TestFriendly__Enabled=true` 時:
|
- `TestFriendly__Enabled=true` 時:
|
||||||
- webhook 收到未知 tenant 會自動建立 tenant
|
- webhook 收到未知 tenant 會自動建立 tenant
|
||||||
- `/webhooks/ses` 不做任何 DB 存取(僅用於測試流程打通)
|
- `/webhooks/ses` 不做任何 DB 存取,但會保留 Member Center callback 流程(僅用於測試流程打通)
|
||||||
|
- Dev Sender(Mock 發信):
|
||||||
|
- `DevSender__Enabled=true`:背景 worker 會處理 `pending` send jobs,並將每位收件人的預計發送內容寫入 `events_inbox`(`source=dev_sender`, `event_type=send.preview`)
|
||||||
|
- `DevSender__PollIntervalSeconds`:輪詢間隔秒數(預設 5)
|
||||||
|
- `ESP__Provider=ses` 時,背景 worker 會改用 SES `SendBulkEmail` 發送
|
||||||
|
- SES 相關參數:`Ses__Region`、`Ses__FromEmail`、`Ses__ConfigurationSet`(可選)、`Ses__TemplateName`
|
||||||
|
- `SendBulkEmail` 會使用 SES 模板名稱:
|
||||||
|
- 先讀 `campaign.template.ses_template_name`
|
||||||
|
- 若未提供則回退 `Ses__TemplateName`
|
||||||
|
- 若設定了 Member Center one-click token endpoint,sender 會在發送前批次呼叫 `/newsletter/one-click-unsubscribe-tokens`,僅發送 `status=issued` 的收件者
|
||||||
|
- 內容替換合約(Mock 與 SES 共用):
|
||||||
|
- `{{email}}`
|
||||||
|
- `{{unsubscribe_url}}`(可在 `template` JSON 中提供 `unsubscribe_url` 模板,例如 `https://member.example/unsub?email={{email}}`)
|
||||||
|
- `{{tenant_id}}` / `{{list_id}}` / `{{campaign_id}}` / `{{send_job_id}}`
|
||||||
- 正式環境建議維持 `false`
|
- 正式環境建議維持 `false`
|
||||||
|
|||||||
108
docs/OPENAPI.md
108
docs/OPENAPI.md
@ -5,11 +5,13 @@
|
|||||||
|
|
||||||
## Auth 與驗證
|
## Auth 與驗證
|
||||||
### 1. 租戶網站 → Send Engine API
|
### 1. 租戶網站 → Send Engine API
|
||||||
使用 OAuth2 Client Credentials 或 JWT(由 Member Center 簽發)。
|
使用 OAuth2 Client Credentials(token 由 Member Center 簽發,Send Engine 以 OIDC/JWKS 驗簽驗證)。
|
||||||
|
若未明確設定 JWT metadata/authority,會回退使用 `MemberCenter__BaseUrl + /.well-known/openid-configuration`。
|
||||||
|
|
||||||
必要 claims:
|
必要 claims:
|
||||||
- `tenant_id`
|
- `tenant_id`
|
||||||
- `scope`(至少 `newsletter:send.write`)
|
- `scope`(至少 `newsletter:send.write`)
|
||||||
|
- 必須包含 `aud`(需符合 `Jwt__Audience`)
|
||||||
|
|
||||||
規則:
|
規則:
|
||||||
- `tenant_id` 只能取自 token,不接受 body 覆寫
|
- `tenant_id` 只能取自 token,不接受 body 覆寫
|
||||||
@ -55,11 +57,12 @@ scope 最小化:
|
|||||||
## 通用錯誤格式
|
## 通用錯誤格式
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": "string_code",
|
"error": "string_code"
|
||||||
"message": "human readable message",
|
|
||||||
"request_id": "uuid"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
補充:
|
||||||
|
- 部分錯誤會附帶 `reason` 或 `message`(例如 webhook 驗證失敗)
|
||||||
|
- `message`、`request_id` 目前非固定欄位
|
||||||
|
|
||||||
## Webhook:Member Center → Send Engine
|
## Webhook:Member Center → Send Engine
|
||||||
### A. 訂閱事件(增量)
|
### A. 訂閱事件(增量)
|
||||||
@ -163,43 +166,73 @@ Request Body:
|
|||||||
- `window_start` 必須小於 `window_end`(若有提供)
|
- `window_start` 必須小於 `window_end`(若有提供)
|
||||||
- `tenant_id` 必須已存在(不存在回 `422 tenant_not_found`)
|
- `tenant_id` 必須已存在(不存在回 `422 tenant_not_found`)
|
||||||
- `list_id` 若不存在,會在該 tenant 下自動建立 placeholder list 後建立 send job
|
- `list_id` 若不存在,會在該 tenant 下自動建立 placeholder list 後建立 send job
|
||||||
|
- `template` 可攜帶替換參數(例如:`{"unsubscribe_url":"https://member.example/unsub?email={{email}}","ses_template_name":"newsletter_default"}`)
|
||||||
|
|
||||||
|
替換合約(Mock/SES 一致):
|
||||||
|
- `{{email}}`
|
||||||
|
- `{{unsubscribe_url}}`
|
||||||
|
- `{{unsubscribe_token}}`
|
||||||
|
- `{{tenant_id}}`
|
||||||
|
- `{{list_id}}`
|
||||||
|
- `{{campaign_id}}`
|
||||||
|
- `{{send_job_id}}`
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"send_job_id": "uuid",
|
"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=\"{{unsubscribe_url}}\">unsubscribe</a></p>",
|
||||||
|
"body_text": "Hello {{email}} | unsubscribe: {{unsubscribe_url}}",
|
||||||
|
"template": {
|
||||||
|
"unsubscribe_url": "https://member.example/unsubscribe?email={{email}}",
|
||||||
|
"ses_template_name": "newsletter_default"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
回應:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"send_job_id": "uuid",
|
||||||
|
"sendJobId": "uuid",
|
||||||
"status": "pending"
|
"status": "pending"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### C-1. Sending Proxy Submit Job(整合規格)
|
說明:
|
||||||
用途:對齊內容網站/會員平台呼叫發信代理的標準接口。
|
- `ESP__Provider=mock`(非 ses)時,會由 Dev Sender 產生 `send.preview` 事件供你檢查替換結果
|
||||||
|
- `ESP__Provider=mock` 時,也會把每位收件人的模擬發送內容輸出到 console log(`MOCK send preview`)
|
||||||
Endpoint:
|
- `ESP__Provider=ses` 時,背景 sender 會用 SES `SendBulkEmail`(每批最多 50)
|
||||||
- `POST /v1/send-jobs`
|
- 若已設定 Member Center one-click token endpoint,發送前會批次呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
|
||||||
|
- 僅 `status=issued` 的收件者會被送出,並把 `unsubscribe_token` 注入替換內容
|
||||||
Request Body(欄位):
|
|
||||||
- `message_type`:`newsletter` | `transactional`
|
|
||||||
- `from`:發件人
|
|
||||||
- `to`:收件人陣列
|
|
||||||
- `subject`:主旨
|
|
||||||
- `html`:HTML 內容
|
|
||||||
- `text`:純文字內容
|
|
||||||
- `headers`:自定義 header(白名單)
|
|
||||||
- `list_unsubscribe.url`:退訂 URL
|
|
||||||
- `list_unsubscribe.mailto`:可選
|
|
||||||
- `tags.campaign_id` / `tags.site_id` / `tags.list_id` / `tags.segment`
|
|
||||||
- `idempotency_key`:冪等鍵
|
|
||||||
|
|
||||||
Response:
|
|
||||||
- `job_id`
|
|
||||||
- `status=queued`
|
|
||||||
|
|
||||||
規則:
|
|
||||||
- 必須帶 Configuration Set + Message Tags 後才能呼叫 SES
|
|
||||||
- `newsletter` 類型需帶:
|
|
||||||
- `List-Unsubscribe`
|
|
||||||
- `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
|
|
||||||
|
|
||||||
### D. 查詢 Send Job
|
### D. 查詢 Send Job
|
||||||
Endpoint:
|
Endpoint:
|
||||||
@ -213,12 +246,18 @@ Response:
|
|||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"tenant_id": "uuid",
|
"tenant_id": "uuid",
|
||||||
|
"tenantId": "uuid",
|
||||||
"list_id": "uuid",
|
"list_id": "uuid",
|
||||||
|
"listId": "uuid",
|
||||||
"campaign_id": "uuid",
|
"campaign_id": "uuid",
|
||||||
|
"campaignId": "uuid",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"scheduled_at": "2026-02-11T02:00:00Z",
|
"scheduled_at": "2026-02-11T02:00:00Z",
|
||||||
|
"scheduledAt": "2026-02-11T02:00:00Z",
|
||||||
"window_start": "2026-02-11T02:00:00Z",
|
"window_start": "2026-02-11T02:00:00Z",
|
||||||
"window_end": "2026-02-11T05:00:00Z"
|
"windowStart": "2026-02-11T02:00:00Z",
|
||||||
|
"window_end": "2026-02-11T05:00:00Z",
|
||||||
|
"windowEnd": "2026-02-11T05:00:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -249,7 +288,8 @@ Response:
|
|||||||
- `POST /webhooks/ses`
|
- `POST /webhooks/ses`
|
||||||
|
|
||||||
驗證:
|
驗證:
|
||||||
- 依 SES/SNS 規格驗簽(可用 `Ses__SkipSignatureValidation=true` 暫時略過)
|
- 目前實作:`Ses__SkipSignatureValidation=false` 時僅要求 `X-Amz-Sns-Signature` header 存在
|
||||||
|
- 正式建議:補上 SES/SNS 憑證鏈與簽章內容驗證
|
||||||
|
|
||||||
Request Body(示意):
|
Request Body(示意):
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
- C# .NET Core
|
- C# .NET Core
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- ESP: SES / SendGrid / Mailgun
|
- ESP: Amazon SES(實作)+ Mock Sender(開發測試)
|
||||||
|
|||||||
@ -11,55 +11,6 @@ security:
|
|||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/v1/send-jobs:
|
|
||||||
post:
|
|
||||||
summary: Submit send job (sending proxy)
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SubmitSendJobRequest'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Queued
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SubmitSendJobResponse'
|
|
||||||
'401':
|
|
||||||
description: Unauthorized
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
'403':
|
|
||||||
description: Forbidden
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
'422':
|
|
||||||
description: Unprocessable Entity (e.g. tenant not found)
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
'409':
|
|
||||||
description: Idempotency conflict
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
'422':
|
|
||||||
description: Validation error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
|
|
||||||
/api/send-jobs:
|
/api/send-jobs:
|
||||||
post:
|
post:
|
||||||
summary: Create send job (legacy/internal)
|
summary: Create send job (legacy/internal)
|
||||||
@ -318,14 +269,23 @@ components:
|
|||||||
schemas:
|
schemas:
|
||||||
CreateSendJobRequest:
|
CreateSendJobRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [list_id, subject]
|
required: [subject]
|
||||||
|
description: |
|
||||||
|
Request accepts both snake_case and camelCase keys.
|
||||||
|
Recommended contract is snake_case for cross-language consistency.
|
||||||
properties:
|
properties:
|
||||||
tenant_id:
|
tenant_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
tenantId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
list_id:
|
list_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
listId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
subject:
|
subject:
|
||||||
@ -333,41 +293,68 @@ components:
|
|||||||
minLength: 1
|
minLength: 1
|
||||||
body_html:
|
body_html:
|
||||||
type: string
|
type: string
|
||||||
|
bodyHtml:
|
||||||
|
type: string
|
||||||
body_text:
|
body_text:
|
||||||
type: string
|
type: string
|
||||||
|
bodyText:
|
||||||
|
type: string
|
||||||
template:
|
template:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
description: |
|
||||||
|
Optional template metadata used by sender runtime.
|
||||||
|
Supported keys:
|
||||||
|
- unsubscribe_url: URL template, e.g. https://member.example/unsubscribe?token={{unsubscribe_token}}
|
||||||
|
- ses_template_name: SES template name override
|
||||||
scheduled_at:
|
scheduled_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
scheduledAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
window_start:
|
window_start:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
windowStart:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
window_end:
|
window_end:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
windowEnd:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
tracking:
|
tracking:
|
||||||
$ref: '#/components/schemas/TrackingOptions'
|
$ref: '#/components/schemas/TrackingOptions'
|
||||||
oneOf:
|
allOf:
|
||||||
- required: [body_html]
|
- anyOf:
|
||||||
- required: [body_text]
|
- required: [list_id]
|
||||||
- required: [template]
|
- required: [listId]
|
||||||
|
- anyOf:
|
||||||
|
- required: [body_html]
|
||||||
|
- required: [bodyHtml]
|
||||||
|
- required: [body_text]
|
||||||
|
- required: [bodyText]
|
||||||
|
- required: [template]
|
||||||
|
|
||||||
CreateSendJobResponse:
|
CreateSendJobResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [send_job_id, status]
|
required: [send_job_id, sendJobId, status]
|
||||||
properties:
|
properties:
|
||||||
send_job_id:
|
send_job_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
sendJobId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, running, completed, failed, cancelled]
|
enum: [pending, running, completed, failed, cancelled]
|
||||||
|
|
||||||
SendJob:
|
SendJob:
|
||||||
type: object
|
type: object
|
||||||
required: [id, tenant_id, list_id, campaign_id, status]
|
required: [id, tenant_id, tenantId, list_id, listId, campaign_id, campaignId, status]
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@ -375,24 +362,42 @@ components:
|
|||||||
tenant_id:
|
tenant_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
tenantId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
list_id:
|
list_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
listId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
campaign_id:
|
campaign_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
campaignId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, running, completed, failed, cancelled]
|
enum: [pending, running, completed, failed, cancelled]
|
||||||
scheduled_at:
|
scheduled_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
scheduledAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
window_start:
|
window_start:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
windowStart:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
window_end:
|
window_end:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
windowEnd:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
SendJobStatusResponse:
|
SendJobStatusResponse:
|
||||||
type: object
|
type: object
|
||||||
@ -405,76 +410,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [pending, running, completed, failed, cancelled]
|
enum: [pending, running, completed, failed, cancelled]
|
||||||
|
|
||||||
SubmitSendJobRequest:
|
|
||||||
type: object
|
|
||||||
required: [message_type, from, to, subject, html, text, tags, idempotency_key]
|
|
||||||
properties:
|
|
||||||
message_type:
|
|
||||||
type: string
|
|
||||||
enum: [newsletter, transactional]
|
|
||||||
from:
|
|
||||||
type: string
|
|
||||||
format: email
|
|
||||||
to:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
format: email
|
|
||||||
minItems: 1
|
|
||||||
subject:
|
|
||||||
type: string
|
|
||||||
minLength: 1
|
|
||||||
html:
|
|
||||||
type: string
|
|
||||||
text:
|
|
||||||
type: string
|
|
||||||
headers:
|
|
||||||
type: object
|
|
||||||
additionalProperties:
|
|
||||||
type: string
|
|
||||||
list_unsubscribe:
|
|
||||||
$ref: '#/components/schemas/ListUnsubscribe'
|
|
||||||
tags:
|
|
||||||
$ref: '#/components/schemas/MessageTags'
|
|
||||||
idempotency_key:
|
|
||||||
type: string
|
|
||||||
minLength: 1
|
|
||||||
|
|
||||||
SubmitSendJobResponse:
|
|
||||||
type: object
|
|
||||||
required: [job_id, status]
|
|
||||||
properties:
|
|
||||||
job_id:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
enum: [queued]
|
|
||||||
|
|
||||||
ListUnsubscribe:
|
|
||||||
type: object
|
|
||||||
required: [url]
|
|
||||||
properties:
|
|
||||||
url:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
mailto:
|
|
||||||
type: string
|
|
||||||
format: email
|
|
||||||
|
|
||||||
MessageTags:
|
|
||||||
type: object
|
|
||||||
required: [campaign_id, site_id, list_id, segment]
|
|
||||||
properties:
|
|
||||||
campaign_id:
|
|
||||||
type: string
|
|
||||||
site_id:
|
|
||||||
type: string
|
|
||||||
list_id:
|
|
||||||
type: string
|
|
||||||
segment:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
TrackingOptions:
|
TrackingOptions:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -573,11 +508,13 @@ components:
|
|||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [error, message, request_id]
|
required: [error]
|
||||||
properties:
|
properties:
|
||||||
error:
|
error:
|
||||||
type: string
|
type: string
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
request_id:
|
request_id:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@ -1,19 +1,44 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SendEngine.Api.Models;
|
namespace SendEngine.Api.Models;
|
||||||
|
|
||||||
public sealed class CreateSendJobRequest
|
public sealed class CreateSendJobRequest
|
||||||
{
|
{
|
||||||
public Guid TenantId { get; set; }
|
public Guid TenantId { get; set; }
|
||||||
|
[JsonPropertyName("tenant_id")]
|
||||||
|
public Guid TenantIdSnakeCase { set => TenantId = value; }
|
||||||
|
|
||||||
public Guid ListId { get; set; }
|
public Guid ListId { get; set; }
|
||||||
|
[JsonPropertyName("list_id")]
|
||||||
|
public Guid ListIdSnakeCase { set => ListId = value; }
|
||||||
|
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|
||||||
public string? Subject { get; set; }
|
public string? Subject { get; set; }
|
||||||
|
|
||||||
public string? BodyHtml { get; set; }
|
public string? BodyHtml { get; set; }
|
||||||
|
[JsonPropertyName("body_html")]
|
||||||
|
public string? BodyHtmlSnakeCase { set => BodyHtml = value; }
|
||||||
|
|
||||||
public string? BodyText { get; set; }
|
public string? BodyText { get; set; }
|
||||||
|
[JsonPropertyName("body_text")]
|
||||||
|
public string? BodyTextSnakeCase { set => BodyText = value; }
|
||||||
|
|
||||||
public JsonElement? Template { get; set; }
|
public JsonElement? Template { get; set; }
|
||||||
|
[JsonPropertyName("scheduled_at")]
|
||||||
|
public DateTimeOffset? ScheduledAtSnakeCase { set => ScheduledAt = value; }
|
||||||
|
|
||||||
public DateTimeOffset? ScheduledAt { get; set; }
|
public DateTimeOffset? ScheduledAt { get; set; }
|
||||||
|
[JsonPropertyName("window_start")]
|
||||||
|
public DateTimeOffset? WindowStartSnakeCase { set => WindowStart = value; }
|
||||||
|
|
||||||
public DateTimeOffset? WindowStart { get; set; }
|
public DateTimeOffset? WindowStart { get; set; }
|
||||||
|
[JsonPropertyName("window_end")]
|
||||||
|
public DateTimeOffset? WindowEndSnakeCase { set => WindowEnd = value; }
|
||||||
|
|
||||||
public DateTimeOffset? WindowEnd { get; set; }
|
public DateTimeOffset? WindowEnd { get; set; }
|
||||||
|
|
||||||
public TrackingOptions? Tracking { get; set; }
|
public TrackingOptions? Tracking { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,6 +51,9 @@ public sealed class TrackingOptions
|
|||||||
public sealed class CreateSendJobResponse
|
public sealed class CreateSendJobResponse
|
||||||
{
|
{
|
||||||
public Guid SendJobId { get; set; }
|
public Guid SendJobId { get; set; }
|
||||||
|
[JsonPropertyName("send_job_id")]
|
||||||
|
public Guid SendJobIdSnakeCase => SendJobId;
|
||||||
|
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,12 +61,29 @@ public sealed class SendJobResponse
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid TenantId { get; set; }
|
public Guid TenantId { get; set; }
|
||||||
|
[JsonPropertyName("tenant_id")]
|
||||||
|
public Guid TenantIdSnakeCase => TenantId;
|
||||||
|
|
||||||
public Guid ListId { get; set; }
|
public Guid ListId { get; set; }
|
||||||
|
[JsonPropertyName("list_id")]
|
||||||
|
public Guid ListIdSnakeCase => ListId;
|
||||||
|
|
||||||
public Guid CampaignId { get; set; }
|
public Guid CampaignId { get; set; }
|
||||||
|
[JsonPropertyName("campaign_id")]
|
||||||
|
public Guid CampaignIdSnakeCase => CampaignId;
|
||||||
|
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = "pending";
|
||||||
public DateTimeOffset? ScheduledAt { get; set; }
|
public DateTimeOffset? ScheduledAt { get; set; }
|
||||||
|
[JsonPropertyName("scheduled_at")]
|
||||||
|
public DateTimeOffset? ScheduledAtSnakeCase => ScheduledAt;
|
||||||
|
|
||||||
public DateTimeOffset? WindowStart { get; set; }
|
public DateTimeOffset? WindowStart { get; set; }
|
||||||
|
[JsonPropertyName("window_start")]
|
||||||
|
public DateTimeOffset? WindowStartSnakeCase => WindowStart;
|
||||||
|
|
||||||
public DateTimeOffset? WindowEnd { get; set; }
|
public DateTimeOffset? WindowEnd { get; set; }
|
||||||
|
[JsonPropertyName("window_end")]
|
||||||
|
public DateTimeOffset? WindowEndSnakeCase => WindowEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SendJobStatusResponse
|
public sealed class SendJobStatusResponse
|
||||||
|
|||||||
@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using SendEngine.Api.Models;
|
using SendEngine.Api.Models;
|
||||||
|
using SendEngine.Api.Services;
|
||||||
using SendEngine.Api.Security;
|
using SendEngine.Api.Security;
|
||||||
using SendEngine.Domain.Entities;
|
using SendEngine.Domain.Entities;
|
||||||
using SendEngine.Infrastructure;
|
using SendEngine.Infrastructure;
|
||||||
@ -16,24 +17,93 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
builder.Services.AddHostedService<DevMockSenderWorker>();
|
||||||
|
|
||||||
var signingKey = builder.Configuration["Jwt:SigningKey"];
|
var jwtAuthority = builder.Configuration["Jwt:Authority"];
|
||||||
if (string.IsNullOrWhiteSpace(signingKey))
|
var jwtMetadataAddress = builder.Configuration["Jwt:MetadataAddress"];
|
||||||
|
var memberCenterBaseUrl = builder.Configuration["MemberCenter:BaseUrl"];
|
||||||
|
if (string.IsNullOrWhiteSpace(jwtAuthority) &&
|
||||||
|
string.IsNullOrWhiteSpace(jwtMetadataAddress) &&
|
||||||
|
!string.IsNullOrWhiteSpace(memberCenterBaseUrl) &&
|
||||||
|
Uri.TryCreate(memberCenterBaseUrl, UriKind.Absolute, out var memberCenterBaseUri))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Jwt:SigningKey is required.");
|
jwtMetadataAddress = new Uri(memberCenterBaseUri, "/.well-known/openid-configuration").ToString();
|
||||||
|
}
|
||||||
|
var signingKey = builder.Configuration["Jwt:SigningKey"];
|
||||||
|
var useOidcJwks = !string.IsNullOrWhiteSpace(jwtAuthority) || !string.IsNullOrWhiteSpace(jwtMetadataAddress);
|
||||||
|
|
||||||
|
if (!useOidcJwks && string.IsNullOrWhiteSpace(signingKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"JWT config missing. Set Jwt:Authority or Jwt:MetadataAddress for JWKS validation, " +
|
||||||
|
"or set Jwt:SigningKey for symmetric key validation.");
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
{
|
{
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
var expectedIssuer = builder.Configuration["Jwt:Issuer"];
|
||||||
|
var expectedAudience = builder.Configuration["Jwt:Audience"];
|
||||||
|
var requireHttpsMetadata = builder.Configuration.GetValue("Jwt:RequireHttpsMetadata", false);
|
||||||
|
|
||||||
|
if (useOidcJwks)
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
if (!string.IsNullOrWhiteSpace(jwtAuthority))
|
||||||
ValidateAudience = true,
|
{
|
||||||
ValidateIssuerSigningKey = true,
|
options.Authority = jwtAuthority;
|
||||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
}
|
||||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey))
|
if (!string.IsNullOrWhiteSpace(jwtMetadataAddress))
|
||||||
|
{
|
||||||
|
options.MetadataAddress = jwtMetadataAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.RequireHttpsMetadata = requireHttpsMetadata;
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = expectedAudience
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(expectedIssuer))
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters.ValidIssuer = expectedIssuer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = expectedIssuer,
|
||||||
|
ValidAudience = expectedAudience,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
if (!context.Request.Path.StartsWithSegments("/api"))
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var logger = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("SendEngine.Api.Auth");
|
||||||
|
logger.LogWarning(
|
||||||
|
context.Exception,
|
||||||
|
"JWT authentication failed. path={Path} expected_issuer={ExpectedIssuer} expected_audience={ExpectedAudience} mode={Mode}",
|
||||||
|
context.Request.Path.Value,
|
||||||
|
expectedIssuer ?? string.Empty,
|
||||||
|
expectedAudience ?? string.Empty,
|
||||||
|
useOidcJwks ? "jwks" : "symmetric");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -84,6 +154,10 @@ app.MapPost("/api/send-jobs", async (
|
|||||||
ILoggerFactory loggerFactory) =>
|
ILoggerFactory loggerFactory) =>
|
||||||
{
|
{
|
||||||
var logger = loggerFactory.CreateLogger("SendEngine.Api.SendJobs");
|
var logger = loggerFactory.CreateLogger("SendEngine.Api.SendJobs");
|
||||||
|
if (!HasScope(httpContext.User, "newsletter:send.write"))
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
var tenantId = GetTenantId(httpContext.User);
|
var tenantId = GetTenantId(httpContext.User);
|
||||||
if (tenantId is null)
|
if (tenantId is null)
|
||||||
{
|
{
|
||||||
@ -187,6 +261,10 @@ app.MapPost("/api/send-jobs", async (
|
|||||||
|
|
||||||
app.MapGet("/api/send-jobs/{id:guid}", async (HttpContext httpContext, Guid id, SendEngineDbContext db) =>
|
app.MapGet("/api/send-jobs/{id:guid}", async (HttpContext httpContext, Guid id, SendEngineDbContext db) =>
|
||||||
{
|
{
|
||||||
|
if (!HasScope(httpContext.User, "newsletter:send.read"))
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
var tenantId = GetTenantId(httpContext.User);
|
var tenantId = GetTenantId(httpContext.User);
|
||||||
if (tenantId is null)
|
if (tenantId is null)
|
||||||
{
|
{
|
||||||
@ -215,6 +293,10 @@ app.MapGet("/api/send-jobs/{id:guid}", async (HttpContext httpContext, Guid id,
|
|||||||
|
|
||||||
app.MapPost("/api/send-jobs/{id:guid}/cancel", async (HttpContext httpContext, Guid id, SendEngineDbContext db) =>
|
app.MapPost("/api/send-jobs/{id:guid}/cancel", async (HttpContext httpContext, Guid id, SendEngineDbContext db) =>
|
||||||
{
|
{
|
||||||
|
if (!HasScope(httpContext.User, "newsletter:send.write"))
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
var tenantId = GetTenantId(httpContext.User);
|
var tenantId = GetTenantId(httpContext.User);
|
||||||
if (tenantId is null)
|
if (tenantId is null)
|
||||||
{
|
{
|
||||||
@ -587,6 +669,18 @@ static Guid? GetTenantId(ClaimsPrincipal user)
|
|||||||
return Guid.TryParse(value, out var tenantId) ? tenantId : null;
|
return Guid.TryParse(value, out var tenantId) ? tenantId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool HasScope(ClaimsPrincipal user, string scope)
|
||||||
|
{
|
||||||
|
var raw = user.FindFirst("scope")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Contains(scope, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
static bool IsSupportedSubscriptionEvent(string eventType)
|
static bool IsSupportedSubscriptionEvent(string eventType)
|
||||||
{
|
{
|
||||||
return eventType is "subscription.activated" or "subscription.unsubscribed" or "preferences.updated";
|
return eventType is "subscription.activated" or "subscription.unsubscribed" or "preferences.updated";
|
||||||
@ -953,7 +1047,7 @@ static async Task NotifyMemberCenterDisableAsync(
|
|||||||
|
|
||||||
var url = ResolveMemberCenterUrl(
|
var url = ResolveMemberCenterUrl(
|
||||||
configuration,
|
configuration,
|
||||||
"MemberCenter:DisableSubscriptionUrl",
|
null,
|
||||||
"MemberCenter:BaseUrl",
|
"MemberCenter:BaseUrl",
|
||||||
"MemberCenter:DisableSubscriptionPath",
|
"MemberCenter:DisableSubscriptionPath",
|
||||||
"/subscriptions/disable");
|
"/subscriptions/disable");
|
||||||
@ -1015,7 +1109,7 @@ static async Task<string?> ResolveMemberCenterAccessTokenAsync(IConfiguration co
|
|||||||
{
|
{
|
||||||
var tokenUrl = ResolveMemberCenterUrl(
|
var tokenUrl = ResolveMemberCenterUrl(
|
||||||
configuration,
|
configuration,
|
||||||
"MemberCenter:TokenUrl",
|
null,
|
||||||
"MemberCenter:BaseUrl",
|
"MemberCenter:BaseUrl",
|
||||||
"MemberCenter:TokenPath",
|
"MemberCenter:TokenPath",
|
||||||
"/oauth/token");
|
"/oauth/token");
|
||||||
@ -1087,15 +1181,18 @@ static async Task<string?> ResolveMemberCenterAccessTokenAsync(IConfiguration co
|
|||||||
|
|
||||||
static string? ResolveMemberCenterUrl(
|
static string? ResolveMemberCenterUrl(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
string fullUrlKey,
|
string? fullUrlKey,
|
||||||
string baseUrlKey,
|
string baseUrlKey,
|
||||||
string pathKey,
|
string pathKey,
|
||||||
string defaultPath)
|
string defaultPath)
|
||||||
{
|
{
|
||||||
var fullUrl = configuration[fullUrlKey];
|
if (!string.IsNullOrWhiteSpace(fullUrlKey))
|
||||||
if (!string.IsNullOrWhiteSpace(fullUrl))
|
|
||||||
{
|
{
|
||||||
return fullUrl;
|
var fullUrl = configuration[fullUrlKey];
|
||||||
|
if (!string.IsNullOrWhiteSpace(fullUrl))
|
||||||
|
{
|
||||||
|
return fullUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var baseUrl = configuration[baseUrlKey];
|
var baseUrl = configuration[baseUrlKey];
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.23" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.23" />
|
||||||
|
<PackageReference Include="AWSSDK.SimpleEmailV2" Version="3.7.401.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
603
src/SendEngine.Api/Services/DevMockSenderWorker.cs
Normal file
603
src/SendEngine.Api/Services/DevMockSenderWorker.cs
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Amazon;
|
||||||
|
using Amazon.SimpleEmailV2;
|
||||||
|
using Amazon.SimpleEmailV2.Model;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SendEngine.Domain.Entities;
|
||||||
|
using SendEngine.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace SendEngine.Api.Services;
|
||||||
|
|
||||||
|
public sealed class DevMockSenderWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<DevMockSenderWorker> _logger;
|
||||||
|
|
||||||
|
public DevMockSenderWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<DevMockSenderWorker> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var devEnabled = _configuration.GetValue("DevSender:Enabled", false);
|
||||||
|
var provider = (_configuration["ESP:Provider"] ?? "mock").Trim().ToLowerInvariant();
|
||||||
|
var sesEnabled = provider == "ses";
|
||||||
|
if (!devEnabled && !sesEnabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Sender worker disabled. esp_provider={Provider} dev_sender_enabled={DevEnabled}", provider, devEnabled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var intervalSeconds = Math.Max(1, _configuration.GetValue("DevSender:PollIntervalSeconds", 5));
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Sender worker started. esp_provider={Provider} dev_sender_enabled={DevEnabled} poll_interval_seconds={IntervalSeconds}",
|
||||||
|
provider,
|
||||||
|
devEnabled,
|
||||||
|
intervalSeconds);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessPendingJobsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "DevMockSenderWorker process loop failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessPendingJobsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var jobs = await db.SendJobs
|
||||||
|
.Where(x => x.Status == "pending" && (!x.ScheduledAt.HasValue || x.ScheduledAt <= now))
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.Take(20)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
await ProcessSingleJobAsync(db, job, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessSingleJobAsync(SendEngineDbContext db, SendJob job, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
job.Status = "running";
|
||||||
|
job.UpdatedAt = now;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var campaign = await db.Campaigns.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == job.CampaignId, cancellationToken);
|
||||||
|
if (campaign is null)
|
||||||
|
{
|
||||||
|
job.Status = "failed";
|
||||||
|
job.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
_logger.LogWarning("DevMockSenderWorker failed job: campaign not found. send_job_id={SendJobId}", job.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriptions = await db.Subscriptions.AsNoTracking()
|
||||||
|
.Where(x => x.ListId == job.ListId && x.Status == "active")
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var issuedTokens = await FetchOneClickUnsubscribeTokensAsync(job.TenantId, job.ListId, subscriptions, cancellationToken);
|
||||||
|
var oneClickUrlConfigured = HasOneClickTokenEndpointConfigured();
|
||||||
|
|
||||||
|
var replacements = subscriptions
|
||||||
|
.Where(subscription =>
|
||||||
|
{
|
||||||
|
if (!oneClickUrlConfigured)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription.ExternalSubscriberId.HasValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return issuedTokens.ContainsKey(subscription.ExternalSubscriberId.Value);
|
||||||
|
})
|
||||||
|
.Select(subscription =>
|
||||||
|
{
|
||||||
|
var unsubscribeToken = subscription.ExternalSubscriberId.HasValue &&
|
||||||
|
issuedTokens.TryGetValue(subscription.ExternalSubscriberId.Value, out var token)
|
||||||
|
? token
|
||||||
|
: null;
|
||||||
|
var values = BuildPlaceholderValues(job, campaign, subscription, unsubscribeToken);
|
||||||
|
var subject = Personalize(campaign.Subject, values);
|
||||||
|
var bodyHtml = Personalize(campaign.BodyHtml, values);
|
||||||
|
var bodyText = Personalize(campaign.BodyText, values);
|
||||||
|
return new RecipientPreview(subscription.Email, values, subject, bodyHtml, bodyText);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
if (oneClickUrlConfigured)
|
||||||
|
{
|
||||||
|
var skipped = subscriptions.Count - replacements.Count;
|
||||||
|
if (skipped > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Sender worker skipped recipients without issued one-click token. send_job_id={SendJobId} skipped_count={SkippedCount}",
|
||||||
|
job.Id,
|
||||||
|
skipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = (_configuration["ESP:Provider"] ?? "mock").Trim().ToLowerInvariant();
|
||||||
|
var deliveredCount = 0;
|
||||||
|
if (provider == "ses")
|
||||||
|
{
|
||||||
|
deliveredCount = await SendViaSesBulkAsync(campaign, replacements, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
deliveredCount = replacements.Count;
|
||||||
|
foreach (var preview in replacements)
|
||||||
|
{
|
||||||
|
preview.Placeholders.TryGetValue("unsubscribe_url", out var unsubscribeUrl);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"MOCK send preview. send_job_id={SendJobId} email={Email} subject={Subject} unsubscribe_url={UnsubscribeUrl} body_html={BodyHtml} body_text={BodyText}",
|
||||||
|
job.Id,
|
||||||
|
preview.Email,
|
||||||
|
preview.Subject,
|
||||||
|
unsubscribeUrl ?? string.Empty,
|
||||||
|
preview.BodyHtml,
|
||||||
|
preview.BodyText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_configuration.GetValue("DevSender:Enabled", false))
|
||||||
|
{
|
||||||
|
var previewEvents = replacements.Select(preview =>
|
||||||
|
{
|
||||||
|
var unsubscribeUrl = preview.Placeholders.GetValueOrDefault("unsubscribe_url", string.Empty);
|
||||||
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
send_job_id = job.Id,
|
||||||
|
campaign_id = campaign.Id,
|
||||||
|
tenant_id = job.TenantId,
|
||||||
|
list_id = job.ListId,
|
||||||
|
email = preview.Email,
|
||||||
|
subject = preview.Subject,
|
||||||
|
body_html = preview.BodyHtml,
|
||||||
|
body_text = preview.BodyText,
|
||||||
|
unsubscribe_url = unsubscribeUrl,
|
||||||
|
placeholders = preview.Placeholders,
|
||||||
|
generated_at = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
return new EventInbox
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = job.TenantId,
|
||||||
|
EventType = "send.preview",
|
||||||
|
Source = "dev_sender",
|
||||||
|
Payload = payload,
|
||||||
|
ReceivedAt = DateTimeOffset.UtcNow,
|
||||||
|
Status = "processed",
|
||||||
|
ProcessedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
db.EventsInbox.AddRange(previewEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = await db.DeliverySummaries
|
||||||
|
.FirstOrDefaultAsync(x => x.TenantId == job.TenantId && x.SendJobId == job.Id, cancellationToken);
|
||||||
|
if (summary is null)
|
||||||
|
{
|
||||||
|
summary = new DeliverySummary
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = job.TenantId,
|
||||||
|
SendJobId = job.Id,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
db.DeliverySummaries.Add(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Total = replacements.Count;
|
||||||
|
summary.Delivered = deliveredCount;
|
||||||
|
summary.Bounced = 0;
|
||||||
|
summary.Complained = 0;
|
||||||
|
summary.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
job.Status = deliveredCount == replacements.Count ? "completed" : "failed";
|
||||||
|
job.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Sender worker completed send job. provider={Provider} send_job_id={SendJobId} tenant_id={TenantId} recipient_count={RecipientCount} delivered_count={DeliveredCount}",
|
||||||
|
provider,
|
||||||
|
job.Id,
|
||||||
|
job.TenantId,
|
||||||
|
replacements.Count,
|
||||||
|
deliveredCount);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
job.Status = "failed";
|
||||||
|
job.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
_logger.LogError(ex, "Sender worker failed job. send_job_id={SendJobId}", job.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> SendViaSesBulkAsync(Campaign campaign, IReadOnlyCollection<RecipientPreview> recipients, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var region = _configuration["Ses:Region"] ?? "us-east-1";
|
||||||
|
var fromEmail = _configuration["Ses:FromEmail"] ?? string.Empty;
|
||||||
|
var templateName = ExtractTemplateString(campaign.Template, "ses_template_name")
|
||||||
|
?? _configuration["Ses:TemplateName"]
|
||||||
|
?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(fromEmail))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("SES send skipped: Ses__FromEmail is empty.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(templateName))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("SES send skipped: ses template name is empty (template.ses_template_name / Ses__TemplateName).");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ses = new AmazonSimpleEmailServiceV2Client(RegionEndpoint.GetBySystemName(region));
|
||||||
|
|
||||||
|
var delivered = 0;
|
||||||
|
foreach (var batch in recipients.Chunk(50))
|
||||||
|
{
|
||||||
|
var request = new SendBulkEmailRequest
|
||||||
|
{
|
||||||
|
FromEmailAddress = fromEmail,
|
||||||
|
ConfigurationSetName = _configuration["Ses:ConfigurationSet"],
|
||||||
|
DefaultContent = new BulkEmailContent
|
||||||
|
{
|
||||||
|
Template = new Template
|
||||||
|
{
|
||||||
|
TemplateName = templateName,
|
||||||
|
TemplateData = "{}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
BulkEmailEntries = batch.Select(preview => new BulkEmailEntry
|
||||||
|
{
|
||||||
|
Destination = new Destination
|
||||||
|
{
|
||||||
|
ToAddresses = new List<string> { preview.Email }
|
||||||
|
},
|
||||||
|
ReplacementEmailContent = new ReplacementEmailContent
|
||||||
|
{
|
||||||
|
ReplacementTemplate = new ReplacementTemplate
|
||||||
|
{
|
||||||
|
ReplacementTemplateData = JsonSerializer.Serialize(preview.Placeholders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await ses.SendBulkEmailAsync(request, cancellationToken);
|
||||||
|
foreach (var result in response.BulkEmailEntryResults)
|
||||||
|
{
|
||||||
|
if (string.Equals(result.Status?.Value, "SUCCESS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
delivered++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"SES bulk entry failed. status={Status} error={Error} message_id={MessageId}",
|
||||||
|
result.Status?.Value,
|
||||||
|
result.Error,
|
||||||
|
result.MessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<Guid, string>> FetchOneClickUnsubscribeTokensAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid listId,
|
||||||
|
IReadOnlyCollection<Subscription> subscriptions,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var endpointUrl = ResolveMemberCenterUrl(
|
||||||
|
_configuration,
|
||||||
|
null,
|
||||||
|
"MemberCenter:BaseUrl",
|
||||||
|
"MemberCenter:OneClickUnsubscribeTokensPath",
|
||||||
|
"/newsletter/one-click-unsubscribe-tokens");
|
||||||
|
if (string.IsNullOrWhiteSpace(endpointUrl))
|
||||||
|
{
|
||||||
|
return new Dictionary<Guid, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriberIds = subscriptions
|
||||||
|
.Where(x => x.ExternalSubscriberId.HasValue)
|
||||||
|
.Select(x => x.ExternalSubscriberId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
if (subscriberIds.Length == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<Guid, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = new HttpClient();
|
||||||
|
var token = await ResolveMemberCenterAccessTokenAsync(_configuration, client, _logger, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Skip one-click token fetch: unable to resolve Member Center access token.");
|
||||||
|
return new Dictionary<Guid, string>();
|
||||||
|
}
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var result = new Dictionary<Guid, string>();
|
||||||
|
foreach (var batch in subscriberIds.Chunk(200))
|
||||||
|
{
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
tenant_id = tenantId,
|
||||||
|
list_id = listId,
|
||||||
|
subscriber_ids = batch
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await client.PostAsJsonAsync(endpointUrl, body, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"One-click token batch request failed. status={StatusCode} body={Body}",
|
||||||
|
(int)response.StatusCode,
|
||||||
|
Truncate(errorBody, 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<OneClickTokensResponse>(cancellationToken: cancellationToken);
|
||||||
|
if (payload?.Items is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in payload.Items)
|
||||||
|
{
|
||||||
|
if (item.SubscriberId == Guid.Empty ||
|
||||||
|
!string.Equals(item.Status, "issued", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.IsNullOrWhiteSpace(item.UnsubscribeToken))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[item.SubscriberId] = item.UnsubscribeToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "One-click token batch request threw exception.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasOneClickTokenEndpointConfigured()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(ResolveMemberCenterUrl(
|
||||||
|
_configuration,
|
||||||
|
null,
|
||||||
|
"MemberCenter:BaseUrl",
|
||||||
|
"MemberCenter:OneClickUnsubscribeTokensPath",
|
||||||
|
"/newsletter/one-click-unsubscribe-tokens"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> BuildPlaceholderValues(
|
||||||
|
SendJob job,
|
||||||
|
Campaign campaign,
|
||||||
|
Subscription subscription,
|
||||||
|
string? unsubscribeToken)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["email"] = subscription.Email,
|
||||||
|
["tenant_id"] = job.TenantId.ToString("D"),
|
||||||
|
["list_id"] = job.ListId.ToString("D"),
|
||||||
|
["campaign_id"] = campaign.Id.ToString("D"),
|
||||||
|
["send_job_id"] = job.Id.ToString("D"),
|
||||||
|
["unsubscribe_token"] = unsubscribeToken ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
var unsubscribeTemplate = ExtractTemplateString(campaign.Template, "unsubscribe_url");
|
||||||
|
var unsubscribeUrl = Personalize(unsubscribeTemplate, values);
|
||||||
|
values["unsubscribe_url"] = unsubscribeUrl;
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ResolveMemberCenterAccessTokenAsync(
|
||||||
|
IConfiguration configuration,
|
||||||
|
HttpClient client,
|
||||||
|
ILogger logger,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tokenUrl = ResolveMemberCenterUrl(
|
||||||
|
configuration,
|
||||||
|
null,
|
||||||
|
"MemberCenter:BaseUrl",
|
||||||
|
"MemberCenter:TokenPath",
|
||||||
|
"/oauth/token");
|
||||||
|
var clientId = configuration["MemberCenter:ClientId"];
|
||||||
|
var clientSecret = configuration["MemberCenter:ClientSecret"];
|
||||||
|
var scope = configuration["MemberCenter:Scope"];
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(tokenUrl) &&
|
||||||
|
!string.IsNullOrWhiteSpace(clientId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(clientSecret))
|
||||||
|
{
|
||||||
|
var form = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "client_credentials",
|
||||||
|
["client_id"] = clientId,
|
||||||
|
["client_secret"] = clientSecret
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(scope))
|
||||||
|
{
|
||||||
|
form["scope"] = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(form), cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||||
|
if (json.RootElement.TryGetProperty("access_token", out var tokenElement))
|
||||||
|
{
|
||||||
|
var accessToken = tokenElement.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||||
|
{
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
logger.LogWarning("MemberCenter token request failed. status={StatusCode} body={Body}", (int)response.StatusCode, Truncate(body, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "MemberCenter token request failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuration["MemberCenter:ApiToken"];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveMemberCenterUrl(
|
||||||
|
IConfiguration configuration,
|
||||||
|
string? fullUrlKey,
|
||||||
|
string baseUrlKey,
|
||||||
|
string pathKey,
|
||||||
|
string defaultPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(fullUrlKey))
|
||||||
|
{
|
||||||
|
var fullUrl = configuration[fullUrlKey];
|
||||||
|
if (!string.IsNullOrWhiteSpace(fullUrl))
|
||||||
|
{
|
||||||
|
return fullUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUrl = configuration[baseUrlKey];
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = configuration[pathKey];
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
path = defaultPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{baseUrl.TrimEnd('/')}/{path.TrimStart('/')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Length <= maxLength ? value : $"{value[..maxLength]}...(truncated)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractTemplateString(string? templateJson, string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(templateJson))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(templateJson);
|
||||||
|
if (doc.RootElement.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return value.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// keep empty when template payload is not a valid object JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Personalize(string? template, IReadOnlyDictionary<string, string> values)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(template))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = template;
|
||||||
|
foreach (var pair in values)
|
||||||
|
{
|
||||||
|
content = content.Replace($"{{{{{pair.Key}}}}}", pair.Value, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RecipientPreview(
|
||||||
|
string Email,
|
||||||
|
Dictionary<string, string> Placeholders,
|
||||||
|
string Subject,
|
||||||
|
string BodyHtml,
|
||||||
|
string BodyText);
|
||||||
|
|
||||||
|
private sealed class OneClickTokensResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<OneClickTokenItem>? Items { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OneClickTokenItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("subscriber_id")]
|
||||||
|
public Guid SubscriberId { get; set; }
|
||||||
|
[JsonPropertyName("unsubscribe_token")]
|
||||||
|
public string? UnsubscribeToken { get; set; }
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user