Skip to content
fullstackhero

Reference

Mailing building block

SMTP or SendGrid email abstraction behind a single IMailService, with multi-recipient delivery and HTML body support.

views 0 Last updated

The Mailing block is the email abstraction the kit’s transactional emails go through. Identity uses it for email confirmation, password reset, and welcome flows; any other module that needs to send mail does so via IMailService. Two implementations ship - SMTP via MailKit / MimeKit, and SendGrid via the official SDK - picked at startup from a single UseSendGrid flag.

What it ships

Extension

  • AddHeroMailing(services) - binds MailOptions, registers one process-wide ISendGridClient singleton (per-send client construction leaks sockets), and a transient IMailService factory that returns SendGridMailService when MailOptions:UseSendGrid is true, otherwise SmtpMailService.

Service interface

public interface IMailService
{
Task SendAsync(MailRequest request, CancellationToken ct);
}

Request model

MailRequest is a constructor-built class - recipients always come as a Collection<string>:

public class MailRequest(
Collection<string> to,
string subject,
string? body = null,
string? from = null,
string? displayName = null,
string? replyTo = null,
string? replyToName = null,
Collection<string>? bcc = null,
Collection<string>? cc = null,
IDictionary<string, byte[]>? attachmentData = null,
IDictionary<string, string>? headers = null,
string? textBody = null);

Body is the HTML part in both implementations (MailKit’s BodyBuilder.HtmlBody; SendGrid’s htmlContent), and TextBody is the optional text/plain alternative sent alongside it as multipart/alternative. From/DisplayName on the request override the configured defaults per send.

:::caution[Write HTML in Body, not bare text] Because Body always lands in the HTML part, plain text placed there is parsed as markup. Two consequences bite in practice: a bare URL is not turned into a link - most clients only auto-link inside text/plain - so an action link arrives as dead text the user cannot click; and any interpolated value (a person’s name, a tenant’s name) is read as markup rather than shown. Build real HTML with an <a href>, HTML-encode every interpolated value, and put the plain wording in TextBody. :::

HtmlEmail helper

FSH.Framework.Mailing.HtmlEmail is the shared shell and the single encoder for outbound mail, so no module ships its own weaker escaping:

  • Encode(value) - full HTML encoding (WebUtility.HtmlEncode, including quotes and apostrophes), safe in both element content and attributes.
  • Shell(heading, innerHtml) - wraps markup in the kit’s document: doctype, charset, viewport and the centred card. heading is encoded; innerHtml is trusted and inserted verbatim, so build it from literals plus Encoded values - never pass user input straight through.
  • LinkAction(heading, intro, actionUrl, actionLabel) - a single-action message: the link as a real button anchor, with the address repeated as text for clients that strip buttons.
  • Notice(heading, message) - a short informational message with no link.
string html = HtmlEmail.LinkAction(
"Reset your password",
"Use the button below to choose a new password.",
resetUri,
"Reset password");
var mail = new MailRequest(to, "Reset your password", html, textBody: $"Reset your password: {resetUri}");

Always pair it with a TextBody alternative.

Implementations

  • SmtpMailService - uses MailKit + MimeKit. Reads MailOptions:Smtp:* for host, port, credentials and connection security; opens a connection per send with the MailOptions:Smtp:Security mode (STARTTLS unless configured otherwise).
  • SendGridMailService - uses the SendGrid SDK via the shared ISendGridClient. One API call per MailRequest; the first To address is the primary recipient, Cc/Bcc/ReplyTo/attachments map onto the SendGrid message.

Options

  • MailOptions - From, DisplayName, UseSendGrid (bool), Smtp (Host / Port / UserName / Password / Security), SendGrid (ApiKey plus optional From / DisplayName overrides).

How modules consume Mailing

Inject IMailService and call SendAsync - or better, do what Identity does and push the send onto the Hangfire email queue so a slow SMTP server never blocks the request:

var mailRequest = new MailRequest(
new Collection<string> { user.Email },
"Confirm Your Email Address",
emailBody,
textBody: $"Please confirm your email address using the following link: {emailVerificationUri}");
jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken));

Identity uses this shape for its email flows: email confirmation, password reset, and the welcome mail. Password reset and the welcome mail render through HtmlEmail (LinkAction / Notice); the confirmation e-mail still builds its own document for now. The billing e-mails in the Notifications module render inside HtmlEmail.Shell and encode every value with HtmlEmail.Encode. In each case the textBody argument carries the same wording in plain text.

Configuration

SMTP

{
"MailOptions": {
"From": "no-reply@example.com",
"DisplayName": "fullstackhero",
"UseSendGrid": false,
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"UserName": "smtp-user",
"Password": "set-via-secrets",
"Security": "StartTls"
}
}
}

