navree

Mail

Mailables rendered from MiniJinja templates; the SMTP, log, array and failover transports; queued sending; configuration, previewing and testing.

On this page

Introduction

Featuremail MailModule sends email. You describe a message as a mailable, usually a pair of MiniJinja templates, one HTML and one plain text, plus the data they need. The Mailer renders it, fills in the default sender, checks the message and hands it to a transport:

TransportWhat it does
SMTPDelivers over SMTP with lettre, on Tokio, with rustls and a connection pool.
logWrites each message to the log instead of sending it. For development.
arrayKeeps messages in memory. For tests.
failoverTries several transports in order until one delivers.

Mailer::queue hands the rendered message to the queue, so a request doesn't wait for the SMTP server. The CRM's account mails (password resets, email verification) go out this way. Its deal notifications and task reminders are mailed by the notification mail channel, from a queue worker and from the scheduler.

Configuration

MailModule renders templates with ViewModule, so the app needs both:

src/app.rs

The module is global: any provider can inject Arc<Mailer>. from_config(), and a plain .module(MailModule), read the mail section. This is the CRM's:

config/app.toml
KeyDefaultMeaning
mail.from[email protected]The default sender, [email protected] or Navree CRM <[email protected]>. Empty means no default: every message must then set its own.
mail.transportlogHow mail is delivered: see transports.
mail.queuenoneThe queue Mailer::queue dispatches to. Without it, queued mail goes on the default queue.

Each key has an environment variable: APP_MAIL__FROM, APP_MAIL__TRANSPORT and APP_MAIL__QUEUE. An SMTP URL usually holds a password, so keep it out of committed files: set it as an environment variable, which overrides config/app.toml, or in .env. A .env entry only counts for keys that config/app.toml doesn't set, because .env is read first (see where settings come from). The CRM's file above sets both from and transport, and a new app's sets transport: remove a key from [mail] before you set it in .env.

.env

The boot fails when the sender doesn't parse, the SMTP URL is invalid or a failover lists no transport. Nothing connects at boot: the SMTP connection opens with the first message.

To configure the module in code instead, use for_root:

Rust

Transports

mail.transport is either a short string or a table with a type:

TransportShort formTable form
SMTP"smtps://user:pass@host" or "smtp://..."{ type = "smtp", url = "smtps://user:pass@host" }
log"log"{ type = "log" }
array"array"{ type = "array" }
failovernone{ type = "failover", transports = ["smtps://user:pass@host", "log"] }

SMTP

The URL says how to connect: scheme://user:password@host:port?tls=mode.

URLSecurityDefault port
smtps://user:[email protected]TLS from the first byte465
smtp://user:[email protected]?tls=requiredSTARTTLS, required587
smtp://user:[email protected]?tls=opportunisticSTARTTLS when the server offers it587
smtp://localhost:1025none25

Use plain smtp:// only for a local relay or a development mail catcher: credentials would travel unencrypted. Percent-encode reserved characters in the user name and password (@ is %40). The URL never appears in Debug output, errors or logs.

When a message has both an HTML and a text body, SMTP sends them as multipart/alternative, so mail clients show the one they prefer. Bcc recipients go in the SMTP envelope only, never in the headers.

Log

The log transport writes each message's sender, recipients and subject at info level with target navree::mail. The bodies are logged only at debug with target navree::mail::body, because they may hold password-reset links or other tokens. To see them in development:

.env

Failover

A failover transport tries its transports in order until one delivers. Each failure is logged as a warning; when they all fail, the error lists each one. As environment variables:

.env

transports takes a list in TOML, or one comma-separated string of short forms.

Mail templates

Mail bodies are templates rendered by ViewModule:

  • Files under the template directories (view.dirs, resources/views by default), so resources/views/mail/welcome.html is the template mail/welcome.html.
  • Or templates a module embeds in the binary with m.templates(...). A file of the same name overrides an embedded template.

The CRM embeds its deal notification mail in crm-deals:

crm-deals/src/notification.rs

DealsModule registers them with m.templates(notification::TEMPLATES). The two files:

deal-notification.html

A few rules matter for mail:

  • .html templates escape every value they print; .txt templates print values as they are.
  • Printing a variable the context doesn't define fails the render, so a typo can't send a mail with a blank where a name should be. {% if title %} still works for optional values.
  • {{ amount_cents|money(currency) }} formats an amount in cents ($1,234.56), and {{ created_at|date("%d %b %Y") }} a date.

Sending mail

Template mail

