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