This version is in beta. Some features may change before release.

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.

Info
umbral-auth does not depend on umbral-email or any mail crate. Pass any sender you like - an async closure, a type that implements 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.

FieldTypeContent
toStringRecipient email address
usernameStringRecipient's username (for personalization)
kindMailKindWhich flow + its raw data - match on this to customize per email type
subjectStringFramework-rendered subject (from the overridable templates)
htmlStringFramework-rendered HTML body
textStringFramework-rendered plain-text body

MailKind tells you exactly which email this is and gives you its parameters:

Code
rust
pub enum MailKind {
EmailVerification { code: String }, // the 6-digit one-time code
PasswordReset { reset_url: String }, // the tokenized link to your reset page
}

Info
MailKind

is

#[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:

Code
rust
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.

Code
rust
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):

Code
txt
templates/auth/email/verify_code.html # {{ code }}, {{ username }}
templates/auth/email/verify_code.txt
templates/auth/email/reset_link.html # {{ reset_url }}, {{ username }}
templates/auth/email/reset_link.txt

The 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:

Code
rust
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.

Warning
Wire a real 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.

authemailmailer