using System.Threading.Channels; using Api.SeaHavenIndustries.Observability; using SeaHaven.Services.Interfaces; using Sentry; namespace Api.SeaHavenIndustries.HostedServices { public static class PasswordResetEmailDelivery { /// /// 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. /// public static IServiceCollection AddPasswordResetEmailDelivery(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); services.AddHostedService(); 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 _channel = Channel.CreateBounded( 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 _logger; private int _pending; public PasswordResetEmailChannel(ILogger logger) { _logger = logger; } public ChannelReader Reader => _channel.Reader; /// Emails accepted and not yet handed to the mail provider. 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 _logger; private readonly IHub _sentryHub; public PasswordResetEmailSenderHostedService( PasswordResetEmailChannel channel, IServiceScopeFactory scopeFactory, ILogger 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(); } } } /// True when the mail provider accepted the email. private async Task SendAsync(PasswordResetEmail email) { await using var scope = _scopeFactory.CreateAsyncScope(); var sender = scope.ServiceProvider.GetRequiredService(); 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; } } }