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    // public:
95    /// Builder: attach a rendered HTML body alongside the existing
96    /// plaintext. The mailer transport sends both as a
97    /// `multipart/alternative` MIME tree; clients pick whichever
98    /// part they prefer. Plaintext stays the source of truth — the
99    /// HTML is a polished alternative, not a replacement.
100    pub fn with_html(mut self, html: impl Into<String>) -> Self {
101        self.html_body = Some(html.into());
102        self
103    }
104}
105
106// public:
107/// Render the framework's polished HTML body for a recovery /
108/// magic-link email. The visual treatment matches DESIGN_CHROME.md:
109/// calm typography, a single brand-accent point of emphasis (the
110/// CTA button), hairline separation, table-based layout for email-
111/// client compatibility, inlined CSS for clients that strip
112/// `<style>` blocks, and a `@media` query for mobile readability.
113///
114/// Inputs are pre-validated by the caller. The function does no
115/// HTML-escaping on `intro` or `fine_print` — those are framework-
116/// owned strings, never user-supplied. `cta_url` is escaped because
117/// it contains the reset token which is base64 (safe but
118/// belt-and-braces).
119pub fn render_recovery_html(parts: RecoveryEmailParts<'_>) -> String {
120    let RecoveryEmailParts {
121        app_name,
122        app_tagline,
123        title,
124        greeting_name,
125        intro,
126        cta_label,
127        cta_url,
128        fine_print,
129        when,
130        request_ip,
131        ua_summary,
132        correlation_id,
133        signature_primary,
134        signature_title,
135        support_email,
136        show_powered_by,
137    } = parts;
138
139    let cta_url_safe = html_attr_escape(cta_url);
140    let cta_url_text = html_text_escape(cta_url);
141    let app_name_text = html_text_escape(app_name);
142    let tagline_text = html_text_escape(app_tagline.unwrap_or("Account security notification"));
143    let title_text = html_text_escape(title);
144    let greeting_text = html_text_escape(greeting_name);
145    let intro_text = html_text_escape(intro);
146    let fine_print_text = html_text_escape(fine_print);
147    let cta_label_text = html_text_escape(cta_label);
148
149    let when_str = when.format("%Y-%m-%d %H:%M UTC").to_string();
150    let ip_row = match request_ip {
151        Some(ip) => format!(
152            "<tr><td style=\"padding:6px 0;color:#6B7280;font-size:13px;\
153             width:90px;vertical-align:top;\">From IP</td>\
154             <td style=\"padding:6px 0;color:#1F2937;font-size:13px;\
155             font-variant-numeric:tabular-nums;\">{}</td></tr>",
156            html_text_escape(ip)
157        ),
158        None => String::new(),
159    };
160    let ua_row = match ua_summary {
161        Some(ua) => format!(
162            "<tr><td style=\"padding:6px 0;color:#6B7280;font-size:13px;\
163             width:90px;vertical-align:top;\">Device</td>\
164             <td style=\"padding:6px 0;color:#1F2937;font-size:13px;\
165             word-break:break-word;\">{}</td></tr>",
166            html_text_escape(ua)
167        ),
168        None => String::new(),
169    };
170
171    // Derive a 6-char reference code from the correlation id. UUID v7 is
172    // 32 hex chars after stripping dashes; the last 6 give the operator a
173    // visible identifier that matches the audit row's correlation_id.
174    let reference_panel = match correlation_id {
175        Some(cid) => {
176            let stripped: String = cid.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
177            let take_from = stripped.len().saturating_sub(6);
178            let code = stripped[take_from..].to_ascii_uppercase();
179            format!(
180                r##"
181    <!-- Verification reference: derived from the per-request correlation id.
182         Operators can match this against the audit log row. -->
183    <table role="presentation" width="100%" cellpadding="0" cellspacing="0"
184      border="0" style="margin:0 0 28px 0;">
185    <tr><td style="padding:18px 20px;background:#F7F9FC;
186      border:1px solid #DEE3EC;border-radius:6px;">
187      <div style="color:#6B7280;font-size:11px;font-weight:600;
188        letter-spacing:0.08em;text-transform:uppercase;margin:0 0 6px 0;">
189        Verification reference
190      </div>
191      <div style="color:#111827;font-family:'SFMono-Regular',Menlo,Consolas,
192        'Liberation Mono',monospace;font-size:22px;font-weight:600;
193        letter-spacing:0.18em;font-variant-numeric:tabular-nums;
194        line-height:1.2;">{}</div>
195      <div style="color:#6B7280;font-size:12px;line-height:1.5;
196        margin:8px 0 0 0;">
197        Keep this for your security records. It identifies this reset
198        attempt in the audit log; you don't need to type it anywhere.
199      </div>
200    </td></tr>
201    </table>"##,
202                html_text_escape(&code)
203            )
204        }
205        None => String::new(),
206    };
207
208    // Signature block — account-owner identity at the bottom of the
209    // email body. Hidden entirely when the caller has no primary line
210    // (e.g. unknown / unset profile fields).
211    let signature_block = match signature_primary {
212        Some(primary) => {
213            let primary_safe = html_text_escape(primary);
214            let title_line = match signature_title {
215                Some(t) => format!(
216                    r##"<div style="color:#6B7280;font-size:13px;line-height:1.5;">{}</div>"##,
217                    html_text_escape(t)
218                ),
219                None => String::new(),
220            };
221            format!(
222                r##"
223    <!-- Account-owner signature. Hidden when profile fields are unset. -->
224    <table role="presentation" width="100%" cellpadding="0" cellspacing="0"
225      border="0" style="margin:0 0 8px 0;">
226    <tr><td style="padding-top:8px;">
227      <div style="color:#6B7280;font-size:11px;font-weight:600;
228        letter-spacing:0.08em;text-transform:uppercase;margin:0 0 6px 0;">
229        Account owner
230      </div>
231      <div style="color:#111827;font-size:14px;font-weight:600;
232        line-height:1.4;">{primary_safe}</div>
233      {title_line}
234      <div style="color:#6B7280;font-size:13px;line-height:1.5;">{app_name_text}</div>
235    </td></tr>
236    </table>"##
237            )
238        }
239        None => String::new(),
240    };
241
242    // Support contact line — rendered inside the operational footer
243    // when the project has set one.
244    let support_line = match support_email {
245        Some(addr) => {
246            let addr_safe = html_attr_escape(addr);
247            let addr_text = html_text_escape(addr);
248            format!(
249                r##"<p style="margin:6px 0 0 0;color:#9CA3AF;font-size:11px;line-height:1.5;">
250      Need help? Contact <a href="mailto:{addr_safe}" style="color:#6B7280;text-decoration:none;">{addr_text}</a>.
251    </p>"##
252            )
253        }
254        None => String::new(),
255    };
256
257    // Opt-in "Powered by RustIO" credit. Off by default.
258    let powered_by_line = if show_powered_by {
259        r##"<p style="margin:10px 0 0 0;color:#D1D5DB;font-size:10px;line-height:1.5;letter-spacing:0.02em;">
260      Powered by RustIO
261    </p>"##.to_string()
262    } else {
263        String::new()
264    };
265
266    // Preheader: shown in inbox preview rows; hidden in the body.
267    let preheader = format!("{title_text} — {fine_print_text}");
268    let preheader_safe = html_text_escape(&preheader);
269
270    format!(
271        r##"<!DOCTYPE html>
272<html lang="en">
273<head>
274<meta charset="utf-8">
275<meta name="viewport" content="width=device-width,initial-scale=1">
276<meta name="color-scheme" content="light">
277<meta name="supported-color-schemes" content="light">
278<title>{title_text}</title>
279<style>
280  /* Mobile readability bump — Apple Mail, Gmail mobile, Outlook iOS */
281  @media only screen and (max-width: 600px) {{
282    .rio-mail-shell {{ padding: 24px 16px !important; }}
283    .rio-mail-card {{ padding: 32px 24px !important; }}
284    .rio-mail-title {{ font-size: 22px !important; }}
285    .rio-mail-cta a {{ padding: 14px 24px !important; }}
286  }}
287</style>
288</head>
289<body style="margin:0;padding:0;background:#F7F9FC;color:#1F2937;
290  font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',Roboto,
291  Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;
292  -webkit-text-size-adjust:100%;">
293
294<!-- Preheader: inbox preview text, hidden in the body itself. -->
295<div style="display:none;font-size:1px;color:#F7F9FC;line-height:1px;
296  max-height:0;max-width:0;opacity:0;overflow:hidden;">{preheader_safe}</div>
297
298<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
299  border="0" style="background:#F7F9FC;">
300<tr>
301<td align="center" class="rio-mail-shell" style="padding:48px 24px;">
302
303  <table role="presentation" width="100%" cellpadding="0" cellspacing="0"
304    border="0" style="max-width:560px;width:100%;
305    background:#FFFFFF;border:1px solid #DEE3EC;border-radius:10px;
306    box-shadow:0 1px 2px rgba(17,24,39,0.04);">
307  <tr>
308  <td class="rio-mail-card" style="padding:40px 40px 32px 40px;">
309
310    <!-- Wordmark + operational descriptor. App identity owns the
311         wordmark; framework name is intentionally absent here. -->
312    <div style="margin:0 0 28px 0;">
313      <div style="font-size:14px;font-weight:700;letter-spacing:-0.005em;
314        color:#0B0F19;line-height:1.3;">
315        {app_name_text}
316      </div>
317      <div style="font-size:11px;font-weight:500;letter-spacing:0.10em;
318        color:#6B7280;text-transform:uppercase;margin-top:4px;">
319        {tagline_text}
320      </div>
321    </div>
322
323    <!-- Title -->
324    <h1 class="rio-mail-title" style="margin:0 0 14px 0;color:#0B0F19;
325      font-size:28px;line-height:1.2;font-weight:700;
326      letter-spacing:-0.018em;">
327      {title_text}
328    </h1>
329
330    <!-- Greeting + intro -->
331    <p style="margin:0 0 12px 0;color:#111827;font-size:15px;
332      line-height:1.65;font-weight:500;">
333      Hello {greeting_text},
334    </p>
335    <p style="margin:0 0 32px 0;color:#374151;font-size:15px;
336      line-height:1.65;">
337      {intro_text}
338    </p>
339
340    <!-- CTA Button: single point of emphasis. Full-width on the
341         card, generous padding, drop shadow for click-confidence. -->
342    <table role="presentation" class="rio-mail-cta" cellpadding="0"
343      cellspacing="0" border="0" width="100%" style="margin:0 0 18px 0;">
344    <tr>
345    <td align="center" style="border-radius:8px;background:#0F8C7E;
346      box-shadow:0 1px 3px rgba(15,140,126,0.30),
347      0 1px 2px rgba(15,140,126,0.18);">
348      <a href="{cta_url_safe}"
349        style="display:block;padding:18px 32px;
350        font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',Roboto,
351        Helvetica,Arial,sans-serif;font-size:16px;font-weight:600;
352        color:#FFFFFF;text-decoration:none;letter-spacing:-0.005em;
353        border-radius:8px;text-align:center;">
354        {cta_label_text}
355      </a>
356    </td>
357    </tr>
358    </table>
359
360    <!-- URL fallback for clients that strip buttons -->
361    <p style="margin:0 0 8px 0;color:#6B7280;font-size:13px;line-height:1.5;">
362      Or paste this link into your browser:
363    </p>
364    <p style="margin:0 0 28px 0;font-size:13px;line-height:1.5;
365      word-break:break-all;font-family:'SFMono-Regular',Menlo,Consolas,
366      'Liberation Mono',monospace;">
367      <a href="{cta_url_safe}" style="color:#0F8C7E;text-decoration:none;">{cta_url_text}</a>
368    </p>
369
370    <!-- Fine print: TTL -->
371    <p style="margin:0 0 32px 0;color:#6B7280;font-size:13px;line-height:1.5;">
372      {fine_print_text}
373    </p>
374{reference_panel}
375    <!-- Divider -->
376    <hr style="border:none;border-top:1px solid #ECEFF4;margin:0 0 24px 0;">
377
378    <!-- Security envelope — system / when / IP / device -->
379    <table role="presentation" width="100%" cellpadding="0" cellspacing="0"
380      border="0" style="margin:0 0 28px 0;">
381    <tr><td style="padding:6px 0;color:#6B7280;font-size:13px;
382      width:90px;vertical-align:top;">System</td>
383      <td style="padding:6px 0;color:#1F2937;font-size:13px;">{app_name_text}</td></tr>
384    <tr><td style="padding:6px 0;color:#6B7280;font-size:13px;
385      width:90px;vertical-align:top;">When</td>
386      <td style="padding:6px 0;color:#1F2937;font-size:13px;
387      font-variant-numeric:tabular-nums;">{when_str}</td></tr>
388    {ip_row}
389    {ua_row}
390    </table>
391
392    <!-- Warning panel: if not you -->
393    <div style="padding:18px 20px;background:#FFF8EB;border:1px solid #F2D9A7;
394      border-radius:6px;margin:0 0 24px 0;">
395      <p style="margin:0;color:#6B4F12;font-size:13px;line-height:1.55;">
396        <strong style="color:#4F3B0A;font-weight:600;">If this wasn't you</strong>
397        — ignore this email. Your password stays unchanged, and the link
398        above will expire on its own. You can also sign in and revoke open
399        sessions from the Sessions page.
400      </p>
401    </div>
402{signature_block}
403  </td>
404  </tr>
405  </table>
406
407  <!-- Footer — operational tone, no marketing. App identity speaks;
408       framework name appears only when explicitly opted-in. -->
409  <table role="presentation" cellpadding="0" cellspacing="0" border="0"
410    style="max-width:560px;width:100%;margin:18px auto 0 auto;">
411  <tr><td align="center" style="padding:0 8px;">
412    <p style="margin:0;color:#9CA3AF;font-size:12px;line-height:1.6;">
413      Session-aware authentication · Audit-logged ·
414      <span style="font-variant-numeric:tabular-nums;">{when_str}</span>
415    </p>
416    <p style="margin:6px 0 0 0;color:#9CA3AF;font-size:11px;line-height:1.5;">
417      You are receiving this because a password reset was requested
418      for your account on {app_name_text}. If that wasn't you,
419      no action is required.
420    </p>
421    {support_line}
422    {powered_by_line}
423  </td></tr>
424  </table>
425
426</td>
427</tr>
428</table>
429
430</body>
431</html>"##,
432    )
433}
434
435// public:
436/// Structured inputs to [`render_recovery_html`]. Kept separate
437/// from `Mail` so the framework's HTML email surface stays
438/// declarative — call-sites pass labelled fields rather than a
439/// long positional argument list.
440///
441/// `#[non_exhaustive]` so future additions (e.g. a project-
442/// supplied accent override, a footer-link tuple) are SemVer-safe.
443#[non_exhaustive]
444pub struct RecoveryEmailParts<'a> {
445    /// User-facing product identity, e.g. `"Library Circulation"`.
446    /// **Required for production**: never set this to a framework
447    /// name. Renders as the brand wordmark, in the security
448    /// envelope's "System" row, and in the email footer.
449    pub app_name: &'a str,
450    /// Optional descriptor under the wordmark — e.g.
451    /// `"Operational library management"`. `None` falls back to
452    /// `"Account security notification"`.
453    pub app_tagline: Option<&'a str>,
454    pub title: &'a str,
455    /// Greeting label resolved by the caller via the documented
456    /// `display_name → first_name → email-local-part → "there"`
457    /// fallback. Rendered as `"Hello {greeting_name},"`.
458    pub greeting_name: &'a str,
459    pub intro: &'a str,
460    pub cta_label: &'a str,
461    pub cta_url: &'a str,
462    pub fine_print: &'a str,
463    pub when: DateTime<Utc>,
464    pub request_ip: Option<&'a str>,
465    pub ua_summary: Option<&'a str>,
466    /// Per-request correlation id (UUID v7). The framework derives
467    /// a 6-character `reference` from its last hex chars and
468    /// renders it inside a security-style panel — operators can
469    /// match the reference to the audit row, and the visual block
470    /// stays compatible with a future MFA verification-code shape.
471    /// `None` hides the reference panel.
472    pub correlation_id: Option<&'a str>,
473    /// Account-owner signature primary line ("Abdulwahed Mansour"
474    /// or a name-equivalent). `None` hides the signature block.
475    pub signature_primary: Option<&'a str>,
476    /// Optional secondary signature line (job title).
477    pub signature_title: Option<&'a str>,
478    /// Optional support contact surfaced in the email footer.
479    pub support_email: Option<&'a str>,
480    /// `true` → render the low-key "Powered by RustIO" footer
481    /// credit. Off by default; the framework name stays invisible.
482    pub show_powered_by: bool,
483}
484
485impl<'a> RecoveryEmailParts<'a> {
486    // public:
487    /// Construct a [`RecoveryEmailParts`] with sensible defaults
488    /// for the optional fields. Required fields are positional;
489    /// everything else lands in a safe "none"/default state and
490    /// callers mutate the public field directly.
491    ///
492    /// Lets external crates (the CLI's `doctor email`
493    /// `--html-preview`, project-side test scaffolding) construct
494    /// the struct around its `#[non_exhaustive]` attribute.
495    pub fn new(
496        app_name: &'a str,
497        title: &'a str,
498        greeting_name: &'a str,
499        intro: &'a str,
500        cta_url: &'a str,
501        fine_print: &'a str,
502        when: DateTime<Utc>,
503    ) -> Self {
504        Self {
505            app_name,
506            app_tagline: None,
507            title,
508            greeting_name,
509            intro,
510            cta_label: "Set a new password",
511            cta_url,
512            fine_print,
513            when,
514            request_ip: None,
515            ua_summary: None,
516            correlation_id: None,
517            signature_primary: None,
518            signature_title: None,
519            support_email: None,
520            show_powered_by: false,
521        }
522    }
523}
524
525/// Minimal HTML-text escape for the visible-text positions in the
526/// recovery template. Covers `&`, `<`, `>`, `"`, `'` — the five
527/// canonical entities.
528fn html_text_escape(s: &str) -> String {
529    let mut out = String::with_capacity(s.len() + 8);
530    for ch in s.chars() {
531        match ch {
532            '&' => out.push_str("&amp;"),
533            '<' => out.push_str("&lt;"),
534            '>' => out.push_str("&gt;"),
535            '"' => out.push_str("&quot;"),
536            '\'' => out.push_str("&#x27;"),
537            _ => out.push(ch),
538        }
539    }
540    out
541}
542
543/// Slightly stricter escape for the `href="..."` attribute
544/// position — drops control bytes that browsers sometimes
545/// interpret. The reset token is base64-url-safe so this is
546/// belt-and-braces.
547fn html_attr_escape(s: &str) -> String {
548    let mut out = String::with_capacity(s.len() + 8);
549    for ch in s.chars() {
550        match ch {
551            '&' => out.push_str("&amp;"),
552            '"' => out.push_str("&quot;"),
553            '\'' => out.push_str("&#x27;"),
554            '<' => out.push_str("&lt;"),
555            '>' => out.push_str("&gt;"),
556            // ASCII control bytes — never legitimate in a URL
557            c if (c as u32) < 0x20 || (c as u32) == 0x7f => { /* drop */ }
558            c => out.push(c),
559        }
560    }
561    out
562}
563
564// public:
565/// Errors a [`Mailer`] can return. The most important variant is
566/// [`MailerError::ConfigurationMissing`] — the framework treats it
567/// as a hard boot failure when production deployments forget to wire
568/// up a real mailer (per the user-locked decision: "Mailer blocking
569/// behaviour" → refuse to start when no mailer is configured for
570/// production).
571#[derive(Debug)]
572#[non_exhaustive]
573pub enum MailerError {
574    /// The mailer is structurally missing — no transport, no API
575    /// key, etc. R1+ boot guards check for this at startup.
576    ConfigurationMissing(String),
577    /// A transient failure (SMTP timeout, 5xx from the API, queue
578    /// full). Recovery flows treat this as "log + uniform user
579    /// response" per the user-locked mailer-blocking-behaviour
580    /// decision: the user sees the same response as success; an
581    /// audit row is written with `metadata.email_send_status =
582    /// "failed"` so the operator can grep for undelivered resets.
583    Transient(String),
584    /// A non-recoverable failure (invalid recipient, blocked
585    /// domain). Surfaces in audit logs the same way as Transient.
586    Permanent(String),
587}
588
589impl fmt::Display for MailerError {
590    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
591        match self {
592            Self::ConfigurationMissing(m) => write!(f, "mailer configuration missing: {m}"),
593            Self::Transient(m) => write!(f, "mailer transient failure: {m}"),
594            Self::Permanent(m) => write!(f, "mailer permanent failure: {m}"),
595        }
596    }
597}
598
599impl std::error::Error for MailerError {}
600
601// public:
602/// Async outbound-mail interface. Project implementations live in
603/// the project crate so the framework never imports `lettre` /
604/// `aws-sdk-ses` / etc.
605///
606/// Implementations MUST be `Send + Sync` and cheap to clone (the
607/// framework holds a single `Arc<dyn Mailer>` for the lifetime of
608/// the process).
609pub trait Mailer: Send + Sync {
610    /// Send one message. Errors are typed; see [`MailerError`].
611    fn send<'a>(
612        &'a self,
613        msg: Mail,
614    ) -> std::pin::Pin<
615        Box<dyn std::future::Future<Output = std::result::Result<(), MailerError>> + Send + 'a>,
616    >;
617}
618
619// public:
620/// Default mailer. Writes the message to `log::info!` instead of
621/// sending it. Safe for dev / CI / testing where outbound SMTP is
622/// forbidden or undesirable; not suitable for production — recovery
623/// emails will be lost (the audit row will record the attempt).
624///
625/// Subjects and recipient addresses appear in the log output;
626/// **bodies are truncated** at 200 chars and **anything that looks
627/// like a token is replaced with a fingerprint** before logging
628/// (doctrine 11 — never log secrets).
629#[derive(Debug, Default, Clone)]
630pub struct LogMailer;
631
632impl LogMailer {
633    // public:
634    pub fn new() -> Self {
635        Self
636    }
637}
638
639impl Mailer for LogMailer {
640    fn send<'a>(
641        &'a self,
642        msg: Mail,
643    ) -> std::pin::Pin<
644        Box<dyn std::future::Future<Output = std::result::Result<(), MailerError>> + Send + 'a>,
645    > {
646        Box::pin(async move {
647            let body_preview: String = msg.text_body.chars().take(200).collect();
648            // Doctrine 11: redact anything that looks like a URL with
649            // a token segment before it lands in the log target.
650            let redacted = redact_likely_tokens(&body_preview);
651            log::info!(
652                target: "rustio_admin::mailer::log",
653                "[LogMailer] to={} subject={:?} body_preview={:?}",
654                msg.to,
655                msg.subject,
656                redacted,
657            );
658            Ok(())
659        })
660    }
661}
662
663/// Replace anything that looks like a URL ending in a long
664/// alphanumeric segment (a reset-token-shaped suffix) with the
665/// `<redacted-link>` placeholder. Pure function; no I/O.
666fn redact_likely_tokens(s: &str) -> String {
667    // Heuristic: any whitespace-delimited segment ≥ 32 chars that's
668    // ASCII alphanumeric + - / _ + : / . is replaced. Catches
669    // "/admin/reset-password/<token>" style URLs and bare tokens.
670    s.split_whitespace()
671        .map(|w| {
672            if w.len() >= 32 && w.chars().all(is_token_url_char) {
673                "<redacted-link>"
674            } else {
675                w
676            }
677        })
678        .collect::<Vec<_>>()
679        .join(" ")
680}
681
682fn is_token_url_char(c: char) -> bool {
683    c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | ':' | '.' | '?' | '&' | '=' | '#')
684}
685
686// public:
687/// Type-erased shared mailer reference. The framework's `Admin`
688/// holds one of these; defaults to `Arc::new(LogMailer)` until a
689/// project overrides via `Admin::mailer(Arc::new(...))`.
690pub type SharedMailer = Arc<dyn Mailer>;
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use chrono::TimeZone;
696
697    #[test]
698    fn framework_envelope_appends_security_footer() {
699        let m = Mail::framework_envelope(
700            "user@example.com",
701            "Test",
702            "Body line.",
703            "Bosphorus & Sham · Stockholm",
704            Some("198.51.100.42"),
705            Some("macOS · Safari 18"),
706            Utc::now(),
707        );
708        assert!(m.text_body.contains("Body line."));
709        assert!(m.text_body.contains("System: Bosphorus & Sham · Stockholm"));
710        assert!(m.text_body.contains("From IP: 198.51.100.42"));
711        assert!(m.text_body.contains("Device:  macOS · Safari 18"));
712        assert!(m.text_body.contains("If this was not you"));
713    }
714
715    #[test]
716    fn framework_envelope_omits_missing_fields() {
717        let m = Mail::framework_envelope(
718            "user@example.com",
719            "Test",
720            "Body.",
721            "ACME",
722            None,
723            None,
724            Utc::now(),
725        );
726        assert!(!m.text_body.contains("From IP:"));
727        assert!(!m.text_body.contains("Device:"));
728        assert!(m.text_body.contains("If this was not you"));
729    }
730
731    #[tokio::test]
732    async fn log_mailer_send_is_ok() {
733        let m = LogMailer::new();
734        let mail = Mail {
735            to: "user@example.com".into(),
736            subject: "Hi".into(),
737            text_body: "test body".into(),
738            html_body: None,
739            headers: Vec::new(),
740        };
741        assert!(m.send(mail).await.is_ok());
742    }
743
744    #[test]
745    fn redact_likely_tokens_redacts_long_alnum_strings() {
746        let s = "Click http://example.com/admin/reset-password/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa to reset.";
747        let r = redact_likely_tokens(s);
748        assert!(r.contains("<redacted-link>"));
749        assert!(!r.contains("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
750    }
751
752    #[test]
753    fn redact_likely_tokens_keeps_short_words() {
754        let s = "Hello user, your account is fine.";
755        let r = redact_likely_tokens(s);
756        assert_eq!(r, s);
757    }
758
759    #[test]
760    fn mailer_error_display() {
761        let e = MailerError::ConfigurationMissing("no SMTP host".into());
762        assert!(format!("{e}").contains("configuration missing"));
763        let e = MailerError::Transient("timeout".into());
764        assert!(format!("{e}").contains("transient"));
765        let e = MailerError::Permanent("blocked".into());
766        assert!(format!("{e}").contains("permanent"));
767    }
768
769    #[test]
770    fn with_html_attaches_alternative_body() {
771        let m = Mail::framework_envelope(
772            "user@example.com",
773            "Test",
774            "Plain.",
775            "ACME",
776            None,
777            None,
778            Utc::now(),
779        )
780        .with_html("<p>Rich</p>");
781        assert!(m.text_body.contains("Plain."));
782        assert_eq!(m.html_body.as_deref(), Some("<p>Rich</p>"));
783    }
784
785    #[test]
786    fn recovery_html_contains_required_markers_and_escapes() {
787        let when = Utc.with_ymd_and_hms(2026, 5, 13, 14, 30, 0).unwrap();
788        let html = render_recovery_html(RecoveryEmailParts {
789            app_name: "Library Circulation",
790            app_tagline: Some("Operational library management"),
791            title: "Reset your password",
792            greeting_name: "Abdulwahed",
793            intro: "We received a request to reset the password for your \
794                    Library Circulation account. Choose a new password to continue.",
795            cta_label: "Set a new password",
796            cta_url: "http://127.0.0.1:3000/admin/reset-password/abc123",
797            fine_print: "This link expires in 30 minutes.",
798            when,
799            request_ip: Some("127.0.0.1"),
800            ua_summary: Some("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15"),
801            correlation_id: Some("019e212b-9f63-7512-be44-daaa8e6267e2"),
802            signature_primary: Some("Abdulwahed Mansour"),
803            signature_title: Some("Principal Administrator"),
804            support_email: Some("support@library.example.com"),
805            show_powered_by: false,
806        });
807        // Structural markers
808        assert!(html.starts_with("<!DOCTYPE html>"));
809        assert!(html.contains("viewport"));
810        assert!(!html.contains("multipart")); // not in HTML body
811                                              // App identity owns the surface — framework name absent.
812        assert!(html.contains("Library Circulation"));
813        assert!(!html.contains("RustIO Admin"));
814        // Tagline replaces the default descriptor when provided.
815        assert!(html.contains("Operational library management"));
816        assert!(!html.contains("Account security notification"));
817        // Required content
818        assert!(html.contains("Reset your password"));
819        assert!(html.contains("Hello Abdulwahed,"));
820        assert!(html.contains("Set a new password"));
821        assert!(html.contains("http://127.0.0.1:3000/admin/reset-password/abc123"));
822        assert!(html.contains("This link expires in 30 minutes."));
823        assert!(html.contains("2026-05-13 14:30 UTC"));
824        assert!(html.contains("127.0.0.1"));
825        assert!(html.contains("Mozilla/5.0"));
826        // Phase 3E: verification reference panel
827        assert!(html.contains("Verification reference"));
828        assert!(html.contains("6267E2"));
829        // Phase 3G: operational footer tone
830        assert!(html.contains("Session-aware authentication"));
831        // Signature block
832        assert!(html.contains("Account owner"));
833        assert!(html.contains("Abdulwahed Mansour"));
834        assert!(html.contains("Principal Administrator"));
835        // Support contact line
836        assert!(html.contains("support@library.example.com"));
837        // Powered-by stays invisible when not opted in
838        assert!(!html.contains("Powered by RustIO"));
839        // Anti-phishing copy
840        assert!(html.contains("If this wasn"));
841        // Brand-accent CTA
842        assert!(html.contains("#0F8C7E"));
843
844        // Write a copy to /tmp so the developer can open it in a
845        // browser to visually verify the rendered email.
846        let _ = std::fs::write("/tmp/rustio-recovery-email-preview.html", &html);
847    }
848
849    #[test]
850    fn recovery_html_powered_by_appears_only_when_opted_in() {
851        let when = Utc.with_ymd_and_hms(2026, 5, 13, 14, 30, 0).unwrap();
852        let html = render_recovery_html(RecoveryEmailParts {
853            app_name: "Library Circulation",
854            app_tagline: None,
855            title: "Reset your password",
856            greeting_name: "there",
857            intro: "We received a request.",
858            cta_label: "Set a new password",
859            cta_url: "http://example/x",
860            fine_print: "Expires soon.",
861            when,
862            request_ip: None,
863            ua_summary: None,
864            correlation_id: None,
865            signature_primary: None,
866            signature_title: None,
867            support_email: None,
868            show_powered_by: true,
869        });
870        // Tagline falls back to the security caption when not set.
871        assert!(html.contains("Account security notification"));
872        // Powered-by line appears.
873        assert!(html.contains("Powered by RustIO"));
874        // No signature block when fields unset.
875        assert!(!html.contains("Account owner"));
876    }
877
878    #[test]
879    fn recovery_html_escapes_html_in_inputs() {
880        let html = render_recovery_html(RecoveryEmailParts {
881            app_name: "<script>alert(1)</script>",
882            app_tagline: Some("<b>raw</b>"),
883            title: "Title & co",
884            greeting_name: "Alice<script>",
885            intro: "Body <em>x</em>",
886            cta_label: "Click >>",
887            cta_url: "http://example.com/?a=1&b=2",
888            fine_print: "Expires in <30> minutes",
889            when: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
890            request_ip: Some("<bad>"),
891            ua_summary: Some("\"chrome\""),
892            correlation_id: None,
893            signature_primary: Some("<sig>"),
894            signature_title: Some("<title>"),
895            support_email: Some("a@<b>"),
896            show_powered_by: false,
897        });
898        // Script tag must NOT be present unescaped anywhere
899        assert!(!html.contains("<script>alert(1)</script>"));
900        // Escape outputs must be present
901        assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
902        assert!(html.contains("Title &amp; co"));
903        assert!(html.contains("Body &lt;em&gt;x&lt;/em&gt;"));
904        assert!(html.contains("Click &gt;&gt;"));
905        // URL attribute escaping for ampersand
906        assert!(html.contains("?a=1&amp;b=2"));
907        assert!(html.contains("&lt;bad&gt;"));
908        assert!(html.contains("&quot;chrome&quot;"));
909    }
910}