Rask.Mail — durable transactional email on your database
In practice: Tutorial Ch 5 · recipe send a transactional email · cheat sheet.
Rask.Mail sends transactional email off the request thread, queued in the app's own database — no message
broker, no Redis. Compose an email whose body is a Rask component rendered to HTML, call
Mail.Send, and a hosted worker delivers it later over SMTP, at-least-once, with exponential-backoff
retries. It also sends delayed email and works with zero configuration in development.
Included in
Rask.Server— nothing to install. It is on; an app that does without it says so:app.Configure(c => c.Mail.Off());
Why queue email
Sending mail inline with a request couples the response to a slow, flaky third party: the SMTP server is down, or takes two seconds, and your user waits (or sees an error) for something that isn't really part of their action. You want to return immediately and deliver the mail in the background, durably: if the process restarts the message isn't lost, and a transient SMTP failure is retried rather than dropped.
Rask.Mail persists each email to a table in your database and a hosted worker polls it, so there's nothing
else to run. And because an email body is just a component, you compose it with the same render pipeline
you already use for pages — no separate templating language.
Use
public sealed partial class WelcomeEmail : Component
{
public string Name { get; set; } = "";
protected override Component? Render() =>
Div[H1[$"Welcome, {Name}!"], P["Thanks for signing up."]];
}
// Program.cs
builder.Services.AddRaskMail<AppDbContext>();
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlite("Data Source=app.db"));
// appsettings.json
{
"Rask": {
"Mail": {
"From": "hello@example.com",
"MaxAttempts": 10,
"Smtp": { "Host": "smtp.example.com", "Port": 587, "User": "…" }
}
}
}
The SMTP password does not belong in that committed file: set Rask__Mail__Smtp__Password in the
environment (or Rask:Mail:Smtp:Password in user secrets). A callback — AddRaskMail<AppDbContext>(o => …) —
runs after the section and wins.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
modelBuilder.AddRaskMail(); // maps the QueuedMail table
}
Worked example: tutorial chapter 5 writes the body component and both of the lines above, then leaves you with the SMTP config and the migration.
Add a migration for the new table before running — rask db add AddMail && rask db update
(or dotnet ef migrations add AddMail directly). Then send from a handler, a render, a request or a job — nothing injected:
await Mail.Send(Email
.To(user.Email, user.Name)
.Subject("Welcome")
.Body(WelcomeEmail.Name(user.Name))); // the chain, not new (RASK014)
await Mail.Send(reminder).In(24.Hours); // send later
Zero-config in development
You don't need an SMTP server to develop. If Smtp is not set, Rask.Mail falls back to:
- a pickup directory — set
Rask:Mail:PickupDirectory(say,"sent-mail") and each message is written as an.emlfile you can open in any mail client; or - logging — with neither
SmtpnorPickupDirectoryset, each send is logged ("would send email to …").
Switch to real delivery in production by adding a Rask:Mail:Smtp section — any key under it turns SMTP on,
so Rask__Mail__Smtp__Host in the environment is enough to start. Nothing else changes.
How it works
Email— a fluent builder:To/AndTo/Cc/Bcc/ReplyTo/From,Subject,Body(component)(orBody(html)), an optionalPlainTextalternative, andAttach.Body(component)renders the component to HTML immediately (Component.ToHtml()), so the built email holds only strings and bytes.IMail— writes oneQueuedMailrow (envelope + already-rendered body) through yourIDbContextFactory<TContext>, defaulting the sender fromMailOptions.Fromwhen the message didn't set one. Because the body is rendered at enqueue time, the stored row is self-contained — there's no component to reconstruct when it's sent.MailProcessor<TContext>— a hostedBackgroundServicethat polls onPollIntervalfor due messages (RunAt <= now, oldest first), hands each to the registeredIMailSender, and stampsProcessedAt. On failure it records the error, increments the attempt count, and pushesRunAtout by an exponential backoff (BaseRetryDelay × 2^(attempts-1), capped atMaxRetryDelay), retrying untilMaxAttempts— after which the message is left as a dead letter for inspection. A failing send never crashes the app. Sent messages are purged afterRetention(default 7 days;TimeSpan.Zerokeeps them).- The tenant travels with the message. In an app with multi-tenancy, each row records the tenant in flight when it was queued, and the processor re-enters it before sending — so anything the send reads is scoped as the page that queued it was. A message queued by the host itself records none.
- So does the user. Each row records
Current.UserIdwhen it was queued, and a customIMailSenderreads the same value. Upgrading adds that column:rask db add AddMailUser && rask db update; the processor logs that line until you do. QueuedMailhas a read face, like every Rask.Data entity:QueuedMail.Where(m => m.ProcessedAt == null).Count()is the queue's depth, with no context of your own.IMailSender— the delivery seam.AddRaskMailpicksMailKitSender(SMTP) whenSmtpis set, elsePickupDirectoryMailSender, elseLogMailSender. Register your ownIMailSenderbeforeAddRaskMailto send through a provider API instead.
Shutdown, and the duplicate-send window
On SIGTERM the processor stops picking up new messages immediately, but the send already talking to
your SMTP server gets MailOptions.ShutdownGracePeriod (default 10s — double the other pillars) to
finish rather than being cancelled mid-conversation.
That default is deliberate, and this is the one place where at-least-once has a visible cost:
A shutdown-interrupted send may already have been delivered. Delivery and the row update are not one transaction. Cancel during the SMTP
DATAphase and the client drops the connection to avoid protocol desync — but the server may already have accepted and queued the message. The row still reads unsent, so the next boot sends it again, and the recipient gets it twice.There is no local fix: the send is not idempotent and its outcome is genuinely unknown to us. The only two options are duplicate (leave the row pending) or silent loss (mark it sent optimistically). Rask chooses duplicate — a duplicate email is an annoyance, a lost transactional email is a bug.
What the grace period buys is that the send is no longer racing a token that fires at SIGTERM; it is racing
one that fires ten seconds later, which converts nearly every deploy-interrupted send into a completed one.
rask.mail.interrupted is the direct answer to "did that deploy duplicate any mail?" — a nonzero rate means
your grace period is shorter than your SMTP server's round trip.
An interrupted send does not count a failed attempt, so a redeploy never marches mail toward its dead
letter. ShutdownGracePeriod cannot exceed HostOptions.ShutdownTimeout; TimeSpan.Zero cancels immediately.
Notes
- Inside
Db.Transactiona send is a row in the transaction. Nothing is delivered before the commit, and a rollback leaves no mail behind. The mail table must be in the same database; otherwiseMail.Sendthrows rather than queue a mail for work that may roll back. - Server-side. The processor is a hosted service and the store is your EF Core database — this is not a browser/WASM concern.
- Running more than one instance is safe. Each processor leases the batch it claims, so an email is
sent by exactly one instance. See running more than one instance
— in particular, the lease bounds but does not eliminate a duplicate send. On SQLite you will still usually
run one instance for the unrelated reason that it is single-writer; because
Mail.Sendwrites while the processor may also be writing, useUseRaskSqlite(WAL + abusy_timeout) on your context so a concurrent send waits for the write lock instead of failing withSQLITE_BUSY. Attemptscounts attempts started, not failures. The claim increments it, so a send that takes the process down with it still counts towardMaxAttempts. An email delivered first time showsAttempts = 1.- Mail vs. jobs.
Rask.Mailis a self-contained queue — you don't needRask.Jobs. If you already run jobs and want email as one step of a larger job, send it inline from the job's handler via a customIMailSender; otherwiseMail.Sendis all you need.