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:
warrenchen 2026-02-25 14:33:34 +09:00
parent 7647a8cb3b
commit 60a24ee7c0
11 changed files with 967 additions and 205 deletions

View File

@ -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=

View File

@ -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

View File

@ -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 下自動建立 listplaceholder
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. 將事件寫入 Inboxappend-only
4. Consumer 解析事件:
- hard bounce → 立即標記 blacklisted同義於 `suppressed`
@ -93,6 +92,7 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
補充:
- Unknown event 不應使 worker crash應記錄後送入 DLQ
- Throttle/暫時性網路錯誤使用指數退避重試
- `SNS -> SQS -> Worker` 架構為正式環境建議,尚未在目前程式碼中落地
回寫規則:
- Send Engine 僅回寫「停用原因」與必要欄位

View File

@ -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 SenderMock 發信):
- `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 endpointsender 會在發送前批次呼叫 `/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`

View File

@ -5,11 +5,13 @@
## Auth 與驗證
### 1. 租戶網站 → Send Engine API
使用 OAuth2 Client Credentials 或 JWT由 Member Center 簽發)。
使用 OAuth2 Client Credentialstoken 由 Member Center 簽發Send Engine 以 OIDC/JWKS 驗簽驗證)。
若未明確設定 JWT metadata/authority會回退使用 `MemberCenter__BaseUrl + /.well-known/openid-configuration`
必要 claims
- `tenant_id`
- `scope`(至少 `newsletter:send.write`
- 必須包含 `aud`(需符合 `Jwt__Audience`
規則:
- `tenant_id` 只能取自 token不接受 body 覆寫
@ -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` 目前非固定欄位
## WebhookMember 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 tokenscope 至少 `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

View File

@ -2,4 +2,4 @@
- C# .NET Core
- PostgreSQL
- ESP: SES / SendGrid / Mailgun
- ESP: Amazon SES實作+ Mock Sender開發測試

View File

@ -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

View File

@ -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

View File

@ -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];

View File

@ -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>

View 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; }
}
}