umbral_auth/mailer.rs
1//! The pluggable email seam. umbral-auth renders bodies via
2//! `umbral::templates` and hands them to whatever `AuthMailer` the app wired
3//! (default: print to stderr). Keeps auth decoupled from any mail crate.
4
5use async_trait::async_trait;
6use std::future::Future;
7use std::sync::{Arc, OnceLock};
8
9/// Which auth flow produced an [`OutgoingMail`], together with that flow's
10/// raw data. Match on this in a custom [`AuthMailer`] to build the message
11/// yourself — e.g. trigger your email provider's own template with the code
12/// or reset URL as a variable — instead of forwarding the framework-rendered
13/// bodies.
14///
15/// Marked `#[non_exhaustive]`: future auth flows (magic links, custom-action
16/// notifications, …) add variants, so always include a `_ => { … }` arm when
17/// you match on it.
18#[derive(Debug, Clone)]
19#[non_exhaustive]
20pub enum MailKind {
21 /// Email-address verification. `code` is the plaintext 6-digit one-time
22 /// code (it expires in 15 minutes; only its hash is stored server-side).
23 EmailVerification { code: String },
24 /// Password reset. `reset_url` is the tokenized link pointing at your
25 /// reset page (it expires in 1 hour, single-use).
26 PasswordReset { reset_url: String },
27}
28
29/// An auth email handed to the configured [`AuthMailer`].
30///
31/// It carries BOTH the framework-rendered bodies (`subject` / `html` / `text`,
32/// produced from the overridable `templates/auth/email/*` templates) AND the
33/// semantic [`MailKind`] plus recipient context. So a simple mailer can just
34/// forward the rendered bodies to a transport, while a mailer that wants full
35/// control can ignore them and build its own message from `kind`, `to`, and
36/// `username` (e.g. call a transactional-email provider with a template id and
37/// the verification code as a merge variable).
38#[derive(Debug, Clone)]
39pub struct OutgoingMail {
40 /// Recipient email address.
41 pub to: String,
42 /// Recipient's username — handy for personalising a custom message.
43 pub username: String,
44 /// Which flow produced this email, plus its raw data (the verification
45 /// code / the reset URL). Match on it to fully customise per email type.
46 pub kind: MailKind,
47 /// Framework-rendered subject line (from the overridable templates).
48 pub subject: String,
49 /// Framework-rendered HTML body (from the overridable templates).
50 pub html: String,
51 /// Framework-rendered plain-text body (from the overridable templates).
52 pub text: String,
53}
54
55/// Failure to hand a message to the transport.
56#[derive(Debug)]
57pub enum AuthMailError {
58 Send(String),
59}
60impl std::fmt::Display for AuthMailError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 AuthMailError::Send(m) => write!(f, "failed to send auth email: {m}"),
64 }
65 }
66}
67impl std::error::Error for AuthMailError {}
68
69/// What the app wires in. Implement for a type, or pass an async closure
70/// `Fn(OutgoingMail) -> Future<Output = Result<(), AuthMailError>>` (blanket
71/// impl below). Delegate to `umbral_email::send` in one line if you use it.
72#[async_trait]
73pub trait AuthMailer: Send + Sync {
74 async fn send(&self, mail: OutgoingMail) -> Result<(), AuthMailError>;
75}
76
77#[async_trait]
78impl<F, Fut> AuthMailer for F
79where
80 F: Fn(OutgoingMail) -> Fut + Send + Sync,
81 Fut: Future<Output = Result<(), AuthMailError>> + Send,
82{
83 async fn send(&self, mail: OutgoingMail) -> Result<(), AuthMailError> {
84 self(mail).await
85 }
86}
87
88/// Default mailer: print the message to stderr (dev-visible code/link) and
89/// log a loud warning if it's the active mailer outside Dev/Test.
90pub struct ConsoleMailer;
91
92#[async_trait]
93impl AuthMailer for ConsoleMailer {
94 async fn send(&self, mail: OutgoingMail) -> Result<(), AuthMailError> {
95 let prod = umbral::settings::get_opt()
96 .map(|s| {
97 !matches!(
98 s.environment,
99 umbral::Environment::Dev | umbral::Environment::Test
100 )
101 })
102 .unwrap_or(false);
103 if prod {
104 tracing::warn!(
105 to = %mail.to,
106 "umbral-auth ConsoleMailer is active in a non-Dev environment — auth emails are \
107 only printed, not delivered. Wire AuthPlugin::mailer(...) for production."
108 );
109 }
110 eprintln!(
111 "\n--- umbral-auth email ---\nTo: {}\nSubject: {}\n\n{}\n-------------------------\n",
112 mail.to, mail.subject, mail.text
113 );
114 Ok(())
115 }
116}
117
118static MAILER: OnceLock<Arc<dyn AuthMailer>> = OnceLock::new();
119
120/// The mailer the flow functions use. Falls back to [`ConsoleMailer`].
121/// Called by the email-verification and password-reset flow helpers
122/// in `challenge.rs`.
123pub(crate) fn active_mailer() -> Arc<dyn AuthMailer> {
124 MAILER
125 .get()
126 .cloned()
127 .unwrap_or_else(|| Arc::new(ConsoleMailer))
128}
129
130/// Install the process mailer. First call wins (mirrors the password policy
131/// seal); `on_ready` calls this once at boot.
132pub(crate) fn install_mailer(m: Arc<dyn AuthMailer>) {
133 let _ = MAILER.set(m);
134}