Tutorial7 October 2026

Send email from ASP.NET Core without blocking the request, and still know when it failed

Inline sends make users wait on your email provider; fire-and-forget loses failures. A database outbox, a BackgroundService that claims rows with SKIP LOCKED, backoff with jitter, and an honest look at duplicates.

In October 2025 someone opened OrchardCore issue #18442 about the Azure Email Communication Services integration. Sending an email, the report says, "locks the foreground thread for the duration of sending the email, which usually takes 5-6 seconds (sometimes considerably more)." The issue suggested the obvious fix, sending it fire-and-forget. The first reply came fourteen minutes later:

We don't want to send e-mails in a fire and forget fashion, because then we won't know if sending fails (both for logging and showing it to the user, if it happened due to a user interaction).

Both positions are right, and the issue is still open. Later in the thread another contributor floated the shape of an answer: "What if we push it into a queue, then have a page that shows all the queued items". This tutorial builds that for a plain ASP.NET Core app on .NET 10, EF Core and PostgreSQL, with nothing but the framework, Npgsql and MailKit.

Why both obvious versions fail

The inline version awaits the provider inside the request:

await db.SaveChangesAsync(cancellationToken);

await sender.SendAsync(ConfirmationFor(order), cancellationToken);

return Results.Ok(new PlaceOrderResponse(order.Id));

With async code no thread is actually blocked while the provider thinks, but the user still is: the response cannot leave until the email has gone. Worse, the order is already committed when the send runs. If the provider times out, the user gets a 500 for an order that exists, and a reasonable user clicks again.

The fire-and-forget version returns at once:

await db.SaveChangesAsync(cancellationToken);

_ = Task.Run(() => sender.SendAsync(ConfirmationFor(order), CancellationToken.None));

return Results.Accepted();

It is fast and it has four problems:

  • Failures go nowhere. Nobody awaits the task, so an exception ends up as an unobserved task exception. There is no retry and no record.
  • Shutdown kills it. The host does not know the task exists. A deploy or a pod eviction stops the process and whatever was in flight is gone.
  • Scopes end. If the sender, or anything it depends on, is a scoped service, the request scope may be disposed before the task runs.
  • Nothing to show. There is no row to query, so you cannot tell a user or a support engineer what happened.

Putting a System.Threading.Channels channel in front of a hosted consumer helps with the first two: the consumer can log failures, retry, and drain on shutdown. But a channel lives in memory, so a crash, or a restart that outlasts the shutdown timeout, still drops whatever was buffered, and there is still nothing to query. The fix for all four is to write the email down before you return.

Step 1: Store the email in the same transaction as the business change

An outbox row holds the message plus the bookkeeping a sender needs: status, attempt count, last error, when to try next, and a lease for crash recovery.

namespace Shop.Outbox;

public enum OutboxEmailStatus
{
    Pending,
    Sending,
    Sent,
    Failed
}

public sealed class OutboxEmail
{
    private const int MaxErrorLength = 2000;

    public Guid Id { get; private set; }
    public string IdempotencyKey { get; private set; } = "";
    public string To { get; private set; } = "";
    public string Subject { get; private set; } = "";
    public string HtmlBody { get; private set; } = "";
    public OutboxEmailStatus Status { get; private set; }
    public int Attempts { get; private set; }
    public string? LastError { get; private set; }
    public DateTimeOffset NextAttemptAt { get; private set; }
    public DateTimeOffset? LockedUntil { get; private set; }
    public DateTimeOffset CreatedAt { get; private set; }
    public DateTimeOffset? SentAt { get; private set; }

    public static OutboxEmail Create(
        string idempotencyKey, string to, string subject, string htmlBody, DateTimeOffset now) => new()
    {
        Id = Guid.CreateVersion7(now),
        IdempotencyKey = idempotencyKey,
        To = to,
        Subject = subject,
        HtmlBody = htmlBody,
        Status = OutboxEmailStatus.Pending,
        NextAttemptAt = now,
        CreatedAt = now
    };

    public EmailMessage ToMessage() => new(Id, IdempotencyKey, To, Subject, HtmlBody);

    public void MarkSent(DateTimeOffset now)
    {
        Status = OutboxEmailStatus.Sent;
        SentAt = now;
        LockedUntil = null;
        LastError = null;
    }

    public void ScheduleRetry(string error, DateTimeOffset nextAttemptAt)
    {
        Status = OutboxEmailStatus.Pending;
        NextAttemptAt = nextAttemptAt;
        LockedUntil = null;
        LastError = Truncate(error);
    }

