Parts · v0.7.0
mailer
Mailer: templated HTML + text email, delivery adapters (SMTP, dev mailbox storage, log, memory), previews, a test email task
- Add it
cargo offrails add mailer- Requires
- nothing
- Needed by
- auth-core, dev-mailbox
- Crates
lettre --no-default-features --features builder,smtp-transport,pool,hostname,tokio1-rustls-tls- Files
11 files at app paths, 21 wiring lines
migration/src/m20261016_000100_create_mailbox_messages.rssrc/kernel/mailbox.rssrc/kernel/mailer.rssrc/mailers/mod.rssrc/mailers/previews.rssrc/mailers/test_mailer.rssrc/tasks/send_test_email.rstemplates/mailers/layout.htmltemplates/mailers/test_mailer/test_email.htmltemplates/mailers/test_mailer/test_email.txttests/app/mailer_test.rs
.offrails/template/parts/mailer/README.md
mailer
Action Mailer for a Rust off Rails app: an Email rendered from two Askama templates (HTML and text), delivered by the adapter the environment selects. SMTP in production (any provider: Postmark, Resend, SES, Mailgun), the dev mailbox table in development (read it with the dev-mailbox sample), a log line when production has no SMTP yet, memory in tests. Mailer previews, a send_test_email task to prove SMTP works, and header-injection checks before anything is sent.
Extracted from Rust off Rails apps and improved: links follow the address actually served in development (a stale APP_URL in .env sent sign-in links to another port), Message-IDs never carry this machine's hostname, line breaks in any header value are refused, and SMTP_URL is parsed with lettre's own URL parser (credentials percent-decoded, smtp:// plain for a local catcher, ?tls=required for STARTTLS).
Guide: .offrails/guides/mailers.md. Depends on: nothing.
Screens
Every screen in light and dark, on desktop and phone: the samples repository.
Install
By hand, in the app root:
cargo add lettre --no-default-features --features builder,smtp-transport,pool,hostname,tokio1-rustls-tls(never with a version; read the resolved one inCargo.lock).- Copy
files/into the app, keeping the paths. In the test file, replaceoffrails_hostwith your crate name. - Wiring (each at its
samples: <anchor>comment when the app has one; otherwise at the spot described):src/kernel/mod.rs:pub mod mailbox;andpub mod mailer;.src/lib.rs:pub mod mailers;.src/config.rs: apub mail: MailConfigfield onConfig;d.field("mail", &self.mail)in itsDebug;let mail = MailConfig::from_env(env)?;infrom_env,mail,in itsSelf { … },mail: MailConfig::test(),inConfig::test(); and theMailConfigstruct with itsimpland redactingDebugat the end of the file (the text is insample.toml).src/state.rs: apub mailer: crate::kernel::mailer::Mailerfield,let mailer = select_mailer(&config, &db)?;before the struct is built,mailer,in it, and theselect_mailerfunction.migration/src/lib.rs:mod m20261016_000100_create_mailbox_messages;andBox::new(m20261016_000100_create_mailbox_messages::Migration),inmigrations().src/tasks/mod.rs:pub mod send_test_email;and the"send_test_email" => send_test_email::run(state, args).await,arm.src/controllers/dev.rs: theSMTP_URL/MAILER/MAIL_FROMfacts on/dev/system..env.example:SMTP_URL,MAILER,MAIL_FROM(commented);compose.yaml:SMTP_URL: ${SMTP_URL:-}.tests/app/main.rs:mod mailer_test;;tests/app/support.rs: thesent_emails()andlast_email_to(address)helpers onTestApp.
cargo fmt --all && cargo run -- db migrate, then the Definition of Done (.offrails/workflow.md).
tools/install.sh mailer <app> does steps 1 to 3 mechanically (it needs the anchors; it refuses and names any it can't find).
After installing
- Set
SMTP_URLandMAIL_FROMin the deploy configuration; until then production logs mail instead of sending it. Prove it from the server:app task send_test_email you@example.com. - Update the
EMAIL:line inAGENTS.md(dev: local mailbox (/dev/mailbox); prod: smtp via <provider>). - Request handlers send through a job, not
state.mailer.deliver(..)directly: install the jobs sample formailers::deliver_later. Until then, deliver from tasks and jobs only. - Install dev-mailbox to read development mail.
Adapting it
- A new mailer: copy
src/mailers/test_mailer.rstosrc/mailers/<name>_mailer.rsand its two templates totemplates/mailers/<name>_mailer/; one function per action returningResult<Email, AppError>; build every link withconfig.app_url+ apaths::helper; add aPreviewper action inpreviews.rs(marked// APP:). A sample that ships a mailer (auth-core) wires itspub modat// samples: mailer modsinsrc/mailers/mod.rsand its previews at// samples: previewsinPREVIEWS; keep both anchors. - Cc, Bcc, Reply-To, attachments:
Email::new(..).cc(..).bcc(..).reply_to(..).attach(Attachment::new(name, type, &bytes)). - Your brand:
templates/mailers/layout.htmlis the shell every HTML email extends. Inline styles only (clients ignore<link>); keep the dark-mode<style>block's class names in step with it. - Try real delivery from development:
MAILER=smtp SMTP_URL=smtp://localhost:1025with a local catcher (Mailpit), or a provider's sandbox. - Tune code detection in the dev mailbox: the
WORDSlist and theBEFORE/AFTERwindow incodes_in(kernel/mailbox.rs,// APP:). A code counts only next to one of those words (or as a 6-digit line of its own); years and dates never do.
Do not change
mailer::validatebefore every send, in every adapter (header_injection_is_refused_before_anything_is_sent).- Development delivers to the dev mailbox unless
MAILERsays otherwise, even withSMTP_URLset (development_always_uses_the_dev_mailbox_even_with_smtp_url_set). MailConfig'sDebugredactsSMTP_URL(the_smtp_url_never_shows_in_config_debug).- Absolute links from
config.app_url, which follows the served address in development (development_links_follow_the_port_being_served). #[serde(default)]on everyEmailfield added later: queued jobs must still decode.
How it works
state::select_mailer picks the adapter once at boot:
APP_ENV | MAILER | Adapter |
|---|---|---|
| test | any | Memory (app.sent_emails()) |
| any | smtp | Smtp (needs SMTP_URL) |
| any | local / log | that one |
| development | unset | Local, always, even with SMTP_URL set |
| production | unset | Smtp with SMTP_URL, otherwise Log |
Mailer::Local builds exactly the message SMTP would send (mailer::build: multipart/alternative, wrapped in multipart/mixed when there are attachments) and stores it with its decoded parts in mailbox_messages, so mail from requests, jobs and tasks lands in one table. kernel/mailbox.rs owns that table: search, filters, counts, trim, and the link and code extraction agents use.
Files
| File | What |
|---|---|
src/kernel/mailer.rs | Email, Attachment, Mailer (Smtp, Local, Log, Memory), validate, build, Preview |
src/kernel/mailbox.rs | the mailbox_messages entity and queries; links_in, codes_in, summary_json |
src/mailers/mod.rs | the mailers module |
src/mailers/test_mailer.rs | test_mailer::test_email: the pattern for every mailer |
src/mailers/previews.rs | PREVIEWS |
src/tasks/send_test_email.rs | app task send_test_email <address> |
templates/mailers/layout.html | the HTML shell every email extends |
templates/mailers/test_mailer/test_email.{html,txt} | the test email |
migration/src/m20261016_000100_create_mailbox_messages.rs | the table |
tests/app/mailer_test.rs | the tests named in CHECKS.md |