Auth mailer
Wire your own email sending for the verification and password-reset flows via AuthPlugin::mailer. Your function receives the email kind and its data (the code / reset URL), so you can fully customize each email. ConsoleMailer is the zero-config dev default.
umbral-auth never talks to an SMTP server or a mail API directly. When a flow needs to send an email - a verification code, a password-reset link - it hands the message to the function you provide via AuthPlugin::mailer(...). You decide how it's delivered (SMTP, a provider API, a queue, a log), and you can decide what it says per email type. With nothing wired, a ConsoleMailer prints to stderr so the flows work locally with zero config.
AuthMailer, or the umbral-email one-liner below.What your mailer receives
Your sender is called with one [OutgoingMail] per email. It carries both a framework-rendered body and the semantic kind plus its raw data - so you can either forward the rendered body as-is, or ignore it and build the message yourself.
| Field | Type | Content |
|---|---|---|
to | String | Recipient email address |
username | String | Recipient's username (for personalization) |
kind | MailKind | Which flow + its raw data - match on this to customize per email type |
subject | String | Framework-rendered subject (from the overridable templates) |
html | String | Framework-rendered HTML body |
text | String | Framework-rendered plain-text body |
MailKind tells you exactly which email this is and gives you its parameters:
pub enum MailKind { EmailVerification { code: String }, // the 6-digit one-time code PasswordReset { reset_url: String }, // the tokenized link to your reset page}MailKindis
#[non_exhaustive]- future auth flows (magic links, custom-action notifications) will add variants, so always include a
_ => { ... }arm when you match on it.
The simplest wiring: forward the rendered body
If you just want delivery and are happy with the default email wording, ignore kind and send the rendered subject/html/text. This one-liner delegates to the umbral-email plugin:
use umbral_auth::{AuthPlugin, AuthUser, OutgoingMail, AuthMailError}; AuthPlugin::new() .with_default_routes() .require_verified_email() .mailer(|m: OutgoingMail| async move { umbral_email::send( umbral_email::EmailMessage::new(m.subject, vec![m.to]) .html_body(m.html) .text_body(m.text), ) .await .map(|_| ()) .map_err(|e| AuthMailError::Send(e.to_string())) });AuthPlugin::mailer accepts any async closure Fn(OutgoingMail) -> Future<Output = Result<(), AuthMailError>>, or any type that implements [AuthMailer].
Full control: build each email yourself
This is the point of kind. When you want to own the content - your branding, your copy, a transactional-email provider's own templates - match on MailKind and build the message from the raw code / reset URL. The framework-rendered html/text are still there as a fallback, but you don't have to use them.
use umbral_auth::{AuthPlugin, AuthUser, OutgoingMail, AuthMailError, MailKind}; AuthPlugin::new() .with_default_routes() .require_verified_email() .mailer(|m: OutgoingMail| async move { // `m.to`, `m.username`, and the raw flow data are all yours. let result = match m.kind { MailKind::EmailVerification { code } => { // e.g. trigger your provider's "verify" template with the code // as a merge variable, fully styled to your brand. my_provider .send_template("verify-email", &m.to, json!({ "name": m.username, "code": code, })) .await } MailKind::PasswordReset { reset_url } => { my_provider .send_template("password-reset", &m.to, json!({ "name": m.username, "reset_url": reset_url, })) .await } // Required: new MailKind variants land here until you handle them. _ => Ok(()), }; result.map_err(|e| AuthMailError::Send(e.to_string())) });This is the safe way to customize the emails: the framework owns when and to whom mail is sent (and keeps the security properties - the code is single-use, the link expires), while you own what the message looks like for each type.
Customizing only the wording (template override)
If you want the default delivery path but different copy, you don't need a custom mailer at all - override the shipped email templates. umbral-auth ships these under its templates/auth/email/ directory; drop a same-named file in your app's templates dir and it wins (first-match-wins):
templates/auth/email/verify_code.html # {{ code }}, {{ username }}templates/auth/email/verify_code.txttemplates/auth/email/reset_link.html # {{ reset_url }}, {{ username }}templates/auth/email/reset_link.txtThe rendered result flows through as OutgoingMail.subject/html/text to whatever mailer is wired.
Implementing AuthMailer on a type
For a sender that holds state (an SMTP pool, an API client), implement the trait. It uses async_trait, so annotate the impl:
use umbral_auth::{AuthMailer, AuthMailError, OutgoingMail, MailKind}; #[derive(Clone)]pub struct SmtpMailer { /* client, from-address, ... */ } #[async_trait::async_trait]impl AuthMailer for SmtpMailer { async fn send(&self, mail: OutgoingMail) -> Result<(), AuthMailError> { // Forward the rendered body, or match on mail.kind for full control. self.client .send(&mail.to, &mail.subject, &mail.html) .await .map_err(|e| AuthMailError::Send(e.to_string())) }} AuthPlugin::new().mailer(SmtpMailer { /* ... */ });Development default
When no mailer is wired, ConsoleMailer is active: every email is written to stderr (recipient, subject, and the plain-text body - so the verification code or reset link is visible in your terminal). Email flows work out of the box with no SMTP config. If ConsoleMailer is ever the active mailer outside Dev/Test, it logs a loud warning, because nothing is actually delivered.
AuthPlugin::mailer(...) before deploying - ConsoleMailer only prints; it does not deliver.See the design note docs/decisions/2026-06-28-auth-full-surface.md for the rationale behind the pluggable mailer seam and why umbral-auth does not take a hard dependency on umbral-email.