TemplateMail covers most mail: recipients, a subject, a template name without its extension, and the context. It renders {template}.html as the HTML body and {template}.txt as the text body, whichever exist (at least one must). Inject Arc<Mailer> to send it:

src/modules/users/onboarding.rs
  • send(&mail) renders and delivers now, and returns the transport's error if delivery fails.
  • queue(&mail) renders now and leaves delivery to a queue worker: see queueing mail.

A template name with an extension renders only that template: mail/welcome.txt as the text body, any other extension as the HTML body. TemplateMail's fields are public (to, subject, template, context), so you can also build it as a struct, as the CRM's notifications do.

Mailables

For mail that computes its subject or recipients, or uses several templates, implement Mailable. Its build method gets the app's Views and returns a Message:

src/modules/deals/mail/deal_won.rs

Send it like any mailable: mailer.queue(&mail).await?. The CLI generates one, with its two templates, in a module:

Shell

It writes mail/deal_won.rs, deal_won.html and deal_won.txt in the module and registers the templates in its register hook.

Messages

Message is the rendered email. Its setters share the field names: from, to, cc, bcc, reply_to, subject, html and text; to, cc and bcc add one address per call. A message is a mailable too, so you can send one without templates:

Rust

Addresses are [email protected] or Ada Lovelace <[email protected]>. Leave from unset to use mail.from.

Validation and errors

Before a message reaches a transport, the mailer fills in the default sender and checks that:

  • it has a sender, and at least one recipient in to, cc or bcc;
  • every address parses, reply_to included;
  • the subject holds no line break.

Every method returns Result<_, MailError>:

VariantWhen
MissingSenderNo from, and mail.from is empty.
NoRecipientsNo to, cc or bcc.
InvalidAddressAn address doesn't parse; names the field and the address.
InvalidHeaderThe subject contains a line break.
ViewA template is missing or failed to render.
TransportThe transport failed, for example the SMTP server refused the message.
AllTransportsFailedEvery transport of a failover failed.
QueueDispatching the mail.send job failed.
ConfigThe mail configuration is invalid.

Errors and info-level logs never contain message bodies or SMTP credentials.

Queueing mail

queue renders and checks the message at once, so a template or address error comes back to the caller before anything is queued. It then dispatches the job mail.send (SendMailJob), whose payload is the rendered message, and returns. A queue worker sends it and retries it if the SMTP server fails, with the default job options: three attempts, with exponential backoff.

  • MailModule registers mail.send, so there is nothing else to wire. Set mail.queue to send mail from its own queue, and run a worker for it: queue:work --queue mail,default.
  • Like every dispatch, queued mail joins the request's unit of work: a request that fails sends no mail.
  • The payload holds the whole message, bodies included, until a worker sends it. With the database driver it sits in the jobs table, which matters for mail that carries reset links or other tokens.
  • In an app without a queue (no dyn Dispatcher), queue sends at once and logs a warning the first time. It doesn't wait for the unit of work: inside a request that then fails, the mail has already gone.

Previewing mail

Navree 0.1 has no route that shows a mail in the browser. While you work on a template:

  • Use the log transport (the CRM's development setting) with navree::mail::body at debug, as above, to read each body in the log.
  • Use a mail catcher. Run one such as Mailpit and point the SMTP transport at it with APP_MAIL__TRANSPORT=smtp://localhost:1025. Open its web UI to see every mail rendered, HTML and text.
  • Render it in code. build a mailable against a set of templates and print it, from a test or a small command:
Rust

In an app, render with the app's own templates by resolving Views: app.get::<Views>().await.

Testing

Set the transport to array in tests. The mailer then keeps every message in memory, and Mailer::array_transport returns that store for assertions:

tests/onboarding.rs
  • sent() returns every message, oldest first; assert_sent(predicate), assert_nothing_sent() and clear() do what they say. A failed assertion lists the recipients and subjects that were sent, not the bodies.
  • This app has no queue, so queue sends at once. In an app with QueueModule, queued mail reaches the array transport only when its job runs: run the queue first with Queue::run_pending, as the CRM's tests do with crm.run_queue(). To check that mail was queued without sending it, use the queue fake and look for SendMailJob: fake.assert_dispatched::<SendMailJob>(|job| job.message.subject == "Welcome to the CRM").
  • To keep a handle on the transport yourself, create an ArrayTransport, keep a clone and install it with .override_value::<dyn MailTransport>(Arc::new(array.clone())).

The CRM reads its sent mail like this in crm-deals/tests/deals/notifications.rs:

Rust

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks