Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions API/Controller/Admin/DTOs/EmailOutboxBulkRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;

namespace OpenShock.API.Controller.Admin.DTOs;

/// <summary>
/// A set of email outbox message ids to apply a bulk requeue/cancel/delete to.
/// </summary>
public sealed class EmailOutboxBulkRequest
{
[Required]
[MaxLength(1000)]
public required Guid[] Ids { get; set; }
}
11 changes: 11 additions & 0 deletions API/Controller/Admin/DTOs/EmailOutboxBulkResultDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace OpenShock.API.Controller.Admin.DTOs;

/// <summary>
/// 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).
/// </summary>
public sealed class EmailOutboxBulkResultDto
{
public required int Requested { get; set; }
public required int Affected { get; set; }
}
41 changes: 41 additions & 0 deletions API/Controller/Admin/DTOs/EmailOutboxMessageDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using OpenShock.Common.OpenShockDb;

namespace OpenShock.API.Controller.Admin.DTOs;

/// <summary>
/// An <see cref="EmailOutboxMessage"/> 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.
/// </summary>
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<string, string> 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
};
}
14 changes: 14 additions & 0 deletions API/Controller/Admin/DTOs/EmailOutboxStatsDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace OpenShock.API.Controller.Admin.DTOs;

/// <summary>
/// Count of email outbox rows in each delivery state, for the admin mail-queue overview tiles.
/// </summary>
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; }
}
20 changes: 20 additions & 0 deletions API/Controller/Admin/DTOs/SendTestEmailDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using OpenShock.Common.OpenShockDb;

namespace OpenShock.API.Controller.Admin.DTOs;

/// <summary>
/// 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).
/// </summary>
public sealed class SendTestEmailDto
{
/// <summary>Which template to preview.</summary>
public required EmailType Type { get; set; }

/// <summary>Destination address for the test email.</summary>
[OpenShock.Common.DataAnnotations.EmailAddress(true)]
public required string Recipient { get; set; }

/// <summary>Optional display name for the recipient.</summary>
public string? RecipientName { get; set; }
}
82 changes: 82 additions & 0 deletions API/Controller/Admin/EmailOutboxBulk.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <response code="200">Bulk requeue applied</response>
/// <response code="401">Unauthorized</response>
[HttpPost("email/outbox/bulk/requeue")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<EmailOutboxBulkResultDto>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<EmailOutboxBulkResultDto> 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 };
}

/// <summary>
/// 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.
/// </summary>
/// <response code="200">Bulk cancel applied</response>
/// <response code="401">Unauthorized</response>
[HttpPost("email/outbox/bulk/cancel")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<EmailOutboxBulkResultDto>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<EmailOutboxBulkResultDto> 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 };
}

/// <summary>
/// Permanently deletes many email outbox messages at once. Missing ids are skipped. Returns how many
/// rows were actually deleted.
/// </summary>
/// <response code="200">Bulk delete applied</response>
/// <response code="401">Unauthorized</response>
[HttpPost("email/outbox/bulk/delete")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<EmailOutboxBulkResultDto>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<EmailOutboxBulkResultDto> 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 };
}
}
37 changes: 37 additions & 0 deletions API/Controller/Admin/EmailOutboxCancel.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <response code="200">Cancelled</response>
/// <response code="401">Unauthorized</response>
/// <response code="404">No such message</response>
/// <response code="409">Message is not pending</response>
[HttpPost("email/outbox/{id}/cancel")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> 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();
}
}
24 changes: 24 additions & 0 deletions API/Controller/Admin/EmailOutboxDelete.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
/// <summary>
/// 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.
/// </summary>
/// <response code="200">Deleted</response>
/// <response code="401">Unauthorized</response>
/// <response code="404">No such message</response>
[HttpDelete("email/outbox/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteEmailOutbox([FromRoute] Guid id, CancellationToken ct)
{
var nDeleted = await _db.EmailOutbox.Where(m => m.Id == id).ExecuteDeleteAsync(ct);

return nDeleted == 0 ? NotFound() : Ok();
}
}
81 changes: 81 additions & 0 deletions API/Controller/Admin/EmailOutboxList.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Lists email outbox messages, paginated. Supports the same OData-style
/// <c>$filter</c>/<c>$orderby</c> as the users listing (e.g. <c>status eq 'failed'</c>,
/// <c>recipient ilike '%@example.com'</c>). Defaults to newest first.
/// </summary>
/// <response code="200">Paginated email outbox messages</response>
/// <response code="401">Unauthorized</response>
[HttpGet("email/outbox")]
[ProducesResponseType<Paginated<EmailOutboxMessageDto>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<IActionResult> 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<EmailOutboxMessageDto>
{
Data = (await deferredMessages.ToArrayAsync()).Select(EmailOutboxMessageDto.FromModel).ToArray(),
Offset = offset,
Limit = limit,
Total = await deferredCount.ValueAsync(),
});
}
}
48 changes: 48 additions & 0 deletions API/Controller/Admin/EmailOutboxRequeue.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <response code="200">Requeued</response>
/// <response code="401">Unauthorized</response>
/// <response code="404">No such message</response>
/// <response code="409">Message is pending or currently being sent</response>
[HttpPost("email/outbox/{id}/requeue")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> 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();
}
}
Loading
Loading