Transactional email — account confirmation, password reset, notifications — is a critical application feature that should be decoupled from HTTP request handling. Sending email synchronously in a controller action adds latency, ties the HTTP response to email server availability, and risks timeouts. The correct pattern: queue the email as a background task, return the HTTP response immediately, and let a background worker send the email asynchronously. This lesson covers both direct SMTP sending with MailKit and template-based HTML emails with FluentEmail.
Email Service Implementation
// dotnet add package FluentEmail.Core
// dotnet add package FluentEmail.Smtp
// dotnet add package FluentEmail.Razor (Razor template engine)
// ── Program.cs — FluentEmail registration ─────────────────────────────────
builder.Services
.AddFluentEmail(builder.Configuration["Email:FromAddress"]!, "BlogApp")
.AddRazorRenderer()
.AddSmtpSender(new SmtpClient(builder.Configuration["Email:SmtpHost"])
{
Port = int.Parse(builder.Configuration["Email:SmtpPort"]!),
Credentials = new NetworkCredential(
builder.Configuration["Email:Username"],
builder.Configuration["Email:Password"]),
EnableSsl = true,
});
builder.Services.AddScoped<IEmailService, EmailService>();
// ── Email service ─────────────────────────────────────────────────────────
public class EmailService(IFluentEmail fluentEmail, ILogger<EmailService> logger) : IEmailService
{
public async Task SendConfirmationEmailAsync(
string toEmail, string displayName, string confirmationUrl,
CancellationToken ct = default)
{
var model = new { DisplayName = displayName, ConfirmationUrl = confirmationUrl };
var result = await fluentEmail
.To(toEmail)
.Subject("Confirm your BlogApp account")
.UsingTemplateFromFile(
// Templates/EmailConfirmation.cshtml — Razor template
Path.Combine(AppContext.BaseDirectory, "Templates", "EmailConfirmation.cshtml"),
model)
.SendAsync(ct);
if (!result.Successful)
logger.LogError("Email send failed to {Email}: {Errors}",
toEmail, string.Join(", ", result.ErrorMessages));
}
public async Task SendPasswordResetEmailAsync(
string toEmail, string resetUrl, CancellationToken ct = default)
{
await fluentEmail
.To(toEmail)
.Subject("Reset your BlogApp password")
.UsingTemplateFromFile(
Path.Combine(AppContext.BaseDirectory, "Templates", "PasswordReset.cshtml"),
new { ResetUrl = resetUrl })
.SendAsync(ct);
}
}
// ── Email template (Templates/EmailConfirmation.cshtml) ───────────────────
// @model dynamic
// <!DOCTYPE html>
// <html><body>
// <h1>Welcome, @Model.DisplayName!</h1>
// <p>Please confirm your account:</p>
// <a href="@Model.ConfirmationUrl" style="...">Confirm Email</a>
// </body></html>
appsettings.Development.json. You see the rendered HTML email in the Mailpit UI without needing real email addresses or real SMTP credentials. This eliminates the risk of accidentally sending test emails to real users and speeds up the development loop significantly.Channel<EmailMessage> (Lesson 4) and let a background service process the queue. The HTTP response returns immediately (202 Accepted) and the email sends within seconds. This pattern prevents: HTTP timeouts on slow email servers, cascading failures when the email provider is down, and the user waiting for email sending before getting feedback. The trade-off is slight delay, which is acceptable for transactional email.appsettings.json. Email service credentials with the ability to send thousands of emails are valuable to spammers. A leaked SendGrid API key has been used to send millions of spam emails in minutes, resulting in account suspension and reputational damage. Treat email credentials with the same sensitivity as database passwords.Common Mistakes
Mistake 1 — Sending email synchronously in controller (HTTP timeout on slow SMTP)
❌ Wrong — controller awaits email send; SMTP server is slow; HTTP request times out after 30 seconds.
✅ Correct — queue email to a background channel; return HTTP response immediately.
Mistake 2 — No retry on failed email sends (lost transactional emails)
❌ Wrong — email send fails once; never retried; user never receives confirmation email.
✅ Correct — implement retry logic (Polly retry policy or Hangfire retry); store failed emails in database for manual investigation.