Skip to main content

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}