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.