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.
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);
}
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)));
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);
}
}
}
}
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)}");
}
}
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)