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: umbral_email::auth_mailer()
If you just want delivery and are happy with the default email wording, delegate to the umbral-email plugin with its one-line adapter — enable that plugin's auth cargo feature and wire it in:
use umbral_auth::{AuthPlugin, AuthUser}; AuthPlugin::new() .with_default_routes() .require_verified_email() .mailer(umbral_email::auth_mailer());auth_mailer() forwards the rendered subject/html/text through umbral_email::send, so UMBRAL_EMAIL_BACKEND (console / SMTP / API) and the usual email_* settings cover auth mail too — one provider config for every framework email, not a separate wire-up per plugin.
umbral-auth itself has no dependency on umbral-email (see Why the adapter lives in umbral-email below) — the adapter only appears once you enable umbral-email's auth feature.You can still hand AuthPlugin::mailer a plain async closure instead — it accepts any Fn(OutgoingMail) -> Future<Output = Result<(), AuthMailError>>, or any type implementing [AuthMailer]:
use umbral_auth::{AuthPlugin, AuthUser, OutgoingMail, AuthMailError}; AuthPlugin::new().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()))});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 { /* ... */ });Sending off-request with umbral_tasks::auth_mailer()
Every mailer above runs inline: the request handler awaits AuthMailer::send directly, so a slow or failing SMTP/provider call blocks the HTTP response, with no retry. Enable umbral-tasks' auth-mailer cargo feature and wire its adapter instead to send through the task queue:
use umbral_auth::{AuthPlugin, AuthUser};use umbral_tasks::TasksPlugin; App::builder() .plugin(TasksPlugin::default()) .plugin( AuthPlugin::<AuthUser>::default() .mailer(umbral_tasks::auth_mailer()), ) .build()?;.mailer(umbral_tasks::auth_mailer()) makes AuthMailer::send enqueue the OutgoingMail as a task and return immediately — the request finishes as soon as the row is written. A built-in task, run by the tasks-worker process, dequeues it and does the real delivery, with the queue's retries and backoff. Delivery defaults to ConsoleMailer (the same zero-config dev default); pass a real mailer to auth_mailer_with to pick what it delivers through instead, without touching umbral-auth at all:
// Task-backed AND delegated to umbral-email's configured backend:AuthPlugin::<AuthUser>::default() .mailer(umbral_tasks::auth_mailer_with(umbral_email::auth_mailer()))TasksPlugin registered in the SAME binary as AuthPlugin — both the web process and any separate tasks-worker process. Without it the enqueued row has no handler: the worker marks it failed, or (with no worker running at all) it just sits pending — queued, never delivered.Why the adapter lives in umbral-email, not umbral-auth
Both umbral_email::auth_mailer() and umbral_tasks::auth_mailer() live in the other plugin, not in umbral-auth. This is deliberate, not an oversight: umbral-auth takes no Cargo dependency (not even an optional, feature-gated one) on either umbral-email or umbral-tasks, so a REST-only or admin-only app never compiles a mail stack or a task queue it never uses. umbral-tasks specifically cannot depend back on umbral-auth — umbral-tasks already optionally depends on umbral-admin, which unconditionally depends on umbral-auth, so the reverse edge would cycle (cargo counts an optional dependency as a graph edge whether or not its feature is active). Each adapter crate depends on umbral-auth instead, behind its own optional feature (umbral-email's auth, umbral-tasks' auth-mailer), and plugs into the plain AuthPlugin::mailer(...) seam like any other AuthMailer.
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.