DEV Community

Cover image for Send emails in ASP.NET Core with SendGrid and a background queue
GANESH KUMAR
GANESH KUMAR

Posted on

Send emails in ASP.NET Core with SendGrid and a background queue

Customers expect an email the moment they order, and another each time the order status changes. The first version most of us write sends the email right inside the checkout request. It works until the email provider is slow, or down, and suddenly checkout is slow, or fails, for a reason that has nothing to do with the order.

Here's how to send order emails with SendGrid in ASP.NET Core, with simplified code. Order and status-update emails are part of eCommerceMVCCore, my ASP.NET Core e-commerce platform, so this is a problem I've spent time on.

Rule one: an email must never fail an order

By the time you send the confirmation, the payment is captured and the order is saved. If the email throws, the customer must still see "Thank you for your order", not an error page. So the checkout only queues the email; something else sends it.

Checkout saves the order and queues the email; a background service sends it through SendGrid

A small in-memory queue

System.Threading.Channels makes a simple, fast queue:

public record EmailMessage(string To, string Subject, string Html);

public class EmailQueue
{
    private readonly Channel<EmailMessage> channel =
        Channel.CreateBounded<EmailMessage>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait
        });

    public ValueTask EnqueueAsync(EmailMessage message) => channel.Writer.WriteAsync(message);
    public IAsyncEnumerable<EmailMessage> ReadAllAsync(CancellationToken ct) => channel.Reader.ReadAllAsync(ct);
}
Enter fullscreen mode Exit fullscreen mode

Register it as a singleton, and checkout just does:

await emailQueue.EnqueueAsync(new EmailMessage(
    order.Email,
    $"Order #{order.Id} confirmed",
    await templates.RenderAsync("OrderConfirmation", order)));
Enter fullscreen mode Exit fullscreen mode

A background service that sends

public class EmailSenderService(EmailQueue queue, IServiceScopeFactory scopes, ILogger<EmailSenderService> log)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var message in queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                using var scope = scopes.CreateScope();
                var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
                await sender.SendAsync(message, stoppingToken);
            }
            catch (Exception ex)
            {
                log.LogError(ex, "Couldn't send email to {To}: {Subject}", message.To, message.Subject);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The try/catch is inside the loop on purpose. One failed email is logged, and the service keeps sending the rest.

The scope matters too. A BackgroundService is a singleton, so anything scoped, like a DbContext or a settings service, has to come from a fresh scope.

Sending through SendGrid

The official SendGrid NuGet package keeps it short:

public class SendGridEmailSender(ISettingsService settings) : IEmailSender
{
    public async Task SendAsync(EmailMessage m, CancellationToken ct)
    {
        var client = new SendGridClient(await settings.GetAsync("SendGridApiKey"));
        var from = new EmailAddress(await settings.GetAsync("FromEmail"), await settings.GetAsync("StoreName"));
        var msg = MailHelper.CreateSingleEmail(from, new EmailAddress(m.To), m.Subject, null, m.Html);

        var response = await client.SendEmailAsync(msg, ct);
        if (!response.IsSuccessStatusCode)
            throw new InvalidOperationException($"SendGrid returned {(int)response.StatusCode}: {await response.Body.ReadAsStringAsync(ct)}");
    }
}
Enter fullscreen mode Exit fullscreen mode

SendEmailAsync doesn't throw on a rejected email; it returns the status. Check IsSuccessStatusCode, or failures will pass silently. The most common one is a 403 because the sender address isn't verified.

I read the API key from database-backed settings, so the store owner can paste it into the admin dashboard instead of editing config files.

Status-update emails

When an admin moves an order from "Placed" to "Shipped", the same queue sends the update. Keep the send out of the status change itself: save the new status first, then enqueue the email.

See it end to end: with the ASP.NET Core 10 e-commerce source code, customers get an email when they order and each time an admin updates the status.

The trade-off of an in-memory queue

If the app restarts with emails still in the channel, they're lost. For a small store, the risk is low and the simplicity is worth it. If every email must be delivered, use the outbox pattern: write the email to a database table in the same transaction as the order, and have the background service send unsent rows and mark them sent. It survives restarts, and you get a record of every email for free.

Deliverability is half the job

The code is the easy part. To keep order emails out of spam, verify your domain in SendGrid (SPF and DKIM), send from an address on that domain, and include a plain, readable layout. Test with a Gmail and an Outlook address before launch.

FAQ

How do I send email from ASP.NET Core?

Use a provider's library, such as SendGrid's, or an SMTP client, wrap it behind an IEmailSender interface, and send outside the request when you can. eCommerceMVCCore uses SendGrid, with the API key set from the admin dashboard.

Why do my SendGrid emails go to spam?

Usually because the sending domain isn't authenticated. Set up SPF and DKIM through SendGrid's domain authentication, send from an address on that domain, and avoid image-only emails.

Should I use an in-memory queue, Hangfire or an outbox?

An in-memory Channel is simplest but loses queued emails on restart. Hangfire adds persistence, retries and a dashboard. An outbox table guarantees the email is recorded with the order in one transaction.

Get the source code

Order confirmation and status-update emails through SendGrid are part of eCommerceMVCCore. Get the ASP.NET Core 10 e-commerce source code.

Do you use an in-memory queue, an outbox table, or a job library like Hangfire for emails? Tell me in the comments.

Top comments (0)