    public void MarkFailed(string error)
    {
        Status = OutboxEmailStatus.Failed;
        LockedUntil = null;
        LastError = Truncate(error);
    }

    public void ReleaseClaim()
    {
        Status = OutboxEmailStatus.Pending;
        Attempts -= 1;
        LockedUntil = null;
    }

    public void ResetForManualRetry(DateTimeOffset now)
    {
        Status = OutboxEmailStatus.Pending;
        Attempts = 0;
        NextAttemptAt = now;
        LastError = null;
    }

    private static string Truncate(string error) =>
        error.Length <= MaxErrorLength ? error : error[..MaxErrorLength];
}

Store the status as a string so the claim query below can compare it by name, make the idempotency key unique, and index the columns the worker filters on:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<OutboxEmail>(email =>
    {
        email.Property(e => e.Status).HasConversion<string>().HasMaxLength(16);
        email.Property(e => e.LastError).HasMaxLength(2000);
        email.HasIndex(e => e.IdempotencyKey).IsUnique();
        email.HasIndex(e => new { e.Status, e.NextAttemptAt });
    });
}

The examples use UseSnakeCaseNamingConvention() from the EFCore.NamingConventions package, so the table is outbox_emails and the columns are next_attempt_at and so on.

The endpoint adds the order and the email to the same DbContext and calls SaveChangesAsync once. Both rows commit together or neither does, so you can never have an order without its confirmation, or a confirmation for an order that rolled back:

private static async Task<IResult> PlaceOrderAsync(
    PlaceOrderRequest request, ShopDbContext db, TimeProvider clock, CancellationToken cancellationToken)
{
    DateTimeOffset now = clock.GetUtcNow();
    Order order = new()
    {
        Id = Guid.CreateVersion7(now),
        CustomerEmail = request.CustomerEmail,
        Total = request.Total,
        PlacedAt = now
    };

    db.Orders.Add(order);
    db.OutboxEmails.Add(OutboxEmail.Create(
        idempotencyKey: OrderConfirmationKey(order.Id),
        to: order.CustomerEmail,
        subject: "We received your order",
        htmlBody: $"<p>Order {order.Id} is confirmed.</p>",
        now: now));

    await db.SaveChangesAsync(cancellationToken);

    return Results.Accepted(
        $"/orders/{order.Id}/confirmation-email",
        new PlaceOrderResponse(order.Id));
}

private static string OrderConfirmationKey(Guid orderId) => $"order-confirmation:{orderId}";

The request now costs one database round trip. 202 Accepted with a Location header is honest about what happened: the email is accepted, not sent. In an MVC or Razor Pages form you would redirect to a confirmation page instead.

Step 2: Claim due rows safely

You will eventually run more than one instance, so two workers must never pick up the same row. PostgreSQL's FOR UPDATE SKIP LOCKED locks the rows a query selects and skips any row another transaction has already locked. Wrapping it in an UPDATE ... RETURNING claims a batch and hands it back in a single atomic statement:

private Task<List<OutboxEmail>> ClaimDueAsync(ShopDbContext db, CancellationToken cancellationToken)
{
    DateTimeOffset now = clock.GetUtcNow();
    DateTimeOffset leaseExpiresAt = now + settings.ClaimLease;
    int batchSize = settings.BatchSize;

    return db.OutboxEmails
        .FromSql($"""
            UPDATE outbox_emails AS e
            SET status = 'Sending',
                attempts = e.attempts + 1,
                locked_until = {leaseExpiresAt}
            FROM (
                SELECT id
                FROM outbox_emails
                WHERE (status = 'Pending' AND next_attempt_at <= {now})
                   OR (status = 'Sending' AND locked_until < {now})
                ORDER BY next_attempt_at
                LIMIT {batchSize}
                FOR UPDATE SKIP LOCKED
            ) AS due
            WHERE e.id = due.id
            RETURNING e.*
            """)
        .ToListAsync(cancellationToken);
}

A few details matter here. FromSql with an interpolated string turns each hole into a parameter, so this is not string concatenation. Because nothing is composed on top of the query, EF Core sends it exactly as written and tracks the returned entities, so later changes are saved with a normal SaveChangesAsync. The second WHERE branch is crash recovery: a row stuck in Sending whose lease has expired belonged to a worker that died, and it becomes claimable again. Counting the attempt at claim time means a worker that crashes mid-send still uses up an attempt, so a message that reliably crashes the process cannot loop forever.

