mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
A send that the mail provider did not accept finished its background transaction as ok, so rejected reset emails looked delivered in tracing. It now finishes as an error with a fixed message that names no recipient.
129 lines
5.3 KiB
C#
129 lines
5.3 KiB
C#
using System.Threading.Channels;
|
|
using Api.SeaHavenIndustries.Observability;
|
|
using SeaHaven.Services.Interfaces;
|
|
using Sentry;
|
|
|
|
namespace Api.SeaHavenIndustries.HostedServices
|
|
{
|
|
public static class PasswordResetEmailDelivery
|
|
{
|
|
/// <summary>
|
|
/// Registers the process-wide reset email queue and the background service that
|
|
/// drains it. Both must be singletons: the request and the sender share one channel.
|
|
/// </summary>
|
|
public static IServiceCollection AddPasswordResetEmailDelivery(this IServiceCollection services)
|
|
{
|
|
services.AddSingleton<PasswordResetEmailChannel>();
|
|
services.AddSingleton<IPasswordResetEmailQueue>(provider => provider.GetRequiredService<PasswordResetEmailChannel>());
|
|
services.AddHostedService<PasswordResetEmailSenderHostedService>();
|
|
return services;
|
|
}
|
|
}
|
|
|
|
public sealed record PasswordResetEmail(string EmailTo, string Subject, string Body);
|
|
|
|
public sealed class PasswordResetEmailChannel : IPasswordResetEmailQueue
|
|
{
|
|
public const int Capacity = 1000;
|
|
|
|
private readonly Channel<PasswordResetEmail> _channel = Channel.CreateBounded<PasswordResetEmail>(
|
|
new BoundedChannelOptions(Capacity)
|
|
{
|
|
// Wait, not DropWrite: with DropWrite, TryWrite reports success and discards
|
|
// the email, so a full queue would still count the request. TryWrite never
|
|
// blocks; under Wait it returns false when the queue is full.
|
|
FullMode = BoundedChannelFullMode.Wait,
|
|
SingleReader = true
|
|
});
|
|
private readonly ILogger<PasswordResetEmailChannel> _logger;
|
|
private int _pending;
|
|
|
|
public PasswordResetEmailChannel(ILogger<PasswordResetEmailChannel> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public ChannelReader<PasswordResetEmail> Reader => _channel.Reader;
|
|
|
|
/// <summary>Emails accepted and not yet handed to the mail provider.</summary>
|
|
public int Pending => Volatile.Read(ref _pending);
|
|
|
|
public bool TryEnqueue(string emailTo, string subject, string body)
|
|
{
|
|
Interlocked.Increment(ref _pending);
|
|
if (_channel.Writer.TryWrite(new PasswordResetEmail(emailTo, subject, body)))
|
|
return true;
|
|
|
|
Interlocked.Decrement(ref _pending);
|
|
_logger.LogWarning("Password reset email queue is full; an email was dropped.");
|
|
return false;
|
|
}
|
|
|
|
public void MarkHandled() => Interlocked.Decrement(ref _pending);
|
|
}
|
|
|
|
public sealed class PasswordResetEmailSenderHostedService : BackgroundService
|
|
{
|
|
private readonly PasswordResetEmailChannel _channel;
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly ILogger<PasswordResetEmailSenderHostedService> _logger;
|
|
private readonly IHub _sentryHub;
|
|
|
|
public PasswordResetEmailSenderHostedService(
|
|
PasswordResetEmailChannel channel,
|
|
IServiceScopeFactory scopeFactory,
|
|
ILogger<PasswordResetEmailSenderHostedService> logger,
|
|
IHub sentryHub)
|
|
{
|
|
_channel = channel;
|
|
_scopeFactory = scopeFactory;
|
|
_logger = logger;
|
|
_sentryHub = sentryHub;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
await foreach (var email in _channel.Reader.ReadAllAsync(stoppingToken))
|
|
{
|
|
using var transaction = SentryObservability.BeginBackgroundTransaction(
|
|
_sentryHub,
|
|
"auth.password-reset-email",
|
|
$"{nameof(PasswordResetEmailSenderHostedService)}.{nameof(SendAsync)}");
|
|
try
|
|
{
|
|
if (await SendAsync(email))
|
|
transaction.FinishOk();
|
|
else
|
|
transaction.FinishError(new InvalidOperationException("The mail provider did not accept the password reset email."));
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
transaction.FinishCancelled();
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// The message can echo the recipient or the body, so only the type is logged.
|
|
_logger.LogError("Password reset email failed with {ExceptionType}.", ex.GetType().FullName);
|
|
transaction.FinishError(ex);
|
|
}
|
|
finally
|
|
{
|
|
_channel.MarkHandled();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>True when the mail provider accepted the email.</summary>
|
|
private async Task<bool> SendAsync(PasswordResetEmail email)
|
|
{
|
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
|
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
|
|
if (await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body))
|
|
return true;
|
|
|
|
_logger.LogWarning("Password reset email was not accepted by the mail provider.");
|
|
return false;
|
|
}
|
|
}
|
|
}
|