Email Sending & Confirmation Tokens
Sending transactional email with embedded HTML/text templates, and a generic token-based confirmation flow for anything that needs "click this link to confirm" (sign-up, cancellation, password setup) without touching the database until the link is actually clicked.
Email templates as embedded resources, not Razor
Each email has a matching HTML and plain-text file embedded as a resource, with a shared HTML layout for header/footer. A static EmailTemplates class does simple {{Token}} placeholder substitution — no Razor rendering engine needed for something this small, and the templates stay easy to preview by just opening the HTML file in a browser.
public class EmailMessage
{
public string ToEmail { get; set; } = string.Empty;
public string ToName { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string HtmlBody { get; set; } = string.Empty;
public string TextBody { get; set; } = string.Empty;
}
public static class EmailTemplates
{
public static EmailMessage EnrollmentConfirmation(string email, string fullName, string link)
{
var values = new Dictionary<string, string>
{
["FullName"] = fullName,
["Email"] = email,
["Link"] = link
};
return new EmailMessage
{
ToEmail = email,
ToName = fullName,
Subject = "Confirm your enrollment",
HtmlBody = RenderHtml("EnrollmentConfirmation.html", "Confirm your enrollment", values),
TextBody = Fill(Read("EnrollmentConfirmation.txt"), values, encode: false)
};
}
// WithdrawalConfirmation(...) and PasswordSetup(...) follow the exact same shape
private static string RenderHtml(string fileName, string title, Dictionary<string, string> values)
{
string content = Fill(Read(fileName), values, encode: true);
return Read("Layout.html")
.Replace("{{Title}}", WebUtility.HtmlEncode(title))
.Replace("{{Content}}", content);
}
private static string Fill(string template, Dictionary<string, string> values, bool encode)
{
foreach (var value in values)
template = template.Replace("{{" + value.Key + "}}", encode ? WebUtility.HtmlEncode(value.Value) : value.Value);
return template;
}
private static string Read(string fileName)
{
string resourceName = $"MyApp.Application.Templates.Email.{fileName}";
using Stream stream = typeof(EmailTemplates).Assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"The email template '{fileName}' was not found.");
using StreamReader reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
The HTML body is HTML-encoded on substitution so a name likeO'Brien & Cocan never break out of the markup; the plain-text body is filled unencoded since it has no markup to break. Both templates need to be marked as<EmbeddedResource>in the.csproj(or just dropped under a folder with a wildcard include), otherwiseGetManifestResourceStreamcomes back null at runtime instead of failing the build.
Sending: SMTP client wrapped behind an interface
Same shape as password hashing — wrap the third-party library (MailKit here) behind a one-method interface so the rest of the app never imports MailKit directly, and swapping providers later touches one class.
public interface IEmailSender
{
Task<bool> SendAsync(EmailMessage message);
}
public class EmailSender : IEmailSender
{
private const int TimeoutInMilliseconds = 10_000;
private readonly EmailSettings _settings; // bound via the Options pattern, see Strongly-Typed Configuration
private readonly ILogger<EmailSender> _logger;
public EmailSender(EmailSettings settings, ILogger<EmailSender> logger)
{
_settings = settings;
_logger = logger;
}
public async Task<bool> SendAsync(EmailMessage message)
{
try
{
using var client = new SmtpClient { Timeout = TimeoutInMilliseconds };
await client.ConnectAsync(_settings.Host, _settings.Port, SecureSocketOptions.Auto);
if (!string.IsNullOrWhiteSpace(_settings.Username))
await client.AuthenticateAsync(_settings.Username, _settings.Password);
await client.SendAsync(message.ToMimeMessage(_settings));
await client.DisconnectAsync(true);
return true;
}
catch (Exception exception)
{
// swallow and report false rather than let a flaky mail server take down
// the request that triggered the email (a sign-up should still succeed
// even if the confirmation mail happens to fail to send)
_logger.LogError(exception, "Sending the email \"{Subject}\" to {Recipient} failed.", message.Subject, message.ToEmail);
return false;
}
}
}
EmailService sits above this as the application-facing API, translating business intent ("send the enrollment confirmation to this person") into a filled-in EmailTemplates call plus a SendAsync:
public interface IEmailService
{
Task<bool> SendEnrollmentConfirmationAsync(string email, string fullName, string link);
Task<bool> SendWithdrawalConfirmationAsync(string email, string fullName, string link);
Task<bool> SendPasswordSetupAsync(string email, string fullName, string link);
}
public class EmailService : IEmailService
{
private readonly IEmailSender _emailSender;
public EmailService(IEmailSender emailSender) => _emailSender = emailSender;
public async Task<bool> SendEnrollmentConfirmationAsync(string email, string fullName, string link)
=> await _emailSender.SendAsync(EmailTemplates.EnrollmentConfirmation(email, fullName, link));
// the other two methods are one-liners in the same shape
}
Generic confirm-by-link token flow
Several actions in the app ("confirm your enrollment", "confirm you want to cancel", "set up your password") all follow the same shape: generate a random token, email a link containing it, and only touch the database once that link is actually clicked. Rather than writing that loop three times, an abstract generic base class owns the token lifecycle and each concrete service only fills in what email to send and what to do once the token comes back.
public interface IConfirmationService<TRequest>
{
Task SendConfirmationAsync(TRequest request);
Task UseConfirmationAsync(string token);
}
public abstract class ConfirmationService<TRequest> : IConfirmationService<TRequest> where TRequest : class
{
// in-memory, process-local store: fine for a confirmation link that expires in
// minutes; a multi-instance deployment would back this with a cache like Redis instead
private readonly ConcurrentDictionary<string, ConfirmationDTO<TRequest>> _confirmations = new();
protected readonly IServiceScopeFactory ServiceScopeFactory;
protected abstract double ExpiryMinutes { get; }
protected abstract string MailError { get; }
protected abstract string TokenError { get; }
protected ConfirmationService(IServiceScopeFactory serviceScopeFactory)
=> ServiceScopeFactory = serviceScopeFactory;
public async Task SendConfirmationAsync(TRequest request)
{
byte[] bytes = RandomNumberGenerator.GetBytes(16);
string token = Convert.ToHexString(bytes).ToLower();
using AsyncServiceScope scope = ServiceScopeFactory.CreateAsyncScope();
if (!await SendEmailAsync(scope, request, token))
throw new ArgumentException(MailError);
_confirmations[token] = new ConfirmationDTO<TRequest>(request, DateTime.UtcNow.AddMinutes(ExpiryMinutes));
}
public async Task UseConfirmationAsync(string token)
{
TRequest? request = _confirmations.Remove(token, out var confirmation) && !confirmation.HasExpired()
? confirmation.Request : null;
if (request is null)
throw new ArgumentException(TokenError);
using AsyncServiceScope scope = ServiceScopeFactory.CreateAsyncScope();
await ProcessConfirmationAsync(scope, request);
}
protected abstract Task<bool> SendEmailAsync(AsyncServiceScope scope, TRequest request, string token);
protected abstract Task ProcessConfirmationAsync(AsyncServiceScope scope, TRequest request);
}
A concrete implementation only decides what email to send and what database write to make once the link is confirmed — the token generation, expiry, and one-time-use removal are inherited for free:
public class EnrollmentConfirmationService : ConfirmationService<EnrollmentRequest>
{
protected override double ExpiryMinutes => 15;
protected override string MailError => "The confirmation email could not be sent. Please try enrolling again.";
protected override string TokenError => "No pending enrollment was found for this link.";
public EnrollmentConfirmationService(IServiceScopeFactory serviceScopeFactory) : base(serviceScopeFactory) { }
protected override async Task<bool> SendEmailAsync(AsyncServiceScope scope, EnrollmentRequest request, string token)
{
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
return await emailService.SendEnrollmentConfirmationAsync(
request.Email!, request.FullName!, $"https://localhost:4200/confirm-enrollment/{token}");
}
protected override async Task ProcessConfirmationAsync(AsyncServiceScope scope, EnrollmentRequest request)
{
// the one write that only happens once the link is actually clicked
var repository = scope.ServiceProvider.GetRequiredService<IEnrollmentRepository>();
await repository.CreateAsync(request);
}
}
Why a scope factory instead of injecting the repository directly:
ConfirmationService<TRequest>is registered as a singleton (it has to be, to hold the in-memory dictionary across requests), but repositories and theDbContextare scoped.IServiceScopeFactory.CreateAsyncScope()is the standard way for a singleton to safely resolve scoped dependencies on demand — one fresh scope per confirmation attempt, disposed right after, rather than capturing a scoped service for the service's entire lifetime.Why the DB write happens in
ProcessConfirmationAsyncand not inSendConfirmationAsync: an unconfirmed enrollment should never exist in the database at all. If the request were saved immediately and only "confirmed" with a boolean flag later, a user who never clicks the link leaves orphaned, half-finished rows behind that something else has to clean up. Holding the pending request in memory means an ignored confirmation email just quietly expires and leaves nothing to clean up.
The controller side is a small, generic pair of endpoints — one to kick off a confirmation, one to consume the token:
[HttpPost]
public async Task<IActionResult> RequestEnrollmentAsync([FromBody] EnrollmentRequest request)
{
await _confirmationService.SendConfirmationAsync(request);
return Ok(ApiResponse<object>.Ok(message: "Check your email to confirm."));
}
[HttpPost("Confirm/{token}")]
public async Task<IActionResult> ConfirmEnrollmentAsync(string token)
{
await _confirmationService.UseConfirmationAsync(token);
return Ok(ApiResponse<object>.Ok(message: "Enrollment confirmed."));
}
Both thrown ArgumentExceptions (bad or expired token, mail delivery failure) fall straight through to the same try/catch → ApiResponse handling every other endpoint already uses, so no special error plumbing is needed here either.
When this pattern is worth reaching for
- Any "confirm via emailed link" flow where you don't want an unconfirmed row sitting in the database.
- A single-instance deployment (or one where "lost confirmations on restart" is an acceptable trade-off) — the token store is in-memory and does not survive a restart or scale-out past one instance.
- Short-lived confirmations (minutes, not days) — there's no background sweep for expired entries here; they're simply treated as invalid on lookup and left to be garbage-collected as the dictionary turns over.