Step 3: Send, retry with backoff, give up loudly

The sender is an interface you own. Keep it small, and let it say when a failure is permanent:

namespace Shop.Outbox;

public sealed record EmailMessage(Guid Id, string IdempotencyKey, string To, string Subject, string HtmlBody);

public interface IEmailSender
{
    Task SendAsync(EmailMessage message, CancellationToken cancellationToken);
}

public sealed class PermanentEmailFailureException(string message, Exception innerException)
    : Exception(message, innerException);

The worker is a BackgroundService with a handful of settings, a scope factory (the DbContext is scoped and the worker is a singleton) and a counter for outcomes:

public sealed class OutboxOptions
{
    public int BatchSize { get; set; } = 10;
    public int MaxAttempts { get; set; } = 8;
    public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2);
    public TimeSpan BaseRetryDelay { get; set; } = TimeSpan.FromSeconds(30);
    public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromHours(2);
    public TimeSpan SendTimeout { get; set; } = TimeSpan.FromSeconds(20);
    public TimeSpan ClaimLease { get; set; } = TimeSpan.FromMinutes(5);
}

public sealed class OutboxEmailWorker(
    IServiceScopeFactory scopeFactory,
    IOptions<OutboxOptions> options,
    TimeProvider clock,
    IMeterFactory meterFactory,
    ILogger<OutboxEmailWorker> logger) : BackgroundService
{
    private readonly OutboxOptions settings = options.Value;
    private readonly Counter<long> outcomes =
        meterFactory.Create("Shop.Outbox").CreateCounter<long>("outbox.email.outcomes");

It sends each claimed row with its own timeout and records the outcome:

private async Task SendAndRecordAsync(OutboxEmail email, IEmailSender sender)
{
    if (email.Attempts > settings.MaxAttempts)
    {
        RecordPermanentFailure(email, "Worker stopped mid-send on the final attempt; delivery state unknown");
        return;
    }

    using CancellationTokenSource timeout = new(settings.SendTimeout, clock);
    try
    {
        await sender.SendAsync(email.ToMessage(), timeout.Token);
        email.MarkSent(clock.GetUtcNow());
        outcomes.Add(1, new KeyValuePair<string, object?>("outcome", "sent"));
    }
    catch (PermanentEmailFailureException ex)
    {
        RecordPermanentFailure(email, ex.Message);
    }
    catch (Exception ex) when (email.Attempts >= settings.MaxAttempts)
    {
        RecordPermanentFailure(email, ex.Message);
    }
    catch (Exception ex)
    {
        DateTimeOffset retryAt = clock.GetUtcNow() + RetryDelay(email.Attempts);
        email.ScheduleRetry(ex.Message, retryAt);
        outcomes.Add(1, new KeyValuePair<string, object?>("outcome", "retry"));
        logger.LogWarning(
            "Email {EmailId} attempt {Attempt} failed, retrying at {RetryAt}: {Error}",
            email.Id, email.Attempts, retryAt, ex.Message);
    }
}

private void RecordPermanentFailure(OutboxEmail email, string error)
{
    email.MarkFailed(error);
    outcomes.Add(1, new KeyValuePair<string, object?>("outcome", "failed"));
    logger.LogError(
        "Email {EmailId} ({IdempotencyKey}) failed permanently after {Attempts} attempts: {Error}",
        email.Id, email.IdempotencyKey, email.Attempts, error);
}

private TimeSpan RetryDelay(int attempt)
{
    double exponentialSeconds = settings.BaseRetryDelay.TotalSeconds * Math.Pow(2, attempt - 1);
    double cappedSeconds = Math.Min(exponentialSeconds, settings.MaxRetryDelay.TotalSeconds);
    double jitteredSeconds = cappedSeconds / 2 + Random.Shared.NextDouble() * cappedSeconds / 2;
    return TimeSpan.FromSeconds(jitteredSeconds);
}

The delay doubles per attempt up to a cap, and the jitter picks a random point in the upper half of that window. Without jitter, every message that failed during the same provider outage retries at the same instant and hits the provider together when it comes back. A permanent SMTP rejection (a 5xx reply) or an address that does not parse is not worth retrying, so the sender throws PermanentEmailFailureException and the row fails immediately; everything else is treated as transient until the attempts run out.

Step 4: Know when it failed

This is the part that first reply was protecting, and with a table it is just a query. The user-facing side reads the status of the email that belongs to their order:

private static async Task<IResult> GetConfirmationEmailStatusAsync(
    Guid orderId, ShopDbContext db, CancellationToken cancellationToken)
{
    string key = OrderConfirmationKey(orderId);
    ConfirmationEmailStatus? status = await db.OutboxEmails
        .Where(e => e.IdempotencyKey == key)
        .Select(e => new ConfirmationEmailStatus(e.Status, e.SentAt))
        .SingleOrDefaultAsync(cancellationToken);

    return status is null ? Results.NotFound() : Results.Ok(status);
}

Leave LastError out of the user-facing response, since SMTP errors can include server details. Operators get it, along with a way to retry once the cause is fixed:

public static class OutboxAdminEndpoints
{
    public static void MapOutboxAdminEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder admin = app.MapGroup("/admin/outbox").RequireAuthorization("Admin");
        admin.MapGet("/failed", ListFailedAsync);
        admin.MapPost("/{id:guid}/retry", RetryAsync);
    }

    private static Task<List<FailedEmailView>> ListFailedAsync(ShopDbContext db, CancellationToken cancellationToken) =>
        db.OutboxEmails
            .Where(e => e.Status == OutboxEmailStatus.Failed)
            .OrderByDescending(e => e.CreatedAt)
            .Take(100)
            .Select(e => new FailedEmailView(e.Id, e.To, e.Subject, e.Attempts, e.LastError, e.CreatedAt))
            .ToListAsync(cancellationToken);

    private static async Task<IResult> RetryAsync(
        Guid id, ShopDbContext db, TimeProvider clock, CancellationToken cancellationToken)
    {
        OutboxEmail? email = await db.OutboxEmails
            .SingleOrDefaultAsync(e => e.Id == id && e.Status == OutboxEmailStatus.Failed, cancellationToken);
        if (email is null)
        {
            return Results.NotFound();
        }

        email.ResetForManualRetry(clock.GetUtcNow());
        await db.SaveChangesAsync(cancellationToken);
        return Results.Accepted();
    }
}

