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/// 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}