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/// Build the stderr block the [`ConsoleMailer`] prints for `mail`.
93///
94/// In Dev/Test (`prod == false`) the full rendered body is shown so a developer
95/// can read the verification code / reset link straight from the console.
96///
97/// Outside Dev/Test (`prod == true`) the secret-bearing body is **suppressed** —
98/// only the recipient and subject are printed. Without this, a deployment that
99/// forgot to wire a real `AuthPlugin::mailer(...)` would emit live, single-use
100/// reset tokens and verification codes into stdout/stderr log aggregation, where
101/// anyone with log access could replay them (audit plugin-auth #7).
102pub fn console_output(mail: &OutgoingMail, prod: bool) -> String {
103 if prod {
104 format!(
105 "\n--- umbral-auth email (BODY SUPPRESSED: non-Dev/Test) ---\nTo: {}\nSubject: {}\n\n\
106 [body withheld — the verification code / reset link is NOT printed outside Dev/Test; \
107 wire AuthPlugin::mailer(...) to deliver it]\n-------------------------\n",
108 mail.to, mail.subject
109 )
110 } else {
111 format!(
112 "\n--- umbral-auth email ---\nTo: {}\nSubject: {}\n\n{}\n-------------------------\n",
113 mail.to, mail.subject, mail.text
114 )
115 }
116}
117
118#[async_trait]
119impl AuthMailer for ConsoleMailer {
120 async fn send(&self, mail: OutgoingMail) -> Result<(), AuthMailError> {
121 let prod = umbral::settings::get_opt()
122 .map(|s| {
123 !matches!(
124 s.environment,
125 umbral::Environment::Dev | umbral::Environment::Test
126 )
127 })
128 .unwrap_or(false);
129 if prod {
130 tracing::warn!(
131 to = %mail.to,
132 "umbral-auth ConsoleMailer is active in a non-Dev environment — auth emails are \
133 only printed (bodies suppressed), not delivered. Wire AuthPlugin::mailer(...) for \
134 production."
135 );
136 }
137 eprint!("{}", console_output(&mail, prod));
138 Ok(())
139 }
140}
141
142static MAILER: OnceLock<Arc<dyn AuthMailer>> = OnceLock::new();
143
144/// The mailer the flow functions use. Falls back to [`ConsoleMailer`].
145/// Called by the email-verification and password-reset flow helpers
146/// in `challenge.rs`.
147pub(crate) fn active_mailer() -> Arc<dyn AuthMailer> {
148 MAILER
149 .get()
150 .cloned()
151 .unwrap_or_else(|| Arc::new(ConsoleMailer))
152}
153
154/// Install the process mailer. First call wins (mirrors the password policy
155/// seal); `on_ready` calls this once at boot.
156pub(crate) fn install_mailer(m: Arc<dyn AuthMailer>) {
157 let _ = MAILER.set(m);
158}