From ed0349a6a539f6173bd60f79fa36f2fc70a42a27 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 6 Jul 2026 14:29:41 +0200 Subject: [PATCH] feat: add admin mail queue management and test-send endpoints Adds admin endpoints to inspect and manage the email outbox and to send preview/test mail, plus the supporting Common/Cron changes: - Common: EmailOutboxPayloadKeys.Preview flag and EmailOutboxMessage.ForPreview factory (always-deliver, no coalescing). - Cron: EmailOutboxDispatcher renders a flagged preview message with a dummy link, touching no request row and minting no token. - API (Admin, /admin/email): list (paginated, $filter/$orderby), stats, requeue, cancel, delete, single test send, and bulk requeue/cancel/delete. Requeue/cancel guard on status via ExecuteUpdate so they race safely against in-flight Cron sends. No DB migration (reuses the existing table). --- .../Admin/DTOs/EmailOutboxBulkRequest.cs | 13 +++ .../Admin/DTOs/EmailOutboxBulkResultDto.cs | 11 +++ .../Admin/DTOs/EmailOutboxMessageDto.cs | 41 ++++++++++ .../Admin/DTOs/EmailOutboxStatsDto.cs | 14 ++++ API/Controller/Admin/DTOs/SendTestEmailDto.cs | 20 +++++ API/Controller/Admin/EmailOutboxBulk.cs | 82 +++++++++++++++++++ API/Controller/Admin/EmailOutboxCancel.cs | 37 +++++++++ API/Controller/Admin/EmailOutboxDelete.cs | 24 ++++++ API/Controller/Admin/EmailOutboxList.cs | 81 ++++++++++++++++++ API/Controller/Admin/EmailOutboxRequeue.cs | 48 +++++++++++ API/Controller/Admin/EmailOutboxStats.cs | 38 +++++++++ API/Controller/Admin/EmailOutboxTestSend.cs | 36 ++++++++ .../OpenShockDb/EmailOutboxMessageTests.cs | 13 +++ .../Keys/EmailOutboxPayloadKeys.cs | 8 ++ .../OpenShockDb/Models/EmailOutboxMessage.cs | 11 +++ .../Email/Outbox/EmailOutboxDispatcher.cs | 29 +++++++ 16 files changed, 506 insertions(+) create mode 100644 API/Controller/Admin/DTOs/EmailOutboxBulkRequest.cs create mode 100644 API/Controller/Admin/DTOs/EmailOutboxBulkResultDto.cs create mode 100644 API/Controller/Admin/DTOs/EmailOutboxMessageDto.cs create mode 100644 API/Controller/Admin/DTOs/EmailOutboxStatsDto.cs create mode 100644 API/Controller/Admin/DTOs/SendTestEmailDto.cs create mode 100644 API/Controller/Admin/EmailOutboxBulk.cs create mode 100644 API/Controller/Admin/EmailOutboxCancel.cs create mode 100644 API/Controller/Admin/EmailOutboxDelete.cs create mode 100644 API/Controller/Admin/EmailOutboxList.cs create mode 100644 API/Controller/Admin/EmailOutboxRequeue.cs create mode 100644 API/Controller/Admin/EmailOutboxStats.cs create mode 100644 API/Controller/Admin/EmailOutboxTestSend.cs diff --git a/API/Controller/Admin/DTOs/EmailOutboxBulkRequest.cs b/API/Controller/Admin/DTOs/EmailOutboxBulkRequest.cs new file mode 100644 index 00000000..023b9641 --- /dev/null +++ b/API/Controller/Admin/DTOs/EmailOutboxBulkRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.API.Controller.Admin.DTOs; + +/// +/// A set of email outbox message ids to apply a bulk requeue/cancel/delete to. +/// +public sealed class EmailOutboxBulkRequest +{ + [Required] + [MaxLength(1000)] + public required Guid[] Ids { get; set; } +} diff --git a/API/Controller/Admin/DTOs/EmailOutboxBulkResultDto.cs b/API/Controller/Admin/DTOs/EmailOutboxBulkResultDto.cs new file mode 100644 index 00000000..15ba07f3 --- /dev/null +++ b/API/Controller/Admin/DTOs/EmailOutboxBulkResultDto.cs @@ -0,0 +1,11 @@ +namespace OpenShock.API.Controller.Admin.DTOs; + +/// +/// Outcome of a bulk email outbox operation: how many ids were requested and how many rows were +/// actually affected (rows that were missing or in an ineligible state are silently not counted). +/// +public sealed class EmailOutboxBulkResultDto +{ + public required int Requested { get; set; } + public required int Affected { get; set; } +} diff --git a/API/Controller/Admin/DTOs/EmailOutboxMessageDto.cs b/API/Controller/Admin/DTOs/EmailOutboxMessageDto.cs new file mode 100644 index 00000000..37c20a67 --- /dev/null +++ b/API/Controller/Admin/DTOs/EmailOutboxMessageDto.cs @@ -0,0 +1,41 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Controller.Admin.DTOs; + +/// +/// An as exposed to admins on the mail-queue page. Mirrors the row +/// verbatim - the outbox stores no rendered body and no usable secret, so every field is safe to show. +/// +public sealed class EmailOutboxMessageDto +{ + public required Guid Id { get; set; } + public required EmailType Type { get; set; } + public required string Recipient { get; set; } + public required string? RecipientName { get; set; } + public required Dictionary Payload { get; set; } + public required string? CoalesceKey { get; set; } + public required EmailStatus Status { get; set; } + public required int AttemptCount { get; set; } + public required DateTimeOffset NextAttemptAt { get; set; } + public required string? LastError { get; set; } + public required DateTimeOffset CreatedAt { get; set; } + public required DateTimeOffset? SentAt { get; set; } + public required DateTimeOffset? FailedAt { get; set; } + + public static EmailOutboxMessageDto FromModel(EmailOutboxMessage m) => new() + { + Id = m.Id, + Type = m.Type, + Recipient = m.Recipient, + RecipientName = m.RecipientName, + Payload = m.Payload, + CoalesceKey = m.CoalesceKey, + Status = m.Status, + AttemptCount = m.AttemptCount, + NextAttemptAt = m.NextAttemptAt, + LastError = m.LastError, + CreatedAt = m.CreatedAt, + SentAt = m.SentAt, + FailedAt = m.FailedAt + }; +} diff --git a/API/Controller/Admin/DTOs/EmailOutboxStatsDto.cs b/API/Controller/Admin/DTOs/EmailOutboxStatsDto.cs new file mode 100644 index 00000000..0a1eeb49 --- /dev/null +++ b/API/Controller/Admin/DTOs/EmailOutboxStatsDto.cs @@ -0,0 +1,14 @@ +namespace OpenShock.API.Controller.Admin.DTOs; + +/// +/// Count of email outbox rows in each delivery state, for the admin mail-queue overview tiles. +/// +public sealed class EmailOutboxStatsDto +{ + public required long Pending { get; set; } + public required long Sending { get; set; } + public required long Sent { get; set; } + public required long Failed { get; set; } + public required long Skipped { get; set; } + public required long Total { get; set; } +} diff --git a/API/Controller/Admin/DTOs/SendTestEmailDto.cs b/API/Controller/Admin/DTOs/SendTestEmailDto.cs new file mode 100644 index 00000000..7293c7b7 --- /dev/null +++ b/API/Controller/Admin/DTOs/SendTestEmailDto.cs @@ -0,0 +1,20 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Controller.Admin.DTOs; + +/// +/// Request to enqueue a preview/test email of a chosen type to a chosen address. The message is +/// delivered by the Cron pipeline with placeholder data and a dummy link (no token, no request row). +/// +public sealed class SendTestEmailDto +{ + /// Which template to preview. + public required EmailType Type { get; set; } + + /// Destination address for the test email. + [OpenShock.Common.DataAnnotations.EmailAddress(true)] + public required string Recipient { get; set; } + + /// Optional display name for the recipient. + public string? RecipientName { get; set; } +} diff --git a/API/Controller/Admin/EmailOutboxBulk.cs b/API/Controller/Admin/EmailOutboxBulk.cs new file mode 100644 index 00000000..8704a81d --- /dev/null +++ b/API/Controller/Admin/EmailOutboxBulk.cs @@ -0,0 +1,82 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.RedisPubSub; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Requeues many email outbox messages at once. Each id is treated exactly like the single-message + /// requeue: only terminal (sent/failed/skipped) rows are affected; pending/sending rows and missing + /// ids are skipped. Returns how many rows were actually requeued. + /// + /// Bulk requeue applied + /// Unauthorized + [HttpPost("email/outbox/bulk/requeue")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task BulkRequeueEmailOutbox([FromBody] EmailOutboxBulkRequest body, [FromServices] IRedisPubService redisPubService, CancellationToken ct) + { + var nowUtc = DateTime.UtcNow; + + var affected = await _db.EmailOutbox + .Where(m => body.Ids.Contains(m.Id) && (m.Status == EmailStatus.Failed || m.Status == EmailStatus.Skipped || m.Status == EmailStatus.Sent)) + .ExecuteUpdateAsync(s => s + .SetProperty(m => m.Status, EmailStatus.Pending) + .SetProperty(m => m.NextAttemptAt, nowUtc) + .SetProperty(m => m.AttemptCount, 0) + .SetProperty(m => m.LastError, (string?)null) + .SetProperty(m => m.FailedAt, (DateTime?)null) + .SetProperty(m => m.SentAt, (DateTime?)null), ct); + + if (affected > 0) + { + await redisPubService.SendEmailOutboxPending(); + } + + return new EmailOutboxBulkResultDto { Requested = body.Ids.Length, Affected = affected }; + } + + /// + /// Cancels many still-pending email outbox messages at once by marking them skipped. Rows that are + /// not pending (already sending or terminal) and missing ids are skipped. Returns how many rows were + /// actually cancelled. + /// + /// Bulk cancel applied + /// Unauthorized + [HttpPost("email/outbox/bulk/cancel")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task BulkCancelEmailOutbox([FromBody] EmailOutboxBulkRequest body, CancellationToken ct) + { + var affected = await _db.EmailOutbox + .Where(m => body.Ids.Contains(m.Id) && m.Status == EmailStatus.Pending) + .ExecuteUpdateAsync(s => s + .SetProperty(m => m.Status, EmailStatus.Skipped) + .SetProperty(m => m.LastError, "Cancelled by admin"), ct); + + return new EmailOutboxBulkResultDto { Requested = body.Ids.Length, Affected = affected }; + } + + /// + /// Permanently deletes many email outbox messages at once. Missing ids are skipped. Returns how many + /// rows were actually deleted. + /// + /// Bulk delete applied + /// Unauthorized + [HttpPost("email/outbox/bulk/delete")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task BulkDeleteEmailOutbox([FromBody] EmailOutboxBulkRequest body, CancellationToken ct) + { + var affected = await _db.EmailOutbox + .Where(m => body.Ids.Contains(m.Id)) + .ExecuteDeleteAsync(ct); + + return new EmailOutboxBulkResultDto { Requested = body.Ids.Length, Affected = affected }; + } +} diff --git a/API/Controller/Admin/EmailOutboxCancel.cs b/API/Controller/Admin/EmailOutboxCancel.cs new file mode 100644 index 00000000..dff23825 --- /dev/null +++ b/API/Controller/Admin/EmailOutboxCancel.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Cancels a still-pending email outbox message by marking it skipped, so it is never sent. Only + /// pending messages can be cancelled; a message already being sent or in a terminal state cannot. + /// + /// Cancelled + /// Unauthorized + /// No such message + /// Message is not pending + [HttpPost("email/outbox/{id}/cancel")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CancelEmailOutbox([FromRoute] Guid id, CancellationToken ct) + { + var updated = await _db.EmailOutbox + .Where(m => m.Id == id && m.Status == EmailStatus.Pending) + .ExecuteUpdateAsync(s => s + .SetProperty(m => m.Status, EmailStatus.Skipped) + .SetProperty(m => m.LastError, "Cancelled by admin"), ct); + + if (updated == 0) + { + var exists = await _db.EmailOutbox.AnyAsync(m => m.Id == id, ct); + return exists ? Conflict() : NotFound(); + } + + return Ok(); + } +} diff --git a/API/Controller/Admin/EmailOutboxDelete.cs b/API/Controller/Admin/EmailOutboxDelete.cs new file mode 100644 index 00000000..d44770e6 --- /dev/null +++ b/API/Controller/Admin/EmailOutboxDelete.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Permanently deletes an email outbox message. If the delivery consumer is mid-send it simply finds + /// the row gone and does nothing, so a delete never corrupts an in-flight send. + /// + /// Deleted + /// Unauthorized + /// No such message + [HttpDelete("email/outbox/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteEmailOutbox([FromRoute] Guid id, CancellationToken ct) + { + var nDeleted = await _db.EmailOutbox.Where(m => m.Id == id).ExecuteDeleteAsync(ct); + + return nDeleted == 0 ? NotFound() : Ok(); + } +} diff --git a/API/Controller/Admin/EmailOutboxList.cs b/API/Controller/Admin/EmailOutboxList.cs new file mode 100644 index 00000000..2348f71e --- /dev/null +++ b/API/Controller/Admin/EmailOutboxList.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Query; +using System.ComponentModel.DataAnnotations; +using System.Net.Mime; +using Z.EntityFramework.Plus; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Lists email outbox messages, paginated. Supports the same OData-style + /// $filter/$orderby as the users listing (e.g. status eq 'failed', + /// recipient ilike '%@example.com'). Defaults to newest first. + /// + /// Paginated email outbox messages + /// Unauthorized + [HttpGet("email/outbox")] + [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task GetEmailOutbox( + [FromQuery(Name = "$filter")] string filterQuery = "", + [FromQuery(Name = "$orderby")] string orderbyQuery = "", + [FromQuery(Name = "$offset")][Range(0, int.MaxValue)] int offset = 0, + [FromQuery(Name = "$limit")][Range(1, 1000)] int limit = 100 + ) + { + var query = _db.EmailOutbox.AsNoTracking(); + + try + { + if (!string.IsNullOrEmpty(filterQuery)) + { + query = query.ApplyFilter(filterQuery); + } + + if (!string.IsNullOrEmpty(orderbyQuery)) + { + query = query.ApplyOrderBy(orderbyQuery); + } + else + { + query = query.OrderByDescending(m => m.CreatedAt); + } + } + catch (QueryStringTokenizerException e) + { + return Problem(ExpressionError.QueryStringInvalidError(e.Message)); + } + catch (DBExpressionBuilderException e) + { + return Problem(ExpressionError.ExpressionExceptionError(e.Message)); + } + catch (FormatException e) + { + return Problem(ExpressionError.ExpressionExceptionError(e.Message)); + } + + var deferredCount = query.DeferredLongCount().FutureValue(); + + if (offset != 0) + { + query = query.Skip(offset); + } + + var deferredMessages = query.Take(limit).Future(); + + return Ok(new Paginated + { + Data = (await deferredMessages.ToArrayAsync()).Select(EmailOutboxMessageDto.FromModel).ToArray(), + Offset = offset, + Limit = limit, + Total = await deferredCount.ValueAsync(), + }); + } +} diff --git a/API/Controller/Admin/EmailOutboxRequeue.cs b/API/Controller/Admin/EmailOutboxRequeue.cs new file mode 100644 index 00000000..318ace5d --- /dev/null +++ b/API/Controller/Admin/EmailOutboxRequeue.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.RedisPubSub; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Requeues a terminal (sent/failed/skipped) email outbox message: resets it to pending, due now, + /// with a fresh attempt budget, and nudges the delivery consumer to send it immediately. + /// + /// Requeued + /// Unauthorized + /// No such message + /// Message is pending or currently being sent + [HttpPost("email/outbox/{id}/requeue")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task RequeueEmailOutbox([FromRoute] Guid id, [FromServices] IRedisPubService redisPubService, CancellationToken ct) + { + var nowUtc = DateTime.UtcNow; + + // Guard on a terminal status so a concurrent Cron send (Sending) or an already-queued row + // (Pending) is left alone - the WHERE simply matches nothing rather than racing the send. + var updated = await _db.EmailOutbox + .Where(m => m.Id == id && (m.Status == EmailStatus.Failed || m.Status == EmailStatus.Skipped || m.Status == EmailStatus.Sent)) + .ExecuteUpdateAsync(s => s + .SetProperty(m => m.Status, EmailStatus.Pending) + .SetProperty(m => m.NextAttemptAt, nowUtc) + .SetProperty(m => m.AttemptCount, 0) + .SetProperty(m => m.LastError, (string?)null) + .SetProperty(m => m.FailedAt, (DateTime?)null) + .SetProperty(m => m.SentAt, (DateTime?)null), ct); + + if (updated == 0) + { + var exists = await _db.EmailOutbox.AnyAsync(m => m.Id == id, ct); + return exists ? Conflict() : NotFound(); + } + + await redisPubService.SendEmailOutboxPending(); + + return Ok(); + } +} diff --git a/API/Controller/Admin/EmailOutboxStats.cs b/API/Controller/Admin/EmailOutboxStats.cs new file mode 100644 index 00000000..77e10a10 --- /dev/null +++ b/API/Controller/Admin/EmailOutboxStats.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.OpenShockDb; +using System.Net.Mime; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Returns the number of email outbox messages in each delivery state. + /// + /// Per-status counts + /// Unauthorized + [HttpGet("email/outbox/stats")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task GetEmailOutboxStats() + { + var counts = await _db.EmailOutbox + .AsNoTracking() + .GroupBy(m => m.Status) + .Select(g => new { Status = g.Key, Count = g.LongCount() }) + .ToDictionaryAsync(x => x.Status, x => x.Count); + + long Count(EmailStatus status) => counts.GetValueOrDefault(status); + + return new EmailOutboxStatsDto + { + Pending = Count(EmailStatus.Pending), + Sending = Count(EmailStatus.Sending), + Sent = Count(EmailStatus.Sent), + Failed = Count(EmailStatus.Failed), + Skipped = Count(EmailStatus.Skipped), + Total = counts.Values.Sum() + }; + } +} diff --git a/API/Controller/Admin/EmailOutboxTestSend.cs b/API/Controller/Admin/EmailOutboxTestSend.cs new file mode 100644 index 00000000..1213f383 --- /dev/null +++ b/API/Controller/Admin/EmailOutboxTestSend.cs @@ -0,0 +1,36 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.RedisPubSub; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Enqueues a preview/test email of the chosen type to the chosen address. It flows through the + /// normal durable delivery pipeline - visible in the outbox listing - but is rendered with + /// placeholder data and a dummy link, so it touches no request row and mints no token. + /// + /// Test email enqueued + /// Invalid recipient address + /// Unauthorized + [HttpPost("email/test")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SendTestEmail([FromBody] SendTestEmailDto body, [FromServices] IRedisPubService redisPubService, CancellationToken ct) + { + // CreatedAt / NextAttemptAt / AttemptCount are filled by their DB defaults (see the context + // mapping) and read back after insert, so the returned DTO carries the real values. + var message = EmailOutboxMessage.ForPreview(body.Type, body.Recipient, body.RecipientName); + + _db.EmailOutbox.Add(message); + await _db.SaveChangesAsync(ct); + + await redisPubService.SendEmailOutboxPending(); + + return Ok(EmailOutboxMessageDto.FromModel(message)); + } +} diff --git a/Common.Tests/OpenShockDb/EmailOutboxMessageTests.cs b/Common.Tests/OpenShockDb/EmailOutboxMessageTests.cs index 0a2a6b9c..f7f806a4 100644 --- a/Common.Tests/OpenShockDb/EmailOutboxMessageTests.cs +++ b/Common.Tests/OpenShockDb/EmailOutboxMessageTests.cs @@ -35,4 +35,17 @@ public async Task Create_AllowsNullRecipientName() await Assert.That(message.RecipientName).IsNull(); await Assert.That(message.Payload[EmailOutboxPayloadKeys.NewEmail]).IsEqualTo("new@example.com"); } + + [Test] + public async Task ForPreview_FlagsPreviewAndNeverCoalesces() + { + var message = EmailOutboxMessage.ForPreview(EmailType.PasswordReset, "admin@example.com", "Admin"); + + await Assert.That(message.Status).IsEqualTo(EmailStatus.Pending); + await Assert.That(message.Type).IsEqualTo(EmailType.PasswordReset); + await Assert.That(message.Recipient).IsEqualTo("admin@example.com"); + // Never coalesced, so every test send is delivered on its own. + await Assert.That(message.CoalesceKey).IsNull(); + await Assert.That(message.Payload[EmailOutboxPayloadKeys.Preview]).IsEqualTo("true"); + } } diff --git a/Common/OpenShockDb/Keys/EmailOutboxPayloadKeys.cs b/Common/OpenShockDb/Keys/EmailOutboxPayloadKeys.cs index ba9781e3..9919fab9 100644 --- a/Common/OpenShockDb/Keys/EmailOutboxPayloadKeys.cs +++ b/Common/OpenShockDb/Keys/EmailOutboxPayloadKeys.cs @@ -21,4 +21,12 @@ public static class EmailOutboxPayloadKeys /// The newly requested address. Used by . public const string NewEmail = "newEmail"; + + /// + /// Present (value "true") on a preview/test message enqueued by an admin. It marks the row so + /// the delivery dispatcher renders the chosen 's template with placeholder + /// data and a dummy link instead of looking up a real request row or minting a token. Never set on + /// a real transactional email. + /// + public const string Preview = "preview"; } diff --git a/Common/OpenShockDb/Models/EmailOutboxMessage.cs b/Common/OpenShockDb/Models/EmailOutboxMessage.cs index 53bc5f79..fcaaa24e 100644 --- a/Common/OpenShockDb/Models/EmailOutboxMessage.cs +++ b/Common/OpenShockDb/Models/EmailOutboxMessage.cs @@ -156,4 +156,15 @@ public static EmailOutboxMessage ForEmailVerification(Guid emailChangeId, Guid u public static EmailOutboxMessage ForEmailChangeNotice(string newEmail, string recipient, string? recipientName) => Create(EmailType.EmailChangeNotice, recipient, recipientName, new Dictionary { [EmailOutboxPayloadKeys.NewEmail] = newEmail }); + + /// + /// A preview/test email of the given , enqueued by an admin to verify the + /// provider end to end. Carries only the flag: the + /// delivery dispatcher renders that type's template with placeholder data and a dummy link rather + /// than touching any request row or minting a token. Always delivered (no coalesce key), so every + /// test send goes out on its own. + /// + public static EmailOutboxMessage ForPreview(EmailType type, string recipient, string? recipientName) => + Create(type, recipient, recipientName, + new Dictionary { [EmailOutboxPayloadKeys.Preview] = "true" }); } diff --git a/Cron/Services/Email/Outbox/EmailOutboxDispatcher.cs b/Cron/Services/Email/Outbox/EmailOutboxDispatcher.cs index dc3d2587..88046ceb 100644 --- a/Cron/Services/Email/Outbox/EmailOutboxDispatcher.cs +++ b/Cron/Services/Email/Outbox/EmailOutboxDispatcher.cs @@ -28,6 +28,14 @@ public async Task SendAsync(EmailOutboxMessage message, Ope { try { + // Admin preview/test send: render the chosen type's template with placeholder data and a + // dummy link, bypassing the request-row lookup and token minting entirely. Kept in front of + // the normal switch so a preview can never touch or mutate a real request row. + if (message.Payload.ContainsKey(EmailOutboxPayloadKeys.Preview)) + { + return await SendPreview(message, cancellationToken); + } + return message.Type switch { EmailType.AccountActivation => await SendAccountActivation(message, db, cancellationToken), @@ -110,6 +118,27 @@ private async Task SendEmailChangeNotice(EmailOutboxMessage return FromSend(await _emailService.EmailChangeNotice(ToContact(message), newEmail, ct)); } + /// + /// Delivers an admin preview/test message (see ): renders + /// the chosen type's real template so an operator can confirm the provider works and see the actual + /// layout, but with a placeholder link/address instead of a real, working secret. Touches no request + /// row and mints no token, so it is safe to send to any address. + /// + private async Task SendPreview(EmailOutboxMessage message, CancellationToken ct) + { + var to = ToContact(message); + var link = new Uri(_frontendOptions.BaseUrl, "/preview/example-link"); + + return message.Type switch + { + EmailType.AccountActivation => FromSend(await _emailService.ActivateAccount(to, link, ct)), + EmailType.PasswordReset => FromSend(await _emailService.PasswordReset(to, link, ct)), + EmailType.EmailVerification => FromSend(await _emailService.VerifyEmail(to, link, ct)), + EmailType.EmailChangeNotice => FromSend(await _emailService.EmailChangeNotice(to, "new-address@example.com", ct)), + _ => EmailDispatchResult.Permanent($"Unknown email type {message.Type}") + }; + } + /// /// Mints a fresh secret token, applies its hash to the request row via , /// and persists it before the email is sent so the emailed link always matches a stored hash.