Nobody watches a page, so alert as well. The worker logs every permanent failure at Error with the email id and idempotency key, and increments the outbox.email.outcomes counter with outcome=failed. Alert on that counter, and on the age of the oldest Pending row: a worker that has quietly stopped produces no failures at all, only a growing queue.

Step 5: Idempotency, and what SMTP cannot promise

There is one window this design cannot close. The provider accepts the message, then the process dies before SaveChangesAsync records Sent. The lease expires, another worker reclaims the row, and the email goes out twice. That is not specific to this code. RFC 1047 described the same gap in SMTP itself in 1988: between the server accepting a message and the client reading the 250 reply, the client "must assume the message was not delivered." SMTP has no way to ask "did you already get this one?", so exactly-once delivery is not available. You choose at-least-once (reclaim stale rows, accept rare duplicates) or at-most-once (never reclaim, accept rare losses). For confirmations and password resets, a rare duplicate is usually the better failure.

What you can do is keep the window narrow and make duplicates identifiable:

  • One key per business event. order-confirmation:{orderId} plus the unique index means the same order can never enqueue two confirmations, whatever the calling code does.
  • Pass the key on. If your provider's HTTP API accepts an idempotency key, send this one, and its own dedupe covers the retry. Over SMTP, a stable Message-ID and a custom header at least let you spot duplicates in logs.
  • Nothing after "accepted" may throw. If disconnecting after a successful send throws, a delivered email becomes a retry. The MailKit sender below treats QUIT as best effort:
public sealed class SmtpEmailSender(IOptions<SmtpOptions> options, ILogger<SmtpEmailSender> logger) : IEmailSender
{
    public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken)
    {
        SmtpOptions smtp = options.Value;
        MimeMessage mime = BuildMime(message, smtp);

        using SmtpClient client = new();
        try
        {
            await client.ConnectAsync(smtp.Host, smtp.Port, SecureSocketOptions.StartTls, cancellationToken);
            await client.AuthenticateAsync(smtp.UserName, smtp.Password, cancellationToken);
            await client.SendAsync(mime, cancellationToken);
        }
        catch (SmtpCommandException ex) when ((int)ex.StatusCode >= 500)
        {
            throw new PermanentEmailFailureException($"SMTP {(int)ex.StatusCode}: {ex.Message}", ex);
        }

        await QuitQuietlyAsync(client, message.Id);
    }

    private static MimeMessage BuildMime(EmailMessage message, SmtpOptions smtp)
    {
        MimeMessage mime = new();
        try
        {
            mime.From.Add(MailboxAddress.Parse(smtp.FromAddress));
            mime.To.Add(MailboxAddress.Parse(message.To));
        }
        catch (ParseException ex)
        {
            throw new PermanentEmailFailureException($"Invalid address: {ex.Message}", ex);
        }

        mime.Subject = message.Subject;
        mime.MessageId = $"{message.Id:N}@{smtp.MessageIdDomain}";
        mime.Headers.Add("X-Idempotency-Key", message.IdempotencyKey);
        mime.Body = new TextPart("html") { Text = message.HtmlBody };
        return mime;
    }

    private async Task QuitQuietlyAsync(SmtpClient client, Guid emailId)
    {
        try
        {
            await client.DisconnectAsync(true, CancellationToken.None);
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "QUIT failed after email {EmailId} was accepted", emailId);
        }
    }
}

