mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 13:59:04 +00:00
The channel used DropWrite, under which TryWrite reports success and discards the email, so a full queue still counted the request and never sent the code. Wait makes TryWrite return false when the queue is full, without blocking, so the request is released and the user can ask again.
123 lines
5 KiB
C#
123 lines
5 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
|
|
{
|
|
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.");
|
|
}
|
|
}
|
|
}
|