shoc-backend/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs

120 lines
4.7 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)
{
FullMode = BoundedChannelFullMode.DropWrite,
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
{
await SendAsync(email);
transaction.FinishOk();
}
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();
}
}
}
private async Task 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))
_logger.LogWarning("Password reset email was not accepted by the mail provider.");
}
}
}