diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 11496b7..6b34a2b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -104,9 +104,12 @@ 4) Send Engine 驗證 tenant scope,更新本地名單快照 ### 6.7 Send Engine 退信/黑名單回寫(選用) -1) Send Engine 判定黑名單(例如 hard bounce / complaint) -2) 呼叫 Member Center API 將 email 加入 `email_blacklist` -3) Member Center 對該 email 的訂閱事件全部忽略且不再推送 +1) Send Engine 依事件類型決定回寫時機與原因碼: +2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫 +3) `complaint`:先取消訂閱,再回寫黑名單 +4) 呼叫 Member Center API 將 email 加入 `email_blacklist` +5) Member Center 對該 email 的訂閱事件全部忽略且不再推送 +6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證 ### 6.5 註冊後銜接 1) 使用者完成註冊 @@ -128,14 +131,14 @@ - POST `/newsletter/preferences` - POST `/webhooks/subscriptions`(Send Engine 端點,Member Center 呼叫) - POST `/webhooks/lists/full-sync`(Send Engine 端點,Member Center 呼叫) -- POST `/api/subscriptions/disable`(Member Center 端點,Send Engine 呼叫) +- POST `/subscriptions/disable`(Member Center 端點,Send Engine 呼叫) ## 7.1 待新增 API / Auth(規劃中) ### API - `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單 - `POST /webhooks/subscriptions`:Member Center → Send Engine 事件推送 - `POST /webhooks/lists/full-sync`:Member Center → Send Engine 全量同步 -- `POST /api/subscriptions/disable`:Send Engine → Member Center 黑名單回寫 +- `POST /subscriptions/disable`:Send Engine → Member Center 黑名單回寫 ### Auth / Scope - OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界 diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 243e687..7425666 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -45,8 +45,10 @@ - [API] Send Engine 更新名單快照 ## F-11 黑名單回寫(Send Engine → Member Center) -- [API] Send Engine 判定黑名單(例如 hard bounce / complaint) -- [API] 呼叫 `POST /api/subscriptions/disable`(scope: `newsletter:events.write`) +- [API] Send Engine 依事件規則處理: +- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫 +- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單 +- [API] 呼叫 `POST /subscriptions/disable`(scope: `newsletter:events.write`),body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at` - [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送 ## F-07 會員資料查看 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 02c65a6..fb41009 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -55,7 +55,9 @@ - 驗證規則: - timestamp 在允許時間窗(例如 ±5 分鐘) - nonce 不可重複(防重放) - - `X-Client-Id` 必須存在且 active,且 tenant 綁定一致 + - `X-Client-Id` 必須存在且 active,且 `auth_clients.tenant_id` 與 payload `tenant_id` 一致 + - 不使用 `X-Client-Id` fallback;缺少 tenant 對應 client 時應略過發送 + - 預設拒絕 `auth_clients.tenant_id = NULL` 的通用 client(除非 Send Engine 明確開啟) - signature 必須匹配 ## OAuth Client 用途分離(強制) @@ -70,12 +72,12 @@ - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`) - secret 分開輪替,禁止共用 -## 待新增 API / Auth(規劃中) +## 整合 API / Auth(狀態) ### API -- `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用) -- `POST /webhooks/subscriptions`:Member Center → Send Engine 事件推送(Send Engine 端點,scope: `newsletter:events.write`) -- `POST /webhooks/lists/full-sync`:Member Center → Send Engine 全量同步(Send Engine 端點,scope: `newsletter:events.write`) -- `POST /api/subscriptions/disable`:Send Engine → Member Center 黑名單回寫(全租戶 email,scope: `newsletter:events.write`) +- `GET /newsletter/subscriptions?list_id=...`:已實作(供發送引擎同步) +- `POST /webhooks/subscriptions`:已實作(Member Center 發送;Send Engine 接收) +- `POST /webhooks/lists/full-sync`:規格已定義(Member Center 發送;Send Engine 接收) +- `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單) ### Auth / Scope - OAuth Client 需綁定 `tenant_id` @@ -84,3 +86,21 @@ - `newsletter:events.read` - `newsletter:events.write` - 發送引擎僅能用上述 scope,禁止 admin 權限 +- `POST /subscriptions/disable` 需 Bearer token 且包含 `newsletter:events.write` +- 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token + +### 回寫原因碼(Send Engine -> Member Center) +- `hard_bounce` +- `soft_bounce_threshold` +- `complaint` +- `suppression` + +### `/subscriptions/disable` 請求欄位(Send Engine -> Member Center) +- `tenant_id`(UUID) +- `subscriber_id`(UUID) +- `list_id`(UUID) +- `reason`(`hard_bounce | soft_bounce_threshold | complaint | suppression`) +- `disabled_by`(建議固定 `send_engine`) +- `occurred_at`(RFC3339) + +Member Center 會用 `subscriber_id + list_id` 查詢訂閱,再驗證 `tenant_id` 邊界;驗證通過後才寫入全域 email 黑名單。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bd41a33..3d94bcb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -436,11 +436,11 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' - /api/subscriptions/disable: + /subscriptions/disable: post: summary: Disable email (global blacklist) security: [{ BearerAuth: [] }] - description: Requires scope `newsletter:events.write`. + description: Requires scope `newsletter:events.write`. Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`. requestBody: required: true content: @@ -466,7 +466,7 @@ paths: /webhooks/subscriptions: post: summary: (NOTE) Member Center -> Send Engine subscription events webhook - description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID). + description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID), tenant-bound, no fallback client id. security: [] parameters: - in: header @@ -498,7 +498,7 @@ paths: /webhooks/lists/full-sync: post: summary: (NOTE) Member Center -> Send Engine full list sync webhook - description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID). + description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID), tenant-bound, no fallback client id. security: [] parameters: - in: header @@ -829,10 +829,14 @@ components: DisableSubscriptionRequest: type: object - required: [email, reason, disabled_by, occurred_at] + required: [tenant_id, subscriber_id, list_id, reason, disabled_by, occurred_at] properties: - email: { type: string, format: email } - reason: { type: string } + tenant_id: { type: string, format: uuid } + subscriber_id: { type: string, format: uuid } + list_id: { type: string, format: uuid } + reason: + type: string + enum: [hard_bounce, soft_bounce_threshold, complaint, suppression] disabled_by: { type: string } occurred_at: { type: string, format: date-time } diff --git a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs index b425692..4ab63ba 100644 --- a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs +++ b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs @@ -21,7 +21,9 @@ public sealed record UpdatePreferencesRequest( [property: JsonPropertyName("preferences")] Dictionary Preferences); public sealed record DisableSubscriptionRequest( - [property: JsonPropertyName("email")] string Email, + [property: JsonPropertyName("tenant_id")] Guid TenantId, + [property: JsonPropertyName("subscriber_id")] Guid SubscriberId, + [property: JsonPropertyName("list_id")] Guid ListId, [property: JsonPropertyName("reason")] string Reason, [property: JsonPropertyName("disabled_by")] string DisabledBy, [property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt); diff --git a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs index 40df088..f98902d 100644 --- a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs +++ b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs @@ -1,20 +1,34 @@ using MemberCenter.Api.Contracts; using MemberCenter.Application.Abstractions; +using MemberCenter.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using OpenIddict.Abstractions; namespace MemberCenter.Api.Controllers; [ApiController] -[Route("api/subscriptions")] +[Route("subscriptions")] public class SubscriptionsController : ControllerBase { - private readonly IEmailBlacklistService _emailBlacklistService; + private static readonly HashSet AllowedReasons = new(StringComparer.OrdinalIgnoreCase) + { + "hard_bounce", + "soft_bounce_threshold", + "complaint", + "suppression" + }; - public SubscriptionsController(IEmailBlacklistService emailBlacklistService) + private readonly IEmailBlacklistService _emailBlacklistService; + private readonly MemberCenterDbContext _dbContext; + + public SubscriptionsController( + IEmailBlacklistService emailBlacklistService, + MemberCenterDbContext dbContext) { _emailBlacklistService = emailBlacklistService; + _dbContext = dbContext; } [Authorize] @@ -26,20 +40,48 @@ public class SubscriptionsController : ControllerBase return Forbid(); } - if (string.IsNullOrWhiteSpace(request.Email) || + if (request.TenantId == Guid.Empty || + request.SubscriberId == Guid.Empty || + request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Reason) || string.IsNullOrWhiteSpace(request.DisabledBy)) { - return BadRequest("email, reason, disabled_by are required."); + return BadRequest("tenant_id, subscriber_id, list_id, reason, disabled_by are required."); + } + + if (!AllowedReasons.Contains(request.Reason)) + { + return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression."); + } + + var target = await ( + from subscription in _dbContext.NewsletterSubscriptions + join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id + where subscription.Id == request.SubscriberId && subscription.ListId == request.ListId + select new + { + subscription.Email, + list.TenantId + }) + .FirstOrDefaultAsync(); + + if (target is null) + { + return NotFound("Subscription not found."); + } + + if (target.TenantId != request.TenantId) + { + return BadRequest("tenant_id does not match subscription/list tenant boundary."); } await _emailBlacklistService.AddOrUpdateAsync( - request.Email, + target.Email, request.Reason, request.DisabledBy, request.OccurredAt); - return Ok(new { email = request.Email, status = "blacklisted" }); + return Ok(new { email = target.Email, status = "blacklisted" }); } private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)