diff --git a/.env.example b/.env.example index dd3770c..6ac9be6 100644 --- a/.env.example +++ b/.env.example @@ -13,10 +13,11 @@ Ses__SkipSignatureValidation=true Bounce__SoftBounceThreshold=5 MemberCenter__BaseUrl= MemberCenter__DisableSubscriptionPath=/subscriptions/disable -MemberCenter__TokenPath=/connect/token +MemberCenter__TokenPath=/oauth/token MemberCenter__DisableSubscriptionUrl= MemberCenter__TokenUrl= MemberCenter__ClientId= MemberCenter__ClientSecret= MemberCenter__Scope=newsletter:events.write MemberCenter__ApiToken= +TestFriendly__Enabled=false diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 3e1de86..102a0ca 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -48,7 +48,8 @@ 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、黑名單) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 11ea88a..f34f2da 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -7,19 +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-name ]` - 使用 Installer 建立 webhook client(`id` 自動隨機產生): - - `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id --client-id --name --scopes ` - - 例如:`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-name ] --client-id --name --scopes ` + - 例如:`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 --client-id --name --scopes --upsert-member-center --mc-base-url --mc-client-id --mc-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`(預設 `/connect/token`) + - `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` diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index fd53618..183be9a 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -161,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 diff --git a/docs/SCHEMA.sql b/docs/SCHEMA.sql index 438fff4..82c4137 100644 --- a/docs/SCHEMA.sql +++ b/docs/SCHEMA.sql @@ -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, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a82cefb..951632d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -41,6 +41,12 @@ paths: 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: diff --git a/src/SendEngine.Api/Models/WebhookModels.cs b/src/SendEngine.Api/Models/WebhookModels.cs index d9e7b7a..4d8360c 100644 --- a/src/SendEngine.Api/Models/WebhookModels.cs +++ b/src/SendEngine.Api/Models/WebhookModels.cs @@ -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? 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 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? Tags { get; set; } } diff --git a/src/SendEngine.Api/Program.cs b/src/SendEngine.Api/Program.cs index 4be52c7..0fd7e9a 100644 --- a/src/SendEngine.Api/Program.cs +++ b/src/SendEngine.Api/Program.cs @@ -58,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) { @@ -105,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(), @@ -192,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" }); } @@ -213,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 @@ -245,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" }); } @@ -267,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 @@ -296,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, @@ -310,28 +448,107 @@ 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 normalizedEmail = request.Email.Trim().ToLowerInvariant(); var normalizedEventType = NormalizeSesEventType(request.EventType, request.BounceType); - request.Email = normalizedEmail; + 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 @@ -348,11 +565,17 @@ app.MapPost("/webhooks/ses", async (HttpContext httpContext, SesEventRequest req db.EventsInbox.Add(inbox); await db.SaveChangesAsync(); - await ApplySesEventAsync(db, builder.Configuration, request, normalizedEventType); + 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(); @@ -369,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 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() @@ -397,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, @@ -407,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? 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; } } @@ -514,37 +730,146 @@ static string NormalizeSesEventType(string? eventType, string? bounceType) 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? 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? 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 subscriber = await db.Subscribers - .FirstOrDefaultAsync(x => x.TenantId == request.TenantId && x.Email == request.Email); - if (subscriber is null) + 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, request.TenantId, subscriber.Id, "hard_bounce", request.OccurredAt); + 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, request.TenantId, subscriber.Id, "soft_bounce_threshold", request.OccurredAt); + 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, request.TenantId, subscriber.Id, "complaint", request.OccurredAt); + await SuppressAndNotifyAsync(db, configuration, logger, request.TenantId, subscriptions, "complaint", request.OccurredAt); return; case "suppression": - await SuppressAndNotifyAsync(db, configuration, request.TenantId, subscriber.Id, "suppression", request.OccurredAt); + await SuppressAndNotifyAsync(db, configuration, logger, request.TenantId, subscriptions, "suppression", request.OccurredAt); return; default: return; @@ -562,13 +887,14 @@ static async Task IsSoftBounceThresholdReachedAsync( threshold = 1; } - var marker = $"\"Email\":\"{normalizedEmail}\""; - var count = await db.EventsInbox.AsNoTracking() - .CountAsync(x => - x.TenantId == tenantId && - x.Source == "ses" && - x.EventType == "ses.soft_bounced" && - x.Payload.Contains(marker)); + var count = await db.Database.SqlQuery($""" + 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; } @@ -576,45 +902,55 @@ static async Task IsSoftBounceThresholdReachedAsync( static async Task SuppressAndNotifyAsync( SendEngineDbContext db, IConfiguration configuration, + ILogger logger, Guid tenantId, - Guid subscriberId, + IReadOnlyCollection subscriptions, string reason, DateTimeOffset occurredAt) { - var subscriber = await db.Subscribers - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == subscriberId); - if (subscriber is null) + if (subscriptions.Count == 0) { return; } var now = DateTimeOffset.UtcNow; - subscriber.Status = "suppressed"; - subscriber.UpdatedAt = now; - - var memberships = await db.ListMembers - .Where(x => x.TenantId == tenantId && x.SubscriberId == subscriberId) - .ToListAsync(); - foreach (var member in memberships) + foreach (var subscription in subscriptions) { - member.Status = "suppressed"; - member.UpdatedAt = now; + subscription.Status = "suppressed"; + subscription.UpdatedAt = now; } await db.SaveChangesAsync(); - var listIds = memberships.Select(x => x.ListId).Distinct().ToArray(); - await NotifyMemberCenterDisableAsync(configuration, tenantId, subscriberId, listIds, reason, occurredAt); + 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, - Guid subscriberId, - IReadOnlyCollection listIds, + 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", @@ -623,24 +959,30 @@ static async Task NotifyMemberCenterDisableAsync( "/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); + 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 listId in listIds) + foreach (var target in targets) { var payload = new { tenant_id = tenantId, - subscriber_id = subscriberId, - list_id = listId, + subscriber_id = target.SubscriberId, + list_id = target.ListId, reason, disabled_by = "send_engine", occurred_at = occurredAt @@ -648,23 +990,35 @@ static async Task NotifyMemberCenterDisableAsync( try { - await client.PostAsJsonAsync(url, payload); + 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 + catch (Exception ex) { - // Best-effort callback: errors are intentionally swallowed for now. + logger.LogError(ex, "MemberCenter callback failed. url={Url} list_id={ListId}", url, target.ListId); } } } -static async Task ResolveMemberCenterAccessTokenAsync(IConfiguration configuration, HttpClient client) +static async Task ResolveMemberCenterAccessTokenAsync(IConfiguration configuration, HttpClient client, ILogger logger) { var tokenUrl = ResolveMemberCenterUrl( configuration, "MemberCenter:TokenUrl", "MemberCenter:BaseUrl", "MemberCenter:TokenPath", - "/connect/token"); + "/oauth/token"); var clientId = configuration["MemberCenter:ClientId"]; var clientSecret = configuration["MemberCenter:ClientSecret"]; var scope = configuration["MemberCenter:Scope"]; @@ -687,6 +1041,11 @@ static async Task ResolveMemberCenterAccessTokenAsync(IConfiguration co 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) { @@ -697,18 +1056,33 @@ static async Task ResolveMemberCenterAccessTokenAsync(IConfiguration co 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 + catch (Exception ex) { - // Fallback to static token below. + logger.LogWarning(ex, "MemberCenter token request error, fallback to static token."); } } - return configuration["MemberCenter:ApiToken"]; + var fallbackToken = configuration["MemberCenter:ApiToken"]; + if (!string.IsNullOrWhiteSpace(fallbackToken)) + { + logger.LogWarning("Using MemberCenter__ApiToken fallback."); + } + + return fallbackToken; } static string? ResolveMemberCenterUrl( @@ -738,3 +1112,24 @@ static string? ResolveMemberCenterUrl( return $"{baseUrl.TrimEnd('/')}/{path.TrimStart('/')}"; } + +static string BuildMaskedAuthHeader(string token) +{ + if (string.IsNullOrWhiteSpace(token)) + { + return "Bearer "; + } + + 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)"; +} diff --git a/src/SendEngine.Api/Security/WebhookValidator.cs b/src/SendEngine.Api/Security/WebhookValidator.cs index 3c89847..c6f6dd7 100644 --- a/src/SendEngine.Api/Security/WebhookValidator.cs +++ b/src/SendEngine.Api/Security/WebhookValidator.cs @@ -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 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(); + 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 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; + } + } diff --git a/src/SendEngine.Api/SendEngine.Api.csproj b/src/SendEngine.Api/SendEngine.Api.csproj index 951623d..72b4269 100644 --- a/src/SendEngine.Api/SendEngine.Api.csproj +++ b/src/SendEngine.Api/SendEngine.Api.csproj @@ -6,11 +6,15 @@ enable - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/src/SendEngine.Domain/Entities/ListMember.cs b/src/SendEngine.Domain/Entities/ListMember.cs deleted file mode 100644 index 33ac39b..0000000 --- a/src/SendEngine.Domain/Entities/ListMember.cs +++ /dev/null @@ -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; } -} diff --git a/src/SendEngine.Domain/Entities/Subscriber.cs b/src/SendEngine.Domain/Entities/Subscription.cs similarity index 73% rename from src/SendEngine.Domain/Entities/Subscriber.cs rename to src/SendEngine.Domain/Entities/Subscription.cs index 15f0df9..31fc935 100644 --- a/src/SendEngine.Domain/Entities/Subscriber.cs +++ b/src/SendEngine.Domain/Entities/Subscription.cs @@ -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; } diff --git a/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.Designer.cs b/src/SendEngine.Infrastructure/Data/Migrations/20260219074637_20260219_Rebaseline.Designer.cs similarity index 88% rename from src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.Designer.cs rename to src/SendEngine.Infrastructure/Data/Migrations/20260219074637_20260219_Rebaseline.Designer.cs index 990d3af..d2e9822 100644 --- a/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.Designer.cs +++ b/src/SendEngine.Infrastructure/Data/Migrations/20260219074637_20260219_Rebaseline.Designer.cs @@ -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 { /// 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("ListId") - .HasColumnType("uuid") - .HasColumnName("list_id"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text") - .HasColumnName("status"); - - b.Property("SubscriberId") - .HasColumnType("uuid") - .HasColumnName("subscriber_id"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - - b.Property("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("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("Id") .ValueGeneratedOnAdd() @@ -453,6 +405,14 @@ namespace SendEngine.Infrastructure.Data.Migrations .HasColumnType("citext") .HasColumnName("email"); + b.Property("ExternalSubscriberId") + .HasColumnType("uuid") + .HasColumnName("external_subscriber_id"); + + b.Property("ListId") + .HasColumnType("uuid") + .HasColumnName("list_id"); + b.Property("Preferences") .HasColumnType("jsonb") .HasColumnName("preferences"); @@ -462,23 +422,22 @@ namespace SendEngine.Infrastructure.Data.Migrations .HasColumnType("text") .HasColumnName("status"); - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - b.Property("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 => diff --git a/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.cs b/src/SendEngine.Infrastructure/Data/Migrations/20260219074637_20260219_Rebaseline.cs similarity index 85% rename from src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.cs rename to src/SendEngine.Infrastructure/Data/Migrations/20260219074637_20260219_Rebaseline.cs index a4893e9..a7291e5 100644 --- a/src/SendEngine.Infrastructure/Data/Migrations/20260210083240_Initial.cs +++ b/src/SendEngine.Infrastructure/Data/Migrations/20260219074637_20260219_Rebaseline.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace SendEngine.Infrastructure.Data.Migrations { /// - public partial class Initial : Migration + public partial class _20260219_Rebaseline : Migration { /// 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(type: "uuid", nullable: false), - tenant_id = table.Column(type: "uuid", nullable: false), - email = table.Column(type: "citext", nullable: false), - status = table.Column(type: "text", nullable: false), - preferences = table.Column(type: "jsonb", nullable: true), - created_at = table.Column(type: "timestamp with time zone", nullable: false), - updated_at = table.Column(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(type: "uuid", nullable: false), - tenant_id = table.Column(type: "uuid", nullable: false), list_id = table.Column(type: "uuid", nullable: false), - subscriber_id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "citext", nullable: false), + external_subscriber_id = table.Column(type: "uuid", nullable: true), status = table.Column(type: "text", nullable: false), + preferences = table.Column(type: "jsonb", nullable: true), created_at = table.Column(type: "timestamp with time zone", nullable: false), updated_at = table.Column(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"); diff --git a/src/SendEngine.Infrastructure/Data/Migrations/SendEngineDbContextModelSnapshot.cs b/src/SendEngine.Infrastructure/Data/Migrations/SendEngineDbContextModelSnapshot.cs index f2aab36..469cebb 100644 --- a/src/SendEngine.Infrastructure/Data/Migrations/SendEngineDbContextModelSnapshot.cs +++ b/src/SendEngine.Infrastructure/Data/Migrations/SendEngineDbContextModelSnapshot.cs @@ -254,54 +254,6 @@ namespace SendEngine.Infrastructure.Data.Migrations b.ToTable("events_inbox", (string)null); }); - modelBuilder.Entity("SendEngine.Domain.Entities.ListMember", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("ListId") - .HasColumnType("uuid") - .HasColumnName("list_id"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text") - .HasColumnName("status"); - - b.Property("SubscriberId") - .HasColumnType("uuid") - .HasColumnName("subscriber_id"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - - b.Property("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("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("Id") .ValueGeneratedOnAdd() @@ -450,6 +402,14 @@ namespace SendEngine.Infrastructure.Data.Migrations .HasColumnType("citext") .HasColumnName("email"); + b.Property("ExternalSubscriberId") + .HasColumnType("uuid") + .HasColumnName("external_subscriber_id"); + + b.Property("ListId") + .HasColumnType("uuid") + .HasColumnName("list_id"); + b.Property("Preferences") .HasColumnType("jsonb") .HasColumnName("preferences"); @@ -459,23 +419,22 @@ namespace SendEngine.Infrastructure.Data.Migrations .HasColumnType("text") .HasColumnName("status"); - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - b.Property("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 => diff --git a/src/SendEngine.Infrastructure/Data/SendEngineDbContext.cs b/src/SendEngine.Infrastructure/Data/SendEngineDbContext.cs index 7c23ae4..a8ada1a 100644 --- a/src/SendEngine.Infrastructure/Data/SendEngineDbContext.cs +++ b/src/SendEngine.Infrastructure/Data/SendEngineDbContext.cs @@ -11,8 +11,7 @@ public sealed class SendEngineDbContext : DbContext public DbSet Tenants => Set(); public DbSet Lists => Set(); - public DbSet Subscribers => Set(); - public DbSet ListMembers => Set(); + public DbSet Subscriptions => Set(); public DbSet EventsInbox => Set(); public DbSet Campaigns => Set(); public DbSet SendJobs => Set(); @@ -47,39 +46,22 @@ public sealed class SendEngineDbContext : DbContext entity.HasOne().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_lists_tenant"); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_subscribers_tenant"); - }); - - modelBuilder.Entity(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().WithMany().HasForeignKey(e => e.TenantId).HasConstraintName("fk_list_members_tenant"); - entity.HasOne().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_list_members_list"); - entity.HasOne().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().WithMany().HasForeignKey(e => e.ListId).HasConstraintName("fk_subscriptions_list"); }); modelBuilder.Entity(entity => diff --git a/src/SendEngine.Installer/Dockerfile b/src/SendEngine.Installer/Dockerfile new file mode 100644 index 0000000..2808cdb --- /dev/null +++ b/src/SendEngine.Installer/Dockerfile @@ -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"] diff --git a/src/SendEngine.Installer/Program.cs b/src/SendEngine.Installer/Program.cs index 43a8e22..95ba023 100644 --- a/src/SendEngine.Installer/Program.cs +++ b/src/SendEngine.Installer/Program.cs @@ -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 --client-id --name --scopes "); + Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id [--tenant-name ]"); + Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id [--tenant-name ] --client-id --name --scopes [--upsert-member-center --mc-base-url --mc-client-id --mc-client-secret [--mc-scope ] [--mc-token-path ] [--mc-upsert-path ] [--mc-token-url ] [--mc-upsert-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(); + 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(); + 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 ParseOptions(string[] args) return result; } + +static MemberCenterUpsertSettings? ResolveMemberCenterUpsertSettings(IReadOnlyDictionary 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 ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings) +{ + using var client = new HttpClient(); + var form = new Dictionary + { + ["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);