SmtpClient here is MailKit's, not System.Net.Mail.SmtpClient, which Microsoft's documentation does not recommend for new development.

Step 6: The loop, and shutting down cleanly

The BackgroundService polls on a PeriodicTimer, drains full batches back to back, and stops claiming as soon as stoppingToken fires:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    using PeriodicTimer timer = new(settings.PollInterval, clock);
    try
    {
        do
        {
            await DrainDueEmailsAsync(stoppingToken);
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }
    catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
    {
        logger.LogInformation("Outbox worker stopped");
    }
}

private async Task DrainDueEmailsAsync(CancellationToken stoppingToken)
{
    int claimed;
    do
    {
        try
        {
            claimed = await ProcessBatchAsync(stoppingToken);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            logger.LogError(ex, "Outbox batch failed; trying again on the next tick");
            return;
        }
    }
    while (claimed == settings.BatchSize && !stoppingToken.IsCancellationRequested);
}

private async Task<int> ProcessBatchAsync(CancellationToken stoppingToken)
{
    await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
    ShopDbContext db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
    IEmailSender sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();

    List<OutboxEmail> batch = await ClaimDueAsync(db, stoppingToken);

    foreach (OutboxEmail email in batch)
    {
        if (stoppingToken.IsCancellationRequested)
        {
            email.ReleaseClaim();
            continue;
        }

        await SendAndRecordAsync(email, sender);
        await db.SaveChangesAsync(CancellationToken.None);
    }

    await db.SaveChangesAsync(CancellationToken.None);
    return batch.Count;
}

Three decisions are deliberate. The send in flight is not cancelled by stoppingToken, because aborting an SMTP conversation halfway is exactly how you land in the duplicate window; it finishes under its own timeout instead. Claimed rows that were not started are released straight back to Pending rather than waiting out their lease. And outcomes are saved with CancellationToken.None, so a shutdown cannot discard the record of a send that already happened. The host's default shutdown timeout (HostOptions.ShutdownTimeout) is 30 seconds, as is Kubernetes' default terminationGracePeriodSeconds, so keep SendTimeout well under that.

Registration ties it together and refuses to start with a lease shorter than a batch could take, which would let a second worker reclaim rows that are still queued in the first worker's batch:

builder.Services.AddDbContext<ShopDbContext>(options => options
    .UseNpgsql(builder.Configuration.GetConnectionString("Shop"))
    .UseSnakeCaseNamingConvention());

builder.Services.AddSingleton(TimeProvider.System);
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();

builder.Services.AddOptions<OutboxOptions>()
    .BindConfiguration("Outbox")
    .Validate(o => o.ClaimLease > o.SendTimeout * o.BatchSize, "ClaimLease must outlast a full batch of sends")
    .ValidateOnStart();
builder.Services.AddHostedService<OutboxEmailWorker>();

If you already run Hangfire, or a message bus with a transactional outbox, use that instead of writing this. The value of building it once is knowing which of these decisions your library makes for you.

If you'd rather not run this yourself

Notavia, the notification API we build, runs this loop as a service. For email, POST /v1/notifications stores the notification as queued and returns 202 Accepted; repeating the request with the same Idempotency-Key header returns the original notification instead of creating a second one, and reusing the key for a different request is rejected. A background processor claims queued rows with FOR UPDATE SKIP LOCKED, retries transient failures with backoff up to a capped number of attempts, puts rows a crashed worker left in Sending back in the queue when it starts, and then marks the notification failed with its last error. You can read the status from GET /v1/notifications/{id} or subscribe to signed notification.sent and notification.failed webhooks. The SMTP caveat above still applies to the final hop. Details are on the Notavia product page.