Compare commits
2 Commits
620a1ae237
...
7647a8cb3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7647a8cb3b | ||
|
|
a7752c8ce0 |
11
.env.example
11
.env.example
@ -10,3 +10,14 @@ Webhook__Secrets__member_center=change_me_webhook_secret
|
||||
Webhook__TimestampSkewSeconds=300
|
||||
Webhook__AllowNullTenantClient=false
|
||||
Ses__SkipSignatureValidation=true
|
||||
Bounce__SoftBounceThreshold=5
|
||||
MemberCenter__BaseUrl=
|
||||
MemberCenter__DisableSubscriptionPath=/subscriptions/disable
|
||||
MemberCenter__TokenPath=/oauth/token
|
||||
MemberCenter__DisableSubscriptionUrl=
|
||||
MemberCenter__TokenUrl=
|
||||
MemberCenter__ClientId=
|
||||
MemberCenter__ClientSecret=
|
||||
MemberCenter__Scope=newsletter:events.write
|
||||
MemberCenter__ApiToken=
|
||||
TestFriendly__Enabled=false
|
||||
|
||||
@ -6,3 +6,5 @@
|
||||
- If the command fails or hangs due to sandbox limits (for example restore/build stalls), rerun with `sandbox_permissions: "require_escalated"`.
|
||||
- The escalation request must include a short justification explaining that sandbox restrictions are blocking normal .NET execution.
|
||||
- Do not change project paths or command intent when escalating; rerun the same command with elevated permissions.
|
||||
- If a sandbox command appears hung, run it with `tty=true` and explicitly terminate the sandbox session first (send `Ctrl+C`) before escalating.
|
||||
- Never leave a hung sandbox process running in the background while starting the escalated rerun.
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
- Campaign / Send Job 基本流程
|
||||
- Sender Adapter(至少一種 ESP)
|
||||
- 投遞與退信記錄
|
||||
- SES 回流建議採 `Configuration Set -> SNS -> SQS -> Worker`
|
||||
|
||||
## 非目標(暫不處理)
|
||||
- 自建 SMTP server
|
||||
|
||||
@ -11,6 +11,44 @@ ESP 介接暫定為 Amazon SES。
|
||||
- Sender Adapter
|
||||
- Delivery & Bounce Handling
|
||||
|
||||
## Sending Proxy 規格整合
|
||||
### 目標與邊界
|
||||
- 接收內容網站或會員平台送來的發送工作
|
||||
- 呼叫 Amazon SES API(優先 SES v2)
|
||||
- 必帶 Configuration Set + Message Tags
|
||||
- 消費 SES 回流事件(Bounce / Complaint / Delivery)
|
||||
- 必要時回寫 Member Center
|
||||
|
||||
不負責:
|
||||
- List-Unsubscribe one-click endpoint 本身的服務實作
|
||||
- 會員最終名單權威資料庫
|
||||
|
||||
### 狀態機(規劃)
|
||||
Job 狀態:
|
||||
- queued
|
||||
- sending
|
||||
- sent
|
||||
- partially_failed
|
||||
- failed
|
||||
- completed
|
||||
|
||||
Recipient 狀態:
|
||||
- pending
|
||||
- sent
|
||||
- delivered
|
||||
- soft_bounced
|
||||
- hard_bounced
|
||||
- complained
|
||||
- suppressed
|
||||
|
||||
### SES 事件回流架構(建議)
|
||||
- `SES Configuration Set -> SNS Topic -> SQS Queue -> ECS Worker`
|
||||
- Worker 職責:
|
||||
- Poll SQS
|
||||
- 解析 SNS envelope 與 SES payload
|
||||
- 更新 DB 狀態
|
||||
- 必要時呼叫 Member Center 停用/註記 API
|
||||
|
||||
## 信任邊界與 Auth 模型
|
||||
### 外部角色
|
||||
- Member Center:事件來源與名單權威來源(authority)
|
||||
|
||||
@ -48,14 +48,19 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
||||
- 選填:排程時間、發送窗口、追蹤設定(open/click)
|
||||
2. Send Engine 驗證 tenant scope 與內容完整性,建立 Send Job。
|
||||
- tenant_id 以 token 為準,body 的 tenant_id 僅作一致性檢查
|
||||
- list_id 必須屬於 tenant scope
|
||||
- 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 回應 message_id → 記錄 delivery log。
|
||||
7. 更新 Send Job 進度(成功/失敗/重試)。
|
||||
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 進度(成功/失敗/重試)。
|
||||
|
||||
控速策略(範例):
|
||||
- 全域 TPS 上限 + tenant TPS 上限
|
||||
@ -70,19 +75,36 @@ Member Center 為多租戶架構,信件內容由各租戶網站產生後送入
|
||||
目的:處理 ESP 回報的 bounce/complaint,並回寫本地名單狀態。
|
||||
|
||||
流程:
|
||||
1. SES 透過 SNS/Webhook 回報事件(bounce/complaint/delivery/open/click)。
|
||||
2. Webhook 驗證簽章與來源(SES/SNS 驗證)。
|
||||
1. SES 事件由 Configuration Set 發送至 SNS,再落到 SQS。
|
||||
2. ECS Worker 輪詢 SQS,解析 SNS envelope 與 SES payload。
|
||||
3. 將事件寫入 Inbox(append-only)。
|
||||
4. Consumer 解析事件:
|
||||
- hard bounce → 標記 bounced + 停用
|
||||
- soft bounce → 記錄次數,超過門檻停用
|
||||
- complaint → 立即停用並列入黑名單
|
||||
- hard bounce → 立即標記 blacklisted(同義於 `suppressed`)
|
||||
- soft bounce → 累計次數,達門檻(預設 5)才標記 blacklisted(`suppressed`)
|
||||
- complaint → 立即取消訂閱並標記 blacklisted(`suppressed`)
|
||||
- suppression 事件 → 直接對應為 `suppressed`(即黑名單)
|
||||
5. 更新 List Store 快照與投遞記錄。
|
||||
6. 回寫 Member Center 以停用訂閱(例如 hard bounce / complaint)。
|
||||
6. 回寫 Member Center(僅在以下條件):
|
||||
- hard bounce:已設黑名單
|
||||
- soft bounce:達門檻後設黑名單
|
||||
- complaint:取消訂閱
|
||||
- suppression:設黑名單
|
||||
|
||||
補充:
|
||||
- Unknown event 不應使 worker crash,應記錄後送入 DLQ
|
||||
- Throttle/暫時性網路錯誤使用指數退避重試
|
||||
|
||||
回寫規則:
|
||||
- Send Engine 僅回寫「停用原因」與必要欄位
|
||||
- Member Center 需提供可標註來源的欄位(例如 `disabled_by=send_engine`)
|
||||
- 回寫原因碼固定為:
|
||||
- `hard_bounce`
|
||||
- `soft_bounce_threshold`
|
||||
- `complaint`
|
||||
- `suppression`
|
||||
|
||||
名詞定義:
|
||||
- `blacklisted` 與 `suppressed` 同義,表示此收件者不可再發送
|
||||
|
||||
資料一致性:
|
||||
- 任何狀態改變需保留歷史(append-only events + current snapshot)
|
||||
|
||||
@ -7,10 +7,33 @@
|
||||
- 需要關閉時請設定 `Db__AutoMigrate=false`
|
||||
- 手動執行可用 `dotnet run --project src/SendEngine.Installer -- migrate`
|
||||
- Webhook Auth 初始化(不使用 SQL 檔,改用 Installer):
|
||||
- 若僅需先建立 tenant 基本資料:
|
||||
- `dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id <tenant_uuid> [--tenant-name <name>]`
|
||||
- 使用 Installer 建立 webhook client(`id` 自動隨機產生):
|
||||
- `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> --client-id <client_id> --name <display_name> --scopes <scope1,scope2>`
|
||||
- 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write`
|
||||
- `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> [--tenant-name <name>] --client-id <client_id> --name <display_name> --scopes <scope1,scope2>`
|
||||
- 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --tenant-name "Tenant A" --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write`
|
||||
- 若 tenant 不存在,Installer 會先自動建立 `tenants` 基本資料,避免 webhook 出現 `tenant_not_found`
|
||||
- 建立成功後,Member Center webhook header `X-Client-Id` 請帶回傳的 `id`
|
||||
- 若要自動同步到 Member Center `POST /integrations/send-engine/webhook-clients/upsert`(保留原手動流程):
|
||||
- `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> --client-id <client_id> --name <display_name> --scopes <scope1,scope2> --upsert-member-center --mc-base-url <member_center_base_url> --mc-client-id <oauth_client_id> --mc-client-secret <oauth_client_secret> --mc-scope newsletter:events.write.global`
|
||||
- 可選參數:
|
||||
- `--mc-token-path`(預設 `/oauth/token`)
|
||||
- `--mc-upsert-path`(預設 `/integrations/send-engine/webhook-clients/upsert`)
|
||||
- `--mc-token-url` / `--mc-upsert-url`(使用完整 URL 時可覆蓋 path 組合)
|
||||
- Webhook 驗證規則為 tenant 綁定:`auth_clients.tenant_id` 必須等於 payload `tenant_id`
|
||||
- 不支援 `X-Client-Id` fallback
|
||||
- 預設拒絕 `tenant_id = NULL` 的通用 client(`Webhook__AllowNullTenantClient=false`)
|
||||
- Member Center 回寫授權(建議):
|
||||
- `MemberCenter__BaseUrl`(建議)
|
||||
- `MemberCenter__DisableSubscriptionPath`(預設 `/subscriptions/disable`)
|
||||
- `MemberCenter__TokenPath`(預設 `/oauth/token`)
|
||||
- `MemberCenter__ClientId`
|
||||
- `MemberCenter__ClientSecret`
|
||||
- `MemberCenter__Scope=newsletter:events.write`
|
||||
- `MemberCenter__DisableSubscriptionUrl` 與 `MemberCenter__TokenUrl` 可用完整 URL 覆蓋(fallback)
|
||||
- `MemberCenter__ApiToken` 僅作暫時 fallback(非首選)
|
||||
- 本機測試輔助(臨時):
|
||||
- `TestFriendly__Enabled=true` 時:
|
||||
- webhook 收到未知 tenant 會自動建立 tenant
|
||||
- `/webhooks/ses` 不做任何 DB 存取(僅用於測試流程打通)
|
||||
- 正式環境建議維持 `false`
|
||||
|
||||
@ -40,6 +40,10 @@ scope 最小化:
|
||||
- `newsletter:events.write`(停用回寫)
|
||||
- `newsletter:list.read`(若未來仍需查詢)
|
||||
|
||||
實作約定:
|
||||
- 優先走 token endpoint(client credentials)
|
||||
- `ApiToken` 僅作暫時 fallback(建議逐步移除)
|
||||
|
||||
## 通用欄位
|
||||
### Timestamp
|
||||
- 欄位:`occurred_at`
|
||||
@ -157,6 +161,8 @@ Request Body:
|
||||
- `subject` 必填,最小長度 1
|
||||
- `body_html` / `body_text` / `template` 至少擇一
|
||||
- `window_start` 必須小於 `window_end`(若有提供)
|
||||
- `tenant_id` 必須已存在(不存在回 `422 tenant_not_found`)
|
||||
- `list_id` 若不存在,會在該 tenant 下自動建立 placeholder list 後建立 send job
|
||||
|
||||
Response:
|
||||
```json
|
||||
@ -166,6 +172,35 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
### 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`
|
||||
|
||||
### D. 查詢 Send Job
|
||||
Endpoint:
|
||||
- `GET /api/send-jobs/{id}`
|
||||
@ -204,9 +239,13 @@ Response:
|
||||
|
||||
## Webhook:SES → Send Engine
|
||||
### F. SES 事件回報
|
||||
用途:接收 bounce/complaint/delivery/open/click 等事件。
|
||||
用途:接收 bounce/hard_bounced/soft_bounced/complaint/suppression/delivery/open/click 等事件。
|
||||
|
||||
Endpoint:
|
||||
推薦架構(正式):
|
||||
- `SES Configuration Set -> SNS -> SQS -> ECS Worker`
|
||||
- 由 Worker 消費事件,不要求對外公開 webhook
|
||||
|
||||
相容模式(可選):
|
||||
- `POST /webhooks/ses`
|
||||
|
||||
驗證:
|
||||
@ -227,6 +266,24 @@ Request Body(示意):
|
||||
Response:
|
||||
- `200 OK`
|
||||
|
||||
事件對應規則(固定):
|
||||
- `hard_bounced`:立即設為黑名單(`suppressed`)
|
||||
- `soft_bounced`:累計達門檻後設為黑名單(`suppressed`)
|
||||
- `complaint`:取消訂閱並回寫 Member Center
|
||||
- `suppression`:設為黑名單(`suppressed`)
|
||||
|
||||
回寫 Member Center 條件:
|
||||
- `hard_bounced`:設黑名單後回寫
|
||||
- `soft_bounced`:達門檻設黑名單後回寫
|
||||
- `complaint`:立即回寫
|
||||
- `suppression`:設黑名單後回寫
|
||||
|
||||
回寫原因碼(固定):
|
||||
- `hard_bounce`
|
||||
- `soft_bounce_threshold`
|
||||
- `complaint`
|
||||
- `suppression`
|
||||
|
||||
## 外部 API:Send Engine → Member Center
|
||||
以下為 Member Center 端提供的 API,非 Send Engine 的 OpenAPI 規格範圍。
|
||||
|
||||
@ -234,7 +291,7 @@ Response:
|
||||
用途:因 hard bounce / complaint 停用訂閱,並在 Member Center 註記來源。
|
||||
|
||||
Endpoint(Member Center 提供):
|
||||
- `POST /api/subscriptions/disable`
|
||||
- `POST /subscriptions/disable`
|
||||
|
||||
Scope:
|
||||
- `newsletter:events.write`
|
||||
@ -257,3 +314,12 @@ Request Body(示意):
|
||||
- `409`:重放或事件重複(nonce / event_id)
|
||||
- `422`:資料格式錯誤
|
||||
- `500`:伺服器內部錯誤
|
||||
|
||||
## Retry 策略(整合規格)
|
||||
- Throttle:指數退避重試
|
||||
- Temporary network error:重試
|
||||
- Hard failure:不重試
|
||||
- Retry 上限可設定(例如 5 次)
|
||||
|
||||
## 相關環境參數
|
||||
- `Bounce__SoftBounceThreshold`:soft bounce 轉黑名單門檻(預設 `5`)
|
||||
|
||||
@ -25,49 +25,25 @@ ALTER TABLE lists
|
||||
ADD CONSTRAINT fk_lists_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id);
|
||||
|
||||
-- Subscribers (per tenant)
|
||||
CREATE TABLE IF NOT EXISTS subscribers (
|
||||
-- List subscriptions (per list, keyed by email)
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
list_id UUID NOT NULL,
|
||||
email CITEXT NOT NULL,
|
||||
external_subscriber_id UUID, -- Member Center subscriber_id
|
||||
status TEXT NOT NULL, -- active/unsubscribed/bounced/complaint
|
||||
preferences JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, email)
|
||||
UNIQUE (list_id, email)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscribers_tenant ON subscribers(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_list ON subscriptions(list_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_external_subscriber ON subscriptions(external_subscriber_id);
|
||||
|
||||
ALTER TABLE subscribers
|
||||
ADD CONSTRAINT fk_subscribers_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id);
|
||||
|
||||
-- List membership snapshot
|
||||
CREATE TABLE IF NOT EXISTS list_members (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
list_id UUID NOT NULL,
|
||||
subscriber_id UUID NOT NULL,
|
||||
status TEXT NOT NULL, -- active/unsubscribed/bounced/complaint
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, list_id, subscriber_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_list_members_tenant ON list_members(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_list_members_list ON list_members(list_id);
|
||||
|
||||
ALTER TABLE list_members
|
||||
ADD CONSTRAINT fk_list_members_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id);
|
||||
|
||||
ALTER TABLE list_members
|
||||
ADD CONSTRAINT fk_list_members_list
|
||||
ALTER TABLE subscriptions
|
||||
ADD CONSTRAINT fk_subscriptions_list
|
||||
FOREIGN KEY (list_id) REFERENCES lists(id);
|
||||
|
||||
ALTER TABLE list_members
|
||||
ADD CONSTRAINT fk_list_members_subscriber
|
||||
FOREIGN KEY (subscriber_id) REFERENCES subscribers(id);
|
||||
|
||||
-- Event inbox (append-only)
|
||||
CREATE TABLE IF NOT EXISTS events_inbox (
|
||||
id UUID PRIMARY KEY,
|
||||
|
||||
@ -11,9 +11,58 @@ 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
|
||||
summary: Create send job (legacy/internal)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
@ -356,6 +405,76 @@ 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:
|
||||
@ -398,7 +517,7 @@ components:
|
||||
format: email
|
||||
status:
|
||||
type: string
|
||||
enum: [active, unsubscribed, bounced, complaint]
|
||||
enum: [active, unsubscribed, bounced, complaint, suppressed]
|
||||
preferences:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@ -436,7 +555,7 @@ components:
|
||||
properties:
|
||||
event_type:
|
||||
type: string
|
||||
enum: [bounce, complaint, delivery, open, click]
|
||||
enum: [bounce, hard_bounced, soft_bounced, complaint, suppression, delivery, open, click]
|
||||
message_id:
|
||||
type: string
|
||||
tenant_id:
|
||||
|
||||
@ -1,40 +1,87 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SendEngine.Api.Models;
|
||||
|
||||
public sealed class SubscriptionEventRequest
|
||||
{
|
||||
[JsonPropertyName("event_id")]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
[JsonPropertyName("event_type")]
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
[JsonPropertyName("list_id")]
|
||||
public Guid ListId { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriber")]
|
||||
public SubscriberPayload Subscriber { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("occurred_at")]
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SubscriberPayload
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("preferences")]
|
||||
public Dictionary<string, object>? Preferences { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FullSyncBatchRequest
|
||||
{
|
||||
[JsonPropertyName("sync_id")]
|
||||
public Guid SyncId { get; set; }
|
||||
|
||||
[JsonPropertyName("batch_no")]
|
||||
public int BatchNo { get; set; }
|
||||
|
||||
[JsonPropertyName("batch_total")]
|
||||
public int BatchTotal { get; set; }
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
[JsonPropertyName("list_id")]
|
||||
public Guid ListId { get; set; }
|
||||
|
||||
[JsonPropertyName("subscribers")]
|
||||
public List<SubscriberPayload> Subscribers { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("occurred_at")]
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SesEventRequest
|
||||
{
|
||||
[JsonPropertyName("event_type")]
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message_id")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bounce_type")]
|
||||
public string? BounceType { get; set; }
|
||||
|
||||
[JsonPropertyName("occurred_at")]
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public Dictionary<string, string>? Tags { get; set; }
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@ -57,13 +58,32 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/webhooks/subscriptions") ||
|
||||
context.Request.Path.StartsWithSegments("/webhooks/lists/full-sync"))
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var rawBody = await reader.ReadToEndAsync();
|
||||
context.Request.Body.Position = 0;
|
||||
context.Items[WebhookValidator.RawBodyItemKey] = rawBody;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }))
|
||||
.WithName("Health")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapPost("/api/send-jobs", async (HttpContext httpContext, CreateSendJobRequest request, SendEngineDbContext db) =>
|
||||
app.MapPost("/api/send-jobs", async (
|
||||
HttpContext httpContext,
|
||||
CreateSendJobRequest request,
|
||||
SendEngineDbContext db,
|
||||
ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("SendEngine.Api.SendJobs");
|
||||
var tenantId = GetTenantId(httpContext.User);
|
||||
if (tenantId is null)
|
||||
{
|
||||
@ -104,6 +124,29 @@ app.MapPost("/api/send-jobs", async (HttpContext httpContext, CreateSendJobReque
|
||||
return Results.UnprocessableEntity(new { error = "window_invalid" });
|
||||
}
|
||||
|
||||
var tenantExists = await db.Tenants.AsNoTracking().AnyAsync(x => x.Id == request.TenantId);
|
||||
if (!tenantExists)
|
||||
{
|
||||
return Results.UnprocessableEntity(new { error = "tenant_not_found" });
|
||||
}
|
||||
|
||||
var listExists = await db.Lists.AsNoTracking()
|
||||
.AnyAsync(x => x.Id == request.ListId && x.TenantId == request.TenantId);
|
||||
if (!listExists)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"CreateSendJob auto-created list because list was missing. tenant_id={TenantId} list_id={ListId}",
|
||||
request.TenantId,
|
||||
request.ListId);
|
||||
db.Lists.Add(new MailingList
|
||||
{
|
||||
Id = request.ListId,
|
||||
TenantId = request.TenantId,
|
||||
Name = $"list-{request.ListId:N}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
var campaign = new Campaign
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@ -191,15 +234,23 @@ app.MapPost("/api/send-jobs/{id:guid}/cancel", async (HttpContext httpContext, G
|
||||
return Results.Ok(new SendJobStatusResponse { Id = sendJob.Id, Status = sendJob.Status });
|
||||
}).RequireAuthorization().WithName("CancelSendJob").WithOpenApi();
|
||||
|
||||
app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, SubscriptionEventRequest request, SendEngineDbContext db) =>
|
||||
app.MapPost("/webhooks/subscriptions", async (
|
||||
HttpContext httpContext,
|
||||
SubscriptionEventRequest request,
|
||||
SendEngineDbContext db,
|
||||
ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("SendEngine.Webhooks.Subscriptions");
|
||||
|
||||
if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.Subscriber.Id == Guid.Empty)
|
||||
{
|
||||
logger.LogWarning("Subscription webhook rejected: tenant_id/list_id/subscriber.id required.");
|
||||
return Results.UnprocessableEntity(new { error = "tenant_id_list_id_subscriber_id_required" });
|
||||
}
|
||||
|
||||
if (!IsSupportedSubscriptionEvent(request.EventType))
|
||||
{
|
||||
logger.LogWarning("Subscription webhook rejected: unsupported_event_type={EventType}", request.EventType);
|
||||
return Results.UnprocessableEntity(new { error = "unsupported_event_type" });
|
||||
}
|
||||
|
||||
@ -212,12 +263,45 @@ app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, Subscript
|
||||
secret,
|
||||
skewSeconds,
|
||||
request.TenantId,
|
||||
allowNullTenantClient);
|
||||
allowNullTenantClient,
|
||||
"newsletter:events.write");
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
var reason = httpContext.Items.TryGetValue(WebhookValidator.FailureReasonItemKey, out var value)
|
||||
? value?.ToString()
|
||||
: "unknown";
|
||||
var statusCode = httpContext.Items.TryGetValue(WebhookValidator.FailureStatusCodeItemKey, out var statusValue) &&
|
||||
statusValue is int code
|
||||
? code
|
||||
: StatusCodes.Status401Unauthorized;
|
||||
logger.LogWarning(
|
||||
"Subscription webhook auth validation failed. tenant_id={TenantId} event_type={EventType} reason={Reason}",
|
||||
request.TenantId,
|
||||
request.EventType,
|
||||
reason);
|
||||
return Results.Json(
|
||||
new
|
||||
{
|
||||
error = reason == "replay_detected" ? "replay_detected" : "webhook_auth_failed",
|
||||
reason
|
||||
},
|
||||
statusCode: statusCode);
|
||||
}
|
||||
|
||||
var testFriendlyEnabled = builder.Configuration.GetValue("TestFriendly:Enabled", false);
|
||||
if (!await EnsureTenantForWebhookAsync(db, request.TenantId, testFriendlyEnabled))
|
||||
{
|
||||
logger.LogWarning("Subscription webhook rejected: tenant_not_found. tenant_id={TenantId}", request.TenantId);
|
||||
return Results.UnprocessableEntity(new { error = "tenant_not_found" });
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Subscription webhook processing started. tenant_id={TenantId} list_id={ListId} subscriber_id={SubscriberId} event_type={EventType}",
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
request.Subscriber.Id,
|
||||
request.EventType);
|
||||
|
||||
var payload = JsonSerializer.Serialize(request);
|
||||
|
||||
var inbox = new EventInbox
|
||||
@ -244,16 +328,36 @@ app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, Subscript
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Subscription webhook conflict. tenant_id={TenantId} list_id={ListId} subscriber_id={SubscriberId} event_type={EventType}",
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
request.Subscriber.Id,
|
||||
request.EventType);
|
||||
return Results.Conflict(new { error = "duplicate_event_or_unique_violation" });
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Subscription webhook processed. tenant_id={TenantId} list_id={ListId} subscriber_id={SubscriberId} event_type={EventType}",
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
request.Subscriber.Id,
|
||||
request.EventType);
|
||||
|
||||
return Results.Ok();
|
||||
}).WithName("SubscriptionWebhook").WithOpenApi();
|
||||
|
||||
app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyncBatchRequest request, SendEngineDbContext db) =>
|
||||
app.MapPost("/webhooks/lists/full-sync", async (
|
||||
HttpContext httpContext,
|
||||
FullSyncBatchRequest request,
|
||||
SendEngineDbContext db,
|
||||
ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("SendEngine.Webhooks.FullSync");
|
||||
|
||||
if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty)
|
||||
{
|
||||
logger.LogWarning("Full-sync webhook rejected: tenant_id/list_id required.");
|
||||
return Results.UnprocessableEntity(new { error = "tenant_id_list_id_required" });
|
||||
}
|
||||
|
||||
@ -266,12 +370,48 @@ app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyn
|
||||
secret,
|
||||
skewSeconds,
|
||||
request.TenantId,
|
||||
allowNullTenantClient);
|
||||
allowNullTenantClient,
|
||||
"newsletter:events.write");
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
var reason = httpContext.Items.TryGetValue(WebhookValidator.FailureReasonItemKey, out var value)
|
||||
? value?.ToString()
|
||||
: "unknown";
|
||||
var statusCode = httpContext.Items.TryGetValue(WebhookValidator.FailureStatusCodeItemKey, out var statusValue) &&
|
||||
statusValue is int code
|
||||
? code
|
||||
: StatusCodes.Status401Unauthorized;
|
||||
logger.LogWarning(
|
||||
"Full-sync webhook auth validation failed. tenant_id={TenantId} list_id={ListId} sync_id={SyncId} reason={Reason}",
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
request.SyncId,
|
||||
reason);
|
||||
return Results.Json(
|
||||
new
|
||||
{
|
||||
error = reason == "replay_detected" ? "replay_detected" : "webhook_auth_failed",
|
||||
reason
|
||||
},
|
||||
statusCode: statusCode);
|
||||
}
|
||||
|
||||
var testFriendlyEnabled = builder.Configuration.GetValue("TestFriendly:Enabled", false);
|
||||
if (!await EnsureTenantForWebhookAsync(db, request.TenantId, testFriendlyEnabled))
|
||||
{
|
||||
logger.LogWarning("Full-sync webhook rejected: tenant_not_found. tenant_id={TenantId}", request.TenantId);
|
||||
return Results.UnprocessableEntity(new { error = "tenant_not_found" });
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Full-sync webhook processing started. tenant_id={TenantId} list_id={ListId} sync_id={SyncId} batch={BatchNo}/{BatchTotal} subscriber_count={SubscriberCount}",
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
request.SyncId,
|
||||
request.BatchNo,
|
||||
request.BatchTotal,
|
||||
request.Subscribers.Count);
|
||||
|
||||
var payload = JsonSerializer.Serialize(request);
|
||||
|
||||
var inbox = new EventInbox
|
||||
@ -295,9 +435,8 @@ app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyn
|
||||
continue;
|
||||
}
|
||||
|
||||
await UpsertSubscriberAndListMemberAsync(
|
||||
await UpsertSubscriptionAsync(
|
||||
db,
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
subscriber.Id,
|
||||
subscriber.Email,
|
||||
@ -309,25 +448,114 @@ app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyn
|
||||
inbox.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"Full-sync webhook processed. tenant_id={TenantId} list_id={ListId} sync_id={SyncId} batch={BatchNo}/{BatchTotal}",
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
request.SyncId,
|
||||
request.BatchNo,
|
||||
request.BatchTotal);
|
||||
|
||||
return Results.Ok();
|
||||
}).WithName("FullSyncWebhook").WithOpenApi();
|
||||
|
||||
app.MapPost("/webhooks/ses", async (HttpContext httpContext, SesEventRequest request, SendEngineDbContext db) =>
|
||||
app.MapPost("/webhooks/ses", async (
|
||||
HttpContext httpContext,
|
||||
SesEventRequest request,
|
||||
SendEngineDbContext db,
|
||||
ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("SendEngine.Webhooks.Ses");
|
||||
|
||||
if (request.TenantId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
|
||||
{
|
||||
logger.LogWarning("SES webhook rejected: tenant_id or email missing.");
|
||||
return Results.UnprocessableEntity(new { error = "tenant_id_email_required" });
|
||||
}
|
||||
|
||||
var skipValidation = builder.Configuration.GetValue("Ses:SkipSignatureValidation", true);
|
||||
logger.LogInformation(
|
||||
"SES webhook received. Ses__SkipSignatureValidation={SkipValidation}",
|
||||
skipValidation);
|
||||
|
||||
var sesSignature = httpContext.Request.Headers["X-Amz-Sns-Signature"].ToString();
|
||||
if (!skipValidation && string.IsNullOrWhiteSpace(sesSignature))
|
||||
{
|
||||
logger.LogWarning("SES webhook rejected: missing X-Amz-Sns-Signature while signature validation is enabled.");
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var normalizedEventType = NormalizeSesEventType(request.EventType, request.BounceType);
|
||||
request.Email = request.Email.Trim().ToLowerInvariant();
|
||||
request.EventType = normalizedEventType;
|
||||
|
||||
// TEST-FRIENDLY TEMPORARY LOGIC:
|
||||
// In local integration testing, skip DB operations but keep callback flow to Member Center.
|
||||
// TODO(remove-test-friendly): Remove this branch once end-to-end DB flow is stable.
|
||||
var testFriendlyEnabled = builder.Configuration.GetValue("TestFriendly:Enabled", false);
|
||||
if (testFriendlyEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[TEST-FRIENDLY][SES] received event_type={0} tenant_id={1} email={2} skip_signature_validation={3}",
|
||||
normalizedEventType,
|
||||
request.TenantId,
|
||||
request.Email,
|
||||
skipValidation);
|
||||
logger.LogWarning("TEST-FRIENDLY enabled: skip DB operations for /webhooks/ses, keep callback flow.");
|
||||
|
||||
if (TryMapDisableReason(normalizedEventType, out var reason))
|
||||
{
|
||||
if (!TryExtractGuidFromTags(request.Tags, "subscriber_id", out var subscriberId) ||
|
||||
!TryExtractGuidFromTags(request.Tags, "list_id", out var listId))
|
||||
{
|
||||
return Results.UnprocessableEntity(new
|
||||
{
|
||||
error = "test_friendly_tags_required",
|
||||
message = "tags.subscriber_id and tags.list_id must be UUID strings when TestFriendly is enabled."
|
||||
});
|
||||
}
|
||||
|
||||
var shouldNotify = true;
|
||||
if (normalizedEventType == "soft_bounced")
|
||||
{
|
||||
var threshold = builder.Configuration.GetValue("Bounce:SoftBounceThreshold", 5);
|
||||
shouldNotify = IsSoftBounceThresholdReachedFromTags(request.Tags, threshold);
|
||||
}
|
||||
|
||||
if (shouldNotify)
|
||||
{
|
||||
await NotifyMemberCenterDisableAsync(
|
||||
builder.Configuration,
|
||||
logger,
|
||||
request.TenantId,
|
||||
new[] { (SubscriberId: subscriberId, ListId: listId) },
|
||||
reason,
|
||||
request.OccurredAt);
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new { status = "ok", mode = "test-friendly-no-db" });
|
||||
}
|
||||
|
||||
if (!await EnsureTenantForWebhookAsync(db, request.TenantId, testFriendlyEnabled))
|
||||
{
|
||||
logger.LogWarning("SES webhook rejected: tenant_not_found. tenant_id={TenantId}", request.TenantId);
|
||||
return Results.UnprocessableEntity(new { error = "tenant_not_found" });
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"SES webhook processing started. mode=normal event_type={EventType} tenant_id={TenantId} email={Email}",
|
||||
normalizedEventType,
|
||||
request.TenantId,
|
||||
request.Email);
|
||||
|
||||
var payload = JsonSerializer.Serialize(request);
|
||||
|
||||
var inbox = new EventInbox
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = request.TenantId,
|
||||
EventType = $"ses.{request.EventType}",
|
||||
EventType = $"ses.{normalizedEventType}",
|
||||
Source = "ses",
|
||||
Payload = payload,
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
@ -337,6 +565,17 @@ app.MapPost("/webhooks/ses", async (HttpContext httpContext, SesEventRequest req
|
||||
db.EventsInbox.Add(inbox);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await ApplySesEventAsync(db, builder.Configuration, logger, request, normalizedEventType);
|
||||
inbox.Status = "processed";
|
||||
inbox.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"SES webhook processed. event_type={EventType} tenant_id={TenantId} email={Email}",
|
||||
normalizedEventType,
|
||||
request.TenantId,
|
||||
request.Email);
|
||||
|
||||
return Results.Ok();
|
||||
}).WithName("SesWebhook").WithOpenApi();
|
||||
|
||||
@ -353,6 +592,33 @@ static bool IsSupportedSubscriptionEvent(string eventType)
|
||||
return eventType is "subscription.activated" or "subscription.unsubscribed" or "preferences.updated";
|
||||
}
|
||||
|
||||
// TEST-FRIENDLY TEMPORARY LOGIC:
|
||||
// When enabled, webhook ingestion can auto-create missing tenants to simplify local end-to-end testing.
|
||||
// TODO(remove-test-friendly): Remove this helper and always require pre-provisioned tenant records.
|
||||
static async Task<bool> EnsureTenantForWebhookAsync(SendEngineDbContext db, Guid tenantId, bool autoCreateTenant)
|
||||
{
|
||||
var tenantExists = await db.Tenants.AsNoTracking().AnyAsync(x => x.Id == tenantId);
|
||||
if (tenantExists)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!autoCreateTenant)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Tenants.Add(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Name = $"tenant-{tenantId:N}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
static async Task EnsureListExistsAsync(SendEngineDbContext db, Guid tenantId, Guid listId)
|
||||
{
|
||||
var listExists = await db.Lists.AsNoTracking()
|
||||
@ -381,9 +647,8 @@ static async Task ApplySubscriptionSnapshotAsync(SendEngineDbContext db, Subscri
|
||||
_ => "active"
|
||||
};
|
||||
|
||||
await UpsertSubscriberAndListMemberAsync(
|
||||
await UpsertSubscriptionAsync(
|
||||
db,
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
request.Subscriber.Id,
|
||||
request.Subscriber.Email,
|
||||
@ -391,74 +656,41 @@ static async Task ApplySubscriptionSnapshotAsync(SendEngineDbContext db, Subscri
|
||||
request.Subscriber.Preferences);
|
||||
}
|
||||
|
||||
static async Task UpsertSubscriberAndListMemberAsync(
|
||||
static async Task UpsertSubscriptionAsync(
|
||||
SendEngineDbContext db,
|
||||
Guid tenantId,
|
||||
Guid listId,
|
||||
Guid subscriberId,
|
||||
Guid externalSubscriberId,
|
||||
string email,
|
||||
string status,
|
||||
Dictionary<string, object>? preferences)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var normalizedEmail = email.Trim().ToLowerInvariant();
|
||||
var effectiveSubscriberId = subscriberId;
|
||||
|
||||
var subscriber = await db.Subscribers
|
||||
.FirstOrDefaultAsync(x => x.Id == subscriberId && x.TenantId == tenantId);
|
||||
if (subscriber is null)
|
||||
{
|
||||
subscriber = await db.Subscribers
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Email == normalizedEmail);
|
||||
if (subscriber is not null)
|
||||
{
|
||||
effectiveSubscriberId = subscriber.Id;
|
||||
}
|
||||
}
|
||||
|
||||
var subscription = await db.Subscriptions
|
||||
.FirstOrDefaultAsync(x => x.ListId == listId && x.Email == normalizedEmail);
|
||||
var preferenceJson = preferences is null ? null : JsonSerializer.Serialize(preferences);
|
||||
if (subscriber is null)
|
||||
if (subscription is null)
|
||||
{
|
||||
subscriber = new Subscriber
|
||||
subscription = new Subscription
|
||||
{
|
||||
Id = subscriberId,
|
||||
TenantId = tenantId,
|
||||
Id = Guid.NewGuid(),
|
||||
ListId = listId,
|
||||
Email = normalizedEmail,
|
||||
ExternalSubscriberId = externalSubscriberId == Guid.Empty ? null : externalSubscriberId,
|
||||
Status = status,
|
||||
Preferences = preferenceJson,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
db.Subscribers.Add(subscriber);
|
||||
db.Subscriptions.Add(subscription);
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriber.Email = normalizedEmail;
|
||||
subscriber.Status = status;
|
||||
subscriber.Preferences = preferenceJson;
|
||||
subscriber.UpdatedAt = now;
|
||||
}
|
||||
|
||||
var listMember = await db.ListMembers
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.ListId == listId && x.SubscriberId == effectiveSubscriberId);
|
||||
if (listMember is null)
|
||||
{
|
||||
listMember = new ListMember
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ListId = listId,
|
||||
SubscriberId = effectiveSubscriberId,
|
||||
Status = status,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
db.ListMembers.Add(listMember);
|
||||
}
|
||||
else
|
||||
{
|
||||
listMember.Status = status;
|
||||
listMember.UpdatedAt = now;
|
||||
subscription.Email = normalizedEmail;
|
||||
subscription.ExternalSubscriberId = externalSubscriberId == Guid.Empty ? subscription.ExternalSubscriberId : externalSubscriberId;
|
||||
subscription.Status = status;
|
||||
subscription.Preferences = preferenceJson;
|
||||
subscription.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
@ -476,6 +708,428 @@ static string NormalizeStatus(string? status, string fallback)
|
||||
"unsubscribed" => "unsubscribed",
|
||||
"bounced" => "bounced",
|
||||
"complaint" => "complaint",
|
||||
"suppressed" => "suppressed",
|
||||
_ => fallback
|
||||
};
|
||||
}
|
||||
|
||||
static string NormalizeSesEventType(string? eventType, string? bounceType)
|
||||
{
|
||||
var normalized = eventType?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
if (normalized == "bounce")
|
||||
{
|
||||
var bounce = bounceType?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
return bounce switch
|
||||
{
|
||||
"hard" => "hard_bounced",
|
||||
"soft" => "soft_bounced",
|
||||
_ => "bounce"
|
||||
};
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static bool TryMapDisableReason(string normalizedEventType, out string reason)
|
||||
{
|
||||
switch (normalizedEventType)
|
||||
{
|
||||
case "hard_bounced":
|
||||
reason = "hard_bounce";
|
||||
return true;
|
||||
case "soft_bounced":
|
||||
reason = "soft_bounce_threshold";
|
||||
return true;
|
||||
case "complaint":
|
||||
reason = "complaint";
|
||||
return true;
|
||||
case "suppression":
|
||||
reason = "suppression";
|
||||
return true;
|
||||
default:
|
||||
reason = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool TryExtractGuidFromTags(Dictionary<string, string>? tags, string key, out Guid value)
|
||||
{
|
||||
value = Guid.Empty;
|
||||
if (tags is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keyCandidates = new[]
|
||||
{
|
||||
key,
|
||||
key.ToLowerInvariant(),
|
||||
key.ToUpperInvariant(),
|
||||
key.Replace("_", string.Empty),
|
||||
ToCamelCase(key)
|
||||
};
|
||||
|
||||
foreach (var candidate in keyCandidates)
|
||||
{
|
||||
if (tags.TryGetValue(candidate, out var raw) && Guid.TryParse(raw, out value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static string ToCamelCase(string value)
|
||||
{
|
||||
var parts = value.Split('_', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return parts[0] + string.Concat(parts.Skip(1).Select(p => char.ToUpperInvariant(p[0]) + p[1..]));
|
||||
}
|
||||
|
||||
static bool IsSoftBounceThresholdReachedFromTags(Dictionary<string, string>? tags, int threshold)
|
||||
{
|
||||
if (threshold < 1)
|
||||
{
|
||||
threshold = 1;
|
||||
}
|
||||
|
||||
if (tags is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tags.TryGetValue("soft_bounce_count", out var raw) && int.TryParse(raw, out var count))
|
||||
{
|
||||
return count >= threshold;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static async Task ApplySesEventAsync(
|
||||
SendEngineDbContext db,
|
||||
IConfiguration configuration,
|
||||
ILogger logger,
|
||||
SesEventRequest request,
|
||||
string normalizedEventType)
|
||||
{
|
||||
var subscriptions = await db.Subscriptions
|
||||
.Join(
|
||||
db.Lists.AsNoTracking(),
|
||||
s => s.ListId,
|
||||
l => l.Id,
|
||||
(s, l) => new { Subscription = s, ListTenantId = l.TenantId })
|
||||
.Where(x => x.ListTenantId == request.TenantId && x.Subscription.Email == request.Email)
|
||||
.Select(x => x.Subscription)
|
||||
.ToListAsync();
|
||||
if (subscriptions.Count == 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SES event ignored: subscription not found. tenant_id={TenantId} email={Email} event_type={EventType}",
|
||||
request.TenantId,
|
||||
request.Email,
|
||||
normalizedEventType);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"SES event matched subscriptions. tenant_id={TenantId} email={Email} matched_count={MatchedCount} event_type={EventType}",
|
||||
request.TenantId,
|
||||
request.Email,
|
||||
subscriptions.Count,
|
||||
normalizedEventType);
|
||||
|
||||
switch (normalizedEventType)
|
||||
{
|
||||
case "hard_bounced":
|
||||
await SuppressAndNotifyAsync(db, configuration, logger, request.TenantId, subscriptions, "hard_bounce", request.OccurredAt);
|
||||
return;
|
||||
case "soft_bounced":
|
||||
var threshold = configuration.GetValue("Bounce:SoftBounceThreshold", 5);
|
||||
var reached = await IsSoftBounceThresholdReachedAsync(db, request.TenantId, request.Email, threshold);
|
||||
if (reached)
|
||||
{
|
||||
await SuppressAndNotifyAsync(db, configuration, logger, request.TenantId, subscriptions, "soft_bounce_threshold", request.OccurredAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Soft bounce threshold not reached yet. tenant_id={TenantId} email={Email} threshold={Threshold}",
|
||||
request.TenantId,
|
||||
request.Email,
|
||||
threshold);
|
||||
}
|
||||
return;
|
||||
case "complaint":
|
||||
await SuppressAndNotifyAsync(db, configuration, logger, request.TenantId, subscriptions, "complaint", request.OccurredAt);
|
||||
return;
|
||||
case "suppression":
|
||||
await SuppressAndNotifyAsync(db, configuration, logger, request.TenantId, subscriptions, "suppression", request.OccurredAt);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<bool> IsSoftBounceThresholdReachedAsync(
|
||||
SendEngineDbContext db,
|
||||
Guid tenantId,
|
||||
string normalizedEmail,
|
||||
int threshold)
|
||||
{
|
||||
if (threshold < 1)
|
||||
{
|
||||
threshold = 1;
|
||||
}
|
||||
|
||||
var count = await db.Database.SqlQuery<int>($"""
|
||||
SELECT count(*)::int AS "Value"
|
||||
FROM events_inbox
|
||||
WHERE tenant_id = {tenantId}
|
||||
AND source = 'ses'
|
||||
AND event_type = 'ses.soft_bounced'
|
||||
AND payload->>'email' = {normalizedEmail}
|
||||
""").SingleAsync();
|
||||
|
||||
return count >= threshold;
|
||||
}
|
||||
|
||||
static async Task SuppressAndNotifyAsync(
|
||||
SendEngineDbContext db,
|
||||
IConfiguration configuration,
|
||||
ILogger logger,
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<Subscription> subscriptions,
|
||||
string reason,
|
||||
DateTimeOffset occurredAt)
|
||||
{
|
||||
if (subscriptions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var subscription in subscriptions)
|
||||
{
|
||||
subscription.Status = "suppressed";
|
||||
subscription.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var notifyTargets = subscriptions
|
||||
.Where(x => x.ExternalSubscriberId.HasValue)
|
||||
.Select(x => (SubscriberId: x.ExternalSubscriberId!.Value, ListId: x.ListId))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
logger.LogInformation(
|
||||
"Subscriptions suppressed. tenant_id={TenantId} reason={Reason} matched_count={MatchedCount} notify_target_count={NotifyCount}",
|
||||
tenantId,
|
||||
reason,
|
||||
subscriptions.Count,
|
||||
notifyTargets.Length);
|
||||
await NotifyMemberCenterDisableAsync(configuration, logger, tenantId, notifyTargets, reason, occurredAt);
|
||||
}
|
||||
|
||||
static async Task NotifyMemberCenterDisableAsync(
|
||||
IConfiguration configuration,
|
||||
ILogger logger,
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<(Guid SubscriberId, Guid ListId)> targets,
|
||||
string reason,
|
||||
DateTimeOffset occurredAt)
|
||||
{
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
logger.LogWarning("MemberCenter callback skipped: no disable targets.");
|
||||
return;
|
||||
}
|
||||
|
||||
var url = ResolveMemberCenterUrl(
|
||||
configuration,
|
||||
"MemberCenter:DisableSubscriptionUrl",
|
||||
"MemberCenter:BaseUrl",
|
||||
"MemberCenter:DisableSubscriptionPath",
|
||||
"/subscriptions/disable");
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
logger.LogWarning("MemberCenter callback skipped: URL is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var client = new HttpClient();
|
||||
var token = await ResolveMemberCenterAccessTokenAsync(configuration, client, logger);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
logger.LogWarning("MemberCenter callback skipped: access token is empty.");
|
||||
return;
|
||||
}
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", token);
|
||||
logger.LogInformation(
|
||||
"MemberCenter callback prepared. url={Url} auth_header={AuthHeader}",
|
||||
url,
|
||||
BuildMaskedAuthHeader(token));
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
tenant_id = tenantId,
|
||||
subscriber_id = target.SubscriberId,
|
||||
list_id = target.ListId,
|
||||
reason,
|
||||
disabled_by = "send_engine",
|
||||
occurred_at = occurredAt
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var payloadJson = JsonSerializer.Serialize(payload);
|
||||
logger.LogInformation(
|
||||
"MemberCenter callback request. method=POST url={Url} headers={Headers} body={Body}",
|
||||
url,
|
||||
$"Authorization:{BuildMaskedAuthHeader(token)}; Content-Type:application/json",
|
||||
payloadJson);
|
||||
|
||||
using var response = await client.PostAsJsonAsync(url, payload);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
logger.LogInformation(
|
||||
"MemberCenter callback response. status={StatusCode} body={Body}",
|
||||
(int)response.StatusCode,
|
||||
Truncate(responseBody, 1000));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "MemberCenter callback failed. url={Url} list_id={ListId}", url, target.ListId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<string?> ResolveMemberCenterAccessTokenAsync(IConfiguration configuration, HttpClient client, ILogger logger)
|
||||
{
|
||||
var tokenUrl = ResolveMemberCenterUrl(
|
||||
configuration,
|
||||
"MemberCenter:TokenUrl",
|
||||
"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
|
||||
{
|
||||
logger.LogInformation(
|
||||
"MemberCenter token request. url={TokenUrl} client_id={ClientId} scope={Scope}",
|
||||
tokenUrl,
|
||||
clientId,
|
||||
scope ?? string.Empty);
|
||||
using var response = await client.PostAsync(tokenUrl, new FormUrlEncodedContent(form));
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var json = await JsonDocument.ParseAsync(stream);
|
||||
if (json.RootElement.TryGetProperty("access_token", out var tokenElement))
|
||||
{
|
||||
var accessToken = tokenElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
logger.LogInformation("MemberCenter token request succeeded.");
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
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 error, fallback to static token.");
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackToken = configuration["MemberCenter:ApiToken"];
|
||||
if (!string.IsNullOrWhiteSpace(fallbackToken))
|
||||
{
|
||||
logger.LogWarning("Using MemberCenter__ApiToken fallback.");
|
||||
}
|
||||
|
||||
return fallbackToken;
|
||||
}
|
||||
|
||||
static string? ResolveMemberCenterUrl(
|
||||
IConfiguration configuration,
|
||||
string fullUrlKey,
|
||||
string baseUrlKey,
|
||||
string pathKey,
|
||||
string defaultPath)
|
||||
{
|
||||
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('/')}";
|
||||
}
|
||||
|
||||
static string BuildMaskedAuthHeader(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return "Bearer <empty>";
|
||||
}
|
||||
|
||||
var visible = token.Length <= 8 ? token : token[^8..];
|
||||
return $"Bearer ***{visible}";
|
||||
}
|
||||
|
||||
static string Truncate(string? input, int maxLen)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return input.Length <= maxLen ? input : $"{input[..maxLen]}...(truncated)";
|
||||
}
|
||||
|
||||
@ -8,16 +8,23 @@ namespace SendEngine.Api.Security;
|
||||
|
||||
public static class WebhookValidator
|
||||
{
|
||||
public const string FailureReasonItemKey = "webhook_auth_failure_reason";
|
||||
public const string FailureStatusCodeItemKey = "webhook_auth_failure_status_code";
|
||||
public const string RawBodyItemKey = "webhook_raw_body";
|
||||
|
||||
public static async Task<IResult?> ValidateAsync(
|
||||
HttpContext context,
|
||||
SendEngineDbContext db,
|
||||
string secret,
|
||||
int maxSkewSeconds,
|
||||
Guid payloadTenantId,
|
||||
bool allowNullTenantClient)
|
||||
bool allowNullTenantClient,
|
||||
params string[] requiredAnyScopes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "server_secret_missing";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status500InternalServerError;
|
||||
return Results.StatusCode(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
var signature = context.Request.Headers["X-Signature"].ToString();
|
||||
@ -28,60 +35,96 @@ public static class WebhookValidator
|
||||
if (string.IsNullOrWhiteSpace(signature) || string.IsNullOrWhiteSpace(timestampHeader)
|
||||
|| string.IsNullOrWhiteSpace(nonce) || string.IsNullOrWhiteSpace(clientIdHeader))
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "missing_required_headers";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status401Unauthorized;
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!long.TryParse(timestampHeader, out var timestampSeconds))
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "invalid_timestamp";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status401Unauthorized;
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var nowSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
if (Math.Abs(nowSeconds - timestampSeconds) > maxSkewSeconds)
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "timestamp_out_of_skew";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status401Unauthorized;
|
||||
return Results.StatusCode(StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(clientIdHeader, out var clientId))
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "invalid_client_id_header";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status401Unauthorized;
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var authClient = await db.AuthClients.AsNoTracking().FirstOrDefaultAsync(x => x.Id == clientId);
|
||||
if (authClient is null)
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "auth_client_not_found";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status403Forbidden;
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (!string.Equals(authClient.Status, "active", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "auth_client_inactive";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status403Forbidden;
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (authClient.TenantId is null && !allowNullTenantClient)
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "null_tenant_client_forbidden";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status403Forbidden;
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (authClient.TenantId is not null && authClient.TenantId.Value != payloadTenantId)
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "tenant_mismatch";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status403Forbidden;
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
context.Request.EnableBuffering();
|
||||
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
context.Request.Body.Position = 0;
|
||||
if (requiredAnyScopes.Length > 0)
|
||||
{
|
||||
var granted = authClient.Scopes ?? Array.Empty<string>();
|
||||
var matched = granted
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Intersect(requiredAnyScopes, StringComparer.OrdinalIgnoreCase)
|
||||
.Any();
|
||||
if (!matched)
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "scope_missing";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status403Forbidden;
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
var body = context.Items.TryGetValue(RawBodyItemKey, out var rawBodyValue) &&
|
||||
rawBodyValue is string rawBody
|
||||
? rawBody
|
||||
: await ReadBodyAsync(context);
|
||||
|
||||
var expected = ComputeHmacHex(secret, body);
|
||||
if (!FixedTimeEquals(expected, signature))
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "invalid_signature";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status401Unauthorized;
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var hasNonce = await db.WebhookNonces.AsNoTracking().AnyAsync(x => x.ClientId == clientId && x.Nonce == nonce);
|
||||
if (hasNonce)
|
||||
{
|
||||
context.Items[FailureReasonItemKey] = "replay_detected";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status409Conflict;
|
||||
return Results.Conflict(new { error = "replay_detected" });
|
||||
}
|
||||
|
||||
@ -95,6 +138,8 @@ public static class WebhookValidator
|
||||
|
||||
db.WebhookNonces.Add(nonceEntry);
|
||||
await db.SaveChangesAsync();
|
||||
context.Items[FailureReasonItemKey] = "ok";
|
||||
context.Items[FailureStatusCodeItemKey] = StatusCodes.Status200OK;
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -113,4 +158,14 @@ public static class WebhookValidator
|
||||
var bBytes = Encoding.UTF8.GetBytes(b);
|
||||
return aBytes.Length == bBytes.Length && CryptographicOperations.FixedTimeEquals(aBytes, bBytes);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadBodyAsync(HttpContext context)
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
context.Request.Body.Position = 0;
|
||||
return body;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -6,11 +6,15 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.23" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.23" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SendEngine.Application\SendEngine.Application.csproj" />
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
namespace SendEngine.Domain.Entities;
|
||||
|
||||
public sealed class ListMember
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid ListId { get; set; }
|
||||
public Guid SubscriberId { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
namespace SendEngine.Domain.Entities;
|
||||
|
||||
public sealed class Subscriber
|
||||
public sealed class Subscription
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid ListId { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public Guid? ExternalSubscriberId { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? Preferences { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
@ -12,8 +12,8 @@ using SendEngine.Infrastructure.Data;
|
||||
namespace SendEngine.Infrastructure.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(SendEngineDbContext))]
|
||||
[Migration("20260210083240_Initial")]
|
||||
partial class Initial
|
||||
[Migration("20260219074637_20260219_Rebaseline")]
|
||||
partial class _20260219_Rebaseline
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@ -257,54 +257,6 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
b.ToTable("events_inbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("ListId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("SubscriberId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("subscriber_id");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_list_members_list");
|
||||
|
||||
b.HasIndex("SubscriberId");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("idx_list_members_tenant");
|
||||
|
||||
b.HasIndex("TenantId", "ListId", "SubscriberId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("list_members", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.MailingList", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -437,7 +389,7 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
b.ToTable("send_jobs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Subscriber", b =>
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Subscription", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@ -453,6 +405,14 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
.HasColumnType("citext")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<Guid?>("ExternalSubscriberId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("external_subscriber_id");
|
||||
|
||||
b.Property<Guid>("ListId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("Preferences")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("preferences");
|
||||
@ -462,23 +422,22 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("idx_subscribers_tenant");
|
||||
b.HasIndex("ExternalSubscriberId")
|
||||
.HasDatabaseName("idx_subscriptions_external_subscriber");
|
||||
|
||||
b.HasIndex("TenantId", "Email")
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_subscriptions_list");
|
||||
|
||||
b.HasIndex("ListId", "Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("subscribers", (string)null);
|
||||
b.ToTable("subscriptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Tenant", b =>
|
||||
@ -586,30 +545,6 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
.HasConstraintName("fk_events_inbox_tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b =>
|
||||
{
|
||||
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_list_members_list");
|
||||
|
||||
b.HasOne("SendEngine.Domain.Entities.Subscriber", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriberId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_list_members_subscriber");
|
||||
|
||||
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_list_members_tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.MailingList", b =>
|
||||
{
|
||||
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
|
||||
@ -661,14 +596,14 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
.HasConstraintName("fk_send_jobs_tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Subscriber", b =>
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Subscription", b =>
|
||||
{
|
||||
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
|
||||
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_subscribers_tenant");
|
||||
.HasConstraintName("fk_subscriptions_list");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.WebhookNonce", b =>
|
||||
@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
namespace SendEngine.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
public partial class _20260219_Rebaseline : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
@ -130,29 +130,6 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "subscribers",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
email = table.Column<string>(type: "citext", nullable: false),
|
||||
status = table.Column<string>(type: "text", nullable: false),
|
||||
preferences = table.Column<string>(type: "jsonb", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_subscribers", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_subscribers_tenant",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "campaigns",
|
||||
columns: table => new
|
||||
@ -185,38 +162,27 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "list_members",
|
||||
name: "subscriptions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
list_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
subscriber_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
email = table.Column<string>(type: "citext", nullable: false),
|
||||
external_subscriber_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
status = table.Column<string>(type: "text", nullable: false),
|
||||
preferences = table.Column<string>(type: "jsonb", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_list_members", x => x.id);
|
||||
table.PrimaryKey("PK_subscriptions", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_list_members_list",
|
||||
name: "fk_subscriptions_list",
|
||||
column: x => x.list_id,
|
||||
principalTable: "lists",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_list_members_subscriber",
|
||||
column: x => x.subscriber_id,
|
||||
principalTable: "subscribers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_list_members_tenant",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
@ -363,27 +329,6 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
table: "events_inbox",
|
||||
column: "event_type");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_list_members_list",
|
||||
table: "list_members",
|
||||
column: "list_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_list_members_tenant",
|
||||
table: "list_members",
|
||||
column: "tenant_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_list_members_subscriber_id",
|
||||
table: "list_members",
|
||||
column: "subscriber_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_list_members_tenant_id_list_id_subscriber_id",
|
||||
table: "list_members",
|
||||
columns: new[] { "tenant_id", "list_id", "subscriber_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_lists_tenant",
|
||||
table: "lists",
|
||||
@ -425,14 +370,19 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
column: "list_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_subscribers_tenant",
|
||||
table: "subscribers",
|
||||
column: "tenant_id");
|
||||
name: "idx_subscriptions_external_subscriber",
|
||||
table: "subscriptions",
|
||||
column: "external_subscriber_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_subscribers_tenant_id_email",
|
||||
table: "subscribers",
|
||||
columns: new[] { "tenant_id", "email" },
|
||||
name: "idx_subscriptions_list",
|
||||
table: "subscriptions",
|
||||
column: "list_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_subscriptions_list_id_email",
|
||||
table: "subscriptions",
|
||||
columns: new[] { "list_id", "email" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
@ -459,17 +409,14 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
migrationBuilder.DropTable(
|
||||
name: "events_inbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "list_members");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "send_batches");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "webhook_nonces");
|
||||
name: "subscriptions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "subscribers");
|
||||
name: "webhook_nonces");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "send_jobs");
|
||||
@ -254,54 +254,6 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
b.ToTable("events_inbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("ListId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("SubscriberId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("subscriber_id");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_list_members_list");
|
||||
|
||||
b.HasIndex("SubscriberId");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("idx_list_members_tenant");
|
||||
|
||||
b.HasIndex("TenantId", "ListId", "SubscriberId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("list_members", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.MailingList", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -434,7 +386,7 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
b.ToTable("send_jobs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Subscriber", b =>
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Subscription", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@ -450,6 +402,14 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
.HasColumnType("citext")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<Guid?>("ExternalSubscriberId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("external_subscriber_id");
|
||||
|
||||
b.Property<Guid>("ListId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("Preferences")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("preferences");
|
||||
@ -459,23 +419,22 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("idx_subscribers_tenant");
|
||||
b.HasIndex("ExternalSubscriberId")
|
||||
.HasDatabaseName("idx_subscriptions_external_subscriber");
|
||||
|
||||
b.HasIndex("TenantId", "Email")
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_subscriptions_list");
|
||||
|
||||
b.HasIndex("ListId", "Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("subscribers", (string)null);
|
||||
b.ToTable("subscriptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Tenant", b =>
|
||||
@ -583,30 +542,6 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
.HasConstraintName("fk_events_inbox_tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b =>
|
||||
{
|
||||
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_list_members_list");
|
||||
|
||||
b.HasOne("SendEngine.Domain.Entities.Subscriber", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriberId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_list_members_subscriber");
|
||||
|
||||
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_list_members_tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.MailingList", b =>
|
||||
{
|
||||
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
|
||||
@ -658,14 +593,14 @@ namespace SendEngine.Infrastructure.Data.Migrations
|
||||
.HasConstraintName("fk_send_jobs_tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Subscriber", b =>
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.Subscription", b =>
|
||||
{
|
||||
b.HasOne("SendEngine.Domain.Entities.Tenant", null)
|
||||
b.HasOne("SendEngine.Domain.Entities.MailingList", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_subscribers_tenant");
|
||||
.HasConstraintName("fk_subscriptions_list");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SendEngine.Domain.Entities.WebhookNonce", b =>
|
||||
|
||||
@ -11,8 +11,7 @@ public sealed class SendEngineDbContext : DbContext
|
||||
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<MailingList> Lists => Set<MailingList>();
|
||||
public DbSet<Subscriber> Subscribers => Set<Subscriber>();
|
||||
public DbSet<ListMember> ListMembers => Set<ListMember>();
|
||||
public DbSet<Subscription> Subscriptions => Set<Subscription>();
|
||||
public DbSet<EventInbox> EventsInbox => Set<EventInbox>();
|
||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||
public DbSet<SendJob> SendJobs => Set<SendJob>();
|
||||
@ -47,39 +46,22 @@ public sealed class SendEngineDbContext : DbContext
|
||||
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_lists_tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Subscriber>(entity =>
|
||||
modelBuilder.Entity<Subscription>(entity =>
|
||||
{
|
||||
entity.ToTable("subscribers");
|
||||
entity.ToTable("subscriptions");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ListId).HasColumnName("list_id");
|
||||
entity.Property(e => e.Email).HasColumnName("email").HasColumnType("citext");
|
||||
entity.Property(e => e.ExternalSubscriberId).HasColumnName("external_subscriber_id");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Preferences).HasColumnName("preferences").HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_subscribers_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Email }).IsUnique();
|
||||
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_subscribers_tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ListMember>(entity =>
|
||||
{
|
||||
entity.ToTable("list_members");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ListId).HasColumnName("list_id");
|
||||
entity.Property(e => e.SubscriberId).HasColumnName("subscriber_id");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.HasIndex(e => e.TenantId).HasDatabaseName("idx_list_members_tenant");
|
||||
entity.HasIndex(e => e.ListId).HasDatabaseName("idx_list_members_list");
|
||||
entity.HasIndex(e => new { e.TenantId, e.ListId, e.SubscriberId }).IsUnique();
|
||||
entity.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_list_members_tenant");
|
||||
entity.HasOne<MailingList>().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_list_members_list");
|
||||
entity.HasOne<Subscriber>().WithMany().HasForeignKey(e => e.SubscriberId).HasConstraintName("fk_list_members_subscriber");
|
||||
entity.HasIndex(e => e.ListId).HasDatabaseName("idx_subscriptions_list");
|
||||
entity.HasIndex(e => new { e.ListId, e.Email }).IsUnique();
|
||||
entity.HasIndex(e => e.ExternalSubscriberId).HasDatabaseName("idx_subscriptions_external_subscriber");
|
||||
entity.HasOne<MailingList>().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_subscriptions_list");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<EventInbox>(entity =>
|
||||
|
||||
20
src/SendEngine.Installer/Dockerfile
Normal file
20
src/SendEngine.Installer/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["Directory.Build.props", "./"]
|
||||
COPY ["NuGet.Config", "./"]
|
||||
COPY ["src/SendEngine.Domain/SendEngine.Domain.csproj", "src/SendEngine.Domain/"]
|
||||
COPY ["src/SendEngine.Application/SendEngine.Application.csproj", "src/SendEngine.Application/"]
|
||||
COPY ["src/SendEngine.Infrastructure/SendEngine.Infrastructure.csproj", "src/SendEngine.Infrastructure/"]
|
||||
COPY ["src/SendEngine.Installer/SendEngine.Installer.csproj", "src/SendEngine.Installer/"]
|
||||
|
||||
RUN dotnet restore "src/SendEngine.Installer/SendEngine.Installer.csproj"
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish "src/SendEngine.Installer/SendEngine.Installer.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "SendEngine.Installer.dll"]
|
||||
@ -4,6 +4,9 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using SendEngine.Domain.Entities;
|
||||
using SendEngine.Infrastructure;
|
||||
using SendEngine.Infrastructure.Data;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
var command = args.Length > 0 ? args[0] : "migrate";
|
||||
|
||||
@ -12,7 +15,8 @@ if (command is "-h" or "--help" or "help")
|
||||
Console.WriteLine("SendEngine Installer");
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> --client-id <string> --name <string> --scopes <csv>");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id <uuid> [--tenant-name <string>]");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> [--tenant-name <string>] --client-id <string> --name <string> --scopes <csv> [--upsert-member-center --mc-base-url <url> --mc-client-id <string> --mc-client-secret <string> [--mc-scope <scope>] [--mc-token-path <path>] [--mc-upsert-path <path>] [--mc-token-url <url>] [--mc-upsert-url <url>]]");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -38,6 +42,7 @@ if (command == "add-webhook-client")
|
||||
{
|
||||
var options = ParseOptions(args);
|
||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||
options.TryGetValue("tenant-name", out var tenantName);
|
||||
options.TryGetValue("client-id", out var clientId);
|
||||
options.TryGetValue("name", out var name);
|
||||
options.TryGetValue("scopes", out var scopesRaw);
|
||||
@ -72,6 +77,20 @@ if (command == "add-webhook-client")
|
||||
using var scope = provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||
|
||||
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
|
||||
if (tenant is null)
|
||||
{
|
||||
tenant = new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Name = string.IsNullOrWhiteSpace(tenantName) ? $"tenant-{tenantId:N}" : tenantName.Trim(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Tenants.Add(tenant);
|
||||
await db.SaveChangesAsync();
|
||||
Console.WriteLine($"Tenant created. tenant_id={tenant.Id}");
|
||||
}
|
||||
|
||||
var exists = await db.AuthClients.AsNoTracking().AnyAsync(x => x.ClientId == clientId);
|
||||
if (exists)
|
||||
{
|
||||
@ -98,6 +117,94 @@ if (command == "add-webhook-client")
|
||||
Console.WriteLine($"tenant_id={entity.TenantId}");
|
||||
Console.WriteLine($"client_id={entity.ClientId}");
|
||||
Console.WriteLine($"scopes={string.Join(",", entity.Scopes)}");
|
||||
|
||||
if (options.ContainsKey("upsert-member-center"))
|
||||
{
|
||||
var mcSettings = ResolveMemberCenterUpsertSettings(options);
|
||||
if (mcSettings is null)
|
||||
{
|
||||
Console.WriteLine("Missing required Member Center options for --upsert-member-center.");
|
||||
Console.WriteLine("Required: --mc-base-url, --mc-client-id, --mc-client-secret");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var token = await ResolveMemberCenterTokenAsync(mcSettings);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
Console.WriteLine("Member Center upsert failed: unable to get access token.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
using var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var upsertPayload = new
|
||||
{
|
||||
tenant_id = tenantId,
|
||||
webhook_client_id = entity.Id
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(mcSettings.UpsertWebhookClientUrl, upsertPayload);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"Member Center upsert failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
Console.WriteLine("Member Center webhook client mapping updated.");
|
||||
Console.WriteLine($"member_center_tenant_id={tenantId}");
|
||||
Console.WriteLine($"member_center_webhook_client_id={entity.Id}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Member Center upsert request failed: {ex.Message}");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (command == "ensure-tenant")
|
||||
{
|
||||
var options = ParseOptions(args);
|
||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||
options.TryGetValue("tenant-name", out var tenantName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantIdRaw))
|
||||
{
|
||||
Console.WriteLine("Missing required option: --tenant-id");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
|
||||
{
|
||||
Console.WriteLine("Invalid --tenant-id, expected UUID.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
using var scope = provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
|
||||
if (tenant is null)
|
||||
{
|
||||
tenant = new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Name = string.IsNullOrWhiteSpace(tenantName) ? $"tenant-{tenantId:N}" : tenantName.Trim(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Tenants.Add(tenant);
|
||||
await db.SaveChangesAsync();
|
||||
Console.WriteLine($"Tenant created. tenant_id={tenant.Id}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Tenant exists. tenant_id={tenant.Id}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -127,3 +234,94 @@ static Dictionary<string, string> ParseOptions(string[] args)
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static MemberCenterUpsertSettings? ResolveMemberCenterUpsertSettings(IReadOnlyDictionary<string, string> options)
|
||||
{
|
||||
options.TryGetValue("mc-base-url", out var baseUrl);
|
||||
options.TryGetValue("mc-client-id", out var clientId);
|
||||
options.TryGetValue("mc-client-secret", out var clientSecret);
|
||||
options.TryGetValue("mc-scope", out var scope);
|
||||
options.TryGetValue("mc-token-url", out var tokenUrl);
|
||||
options.TryGetValue("mc-upsert-url", out var upsertUrl);
|
||||
options.TryGetValue("mc-token-path", out var tokenPath);
|
||||
options.TryGetValue("mc-upsert-path", out var upsertPath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) ||
|
||||
string.IsNullOrWhiteSpace(clientId) ||
|
||||
string.IsNullOrWhiteSpace(clientSecret))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
scope = string.IsNullOrWhiteSpace(scope) ? "newsletter:events.write.global" : scope;
|
||||
tokenPath = string.IsNullOrWhiteSpace(tokenPath) ? "/oauth/token" : tokenPath;
|
||||
upsertPath = string.IsNullOrWhiteSpace(upsertPath) ? "/integrations/send-engine/webhook-clients/upsert" : upsertPath;
|
||||
tokenUrl = string.IsNullOrWhiteSpace(tokenUrl) ? CombineUrl(baseUrl, tokenPath) : tokenUrl;
|
||||
upsertUrl = string.IsNullOrWhiteSpace(upsertUrl) ? CombineUrl(baseUrl, upsertPath) : upsertUrl;
|
||||
|
||||
return new MemberCenterUpsertSettings(tokenUrl, upsertUrl, clientId, clientSecret, scope);
|
||||
}
|
||||
|
||||
static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = settings.ClientId,
|
||||
["client_secret"] = settings.ClientSecret
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(settings.Scope))
|
||||
{
|
||||
form["scope"] = settings.Scope;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsync(settings.TokenUrl, new FormUrlEncodedContent(form));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
||||
return null;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("access_token", out var tokenElement))
|
||||
{
|
||||
var token = tokenElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Member Center token request error: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static string CombineUrl(string baseUrl, string path)
|
||||
{
|
||||
return $"{baseUrl.TrimEnd('/')}/{path.TrimStart('/')}";
|
||||
}
|
||||
|
||||
static string Truncate(string? input, int maxLen)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return input.Length <= maxLen ? input : $"{input[..maxLen]}...(truncated)";
|
||||
}
|
||||
|
||||
sealed record MemberCenterUpsertSettings(
|
||||
string TokenUrl,
|
||||
string UpsertWebhookClientUrl,
|
||||
string ClientId,
|
||||
string ClientSecret,
|
||||
string Scope);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user