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