rustio_admin/email/mod.rs
1//! Email delivery abstraction.
2//!
3//! Doctrine 6: email is operational infrastructure, not business
4//! logic. Recovery flows compose [`Mail`] objects with a fixed
5//! envelope ([`Mail::framework_envelope`]) and dispatch them through
6//! a project-supplied [`Mailer`] implementation. The framework
7//! refuses to lock into SMTP — projects ship whatever transport
8//! their organisation already uses (SES, Mailgun, Postmark, internal
9//! relay, queued background job, etc.).
10//!
11//! ## What the framework provides
12//!
13//! - The [`Mailer`] trait — one async method, `send`.
14//! - [`LogMailer`] — the safe default. Writes the would-be email to
15//! `log::info!` so reset links stay visible during dev / CI without
16//! a mail server. **Not suitable for production**: in production a
17//! real mailer must be configured or recovery emails will be
18//! silently lost.
19//! - [`Mail`] + [`Mail::framework_envelope`] for canonical headers
20//! (timestamp, source IP, browser/OS summary, "if this was not you"
21//! guidance) per doctrine 6.
22//!
23//! ## Project override
24//!
25//! ```ignore
26//! let admin = Admin::new()
27//! .mailer(Arc::new(MyProjectMailer::new(/* SES, Mailgun, … */)));
28//! ```
29//!
30//! The default is [`LogMailer`] — projects opt in to a real mailer
31//! by registering one. R1+ recovery flows will read the configured
32//! mailer from `Admin` and refuse to boot in `production` mode if
33//! none is registered.
34
35use std::fmt;
36use std::sync::Arc;
37
38use chrono::{DateTime, Utc};
39
40/// One outbound message. Plaintext body is required; HTML is
41/// optional. Extra headers are project-controlled.
42#[derive(Debug, Clone)]
43pub struct Mail {
44 pub to: String,
45 pub subject: String,
46 pub text_body: String,
47 pub html_body: Option<String>,
48 pub headers: Vec<(String, String)>,
49}
50
51impl Mail {
52 /// Build a message with the framework's canonical security
53 /// envelope appended to the plaintext body. Used by recovery
54 /// flows so every framework-emitted email carries the same
55 /// "where, when, what, who" context — anti-phishing parity.
56 ///
57 /// `system_name` should be the project's `SiteBranding::site_header`
58 /// (the human label of the install); `request_ip` and `ua_summary`
59 /// are best-effort context lifted from the triggering request.
60 pub fn framework_envelope(
61 to: impl Into<String>,
62 subject: impl Into<String>,
63 body: impl Into<String>,
64 system_name: &str,
65 request_ip: Option<&str>,
66 ua_summary: Option<&str>,
67 when: DateTime<Utc>,
68 ) -> Self {
69 let mut text = body.into();
70 text.push_str("\n\n— — —\n");
71 text.push_str(&format!("System: {system_name}\n"));
72 text.push_str(&format!("When: {} UTC\n", when.format("%Y-%m-%d %H:%M")));
73 if let Some(ip) = request_ip {
74 text.push_str(&format!("From IP: {ip}\n"));
75 }
76 if let Some(ua) = ua_summary {
77 text.push_str(&format!("Device: {ua}\n"));
78 }
79 text.push_str(
80 "\nIf this was not you, sign in and visit /admin/account/sessions to revoke \
81 sessions, then ask your administrator to reset your account.\n",
82 );
83 Mail {
84 to: to.into(),
85 subject: subject.into(),
86 text_body: text,
87 html_body: None,
88 headers: Vec::new(),
89 }
90 }
91}
92
93/// Errors a [`Mailer`] can return. The most important variant is
94/// [`MailerError::ConfigurationMissing`] — the framework treats it
95/// as a hard boot failure when production deployments forget to wire
96/// up a real mailer (per the user-locked decision: "Mailer blocking
97/// behaviour" → refuse to start when no mailer is configured for
98/// production).
99#[derive(Debug)]
100#[non_exhaustive]
101pub enum MailerError {
102 /// The mailer is structurally missing — no transport, no API
103 /// key, etc. R1+ boot guards check for this at startup.
104 ConfigurationMissing(String),
105 /// A transient failure (SMTP timeout, 5xx from the API, queue
106 /// full). Recovery flows treat this as "log + uniform user
107 /// response" per the user-locked mailer-blocking-behaviour
108 /// decision: the user sees the same response as success; an
109 /// audit row is written with `metadata.email_send_status =
110 /// "failed"` so the operator can grep for undelivered resets.
111 Transient(String),
112 /// A non-recoverable failure (invalid recipient, blocked
113 /// domain). Surfaces in audit logs the same way as Transient.
114 Permanent(String),
115}
116
117impl fmt::Display for MailerError {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 Self::ConfigurationMissing(m) => write!(f, "mailer configuration missing: {m}"),
121 Self::Transient(m) => write!(f, "mailer transient failure: {m}"),
122 Self::Permanent(m) => write!(f, "mailer permanent failure: {m}"),
123 }
124 }
125}
126
127impl std::error::Error for MailerError {}
128
129/// Async outbound-mail interface. Project implementations live in
130/// the project crate so the framework never imports `lettre` /
131/// `aws-sdk-ses` / etc.
132///
133/// Implementations MUST be `Send + Sync` and cheap to clone (the
134/// framework holds a single `Arc<dyn Mailer>` for the lifetime of
135/// the process).
136pub trait Mailer: Send + Sync {
137 /// Send one message. Errors are typed; see [`MailerError`].
138 fn send<'a>(
139 &'a self,
140 msg: Mail,
141 ) -> std::pin::Pin<
142 Box<dyn std::future::Future<Output = std::result::Result<(), MailerError>> + Send + 'a>,
143 >;
144}
145
146/// Default mailer. Writes the message to `log::info!` instead of
147/// sending it. Safe for dev / CI / testing where outbound SMTP is
148/// forbidden or undesirable; not suitable for production — recovery
149/// emails will be lost (the audit row will record the attempt).
150///
151/// Subjects and recipient addresses appear in the log output;
152/// **bodies are truncated** at 200 chars and **anything that looks
153/// like a token is replaced with a fingerprint** before logging
154/// (doctrine 11 — never log secrets).
155#[derive(Debug, Default, Clone)]
156pub struct LogMailer;
157
158impl LogMailer {
159 pub fn new() -> Self {
160 Self
161 }
162}
163
164impl Mailer for LogMailer {
165 fn send<'a>(
166 &'a self,
167 msg: Mail,
168 ) -> std::pin::Pin<
169 Box<dyn std::future::Future<Output = std::result::Result<(), MailerError>> + Send + 'a>,
170 > {
171 Box::pin(async move {
172 let body_preview: String = msg.text_body.chars().take(200).collect();
173 // Doctrine 11: redact anything that looks like a URL with
174 // a token segment before it lands in the log target.
175 let redacted = redact_likely_tokens(&body_preview);
176 log::info!(
177 target: "rustio_admin::mailer::log",
178 "[LogMailer] to={} subject={:?} body_preview={:?}",
179 msg.to,
180 msg.subject,
181 redacted,
182 );
183 Ok(())
184 })
185 }
186}
187
188/// Replace anything that looks like a URL ending in a long
189/// alphanumeric segment (a reset-token-shaped suffix) with the
190/// `<redacted-link>` placeholder. Pure function; no I/O.
191fn redact_likely_tokens(s: &str) -> String {
192 // Heuristic: any whitespace-delimited segment ≥ 32 chars that's
193 // ASCII alphanumeric + - / _ + : / . is replaced. Catches
194 // "/admin/reset-password/<token>" style URLs and bare tokens.
195 s.split_whitespace()
196 .map(|w| {
197 if w.len() >= 32 && w.chars().all(is_token_url_char) {
198 "<redacted-link>"
199 } else {
200 w
201 }
202 })
203 .collect::<Vec<_>>()
204 .join(" ")
205}
206
207fn is_token_url_char(c: char) -> bool {
208 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | ':' | '.' | '?' | '&' | '=' | '#')
209}
210
211/// Type-erased shared mailer reference. The framework's `Admin`
212/// holds one of these; defaults to `Arc::new(LogMailer)` until a
213/// project overrides via `Admin::mailer(Arc::new(...))`.
214pub type SharedMailer = Arc<dyn Mailer>;
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn framework_envelope_appends_security_footer() {
222 let m = Mail::framework_envelope(
223 "user@example.com",
224 "Test",
225 "Body line.",
226 "Bosphorus & Sham · Stockholm",
227 Some("198.51.100.42"),
228 Some("macOS · Safari 18"),
229 Utc::now(),
230 );
231 assert!(m.text_body.contains("Body line."));
232 assert!(m.text_body.contains("System: Bosphorus & Sham · Stockholm"));
233 assert!(m.text_body.contains("From IP: 198.51.100.42"));
234 assert!(m.text_body.contains("Device: macOS · Safari 18"));
235 assert!(m.text_body.contains("If this was not you"));
236 }
237
238 #[test]
239 fn framework_envelope_omits_missing_fields() {
240 let m = Mail::framework_envelope(
241 "user@example.com",
242 "Test",
243 "Body.",
244 "ACME",
245 None,
246 None,
247 Utc::now(),
248 );
249 assert!(!m.text_body.contains("From IP:"));
250 assert!(!m.text_body.contains("Device:"));
251 assert!(m.text_body.contains("If this was not you"));
252 }
253
254 #[tokio::test]
255 async fn log_mailer_send_is_ok() {
256 let m = LogMailer::new();
257 let mail = Mail {
258 to: "user@example.com".into(),
259 subject: "Hi".into(),
260 text_body: "test body".into(),
261 html_body: None,
262 headers: Vec::new(),
263 };
264 assert!(m.send(mail).await.is_ok());
265 }
266
267 #[test]
268 fn redact_likely_tokens_redacts_long_alnum_strings() {
269 let s = "Click http://example.com/admin/reset-password/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa to reset.";
270 let r = redact_likely_tokens(s);
271 assert!(r.contains("<redacted-link>"));
272 assert!(!r.contains("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
273 }
274
275 #[test]
276 fn redact_likely_tokens_keeps_short_words() {
277 let s = "Hello user, your account is fine.";
278 let r = redact_likely_tokens(s);
279 assert_eq!(r, s);
280 }
281
282 #[test]
283 fn mailer_error_display() {
284 let e = MailerError::ConfigurationMissing("no SMTP host".into());
285 assert!(format!("{e}").contains("configuration missing"));
286 let e = MailerError::Transient("timeout".into());
287 assert!(format!("{e}").contains("transient"));
288 let e = MailerError::Permanent("blocked".into());
289 assert!(format!("{e}").contains("permanent"));
290 }
291}