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__ApiKey=change_me
|
||||
Db__AutoMigrate=true
|
||||
Jwt__Issuer=member_center
|
||||
Jwt__Audience=send_engine
|
||||
Jwt__SigningKey=change_me_jwt_signing_key
|
||||
Jwt__Issuer=http://localhost:7850/
|
||||
Jwt__Audience=send_engine_api
|
||||
Jwt__Authority=
|
||||
Jwt__MetadataAddress=
|
||||
Jwt__RequireHttpsMetadata=false
|
||||
Jwt__SigningKey=
|
||||
Webhook__Secrets__member_center=change_me_webhook_secret
|
||||
Webhook__TimestampSkewSeconds=300
|
||||
Webhook__AllowNullTenantClient=false
|
||||
@ -14,10 +17,15 @@ Bounce__SoftBounceThreshold=5
|
||||
MemberCenter__BaseUrl=
|
||||
MemberCenter__DisableSubscriptionPath=/subscriptions/disable
|
||||
MemberCenter__TokenPath=/oauth/token
|
||||
MemberCenter__DisableSubscriptionUrl=
|
||||
MemberCenter__TokenUrl=
|
||||
MemberCenter__OneClickUnsubscribeTokensPath=/newsletter/one-click-unsubscribe-tokens
|
||||
MemberCenter__ClientId=
|
||||
MemberCenter__ClientSecret=
|
||||
MemberCenter__Scope=newsletter:events.write
|
||||
MemberCenter__ApiToken=
|
||||
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 本身的服務實作
|
||||
- 會員最終名單權威資料庫
|
||||
|
||||
### 狀態機(規劃)
|
||||
Job 狀態:
|
||||
### 狀態機
|
||||
Job 狀態(目前實作):
|
||||
- pending
|
||||
- running
|
||||
- completed
|
||||
- failed
|
||||
- cancelled
|
||||
|
||||
Job 狀態(規劃擴充):
|
||||
- queued
|
||||
- sending
|
||||
- sent
|
||||
- partially_failed
|
||||
- failed
|
||||
- completed
|
||||
|
||||
Recipient 狀態:
|
||||
- pending
|
||||
@ -49,6 +54,10 @@ Recipient 狀態:
|
||||
- 更新 DB 狀態
|
||||
- 必要時呼叫 Member Center 停用/註記 API
|
||||
|
||||
目前實作:
|
||||
- 先以 `POST /webhooks/ses` 接收事件並更新資料
|
||||
- `SNS -> SQS -> Worker` 尚未落地
|
||||
|
||||
## 信任邊界與 Auth 模型
|
||||
### 外部角色
|
||||
- Member Center:事件來源與名單權威來源(authority)
|
||||
|
||||
@ -40,9 +40,9 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
||||
- 可於 Send Engine 端提供 `sync_received` 回應與進度回報
|
||||
|
||||
## 2. 發送排程流程
|
||||
目的:從租戶網站送入的內容建立 Send Job,切分送信任務並控速。
|
||||
目的:從租戶網站送入的內容建立 Send Job,並由背景 worker 執行發送。
|
||||
|
||||
流程:
|
||||
目前實作流程:
|
||||
1. 租戶網站以 Member Center 簽發的 token 呼叫 Send Engine API 建立 Campaign/Send Job:
|
||||
- 必填:tenant_id、list_id、內容(subject/body/template 其一或組合)
|
||||
- 選填:排程時間、發送窗口、追蹤設定(open/click)
|
||||
@ -50,17 +50,16 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
||||
- tenant_id 以 token 為準,body 的 tenant_id 僅作一致性檢查
|
||||
- tenant 必須預先存在(建議由 Installer 建立)
|
||||
- list_id 若不存在,Send Engine 會在該 tenant 下自動建立 list(placeholder)
|
||||
3. Scheduler 在排程時間點啟動 Send Job:
|
||||
- 讀取 List Store 快照
|
||||
- 依規則過濾(已退訂、bounced、黑名單)
|
||||
4. 切分成可控批次(batch),寫入 Outbox。
|
||||
5. Sender Worker 取出 batch,轉成 SES API 請求。
|
||||
6. 發送時必帶:
|
||||
- SES Configuration Set
|
||||
- Message Tags(至少含 campaign_id / site_id / list_id)
|
||||
- Newsletter 類型需帶 `List-Unsubscribe` 與 `List-Unsubscribe-Post` headers
|
||||
7. SES 回應 message_id → 記錄 delivery log。
|
||||
8. 更新 Send Job 進度(成功/失敗/重試)。
|
||||
3. `DevMockSenderWorker` 輪詢 `send_jobs(status=pending)`,執行時先改為 `running`。
|
||||
4. worker 讀取 `subscriptions(status=active)`,並過濾不可發送對象:
|
||||
- unsubscribed / suppressed 不發送
|
||||
- 若啟用 one-click token endpoint,僅發送 `status=issued` 的收件者
|
||||
5. 發送執行:
|
||||
- `ESP__Provider=mock`:僅模擬發送,寫入預覽事件並輸出 console log
|
||||
- `ESP__Provider=ses`:使用 SES v2 `SendBulkEmail`(每批最多 50)
|
||||
6. 更新 Send Job 狀態:
|
||||
- 全部成功:`completed`
|
||||
- 例外或部分失敗:`failed`
|
||||
|
||||
控速策略(範例):
|
||||
- 全域 TPS 上限 + tenant TPS 上限
|
||||
@ -74,9 +73,9 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
||||
## 3. 退信處理流程
|
||||
目的:處理 ESP 回報的 bounce/complaint,並回寫本地名單狀態。
|
||||
|
||||
流程:
|
||||
1. SES 事件由 Configuration Set 發送至 SNS,再落到 SQS。
|
||||
2. ECS Worker 輪詢 SQS,解析 SNS envelope 與 SES payload。
|
||||
目前實作流程(Webhook):
|
||||
1. 由 `POST /webhooks/ses` 接收 SES 事件 payload。
|
||||
2. 驗證(可透過 `Ses__SkipSignatureValidation` 控制是否要求簽章)。
|
||||
3. 將事件寫入 Inbox(append-only)。
|
||||
4. Consumer 解析事件:
|
||||
- hard bounce → 立即標記 blacklisted(同義於 `suppressed`)
|
||||
@ -93,6 +92,7 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
||||
補充:
|
||||
- Unknown event 不應使 worker crash,應記錄後送入 DLQ
|
||||
- Throttle/暫時性網路錯誤使用指數退避重試
|
||||
- `SNS -> SQS -> Worker` 架構為正式環境建議,尚未在目前程式碼中落地
|
||||
|
||||
回寫規則:
|
||||
- Send Engine 僅回寫「停用原因」與必要欄位
|
||||
|
||||
@ -27,13 +27,35 @@
|
||||
- `MemberCenter__BaseUrl`(建議)
|
||||
- `MemberCenter__DisableSubscriptionPath`(預設 `/subscriptions/disable`)
|
||||
- `MemberCenter__TokenPath`(預設 `/oauth/token`)
|
||||
- `MemberCenter__OneClickUnsubscribeTokensPath`(預設 `/newsletter/one-click-unsubscribe-tokens`)
|
||||
- `MemberCenter__ClientId`
|
||||
- `MemberCenter__ClientSecret`
|
||||
- `MemberCenter__Scope=newsletter:events.write`
|
||||
- `MemberCenter__DisableSubscriptionUrl` 與 `MemberCenter__TokenUrl` 可用完整 URL 覆蓋(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` 時:
|
||||
- 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`
|
||||
|
||||
108
docs/OPENAPI.md
108
docs/OPENAPI.md
@ -5,11 +5,13 @@
|
||||
|
||||
## Auth 與驗證
|
||||
### 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:
|
||||
- `tenant_id`
|
||||
- `scope`(至少 `newsletter:send.write`)
|
||||
- 必須包含 `aud`(需符合 `Jwt__Audience`)
|
||||
|
||||
規則:
|
||||
- `tenant_id` 只能取自 token,不接受 body 覆寫
|
||||
@ -55,11 +57,12 @@ scope 最小化:
|
||||
## 通用錯誤格式
|
||||
```json
|
||||
{
|
||||
"error": "string_code",
|
||||
"message": "human readable message",
|
||||
"request_id": "uuid"
|
||||
"error": "string_code"
|
||||
}
|
||||
```
|
||||
補充:
|
||||
- 部分錯誤會附帶 `reason` 或 `message`(例如 webhook 驗證失敗)
|
||||
- `message`、`request_id` 目前非固定欄位
|
||||
|
||||
## Webhook:Member Center → Send Engine
|
||||
### A. 訂閱事件(增量)
|
||||
@ -163,43 +166,73 @@ Request Body:
|
||||
- `window_start` 必須小於 `window_end`(若有提供)
|
||||
- `tenant_id` 必須已存在(不存在回 `422 tenant_not_found`)
|
||||
- `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:
|
||||
```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=\"{{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"
|
||||
}
|
||||
```
|
||||
|
||||
### C-1. Sending Proxy Submit Job(整合規格)
|
||||
用途:對齊內容網站/會員平台呼叫發信代理的標準接口。
|
||||
|
||||
Endpoint:
|
||||
- `POST /v1/send-jobs`
|
||||
|
||||
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`
|
||||
說明:
|
||||
- `ESP__Provider=mock`(非 ses)時,會由 Dev Sender 產生 `send.preview` 事件供你檢查替換結果
|
||||
- `ESP__Provider=mock` 時,也會把每位收件人的模擬發送內容輸出到 console log(`MOCK send preview`)
|
||||
- `ESP__Provider=ses` 時,背景 sender 會用 SES `SendBulkEmail`(每批最多 50)
|
||||
- 若已設定 Member Center one-click token endpoint,發送前會批次呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
|
||||
- 僅 `status=issued` 的收件者會被送出,並把 `unsubscribe_token` 注入替換內容
|
||||
|
||||
### D. 查詢 Send Job
|
||||
Endpoint:
|
||||
@ -213,12 +246,18 @@ 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",
|
||||
"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`
|
||||
|
||||
驗證:
|
||||
- 依 SES/SNS 規格驗簽(可用 `Ses__SkipSignatureValidation=true` 暫時略過)
|
||||
- 目前實作:`Ses__SkipSignatureValidation=false` 時僅要求 `X-Amz-Sns-Signature` header 存在
|
||||
- 正式建議:補上 SES/SNS 憑證鏈與簽章內容驗證
|
||||
|
||||
Request Body(示意):
|
||||
```json
|
||||
|
||||
@ -2,4 +2,4 @@
|
||||
|
||||
- C# .NET Core
|
||||
- PostgreSQL
|
||||
- ESP: SES / SendGrid / Mailgun
|
||||
- ESP: Amazon SES(實作)+ Mock Sender(開發測試)
|
||||
|
||||
@ -11,55 +11,6 @@ security:
|
||||
- bearerAuth: []
|
||||
|
||||
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:
|
||||
post:
|
||||
summary: Create send job (legacy/internal)
|
||||
@ -318,14 +269,23 @@ components:
|
||||
schemas:
|
||||
CreateSendJobRequest:
|
||||
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:
|
||||
tenant_id:
|
||||
type: string
|
||||
format: uuid
|
||||
tenantId:
|
||||
type: string
|
||||
format: uuid
|
||||
list_id:
|
||||
type: string
|
||||
format: uuid
|
||||
listId:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
subject:
|
||||
@ -333,41 +293,68 @@ components:
|
||||
minLength: 1
|
||||
body_html:
|
||||
type: string
|
||||
bodyHtml:
|
||||
type: string
|
||||
body_text:
|
||||
type: string
|
||||
bodyText:
|
||||
type: string
|
||||
template:
|
||||
type: object
|
||||
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:
|
||||
type: string
|
||||
format: date-time
|
||||
scheduledAt:
|
||||
type: string
|
||||
format: date-time
|
||||
window_start:
|
||||
type: string
|
||||
format: date-time
|
||||
windowStart:
|
||||
type: string
|
||||
format: date-time
|
||||
window_end:
|
||||
type: string
|
||||
format: date-time
|
||||
windowEnd:
|
||||
type: string
|
||||
format: date-time
|
||||
tracking:
|
||||
$ref: '#/components/schemas/TrackingOptions'
|
||||
oneOf:
|
||||
- required: [body_html]
|
||||
- required: [body_text]
|
||||
- required: [template]
|
||||
allOf:
|
||||
- anyOf:
|
||||
- required: [list_id]
|
||||
- required: [listId]
|
||||
- anyOf:
|
||||
- required: [body_html]
|
||||
- required: [bodyHtml]
|
||||
- required: [body_text]
|
||||
- required: [bodyText]
|
||||
- required: [template]
|
||||
|
||||
CreateSendJobResponse:
|
||||
type: object
|
||||
required: [send_job_id, status]
|
||||
required: [send_job_id, sendJobId, status]
|
||||
properties:
|
||||
send_job_id:
|
||||
type: string
|
||||
format: uuid
|
||||
sendJobId:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, running, completed, failed, cancelled]
|
||||
|
||||
SendJob:
|
||||
type: object
|
||||
required: [id, tenant_id, list_id, campaign_id, status]
|
||||
required: [id, tenant_id, tenantId, list_id, listId, campaign_id, campaignId, status]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@ -375,24 +362,42 @@ components:
|
||||
tenant_id:
|
||||
type: string
|
||||
format: uuid
|
||||
tenantId:
|
||||
type: string
|
||||
format: uuid
|
||||
list_id:
|
||||
type: string
|
||||
format: uuid
|
||||
listId:
|
||||
type: string
|
||||
format: uuid
|
||||
campaign_id:
|
||||
type: string
|
||||
format: uuid
|
||||
campaignId:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, running, completed, failed, cancelled]
|
||||
scheduled_at:
|
||||
type: string
|
||||
format: date-time
|
||||
scheduledAt:
|
||||
type: string
|
||||
format: date-time
|
||||
window_start:
|
||||
type: string
|
||||
format: date-time
|
||||
windowStart:
|
||||
type: string
|
||||
format: date-time
|
||||
window_end:
|
||||
type: string
|
||||
format: date-time
|
||||
windowEnd:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
SendJobStatusResponse:
|
||||
type: object
|
||||
@ -405,76 +410,6 @@ components:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
@ -573,11 +508,13 @@ components:
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required: [error, message, request_id]
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
|
||||
@ -1,19 +1,44 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SendEngine.Api.Models;
|
||||
|
||||
public sealed class CreateSendJobRequest
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public Guid TenantIdSnakeCase { set => TenantId = value; }
|
||||
|
||||
public Guid ListId { get; set; }
|
||||
[JsonPropertyName("list_id")]
|
||||
public Guid ListIdSnakeCase { set => ListId = value; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Subject { get; set; }
|
||||
|
||||
public string? BodyHtml { get; set; }
|
||||
[JsonPropertyName("body_html")]
|
||||
public string? BodyHtmlSnakeCase { set => BodyHtml = value; }
|
||||
|
||||
public string? BodyText { get; set; }
|
||||
[JsonPropertyName("body_text")]
|
||||
public string? BodyTextSnakeCase { set => BodyText = value; }
|
||||
|
||||
public JsonElement? Template { get; set; }
|
||||
[JsonPropertyName("scheduled_at")]
|
||||
public DateTimeOffset? ScheduledAtSnakeCase { set => ScheduledAt = value; }
|
||||
|
||||
public DateTimeOffset? ScheduledAt { get; set; }
|
||||
[JsonPropertyName("window_start")]
|
||||
public DateTimeOffset? WindowStartSnakeCase { set => WindowStart = value; }
|
||||
|
||||
public DateTimeOffset? WindowStart { get; set; }
|
||||
[JsonPropertyName("window_end")]
|
||||
public DateTimeOffset? WindowEndSnakeCase { set => WindowEnd = value; }
|
||||
|
||||
public DateTimeOffset? WindowEnd { get; set; }
|
||||
|
||||
public TrackingOptions? Tracking { get; set; }
|
||||
}
|
||||
|
||||
@ -26,6 +51,9 @@ public sealed class TrackingOptions
|
||||
public sealed class CreateSendJobResponse
|
||||
{
|
||||
public Guid SendJobId { get; set; }
|
||||
[JsonPropertyName("send_job_id")]
|
||||
public Guid SendJobIdSnakeCase => SendJobId;
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
}
|
||||
|
||||
@ -33,12 +61,29 @@ public sealed class SendJobResponse
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public Guid TenantIdSnakeCase => TenantId;
|
||||
|
||||
public Guid ListId { get; set; }
|
||||
[JsonPropertyName("list_id")]
|
||||
public Guid ListIdSnakeCase => ListId;
|
||||
|
||||
public Guid CampaignId { get; set; }
|
||||
[JsonPropertyName("campaign_id")]
|
||||
public Guid CampaignIdSnakeCase => CampaignId;
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
public DateTimeOffset? ScheduledAt { get; set; }
|
||||
[JsonPropertyName("scheduled_at")]
|
||||
public DateTimeOffset? ScheduledAtSnakeCase => ScheduledAt;
|
||||
|
||||
public DateTimeOffset? WindowStart { get; set; }
|
||||
[JsonPropertyName("window_start")]
|
||||
public DateTimeOffset? WindowStartSnakeCase => WindowStart;
|
||||
|
||||
public DateTimeOffset? WindowEnd { get; set; }
|
||||
[JsonPropertyName("window_end")]
|
||||
public DateTimeOffset? WindowEndSnakeCase => WindowEnd;
|
||||
}
|
||||
|
||||
public sealed class SendJobStatusResponse
|
||||
|
||||
@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SendEngine.Api.Models;
|
||||
using SendEngine.Api.Services;
|
||||
using SendEngine.Api.Security;
|
||||
using SendEngine.Domain.Entities;
|
||||
using SendEngine.Infrastructure;
|
||||
@ -16,24 +17,93 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Services.AddHostedService<DevMockSenderWorker>();
|
||||
|
||||
var signingKey = builder.Configuration["Jwt:SigningKey"];
|
||||
if (string.IsNullOrWhiteSpace(signingKey))
|
||||
var jwtAuthority = builder.Configuration["Jwt:Authority"];
|
||||
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)
|
||||
.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,
|
||||
ValidateAudience = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey))
|
||||
if (!string.IsNullOrWhiteSpace(jwtAuthority))
|
||||
{
|
||||
options.Authority = jwtAuthority;
|
||||
}
|
||||
|
||||
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) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("SendEngine.Api.SendJobs");
|
||||
if (!HasScope(httpContext.User, "newsletter:send.write"))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
var tenantId = GetTenantId(httpContext.User);
|
||||
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) =>
|
||||
{
|
||||
if (!HasScope(httpContext.User, "newsletter:send.read"))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
var tenantId = GetTenantId(httpContext.User);
|
||||
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) =>
|
||||
{
|
||||
if (!HasScope(httpContext.User, "newsletter:send.write"))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
var tenantId = GetTenantId(httpContext.User);
|
||||
if (tenantId is null)
|
||||
{
|
||||
@ -587,6 +669,18 @@ static Guid? GetTenantId(ClaimsPrincipal user)
|
||||
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)
|
||||
{
|
||||
return eventType is "subscription.activated" or "subscription.unsubscribed" or "preferences.updated";
|
||||
@ -953,7 +1047,7 @@ static async Task NotifyMemberCenterDisableAsync(
|
||||
|
||||
var url = ResolveMemberCenterUrl(
|
||||
configuration,
|
||||
"MemberCenter:DisableSubscriptionUrl",
|
||||
null,
|
||||
"MemberCenter:BaseUrl",
|
||||
"MemberCenter:DisableSubscriptionPath",
|
||||
"/subscriptions/disable");
|
||||
@ -1015,7 +1109,7 @@ static async Task<string?> ResolveMemberCenterAccessTokenAsync(IConfiguration co
|
||||
{
|
||||
var tokenUrl = ResolveMemberCenterUrl(
|
||||
configuration,
|
||||
"MemberCenter:TokenUrl",
|
||||
null,
|
||||
"MemberCenter:BaseUrl",
|
||||
"MemberCenter:TokenPath",
|
||||
"/oauth/token");
|
||||
@ -1087,15 +1181,18 @@ static async Task<string?> ResolveMemberCenterAccessTokenAsync(IConfiguration co
|
||||
|
||||
static string? ResolveMemberCenterUrl(
|
||||
IConfiguration configuration,
|
||||
string fullUrlKey,
|
||||
string? fullUrlKey,
|
||||
string baseUrlKey,
|
||||
string pathKey,
|
||||
string defaultPath)
|
||||
{
|
||||
var fullUrl = configuration[fullUrlKey];
|
||||
if (!string.IsNullOrWhiteSpace(fullUrl))
|
||||
if (!string.IsNullOrWhiteSpace(fullUrlKey))
|
||||
{
|
||||
return fullUrl;
|
||||
var fullUrl = configuration[fullUrlKey];
|
||||
if (!string.IsNullOrWhiteSpace(fullUrl))
|
||||
{
|
||||
return fullUrl;
|
||||
}
|
||||
}
|
||||
|
||||
var baseUrl = configuration[baseUrlKey];
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||
<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">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<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