diff --git a/.env.example b/.env.example index 7f0209b..bd99bfa 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ ASPNETCORE_ENVIRONMENT=Development +# Reduce noisy SQL statement logs (EF Core): +Logging__LogLevel__Microsoft__EntityFrameworkCore=Warning +Logging__LogLevel__Microsoft__EntityFrameworkCore__Database__Command=Warning ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres;Password=postgres ESP__Provider=ses Db__AutoMigrate=true diff --git a/README.md b/README.md index e5043fd..5feddf8 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,5 @@ mass_mail_engine/ - SES/SNS 簽章完整驗證(目前 `Ses__SkipSignatureValidation=false` 僅檢查 header 存在) - 事件重試/DLQ 策略補強(目前主要依 SQS redrive policy) - recipient 狀態機擴充(delivery/open/click 的完整優先序與狀態轉換) +- 與 Member Center 對齊並同步可讀的 list 名稱(避免 auto-created `list-{uuid}` 成為最終顯示) +- SES 郵件 header 補 `List-ID`(優先使用同步後的 list 名稱),避免 Gmail 取消訂閱視窗顯示 `(Unknown)` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 12ac2b1..06be3de 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -27,6 +27,8 @@ - 使用 Installer 建立 webhook client(`id` 自動隨機產生): - `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` + - 若 `client_id` 已存在且屬於同一 tenant,Installer 會重用既有 webhook client(不重複建立),可直接搭配 `--upsert-member-center` 做同步 + - 若 `client_id` 已存在但屬於不同 tenant,Installer 會中止並回報錯誤 - 若 tenant 不存在,Installer 會先自動建立 `tenants` 基本資料,避免 webhook 出現 `tenant_not_found` - 建立成功後,Member Center webhook header `X-Client-Id` 請帶回傳的 `id` - 若要自動同步到 Member Center `POST /integrations/send-engine/webhook-clients/upsert`(保留原手動流程): diff --git a/src/SendEngine.Api/appsettings.json b/src/SendEngine.Api/appsettings.json index 4d56694..2a774d0 100644 --- a/src/SendEngine.Api/appsettings.json +++ b/src/SendEngine.Api/appsettings.json @@ -1,9 +1,11 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/SendEngine.Installer/Program.cs b/src/SendEngine.Installer/Program.cs index 95ba023..df5e73a 100644 --- a/src/SendEngine.Installer/Program.cs +++ b/src/SendEngine.Installer/Program.cs @@ -9,57 +9,78 @@ using System.Net.Http.Json; using System.Text.Json; var command = args.Length > 0 ? args[0] : "migrate"; +var verboseErrors = args.Any(x => string.Equals(x, "--verbose-errors", StringComparison.OrdinalIgnoreCase)); +var startedAt = DateTimeOffset.UtcNow; -if (command is "-h" or "--help" or "help") +AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => { - Console.WriteLine("SendEngine Installer"); - Console.WriteLine("Usage:"); - Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate"); - 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; -} + WriteError("Unhandled installer exception."); + if (eventArgs.ExceptionObject is Exception ex) + { + WriteError(verboseErrors ? ex.ToString() : ex.Message); + } +}; -var configuration = new ConfigurationBuilder() - .AddEnvironmentVariables() - .Build(); - -var services = new ServiceCollection(); -services.AddInfrastructure(configuration); - -var provider = services.BuildServiceProvider(); - -if (command == "migrate") +try { - using var scope = provider.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); - Console.WriteLine("Database migration completed."); - return; -} + // Always print a startup line so container logs are never completely empty. + WriteError($"[installer] start command={command} utc={startedAt:O}"); -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); + 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 -- 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 ]]"); + Console.WriteLine(" optional: --verbose-errors (print full exception stack traces to stderr)"); + return; + } + + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .Build(); + + var services = new ServiceCollection(); + services.AddInfrastructure(configuration); + Verbose("infrastructure registered"); + + var provider = services.BuildServiceProvider(); + Verbose("service provider built"); + + if (command == "migrate") + { + Verbose("running migrate"); + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + Console.WriteLine("Database migration completed."); + return; + } + + if (command == "add-webhook-client") + { + Verbose("running 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); if (string.IsNullOrWhiteSpace(tenantIdRaw) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(scopesRaw)) { - Console.WriteLine("Missing required options."); - Console.WriteLine("Required: --tenant-id, --client-id, --name, --scopes"); + WriteError("Missing required options."); + WriteError("Required: --tenant-id, --client-id, --name, --scopes"); Environment.Exit(1); } if (!Guid.TryParse(tenantIdRaw, out var tenantId)) { - Console.WriteLine("Invalid --tenant-id, expected UUID."); + WriteError("Invalid --tenant-id, expected UUID."); Environment.Exit(1); } @@ -70,12 +91,14 @@ if (command == "add-webhook-client") if (scopes.Length == 0) { - Console.WriteLine("Invalid --scopes, expected comma-separated values."); + WriteError("Invalid --scopes, expected comma-separated values."); Environment.Exit(1); } using var scope = provider.CreateScope(); + Verbose("db scope created"); var db = scope.ServiceProvider.GetRequiredService(); + Verbose("db context resolved"); var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId); if (tenant is null) @@ -91,28 +114,36 @@ if (command == "add-webhook-client") Console.WriteLine($"Tenant created. tenant_id={tenant.Id}"); } - var exists = await db.AuthClients.AsNoTracking().AnyAsync(x => x.ClientId == clientId); - if (exists) + var entity = await db.AuthClients.FirstOrDefaultAsync(x => x.ClientId == clientId); + if (entity is not null) { - Console.WriteLine($"client_id already exists: {clientId}"); - Environment.Exit(1); + if (entity.TenantId != tenantId) + { + WriteError($"client_id already exists under different tenant. client_id={clientId} existing_tenant_id={entity.TenantId} requested_tenant_id={tenantId}"); + Environment.Exit(1); + } + + Console.WriteLine("Webhook client already exists. Reusing existing record."); + } + else + { + entity = new AuthClient + { + Id = Guid.NewGuid(), + TenantId = tenantId, + ClientId = clientId, + Name = name, + Scopes = scopes, + Status = "active", + CreatedAt = DateTimeOffset.UtcNow + }; + + db.AuthClients.Add(entity); + await db.SaveChangesAsync(); + Verbose("auth client saved"); + Console.WriteLine("Webhook client created."); } - var entity = new AuthClient - { - Id = Guid.NewGuid(), - TenantId = tenantId, - ClientId = clientId, - Name = name, - Scopes = scopes, - Status = "active", - CreatedAt = DateTimeOffset.UtcNow - }; - - db.AuthClients.Add(entity); - await db.SaveChangesAsync(); - - Console.WriteLine("Webhook client created."); Console.WriteLine($"id={entity.Id}"); Console.WriteLine($"tenant_id={entity.TenantId}"); Console.WriteLine($"client_id={entity.ClientId}"); @@ -123,15 +154,15 @@ if (command == "add-webhook-client") 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"); + WriteError("Missing required Member Center options for --upsert-member-center."); + WriteError("Required: --mc-base-url, --mc-client-id, --mc-client-secret"); Environment.Exit(1); } - var token = await ResolveMemberCenterTokenAsync(mcSettings); + var token = await ResolveMemberCenterTokenAsync(mcSettings, verboseErrors); if (string.IsNullOrWhiteSpace(token)) { - Console.WriteLine("Member Center upsert failed: unable to get access token."); + WriteError("Member Center upsert failed: unable to get access token."); Environment.Exit(1); } @@ -145,11 +176,12 @@ if (command == "add-webhook-client") try { + Verbose($"calling member center upsert url={mcSettings.UpsertWebhookClientUrl}"); 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)}"); + WriteError($"Member Center upsert failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}"); Environment.Exit(1); } @@ -159,34 +191,38 @@ if (command == "add-webhook-client") } catch (Exception ex) { - Console.WriteLine($"Member Center upsert request failed: {ex.Message}"); + WriteError("Member Center upsert request failed."); + WriteError(verboseErrors ? ex.ToString() : ex.Message); Environment.Exit(1); } } - return; -} + return; + } -if (command == "ensure-tenant") -{ - var options = ParseOptions(args); - options.TryGetValue("tenant-id", out var tenantIdRaw); - options.TryGetValue("tenant-name", out var tenantName); + if (command == "ensure-tenant") + { + Verbose("running 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"); + WriteError("Missing required option: --tenant-id"); Environment.Exit(1); } if (!Guid.TryParse(tenantIdRaw, out var tenantId)) { - Console.WriteLine("Invalid --tenant-id, expected UUID."); + WriteError("Invalid --tenant-id, expected UUID."); Environment.Exit(1); } using var scope = provider.CreateScope(); + Verbose("db scope created"); var db = scope.ServiceProvider.GetRequiredService(); + Verbose("db context resolved"); var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId); if (tenant is null) { @@ -205,10 +241,17 @@ if (command == "ensure-tenant") Console.WriteLine($"Tenant exists. tenant_id={tenant.Id}"); } - return; -} + return; + } -Console.WriteLine($"Unknown command: {command}"); + Console.WriteLine($"Unknown command: {command}"); +} +catch (Exception ex) +{ + WriteError("Installer failed with unhandled error."); + WriteError(verboseErrors ? ex.ToString() : ex.Message); + Environment.Exit(1); +} static Dictionary ParseOptions(string[] args) { @@ -262,7 +305,7 @@ static MemberCenterUpsertSettings? ResolveMemberCenterUpsertSettings(IReadOnlyDi return new MemberCenterUpsertSettings(tokenUrl, upsertUrl, clientId, clientSecret, scope); } -static async Task ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings) +static async Task ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings, bool verboseErrors) { using var client = new HttpClient(); var form = new Dictionary @@ -282,7 +325,7 @@ static async Task ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { - Console.WriteLine($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}"); + WriteError($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}"); return null; } @@ -298,7 +341,8 @@ static async Task ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti } catch (Exception ex) { - Console.WriteLine($"Member Center token request error: {ex.Message}"); + WriteError("Member Center token request error."); + WriteError(verboseErrors ? ex.ToString() : ex.Message); } return null; @@ -319,6 +363,19 @@ static string Truncate(string? input, int maxLen) return input.Length <= maxLen ? input : $"{input[..maxLen]}...(truncated)"; } +static void WriteError(string message) +{ + Console.Error.WriteLine(message); +} + +void Verbose(string message) +{ + if (verboseErrors) + { + WriteError($"[installer][verbose] {message}"); + } +} + sealed record MemberCenterUpsertSettings( string TokenUrl, string UpsertWebhookClientUrl,