diff --git a/.env.example b/.env.example index 6ac9be6..44b66a7 100644 --- a/.env.example +++ b/.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= diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a82b9a9..33e2a8c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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) diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 102a0ca..53c8502 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -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 僅回寫「停用原因」與必要欄位 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f34f2da..7382f33 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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` diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 183be9a..19d9b8e 100644 --- a/docs/OPENAPI.md +++ b/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 " \ + -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": "

Hello {{email}}

unsubscribe

", + "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 diff --git a/docs/TECH_STACK.md b/docs/TECH_STACK.md index 05f3aac..3ac0410 100644 --- a/docs/TECH_STACK.md +++ b/docs/TECH_STACK.md @@ -2,4 +2,4 @@ - C# .NET Core - PostgreSQL -- ESP: SES / SendGrid / Mailgun +- ESP: Amazon SES(實作)+ Mock Sender(開發測試) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 951632d..2dfec40 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 diff --git a/src/SendEngine.Api/Models/SendJobModels.cs b/src/SendEngine.Api/Models/SendJobModels.cs index 9f0c5b1..2d15ee9 100644 --- a/src/SendEngine.Api/Models/SendJobModels.cs +++ b/src/SendEngine.Api/Models/SendJobModels.cs @@ -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 diff --git a/src/SendEngine.Api/Program.cs b/src/SendEngine.Api/Program.cs index 0fd7e9a..6051dde 100644 --- a/src/SendEngine.Api/Program.cs +++ b/src/SendEngine.Api/Program.cs @@ -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(); -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() + .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 ResolveMemberCenterAccessTokenAsync(IConfiguration co { var tokenUrl = ResolveMemberCenterUrl( configuration, - "MemberCenter:TokenUrl", + null, "MemberCenter:BaseUrl", "MemberCenter:TokenPath", "/oauth/token"); @@ -1087,15 +1181,18 @@ static async Task 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]; diff --git a/src/SendEngine.Api/SendEngine.Api.csproj b/src/SendEngine.Api/SendEngine.Api.csproj index 72b4269..783de8c 100644 --- a/src/SendEngine.Api/SendEngine.Api.csproj +++ b/src/SendEngine.Api/SendEngine.Api.csproj @@ -9,6 +9,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/SendEngine.Api/Services/DevMockSenderWorker.cs b/src/SendEngine.Api/Services/DevMockSenderWorker.cs new file mode 100644 index 0000000..6469f6f --- /dev/null +++ b/src/SendEngine.Api/Services/DevMockSenderWorker.cs @@ -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 _logger; + + public DevMockSenderWorker( + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + ILogger 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(); + + 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 SendViaSesBulkAsync(Campaign campaign, IReadOnlyCollection 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 { 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> FetchOneClickUnsubscribeTokensAsync( + Guid tenantId, + Guid listId, + IReadOnlyCollection subscriptions, + CancellationToken cancellationToken) + { + var endpointUrl = ResolveMemberCenterUrl( + _configuration, + null, + "MemberCenter:BaseUrl", + "MemberCenter:OneClickUnsubscribeTokensPath", + "/newsletter/one-click-unsubscribe-tokens"); + if (string.IsNullOrWhiteSpace(endpointUrl)) + { + return new Dictionary(); + } + + var subscriberIds = subscriptions + .Where(x => x.ExternalSubscriberId.HasValue) + .Select(x => x.ExternalSubscriberId!.Value) + .Distinct() + .ToArray(); + if (subscriberIds.Length == 0) + { + return new Dictionary(); + } + + 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(); + } + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var result = new Dictionary(); + 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(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 BuildPlaceholderValues( + SendJob job, + Campaign campaign, + Subscription subscription, + string? unsubscribeToken) + { + var values = new Dictionary(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 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 + { + ["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 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 Placeholders, + string Subject, + string BodyHtml, + string BodyText); + + private sealed class OneClickTokensResponse + { + [JsonPropertyName("items")] + public List? 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; } + } +}