Security is MailKit’s SecureSocketOptions, bound by name. It defaults to StartTls, which is what the service always did before the setting existed, so leaving it out changes nothing.

ValueUse it for
StartTls (default)Submission on port 587: connect in plain text, then upgrade with STARTTLS. Fails with NotSupportedException if the server does not offer STARTTLS.
SslOnConnectImplicit TLS, usually port 465.
StartTlsWhenAvailableUpgrade if the server offers STARTTLS, otherwise stay in plain text.
AutoLet MailKit pick the TLS mode; if the server supports no SSL or TLS, the connection continues unencrypted.
NonePlain SMTP with no TLS, for a local catcher such as Mailpit, MailHog or smtp4dev. Never for a real provider: credentials and mail would cross the network unencrypted.

An unknown name (a typo such as Plain) makes the options fail to bind, so the API does not start rather than guessing a mode.

Local mail catcher (Docker Compose)

deploy/docker/docker-compose.yml reads the SMTP target from .env and ships a Mailpit service (axllent/mailpit:v1.31.3) under the mail-catcher compose profile. The defaults in .env.example enable that profile and point the API at it, so confirmation, password-reset and welcome e-mails work without an SMTP account, and none of them is delivered:

.env
COMPOSE_PROFILES=mail-catcher
FSH_SMTP_HOST=mailpit
FSH_SMTP_PORT=1025
FSH_SMTP_SECURITY=None
FSH_SMTP_USERNAME=
FSH_SMTP_PASSWORD=
FSH_MAIL_FROM=no-reply@fsh.local

The compose file maps these to MailOptions__Smtp__Host, Port, Security, UserName, Password and MailOptions__From on the api service. Mailpit’s SMTP port stays on the compose network. Its inbox UI is published on the host loopback only, at http://localhost:8025 (FSH_MAILPIT_PORT), because it holds live password-reset and confirmation links.

For real delivery, edit .env only, never the compose file: set the FSH_SMTP_* values to your provider (FSH_SMTP_SECURITY=StartTls on port 587, SslOnConnect on 465), set FSH_MAIL_FROM to a sender it accepts (appsettings.Production.json leaves MailOptions:From blank), and delete the COMPOSE_PROFILES line so the catcher no longer runs.

SendGrid

{
"MailOptions": {
"From": "no-reply@example.com",
"DisplayName": "fullstackhero",
"UseSendGrid": true,
"SendGrid": {
"ApiKey": "set-via-secrets"
}
}
}

How to extend

Add an SES (or Mailgun, Postmark…) provider

Implement IMailService against the SDK of your choice and register it in place of the existing implementations. Both SmtpMailService and SendGridMailService are templates of “translate MailRequest to the provider’s API.”

Add templating

Build a separate IMailTemplateRenderer service. Resolve a Razor or Scriban template, render to HTML, then call IMailService.SendAsync. Keep templating outside IMailService so you can swap providers without rewriting templates.

Add a retry policy

Microsoft.Extensions.Http.Resilience (already a dep of the Web block) lets you wrap any HttpClient (SendGrid uses one) with a Polly v8 pipeline. For SMTP, wrap SmtpMailService with a decorator that retries on transient failures (timeout, 4xx with try-again codes).

Gotchas

  • MailRequest.To is always a Collection<string> - even for one recipient. Note the SendGrid path builds a single email to To[0] (extra recipients ride along only as Cc/Bcc); the SMTP path addresses everyone in To. Bulk lists of 100+ recipients should be split into multiple SendAsync calls so a single bad address doesn’t fail the whole batch.
  • No built-in templating. This is by design. If you need it, ship IMailTemplateRenderer next to your handlers.
  • SmtpMailService opens a fresh connection per send. Heavy traffic? Switch to SendGrid or wrap with a pool. The kit doesn’t ship a long-lived SMTP client.
  • SendGrid charges per API call. Bulk personalisation via the SendGrid Personalisations API is faster and cheaper for newsletter-style use. The kit’s wrapper is the transactional path; replace it for marketing.
  • Plaintext credentials in appsettings.json are a footgun. Use environment variables, user-secrets, or your cloud secrets manager. Never check MailOptions:Smtp:Password into git.

Critical files

  • src/BuildingBlocks/Mailing/Extensions.cs
  • src/BuildingBlocks/Mailing/Services/IMailService.cs
  • src/BuildingBlocks/Mailing/Services/SmtpMailService.cs
  • src/BuildingBlocks/Mailing/Services/SendGridMailService.cs
  • src/BuildingBlocks/Mailing/MailOptions.cs
  • src/BuildingBlocks/Mailing/HtmlEmail.cs
  • Web - FshPlatformOptions.EnableMailing toggle.
  • Identity module - the biggest consumer (confirmation, reset, welcome).
  • Notifications module - in-app inbox; pair it with email for double-channel delivery.