Skip to main content

autumn_web/
mail.rs

1//! Transactional email support.
2//!
3//! The public surface is intentionally small: build a [`Mail`] value, send it
4//! through the cloneable [`Mailer`] extractor, and swap transports through the
5//! [`MailTransport`] trait when SMTP is not the right coffin lining.
6
7// autumn-panic-gate: request-path module — production code path must be panic-free.
8// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
9// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
10#![cfg_attr(
11    not(test),
12    deny(
13        clippy::unwrap_used,
14        clippy::expect_used,
15        clippy::panic,
16        clippy::unreachable,
17        clippy::todo,
18        clippy::unimplemented,
19        clippy::indexing_slicing,
20    )
21)]
22
23use std::future::Future;
24use std::path::{Path, PathBuf};
25use std::pin::Pin;
26use std::sync::Arc;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::time::{SystemTime, UNIX_EPOCH};
29
30use axum::extract::FromRequestParts;
31use axum::response::{Html, IntoResponse, Response};
32use lettre::message::header::{ContentTransferEncoding, ContentType};
33use lettre::message::{
34    Attachment as LettreAttachment, Body as LettreBody, Mailbox, MultiPart, SinglePart,
35};
36use lettre::transport::smtp::authentication::Credentials;
37use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
38use serde::{Deserialize, Serialize};
39use thiserror::Error;
40
41use crate::{AppState, AutumnError, AutumnResult};
42
43/// Mail transport selected by `[mail].transport`.
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Transport {
47    /// Write full email contents to the tracing log at INFO.
48    Log,
49    /// Write RFC 822 `.eml` files under `target/mail` or a configured dir.
50    File,
51    /// Send through SMTP using Lettre.
52    Smtp,
53    /// Drop all email sends successfully.
54    #[default]
55    Disabled,
56}
57
58impl Transport {
59    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
60        match value.trim().to_ascii_lowercase().as_str() {
61            "log" => Some(Self::Log),
62            "file" => Some(Self::File),
63            "smtp" => Some(Self::Smtp),
64            "disabled" => Some(Self::Disabled),
65            _ => None,
66        }
67    }
68}
69
70/// SMTP TLS mode.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
72#[serde(rename_all = "lowercase")]
73pub enum TlsMode {
74    /// Plain connection; useful only for local test SMTP sinks.
75    Disabled,
76    /// Upgrade with STARTTLS.
77    #[default]
78    StartTls,
79    /// Connect with wrapper TLS.
80    Tls,
81}
82
83impl TlsMode {
84    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
85        match value.trim().to_ascii_lowercase().as_str() {
86            "disabled" => Some(Self::Disabled),
87            "starttls" | "start_tls" => Some(Self::StartTls),
88            "tls" => Some(Self::Tls),
89            _ => None,
90        }
91    }
92}
93
94/// SMTP configuration nested under `[mail.smtp]`.
95#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
96pub struct SmtpConfig {
97    /// SMTP host name.
98    #[serde(default)]
99    pub host: Option<String>,
100    /// SMTP port. Defaults to 587 for STARTTLS, 465 for TLS, and 25 for disabled TLS.
101    #[serde(default)]
102    pub port: Option<u16>,
103    /// Optional SMTP username.
104    #[serde(default)]
105    pub username: Option<String>,
106    /// Environment variable containing the SMTP password.
107    #[serde(default)]
108    pub password_env: Option<String>,
109    /// TLS behavior.
110    #[serde(default)]
111    pub tls: TlsMode,
112}
113
114impl Default for SmtpConfig {
115    fn default() -> Self {
116        Self {
117            host: None,
118            port: None,
119            username: None,
120            password_env: None,
121            tls: TlsMode::StartTls,
122        }
123    }
124}
125
126/// `[mail]` config section.
127#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
128#[allow(clippy::struct_excessive_bools)] // independent transport/prod/unsubscribe toggles
129pub struct MailConfig {
130    /// Active transport.
131    #[serde(default)]
132    pub transport: Transport,
133    /// Default From header.
134    #[serde(default)]
135    pub from: Option<String>,
136    /// Default Reply-To header.
137    #[serde(default)]
138    pub reply_to: Option<String>,
139    /// Permit log transport in `prod`.
140    #[serde(default)]
141    pub allow_log_in_production: bool,
142    /// Acknowledge that `deliver_later` may use the in-process Tokio fallback in
143    /// `prod`. Without a registered durable [`MailDeliveryQueue`], this is the
144    /// only way to start the app in `prod` with an active mail transport.
145    #[serde(default)]
146    pub allow_in_process_deliver_later_in_production: bool,
147    /// Directory for file transport.
148    #[serde(default = "default_file_dir")]
149    pub file_dir: PathBuf,
150    /// Force-enable the dev mail preview UI.
151    ///
152    /// The UI is auto-enabled in `dev` when `mail.transport = "file"`.
153    /// Setting this flag outside `dev` is rejected at startup.
154    #[serde(default)]
155    pub preview: bool,
156    /// Base URL for RFC 8058 one-click `List-Unsubscribe` links, e.g.
157    /// `https://app.example.com`. Required (alongside or instead of
158    /// [`unsubscribe_mailto`](Self::unsubscribe_mailto)) for any `#[mailer]`
159    /// that declares `list_unsubscribe`.
160    #[serde(default)]
161    pub unsubscribe_base_url: Option<String>,
162    /// `mailto:` fallback address for the `List-Unsubscribe` header, e.g.
163    /// `unsubscribe@example.com`.
164    #[serde(default)]
165    pub unsubscribe_mailto: Option<String>,
166    /// Validity window for signed unsubscribe tokens, in days.
167    #[serde(default = "default_unsubscribe_ttl_days")]
168    pub unsubscribe_token_ttl_days: i64,
169    /// Opt in to mounting the framework's default one-click unsubscribe endpoint
170    /// (`GET`/`POST /_autumn/unsubscribe`). Off by default so JSON-only apps
171    /// never get an HTML endpoint they didn't ask for; also settable via
172    /// [`AppBuilder::mount_unsubscribe_endpoint`](crate::app::AppBuilder::mount_unsubscribe_endpoint).
173    #[serde(default)]
174    pub mount_unsubscribe_endpoint: bool,
175    /// Default for CSS inlining of HTML mail bodies (issue #1254).
176    ///
177    /// When `true`, every HTML body sent through a [`Mailer`] built from this
178    /// config has its `<style>` rules inlined onto matching elements as
179    /// `style="…"` attributes at send time, so it renders styled in clients
180    /// that strip `<head>`/`<style>` (Gmail, Outlook). Off by default —
181    /// existing apps are unaffected until they opt in. A per-message
182    /// [`MailBuilder::inline_css`] call overrides this default in either
183    /// direction (explicit builder value wins).
184    #[serde(default)]
185    pub inline_css: bool,
186    /// SMTP settings.
187    #[serde(default)]
188    pub smtp: SmtpConfig,
189}
190
191/// Whether `url` is an absolute `https://` URL with a non-empty host and no
192/// query/fragment, e.g. `https://app.example.com` or `…/base`. Rejects bare
193/// `https://`, `https:///path`, and bases carrying `?`/`#` (the unsubscribe
194/// path/token is appended afterwards, so a query/fragment base would not route).
195fn is_valid_https_base_url(url: &str) -> bool {
196    // Reject characters that are unsafe inside an RFC 2369 angle-bracket URI or
197    // would survive into the raw header: `Url::parse` percent-encodes a space or
198    // `<`/`>` in the path, but the *original* string is what gets rendered as
199    // `<…?token=…>`, so a raw `<`/`>`/whitespace/control char would close or
200    // corrupt the `List-Unsubscribe` value.
201    if url
202        .chars()
203        .any(|c| c.is_control() || c.is_whitespace() || matches!(c, '<' | '>'))
204    {
205        return false;
206    }
207    // Require the raw input to be literally `https://<authority>…`. `url::Url`
208    // normalizes a missing/short authority (`https:`, `https:app.example.com`,
209    // `https:/app.example.com`, `https:///path`) into a valid HTTPS URL with a
210    // host, but the *original* malformed string is what gets rendered into the
211    // header — so reject anything that isn't `https://` followed by a non-`/`
212    // authority character.
213    match url.strip_prefix("https://") {
214        Some(rest) if !rest.is_empty() && !rest.starts_with('/') => {}
215        _ => return false,
216    }
217    let Ok(parsed) = ::url::Url::parse(url) else {
218        return false;
219    };
220    // Require an absolute https:// URL with a real host and a valid authority.
221    // Parsing (rather than splitting on `/`) rejects malformed authorities like
222    // `https://app.example.com:abc` (bad port) or `https://@/base` (empty host).
223    // No credentials in the link, and no query/fragment — either would break the
224    // appended `?token=…`.
225    parsed.scheme() == "https"
226        && parsed.host_str().is_some_and(|h| !h.is_empty())
227        && parsed.username().is_empty()
228        && parsed.password().is_none()
229        && parsed.query().is_none()
230        && parsed.fragment().is_none()
231}
232
233/// Whether `value` is a usable unsubscribe mailbox — a bare `local@domain` or a
234/// `mailto:local@domain` URI, with non-empty parts and no whitespace.
235fn is_valid_mailto_address(value: &str) -> bool {
236    // Reject control characters and RFC 2369 delimiters anywhere in the value
237    // (including inside a `?subject=…` query): the value is rendered verbatim
238    // inside `<mailto:…>`, so a control char (CRLF injection, e.g. an extra
239    // `Bcc:`) or a `<`/`>`/`,` (which would close the entry and inject an extra
240    // `List-Unsubscribe` target) must not pass.
241    if value
242        .chars()
243        .any(|c| c.is_control() || matches!(c, '<' | '>' | ','))
244    {
245        return false;
246    }
247    let address = value
248        .trim()
249        .strip_prefix("mailto:")
250        .unwrap_or_else(|| value.trim());
251    // Drop any `?subject=…` parameters before validating the address itself.
252    let address = address.split('?').next().unwrap_or("");
253    match address.split_once('@') {
254        Some((local, domain)) => {
255            !local.is_empty()
256                && !domain.is_empty()
257                && domain.contains('.')
258                && !address.contains(char::is_whitespace)
259                // Reject any other URI scheme (e.g. `https://unsub@example.com`):
260                // `:` / `/` here mean the value is not a bare mailbox, and it
261                // would otherwise render as a bogus `<mailto:https://…>` header.
262                && !address.contains([':', '/'])
263        }
264        None => false,
265    }
266}
267
268const fn default_unsubscribe_ttl_days() -> i64 {
269    crate::mail::unsubscribe::DEFAULT_TOKEN_TTL_DAYS
270}
271
272impl Default for MailConfig {
273    fn default() -> Self {
274        Self {
275            transport: Transport::Disabled,
276            from: None,
277            reply_to: None,
278            allow_log_in_production: false,
279            allow_in_process_deliver_later_in_production: false,
280            file_dir: default_file_dir(),
281            preview: false,
282            unsubscribe_base_url: None,
283            unsubscribe_mailto: None,
284            unsubscribe_token_ttl_days: default_unsubscribe_ttl_days(),
285            mount_unsubscribe_endpoint: false,
286            inline_css: false,
287            smtp: SmtpConfig::default(),
288        }
289    }
290}
291
292impl MailConfig {
293    /// Validate semantic mail configuration.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`crate::config::ConfigError::Validation`] for unsafe profile
298    /// combinations or missing SMTP settings.
299    pub fn validate(&self, profile: Option<&str>) -> Result<(), crate::config::ConfigError> {
300        if matches!(profile, Some("prod" | "production"))
301            && self.transport == Transport::Log
302            && !self.allow_log_in_production
303        {
304            return Err(crate::config::ConfigError::Validation(
305                "mail.transport = \"log\" is disabled in prod; set mail.allow_log_in_production = true to acknowledge this explicitly".to_owned(),
306            ));
307        }
308
309        if self.transport == Transport::Smtp
310            && self.smtp.host.as_deref().map_or("", str::trim).is_empty()
311        {
312            return Err(crate::config::ConfigError::Validation(
313                "mail.smtp.host is required when mail.transport = \"smtp\"".to_owned(),
314            ));
315        }
316
317        if self.preview && !matches!(profile, Some("dev" | "development")) {
318            return Err(crate::config::ConfigError::Validation(
319                "mail.preview = true is only allowed in dev; refusing to mount /_autumn/mail outside the dev profile".to_owned(),
320            ));
321        }
322
323        if self.unsubscribe_token_ttl_days <= 0 {
324            return Err(crate::config::ConfigError::Validation(
325                "mail.unsubscribe_token_ttl_days must be a positive number of days; a non-positive value would make every unsubscribe token immediately expired".to_owned(),
326            ));
327        }
328
329        if matches!(profile, Some("prod" | "production"))
330            && let Some(base) = self.unsubscribe_base_url.as_deref().map(str::trim)
331            && !base.is_empty()
332            && !is_valid_https_base_url(base)
333        {
334            return Err(crate::config::ConfigError::Validation(
335                "mail.unsubscribe_base_url must be an absolute https:// URL with a host in prod; mailbox providers require HTTPS for RFC 8058 one-click unsubscribe".to_owned(),
336            ));
337        }
338
339        if matches!(profile, Some("prod" | "production"))
340            && let Some(mailto) = self.unsubscribe_mailto.as_deref().map(str::trim)
341            && !mailto.is_empty()
342            && !is_valid_mailto_address(mailto)
343        {
344            return Err(crate::config::ConfigError::Validation(
345                "mail.unsubscribe_mailto must be a bare mailbox address (or mailto: URI) like unsubscribe@example.com".to_owned(),
346            ));
347        }
348
349        Ok(())
350    }
351
352    pub(crate) fn preview_routes_enabled(&self, profile: Option<&str>) -> bool {
353        matches!(profile, Some("dev" | "development"))
354            && (self.preview || self.transport == Transport::File)
355    }
356
357    /// Whether a base URL is configured. A `mailto`-only configuration emits a
358    /// `List-Unsubscribe: <mailto:…>` header but needs no HTTP endpoint.
359    pub(crate) fn unsubscribe_base_url_set(&self) -> bool {
360        self.unsubscribe_base_url
361            .as_deref()
362            .is_some_and(|s| !s.trim().is_empty())
363    }
364
365    /// Whether the framework's default one-click unsubscribe endpoint should be
366    /// mounted: the app opted in **and** a base URL is configured. Opt-in keeps
367    /// JSON-only apps free of an HTML endpoint they never requested.
368    pub(crate) fn should_mount_unsubscribe_endpoint(&self) -> bool {
369        self.mount_unsubscribe_endpoint && self.unsubscribe_base_url_set()
370    }
371}
372
373fn default_file_dir() -> PathBuf {
374    PathBuf::from("target/mail")
375}
376
377/// Renderable mail body input.
378pub trait IntoMailBody {
379    /// Convert into owned body text.
380    fn into_mail_body(self) -> String;
381}
382
383impl IntoMailBody for String {
384    fn into_mail_body(self) -> String {
385        self
386    }
387}
388
389impl IntoMailBody for &str {
390    fn into_mail_body(self) -> String {
391        self.to_owned()
392    }
393}
394
395impl IntoMailBody for maud::Markup {
396    fn into_mail_body(self) -> String {
397        self.into_string()
398    }
399}
400
401/// Placeholder token in shared mailer layouts marking where the per-mailer body
402/// fragment is inserted.
403///
404/// Layouts that do not contain this marker are ignored and the raw body is
405/// delivered instead (prevents silent content loss).
406pub const MAIL_LAYOUT_CONTENT_MARKER: &str = "{{ content }}";
407
408/// Compose a `layout` string and a `body` fragment by replacing
409/// [`MAIL_LAYOUT_CONTENT_MARKER`] with `body`.
410///
411/// If the layout does not contain the marker, `body` is returned unchanged so
412/// content is never silently dropped.
413#[must_use]
414pub fn compose_layout(layout: &str, body: &str) -> String {
415    if layout.contains(MAIL_LAYOUT_CONTENT_MARKER) {
416        layout.replace(MAIL_LAYOUT_CONTENT_MARKER, body)
417    } else {
418        body.to_owned()
419    }
420}
421
422/// Whether `html` contains a `<style` tag (case-insensitive), i.e. there is any
423/// embedded stylesheet worth inlining. Allocation-free ASCII scan — the fast
424/// path for the common case of plain-text or already-inlined bodies.
425fn html_contains_style_block(html: &str) -> bool {
426    html.as_bytes()
427        .windows(6)
428        .any(|window| window.eq_ignore_ascii_case(b"<style"))
429}
430
431/// Whether `html` looks like a full HTML *document* — it carries a `<!doctype`,
432/// `<html`, or `<body` marker — rather than a bare fragment. Autumn permits raw
433/// fragment bodies when no layout wraps them, so [`inline_css_html`] uses this to
434/// decide whether to strip the synthetic document wrappers `css-inline` adds. A
435/// user-authored `<body>` is therefore recognized as a document and its
436/// structure is left untouched. Allocation-free case-insensitive ASCII scan.
437fn html_is_full_document(html: &str) -> bool {
438    let bytes = html.as_bytes();
439    bytes.windows(5).any(|w| w.eq_ignore_ascii_case(b"<html"))
440        || bytes.windows(5).any(|w| w.eq_ignore_ascii_case(b"<body"))
441        || bytes
442            .windows(9)
443            .any(|w| w.eq_ignore_ascii_case(b"<!doctype"))
444}
445
446/// Strip the synthetic `<html>`/`<head>`/`<body>` wrappers that `css-inline`'s
447/// document mode adds around fragment input, reconstructing the fragment.
448///
449/// [`inline_css_html`] always inlines in document mode because `css-inline`'s
450/// fragment mode drops retained `@media`/at-rules (it re-homes them to `<head>`,
451/// which a fragment lacks). Document mode instead wraps a fragment body in
452/// synthetic structural tags. This is only ever called on output we produced by
453/// document-inlining a body we already determined was a *fragment*, so those
454/// wrappers are always `css-inline`'s own, never user-authored.
455///
456/// `css-inline` (via `html5ever`) serializes a document as a canonical,
457/// attribute-free `<html><head>…</head><body>…</body></html>`, so exact-literal
458/// matching is safe. The result is the `<head>` contents (the retained `<style>`
459/// block carrying un-inlinable `@media`/pseudo rules, per AC5) followed by the
460/// `<body>` contents — preserving the original fragment ordering of `<style>`
461/// before body content. If the expected shape is absent, the input is returned
462/// unchanged rather than risking corruption.
463fn unwrap_synthetic_document(doc: &str) -> String {
464    // html5ever emits these exact byte sequences, lowercased and without
465    // attributes or whitespace, for the wrappers it synthesizes.
466    let inner = doc
467        .strip_prefix("<html>")
468        .and_then(|rest| rest.strip_suffix("</html>"))
469        .unwrap_or(doc);
470    let (head, after_head) = match inner.strip_prefix("<head>") {
471        Some(rest) => match rest.split_once("</head>") {
472            Some(split) => split,
473            // Malformed/unexpected shape: don't risk corrupting the body.
474            None => return doc.to_owned(),
475        },
476        None => ("", inner),
477    };
478    let body = after_head
479        .strip_prefix("<body>")
480        .map_or(after_head, |rest| {
481            rest.strip_suffix("</body>").unwrap_or(rest)
482        });
483    format!("{head}{body}")
484}
485
486/// Inline the `<style>` rules of an HTML mail body onto matching elements as
487/// `style="…"` attributes, so the message renders styled in clients that strip
488/// `<head>`/`<style>` (Gmail, Outlook). See issue #1254.
489///
490/// Behavior:
491/// - Bodies with no `<style>` block are returned unchanged (fast path), so
492///   plain-text and already-fully-inlined bodies pass through byte-for-byte.
493/// - `<style>` blocks are retained, but rules that were successfully inlined are
494///   stripped from them — so what remains is exactly the un-inlinable
495///   `@media`/pseudo-class rules, which still reach clients that honor them.
496///   Because the inlinable rules are removed from the retained block, running
497///   this again is a no-op: inlining is idempotent.
498/// - Remote/`<link>` stylesheets are never fetched (the `css-inline` network
499///   feature is not compiled in) — only embedded `<style>` CSS is inlined. The
500///   `<link rel="stylesheet">` tags themselves are preserved in the body so the
501///   linked CSS still reaches clients rather than being silently dropped.
502/// - A raw *fragment* body (no `<html>`/`<body>`/doctype) stays a fragment.
503///   `css-inline`'s document mode wraps fragment output in synthetic
504///   `<html>`/`<head>`/`<body>` tags; those wrappers are stripped back off (see
505///   [`unwrap_synthetic_document`]) so opting into inlining never promotes a
506///   fragment MIME body into a full document. Full-document bodies keep their
507///   structure unchanged.
508///
509/// # Errors
510///
511/// Returns [`MailError::CssInline`] if the body cannot be parsed/inlined, rather
512/// than returning a silently corrupted body.
513fn inline_css_html(html: &str) -> Result<String, MailError> {
514    // Fast path: nothing to inline. Keeps text-like, fragment, and
515    // already-inlined bodies byte-identical and makes re-inlining idempotent.
516    if !html_contains_style_block(html) {
517        return Ok(html.to_owned());
518    }
519    let inliner = css_inline::CSSInliner::options()
520        // Retain `<style>` so un-inlinable rules survive…
521        .keep_style_tags(true)
522        // …including `@media`/other at-rules (dropped by default), so responsive
523        // tweaks still work in clients that honor them.
524        .keep_at_rules(true)
525        // …but drop the rules we did inline, leaving only the un-inlinable ones
526        // in the retained block (also what makes a second pass a no-op).
527        .remove_inlined_selectors(true)
528        // Never reach out to the network for `<link>`ed stylesheets.
529        .load_remote_stylesheets(false)
530        // …but since we do NOT fetch them, keep the `<link rel="stylesheet">`
531        // tags in the body (dropped by default) so the linked CSS still reaches
532        // clients rather than being silently discarded from the delivered body.
533        .keep_link_tags(true)
534        // Also emit the presentational HTML `width`/`height` attributes (from the
535        // inlined CSS dimensions) on `table`/`td`/`th`/`img` — both default off.
536        // Outlook-family clients ignore CSS `width`/`height`, so without these
537        // attributes those elements lose their intended sizing there.
538        .apply_width_attributes(true)
539        .apply_height_attributes(true)
540        .build();
541    // Inline in document mode. Its fragment mode would avoid the `<html>`/`<body>`
542    // wrapping but drops retained `@media`/at-rules (it re-homes them to `<head>`,
543    // which a fragment lacks) — breaking AC5. So document-inline unconditionally,
544    // then, for a fragment body, strip the synthetic wrappers back off so the
545    // MIME body stays a fragment. Full documents keep their structure as-is.
546    let is_fragment = !html_is_full_document(html);
547    let rendered = inliner
548        .inline(html)
549        // Defensive / effectively unreachable: with remote-stylesheet loading
550        // disabled above and no file loader configured, `css-inline` only errors
551        // on IO/network — both compiled out here. It is fully lenient toward
552        // malformed CSS/HTML (garbage `<style>` bodies inline to an unchanged
553        // fragment, never an error). We still surface the typed error rather than
554        // `expect`ing, to keep the API stable if those loaders are ever enabled.
555        .map_err(|error| MailError::CssInline(error.to_string()))?;
556    Ok(if is_fragment {
557        unwrap_synthetic_document(&rendered)
558    } else {
559        rendered
560    })
561}
562
563/// A file attached to a [`Mail`] message.
564///
565/// Built via [`MailBuilder::attach`]. Carries raw, undecoded bytes so it
566/// round-trips byte-identical through every transport and through a durable
567/// [`MailDeliveryQueue`].
568#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub struct MailAttachment {
570    /// Attachment filename, as presented to the recipient's mail client.
571    pub filename: String,
572    /// Declared MIME content type (e.g. `"application/pdf"`).
573    pub content_type: String,
574    /// Raw attachment bytes.
575    pub bytes: Vec<u8>,
576}
577
578impl std::fmt::Debug for MailAttachment {
579    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580        f.debug_struct("MailAttachment")
581            .field("filename", &self.filename)
582            .field("content_type", &self.content_type)
583            .field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
584            .finish()
585    }
586}
587
588/// A transactional email.
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct Mail {
591    /// Optional From header. Falls back to [`Mailer`]'s default.
592    pub from: Option<String>,
593    /// Optional Reply-To header. Falls back to [`Mailer`]'s default.
594    pub reply_to: Option<String>,
595    /// To recipients.
596    pub to: Vec<String>,
597    /// Subject header.
598    pub subject: String,
599    /// HTML body.
600    pub html: Option<String>,
601    /// Plain-text body.
602    pub text: Option<String>,
603    /// Logical list / suppression scope for RFC 8058 one-click
604    /// `List-Unsubscribe` (e.g. `"weekly_digest"`). Set by the
605    /// `#[mailer(list_unsubscribe = "...")]` macro. `None` for transactional
606    /// mail that must never carry unsubscribe headers (password resets, MFA
607    /// codes, security alerts). See [`crate::mail::unsubscribe`].
608    pub list_unsubscribe: Option<String>,
609    /// Additional raw headers emitted on the wire by every transport. Used to
610    /// carry the computed `List-Unsubscribe` / `List-Unsubscribe-Post` headers,
611    /// but available for any custom header.
612    pub extra_headers: Vec<(String, String)>,
613    /// Files attached to this message, in declared order.
614    #[serde(default)]
615    pub attachments: Vec<MailAttachment>,
616    /// When `true`, [`Mailer::send`] delivers this message even to addresses on
617    /// the bounce/complaint [`suppression`] list. Set via
618    /// [`MailBuilder::ignore_suppression`] for genuinely critical mail
619    /// (password resets, MFA codes, security alerts) that must reach the
620    /// recipient regardless of prior delivery failures. `false` by default.
621    #[serde(default)]
622    pub ignore_suppression: bool,
623    /// Per-message override for CSS inlining (issue #1254).
624    ///
625    /// `Some(true)`/`Some(false)` force inlining on/off for this message,
626    /// overriding the [`Mailer`]'s configured default; `None` (the default)
627    /// defers to [`MailConfig::inline_css`]. Set via
628    /// [`MailBuilder::inline_css`]. On the deferred/durable path a `None` is
629    /// frozen to the originating mailer's default before the message is
630    /// persisted to a [`MailDeliveryQueue`], so the enqueued job is
631    /// self-describing and deferred mail inlines consistently with an immediate
632    /// send even when a different worker consumes the queue.
633    #[serde(default)]
634    pub inline_css: Option<bool>,
635}
636
637/// Stable root path for the dev mail preview UI.
638pub const MAIL_PREVIEW_PATH: &str = "/_autumn/mail";
639
640const MAIL_PREVIEW_MESSAGE_PATH: &str = "/_autumn/mail/messages/{message_id}";
641const MAIL_PREVIEW_TEMPLATE_PATH: &str = "/_autumn/mail/previews/{mailer}/{method}";
642
643/// A developer-authored, zero-argument mail template preview.
644#[derive(Clone)]
645pub struct MailPreview {
646    mailer: &'static str,
647    method: &'static str,
648    render: fn() -> Mail,
649}
650
651impl std::fmt::Debug for MailPreview {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        f.debug_struct("MailPreview")
654            .field("mailer", &self.mailer)
655            .field("method", &self.method)
656            .finish_non_exhaustive()
657    }
658}
659
660impl MailPreview {
661    /// Register a mail preview for the dev mail preview UI.
662    #[must_use]
663    pub const fn new(mailer: &'static str, method: &'static str, render: fn() -> Mail) -> Self {
664        Self {
665            mailer,
666            method,
667            render,
668        }
669    }
670
671    /// Mailer type label used in preview URLs.
672    #[must_use]
673    pub const fn mailer(&self) -> &'static str {
674        self.mailer
675    }
676
677    /// Preview method label used in preview URLs.
678    #[must_use]
679    pub const fn method(&self) -> &'static str {
680        self.method
681    }
682
683    /// Render the preview without invoking any configured transport.
684    ///
685    /// # Errors
686    ///
687    /// Returns [`MailPreviewError::PreviewPanicked`] if the preview function
688    /// panics while constructing sample data.
689    pub fn render(&self) -> Result<Mail, MailPreviewError> {
690        std::panic::catch_unwind(|| (self.render)()).map_err(|_| {
691            MailPreviewError::PreviewPanicked {
692                mailer: self.mailer,
693                method: self.method,
694            }
695        })
696    }
697}
698
699/// Collection of registered mail previews stored on [`AppState`].
700#[derive(Debug, Clone, Default)]
701pub struct MailPreviewRegistry {
702    previews: Arc<Vec<MailPreview>>,
703}
704
705impl MailPreviewRegistry {
706    /// Create a registry from preview registrations.
707    #[must_use]
708    pub fn new(previews: Vec<MailPreview>) -> Self {
709        Self {
710            previews: Arc::new(previews),
711        }
712    }
713
714    /// Registered previews.
715    #[must_use]
716    pub fn previews(&self) -> &[MailPreview] {
717        &self.previews
718    }
719
720    fn find(&self, mailer: &str, method: &str) -> Option<MailPreview> {
721        self.previews
722            .iter()
723            .find(|preview| preview.mailer == mailer && preview.method == method)
724            .cloned()
725    }
726}
727
728/// Dev mail preview UI errors.
729#[derive(Debug, Error)]
730pub enum MailPreviewError {
731    /// File transport preview IO failed.
732    #[error("mail preview file IO failed: {0}")]
733    Io(#[from] std::io::Error),
734    /// Requested captured message was not found.
735    #[error("captured mail message not found: {0}")]
736    NotFound(String),
737    /// Requested message id is not a single `.eml` filename.
738    #[error("invalid captured mail message id: {0}")]
739    InvalidMessageId(String),
740    /// Developer-authored preview panicked while rendering sample data.
741    #[error("mail preview {mailer}::{method} panicked while rendering")]
742    PreviewPanicked {
743        /// Mailer label.
744        mailer: &'static str,
745        /// Method label.
746        method: &'static str,
747    },
748}
749
750impl Mail {
751    /// Start building a mail message.
752    #[must_use]
753    pub fn builder() -> MailBuilder {
754        MailBuilder::default()
755    }
756
757    fn with_defaults(mut self, defaults: &MailerDefaults) -> Self {
758        if self.from.is_none() {
759            self.from.clone_from(&defaults.from);
760        }
761        if self.reply_to.is_none() {
762            self.reply_to.clone_from(&defaults.reply_to);
763        }
764        self
765    }
766}
767
768/// Builder for [`Mail`].
769#[derive(Debug, Clone, Default)]
770pub struct MailBuilder {
771    from: Option<String>,
772    reply_to: Option<String>,
773    to: Vec<String>,
774    subject: Option<String>,
775    html: Option<String>,
776    text: Option<String>,
777    html_layout: Option<String>,
778    text_layout: Option<String>,
779    list_unsubscribe: Option<String>,
780    extra_headers: Vec<(String, String)>,
781    attachments: Vec<MailAttachment>,
782    ignore_suppression: bool,
783    inline_css: Option<bool>,
784}
785
786impl MailBuilder {
787    /// Set a message-specific From header.
788    #[must_use]
789    pub fn from(mut self, from: impl Into<String>) -> Self {
790        self.from = Some(from.into());
791        self
792    }
793
794    /// Set a message-specific Reply-To header.
795    #[must_use]
796    pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
797        self.reply_to = Some(reply_to.into());
798        self
799    }
800
801    /// Add a To recipient.
802    #[must_use]
803    pub fn to(mut self, to: impl Into<String>) -> Self {
804        self.to.push(to.into());
805        self
806    }
807
808    /// Set the subject.
809    #[must_use]
810    pub fn subject(mut self, subject: impl Into<String>) -> Self {
811        self.subject = Some(subject.into());
812        self
813    }
814
815    /// Set the HTML body.
816    #[must_use]
817    pub fn html(mut self, html: impl IntoMailBody) -> Self {
818        self.html = Some(html.into_mail_body());
819        self
820    }
821
822    /// Set the plain-text body.
823    #[must_use]
824    pub fn text(mut self, text: impl IntoMailBody) -> Self {
825        self.text = Some(text.into_mail_body());
826        self
827    }
828
829    /// Tag this message with a logical list / suppression scope, opting it into
830    /// RFC 8058 one-click `List-Unsubscribe` handling at send time.
831    ///
832    /// Authors normally set this declaratively via
833    /// `#[mailer(list_unsubscribe = "...")]`; this builder method exists for
834    /// hand-rolled mail and previews.
835    #[must_use]
836    pub fn list_unsubscribe(mut self, scope: impl Into<String>) -> Self {
837        self.list_unsubscribe = Some(scope.into());
838        self
839    }
840
841    /// Bypass the bounce/complaint [`suppression`] list for this message.
842    ///
843    /// [`Mailer::send`] normally skips recipients that have hard-bounced or
844    /// filed a spam complaint. Call this for genuinely critical mail —
845    /// password resets, MFA codes, security alerts — that must be delivered
846    /// even to a suppressed address. Use sparingly: repeatedly sending to a
847    /// hard-bounced address is exactly what damages sender reputation.
848    #[must_use]
849    pub const fn ignore_suppression(mut self) -> Self {
850        self.ignore_suppression = true;
851        self
852    }
853
854    /// Force CSS inlining on or off for this message, overriding the
855    /// [`Mailer`]'s configured [`MailConfig::inline_css`] default.
856    ///
857    /// When enabled, the HTML body's `<style>` rules are inlined onto matching
858    /// elements as `style="…"` attributes at send time so the message renders
859    /// styled in clients that strip `<head>`/`<style>` (Gmail, Outlook).
860    /// Un-inlinable `@media`/pseudo-class rules are preserved in a retained
861    /// `<style>` block. Text bodies and HTML with no `<style>` block are left
862    /// untouched.
863    ///
864    /// Precedence: an explicit call here always wins over the config default —
865    /// `inline_css(false)` opts a single message out even when the environment
866    /// defaults inlining on, and `inline_css(true)` opts a single message in
867    /// when the default is off.
868    #[must_use]
869    pub const fn inline_css(mut self, enabled: bool) -> Self {
870        self.inline_css = Some(enabled);
871        self
872    }
873
874    /// Add a raw header emitted by every transport.
875    #[must_use]
876    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
877        self.extra_headers.push((name.into(), value.into()));
878        self
879    }
880
881    /// Attach a file. Calling this repeatedly appends attachments in the
882    /// order they were declared; the SMTP and file transports both encode
883    /// them as `multipart/mixed` parts with a `base64`
884    /// `Content-Transfer-Encoding`.
885    ///
886    /// ```rust,ignore
887    /// let mail = Mail::builder()
888    ///     .to("ada@example.com")
889    ///     .subject("Your invoice")
890    ///     .text("Your invoice is attached.")
891    ///     .attach("invoice.pdf", "application/pdf", pdf_bytes)
892    ///     .build()?;
893    /// ```
894    #[must_use]
895    pub fn attach(
896        mut self,
897        filename: impl Into<String>,
898        content_type: impl Into<String>,
899        bytes: impl Into<Vec<u8>>,
900    ) -> Self {
901        self.attachments.push(MailAttachment {
902            filename: filename.into(),
903            content_type: content_type.into(),
904            bytes: bytes.into(),
905        });
906        self
907    }
908
909    /// Wrap the HTML and text bodies in a shared layout.
910    ///
911    /// The layout strings must contain [`MAIL_LAYOUT_CONTENT_MARKER`]
912    /// (`{{ content }}`) where the per-mailer body fragment should be inserted.
913    /// If a layout does not contain the marker the raw body is delivered
914    /// unchanged (content is never silently dropped).
915    ///
916    /// Call this method with the shared `_layout.html` and `_layout.txt`
917    /// templates. Omitting the call delivers the raw body — use that as the
918    /// per-mailer opt-out for fully-custom or one-line plaintext messages.
919    #[must_use]
920    pub fn layout(
921        mut self,
922        html_layout: impl IntoMailBody,
923        text_layout: impl IntoMailBody,
924    ) -> Self {
925        self.html_layout = Some(html_layout.into_mail_body());
926        self.text_layout = Some(text_layout.into_mail_body());
927        self
928    }
929
930    /// Build the mail.
931    ///
932    /// # Errors
933    ///
934    /// Returns [`MailError::InvalidMessage`] when required fields are missing.
935    pub fn build(self) -> Result<Mail, MailError> {
936        if self.to.is_empty() {
937            return Err(MailError::InvalidMessage(
938                "mail must have at least one recipient".to_owned(),
939            ));
940        }
941        let subject = self
942            .subject
943            .filter(|s| !s.trim().is_empty())
944            .ok_or_else(|| MailError::InvalidMessage("mail subject is required".to_owned()))?;
945        if self.html.is_none() && self.text.is_none() {
946            return Err(MailError::InvalidMessage(
947                "mail must include html or text body".to_owned(),
948            ));
949        }
950        for attachment in &self.attachments {
951            if attachment.filename.trim().is_empty()
952                || attachment.filename.chars().any(char::is_control)
953            {
954                return Err(MailError::InvalidMessage(format!(
955                    "attachment filename {:?} must be non-empty and free of control characters",
956                    attachment.filename
957                )));
958            }
959            if let Err(error) = ContentType::parse(&attachment.content_type) {
960                return Err(MailError::InvalidMessage(format!(
961                    "attachment {:?} has invalid content type {:?}: {error}",
962                    attachment.filename, attachment.content_type
963                )));
964            }
965        }
966        // A layout is only applied when the corresponding body is present.
967        // If only one of html/text is set, the other layout half is intentionally
968        // skipped rather than erroring — a text-only mailer may legitimately pass
969        // an html_layout that has no effect, and vice-versa.
970        let html = match (self.html, self.html_layout) {
971            (Some(body), Some(layout)) => Some(compose_layout(&layout, &body)),
972            (html, _) => html, // layout without a body: silently unused (by design)
973        };
974        let text = match (self.text, self.text_layout) {
975            (Some(body), Some(layout)) => Some(compose_layout(&layout, &body)),
976            (text, _) => text, // layout without a body: silently unused (by design)
977        };
978        Ok(Mail {
979            from: self.from,
980            reply_to: self.reply_to,
981            to: self.to,
982            subject,
983            html,
984            text,
985            list_unsubscribe: self.list_unsubscribe,
986            extra_headers: self.extra_headers,
987            attachments: self.attachments,
988            ignore_suppression: self.ignore_suppression,
989            inline_css: self.inline_css,
990        })
991    }
992}
993
994/// Mailer errors.
995#[derive(Debug, Error)]
996pub enum MailError {
997    /// Message could not be built or validated.
998    #[error("invalid mail message: {0}")]
999    InvalidMessage(String),
1000    /// Deferred delivery could not be scheduled.
1001    #[error("mail runtime unavailable: {0}")]
1002    RuntimeUnavailable(String),
1003    /// Address parsing failed.
1004    #[error("invalid mail address {address:?}: {source}")]
1005    InvalidAddress {
1006        /// Address that failed to parse.
1007        address: String,
1008        /// Lettre parse error.
1009        source: lettre::address::AddressError,
1010    },
1011    /// Lettre message construction failed.
1012    #[error("failed to build mail message: {0}")]
1013    Build(#[from] lettre::error::Error),
1014    /// SMTP transport failed.
1015    #[error("smtp send failed: {0}")]
1016    Smtp(#[from] lettre::transport::smtp::Error),
1017    /// File transport failed.
1018    #[error("file mail transport failed: {0}")]
1019    Io(#[from] std::io::Error),
1020    /// Every recipient of the message is on the bounce/complaint
1021    /// [`suppression`] list, so nothing was delivered. Distinct from success:
1022    /// callers can distinguish "sent" from "intentionally dropped". Bypass with
1023    /// [`MailBuilder::ignore_suppression`] for critical mail.
1024    #[error("all recipients are on the mail suppression list; nothing was sent")]
1025    AllRecipientsSuppressed,
1026    /// CSS inlining of the HTML body failed (issue #1254). `send` fails loudly
1027    /// with this typed error instead of delivering a corrupted body — the
1028    /// message is not sent, so callers can decide how to recover. Defensive:
1029    /// with remote and file loaders disabled, `css-inline` is fully lenient and
1030    /// this path is effectively unreachable, but the variant keeps the API
1031    /// stable if those loaders are ever enabled.
1032    #[error("failed to inline CSS into HTML mail body: {0}")]
1033    CssInline(String),
1034}
1035
1036/// Escape hatch for custom transports.
1037pub trait MailTransport: Send + Sync {
1038    /// Send a mail message.
1039    fn send<'a>(
1040        &'a self,
1041        mail: Mail,
1042    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1043
1044    /// Returns `true` if this transport is intentionally a no-op (e.g.
1045    /// [`Transport::Disabled`] for review apps and tests).
1046    ///
1047    /// When `true`, [`Mailer::deliver_later`] short-circuits before the queue
1048    /// or in-process fallback so deferred mail honors the same "drop
1049    /// everything" contract as immediate sends. Custom transports that mean
1050    /// "drop all mail" can override this to opt into the same behavior; the
1051    /// default of `false` preserves the existing contract for transports that
1052    /// merely capture mail (file, log, etc.) or send it (SMTP, custom APIs).
1053    fn is_disabled(&self) -> bool {
1054        false
1055    }
1056}
1057
1058/// Durable backend for [`Mailer::deliver_later`].
1059///
1060/// Implementors persist the mail (DB row, Redis stream, Harvest job, etc.) and
1061/// return as soon as the handoff is durable. The framework's in-process Tokio
1062/// fallback is intentionally not durable; production deployments should
1063/// register a real implementation via [`MailDeliveryQueueHandle`] before
1064/// `install_mailer` runs, or set
1065/// [`MailConfig::allow_in_process_deliver_later_in_production`] to opt into the
1066/// fallback explicitly.
1067pub trait MailDeliveryQueue: Send + Sync {
1068    /// Enqueue a mail for durable later delivery.
1069    fn enqueue<'a>(
1070        &'a self,
1071        mail: Mail,
1072    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1073}
1074
1075/// Cloneable handle to a [`MailDeliveryQueue`].
1076///
1077/// Designed for storage on [`AppState`] extensions. Plugins
1078/// (Harvest, custom Redis, etc.) install this before `install_mailer` runs and
1079/// the mailer picks it up.
1080#[derive(Clone)]
1081pub struct MailDeliveryQueueHandle(Arc<dyn MailDeliveryQueue>);
1082
1083impl MailDeliveryQueueHandle {
1084    /// Wrap a queue implementation in a cloneable handle.
1085    #[must_use]
1086    pub fn new(queue: impl MailDeliveryQueue + 'static) -> Self {
1087        Self(Arc::new(queue))
1088    }
1089
1090    /// Wrap an already-shared queue implementation.
1091    #[must_use]
1092    pub fn from_arc(queue: Arc<dyn MailDeliveryQueue>) -> Self {
1093        Self(queue)
1094    }
1095
1096    /// Borrow the inner queue.
1097    #[must_use]
1098    pub fn inner(&self) -> &Arc<dyn MailDeliveryQueue> {
1099        &self.0
1100    }
1101}
1102
1103impl std::fmt::Debug for MailDeliveryQueueHandle {
1104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1105        f.debug_struct("MailDeliveryQueueHandle").finish()
1106    }
1107}
1108
1109// ── RFC 8058 List-Unsubscribe ────────────────────────────────────────────────
1110
1111/// Stable root path for the framework's default one-click unsubscribe endpoint.
1112pub const UNSUBSCRIBE_PATH: &str = "/_autumn/unsubscribe";
1113
1114/// Compile-time registration of a `#[mailer(list_unsubscribe = "...")]`.
1115///
1116/// Emitted by the `#[mailer]` macro. Lets production startup and `autumn doctor`
1117/// enumerate which logical lists exist so they can fail closed when the app has
1118/// no unsubscribe destination configured.
1119#[derive(Debug)]
1120pub struct MailerListUnsubscribeDescriptor {
1121    /// Mailer type name (e.g. `WeeklyDigestMailer`).
1122    pub mailer: &'static str,
1123    /// Logical list / suppression scope (e.g. `weekly_digest`).
1124    pub scope: &'static str,
1125}
1126
1127inventory::collect!(MailerListUnsubscribeDescriptor);
1128
1129/// Every `list_unsubscribe` declaration registered across the binary.
1130#[must_use]
1131pub fn registered_list_unsubscribe_scopes() -> Vec<&'static MailerListUnsubscribeDescriptor> {
1132    inventory::iter::<MailerListUnsubscribeDescriptor>
1133        .into_iter()
1134        .collect()
1135}
1136
1137/// Returns `true` when any `#[mailer]` in this binary opted into
1138/// `list_unsubscribe`.
1139#[must_use]
1140pub fn has_list_unsubscribe_mailers() -> bool {
1141    inventory::iter::<MailerListUnsubscribeDescriptor>
1142        .into_iter()
1143        .next()
1144        .is_some()
1145}
1146
1147/// Whether production startup must fail closed: a `#[mailer]` declares
1148/// `list_unsubscribe` but the app configured no unsubscribe destination.
1149#[must_use]
1150#[allow(clippy::fn_params_excessive_bools)]
1151pub(crate) const fn unsubscribe_config_fail_closed(
1152    enforce: bool,
1153    in_production: bool,
1154    has_list_mailers: bool,
1155    unsubscribe_configured: bool,
1156) -> bool {
1157    enforce && in_production && has_list_mailers && !unsubscribe_configured
1158}
1159
1160/// Encrypted, short-lived, stateless unsubscribe tokens.
1161///
1162/// A token is `base64url(version ‖ nonce ‖ AES-256-GCM(payload))`, where the
1163/// inner payload is `base64url(subscriber).base64url(list_id).expiry`. The cipher
1164/// key is derived from the app signing key (`ResolvedSigningKeys`) via HMAC-SHA256
1165/// with a domain-separation label. AES-256-GCM provides both confidentiality and
1166/// authenticity: unlike a plain signed token the recipient address is **not**
1167/// recoverable from the URL (so it can't leak from proxy/browser/link-scanner
1168/// logs), and the GCM tag makes the token tamper-proof. Verification tries the
1169/// current key, then any rotation-grace `previous` keys. Stateless — no
1170/// server-side token storage.
1171pub mod unsubscribe {
1172    use aes_gcm::aead::{Aead, KeyInit};
1173    use aes_gcm::{Aes256Gcm, Nonce};
1174    use base64::Engine as _;
1175    use hmac::{Hmac, Mac};
1176    use sha2::Sha256;
1177
1178    use crate::security::config::ResolvedSigningKeys;
1179
1180    /// Default validity window for unsubscribe tokens, in days.
1181    pub const DEFAULT_TOKEN_TTL_DAYS: i64 = 30;
1182
1183    const ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
1184    /// Token format version (first byte of the encrypted blob).
1185    const TOKEN_VERSION: u8 = 1;
1186    /// AES-GCM nonce length in bytes.
1187    const NONCE_LEN: usize = 12;
1188    /// Domain-separation label for deriving the token cipher key from a signing
1189    /// key, so it is independent of other uses of the signing secret.
1190    const KEY_CONTEXT: &[u8] = b"autumn:unsubscribe-token:v1";
1191
1192    /// A verified unsubscribe request decoded from a signed token.
1193    #[derive(Debug, Clone, PartialEq, Eq)]
1194    pub struct Unsubscribed {
1195        /// Opaque subscriber identifier (email address by default).
1196        pub subscriber: String,
1197        /// Logical list / suppression scope.
1198        pub list_id: String,
1199    }
1200
1201    /// Reasons an unsubscribe token fails to verify.
1202    #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1203    pub enum TokenError {
1204        /// Structure or encoding is invalid.
1205        #[error("unsubscribe token is malformed")]
1206        Malformed,
1207        /// Signature did not match any current or previous signing key.
1208        #[error("unsubscribe token signature is invalid")]
1209        BadSignature,
1210        /// Token is past its expiry.
1211        #[error("unsubscribe token has expired")]
1212        Expired,
1213    }
1214
1215    /// Derive a 32-byte AES-256 key from a signing key via HMAC-SHA256 with a
1216    /// domain-separation label.
1217    #[allow(
1218        clippy::expect_used,
1219        reason = "infallible: HMAC accepts any key length"
1220    )]
1221    fn derive_key(signing_key: &[u8]) -> [u8; 32] {
1222        let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(signing_key)
1223            .expect("HMAC accepts any key length");
1224        mac.update(KEY_CONTEXT);
1225        let bytes = mac.finalize().into_bytes();
1226        let mut key = [0u8; 32];
1227        key.copy_from_slice(&bytes);
1228        key
1229    }
1230
1231    /// The inner authenticated plaintext: `b64(subscriber).b64(list_id).expiry`.
1232    fn plaintext(subscriber: &str, list_id: &str, expiry_unix: i64) -> String {
1233        format!(
1234            "{}.{}.{expiry_unix}",
1235            ENGINE.encode(subscriber.as_bytes()),
1236            ENGINE.encode(list_id.as_bytes()),
1237        )
1238    }
1239
1240    /// Mint an encrypted unsubscribe token valid until `expiry_unix`.
1241    ///
1242    /// The subscriber/list/expiry (seconds since epoch) are encrypted and
1243    /// authenticated with AES-256-GCM, so they are not recoverable from the URL.
1244    ///
1245    /// # Panics
1246    ///
1247    /// Panics if the OS RNG is unavailable.
1248    #[must_use]
1249    #[allow(
1250        clippy::expect_used,
1251        reason = "infallible crypto: AES-256-GCM over a 32-byte derived key; an OS RNG failure is an unrecoverable environment fault surfaced as a documented panic"
1252    )]
1253    pub fn sign_token(
1254        keys: &ResolvedSigningKeys,
1255        subscriber: &str,
1256        list_id: &str,
1257        expiry_unix: i64,
1258    ) -> String {
1259        let key = derive_key(&keys.current);
1260        let cipher = Aes256Gcm::new_from_slice(&key).expect("derived key is always 32 bytes");
1261        let mut nonce_bytes = [0u8; NONCE_LEN];
1262        getrandom::getrandom(&mut nonce_bytes).expect("OS RNG failed");
1263        let ciphertext = cipher
1264            .encrypt(
1265                Nonce::from_slice(&nonce_bytes),
1266                plaintext(subscriber, list_id, expiry_unix).as_bytes(),
1267            )
1268            .expect("AES-GCM encryption cannot fail for valid inputs");
1269        let mut blob = Vec::with_capacity(1 + NONCE_LEN + ciphertext.len());
1270        blob.push(TOKEN_VERSION);
1271        blob.extend_from_slice(&nonce_bytes);
1272        blob.extend_from_slice(&ciphertext);
1273        ENGINE.encode(blob)
1274    }
1275
1276    /// Verify a token and decode its subscriber/list, rejecting bad signatures
1277    /// and expired tokens.
1278    ///
1279    /// # Errors
1280    ///
1281    /// Returns [`TokenError`] when the token is malformed, its signature is
1282    /// invalid, or it has expired relative to `now_unix`.
1283    #[allow(
1284        clippy::indexing_slicing,
1285        reason = "blob.len() is checked to be >= 1 + NONCE_LEN above, so these indices are in bounds"
1286    )]
1287    pub fn verify_token(
1288        keys: &ResolvedSigningKeys,
1289        token: &str,
1290        now_unix: i64,
1291    ) -> Result<Unsubscribed, TokenError> {
1292        let blob = ENGINE.decode(token).map_err(|_| TokenError::Malformed)?;
1293        if blob.len() < 1 + NONCE_LEN {
1294            return Err(TokenError::Malformed);
1295        }
1296        if blob[0] != TOKEN_VERSION {
1297            return Err(TokenError::Malformed);
1298        }
1299        let nonce = Nonce::from_slice(&blob[1..=NONCE_LEN]);
1300        let ciphertext = &blob[1 + NONCE_LEN..];
1301        // Try the current key first, then any rotation-grace `previous` keys. A
1302        // wrong key (or any tampering) fails AES-GCM authentication.
1303        let payload = std::iter::once(&keys.current)
1304            .chain(keys.previous.iter())
1305            .find_map(|signing_key| {
1306                let key = derive_key(signing_key);
1307                // The derived key is always 32 bytes, so construction never fails;
1308                // `.ok()?` keeps this panic-free regardless.
1309                let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
1310                cipher.decrypt(nonce, ciphertext).ok()
1311            })
1312            .ok_or(TokenError::BadSignature)?;
1313        let payload = String::from_utf8(payload).map_err(|_| TokenError::Malformed)?;
1314        let mut parts = payload.split('.');
1315        let subscriber_b64 = parts.next().ok_or(TokenError::Malformed)?;
1316        let list_b64 = parts.next().ok_or(TokenError::Malformed)?;
1317        let expiry_s = parts.next().ok_or(TokenError::Malformed)?;
1318        if parts.next().is_some() {
1319            return Err(TokenError::Malformed);
1320        }
1321        let expiry: i64 = expiry_s.parse().map_err(|_| TokenError::Malformed)?;
1322        if now_unix > expiry {
1323            return Err(TokenError::Expired);
1324        }
1325        let subscriber = decode_field(subscriber_b64)?;
1326        let list_id = decode_field(list_b64)?;
1327        Ok(Unsubscribed {
1328            subscriber,
1329            list_id,
1330        })
1331    }
1332
1333    fn decode_field(encoded: &str) -> Result<String, TokenError> {
1334        let bytes = ENGINE.decode(encoded).map_err(|_| TokenError::Malformed)?;
1335        String::from_utf8(bytes).map_err(|_| TokenError::Malformed)
1336    }
1337
1338    /// Build the one-click unsubscribe URL for `token` rooted at `base_url`.
1339    #[must_use]
1340    pub fn unsubscribe_url(base_url: &str, token: &str) -> String {
1341        format!(
1342            "{}{}?token={token}",
1343            base_url.trim_end_matches('/'),
1344            super::UNSUBSCRIBE_PATH,
1345        )
1346    }
1347}
1348
1349/// Persistent record of recipients who unsubscribed from a logical list.
1350///
1351/// Implementors store one row per `(subscriber, list_id)` and answer
1352/// suppression queries at send time. Mirrors [`MailDeliveryQueue`]: register a
1353/// [`SuppressionStoreHandle`] on [`AppState`] (or let the framework auto-wire a
1354/// `db`-feature `DbSuppressionStore` backend) before `install_mailer` runs.
1355pub trait SuppressionStore: Send + Sync {
1356    /// Returns `true` when `subscriber` has unsubscribed from `list_id`.
1357    fn is_suppressed<'a>(
1358        &'a self,
1359        subscriber: &'a str,
1360        list_id: &'a str,
1361    ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>>;
1362
1363    /// Record that `subscriber` unsubscribed from `list_id` (idempotent).
1364    fn suppress<'a>(
1365        &'a self,
1366        subscriber: &'a str,
1367        list_id: &'a str,
1368    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1369}
1370
1371/// Cloneable handle to a [`SuppressionStore`] for storage on [`AppState`].
1372#[derive(Clone)]
1373pub struct SuppressionStoreHandle(Arc<dyn SuppressionStore>);
1374
1375impl SuppressionStoreHandle {
1376    /// Wrap a store implementation.
1377    #[must_use]
1378    pub fn new(store: impl SuppressionStore + 'static) -> Self {
1379        Self(Arc::new(store))
1380    }
1381
1382    /// Wrap an already-shared store implementation.
1383    #[must_use]
1384    pub fn from_arc(store: Arc<dyn SuppressionStore>) -> Self {
1385        Self(store)
1386    }
1387
1388    /// Borrow the inner store.
1389    #[must_use]
1390    pub fn inner(&self) -> &Arc<dyn SuppressionStore> {
1391        &self.0
1392    }
1393}
1394
1395impl std::fmt::Debug for SuppressionStoreHandle {
1396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1397        f.debug_struct("SuppressionStoreHandle").finish()
1398    }
1399}
1400
1401/// In-memory [`SuppressionStore`] for tests, review apps, and single-process dev.
1402///
1403/// State is process-local and lost on restart; use `DbSuppressionStore` in
1404/// production.
1405#[derive(Debug, Default, Clone)]
1406pub struct InMemorySuppressionStore {
1407    suppressed: Arc<std::sync::Mutex<std::collections::HashSet<(String, String)>>>,
1408}
1409
1410impl InMemorySuppressionStore {
1411    /// Create an empty in-memory store.
1412    #[must_use]
1413    pub fn new() -> Self {
1414        Self::default()
1415    }
1416}
1417
1418impl SuppressionStore for InMemorySuppressionStore {
1419    fn is_suppressed<'a>(
1420        &'a self,
1421        subscriber: &'a str,
1422        list_id: &'a str,
1423    ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
1424        Box::pin(async move {
1425            let key = (subscriber.to_owned(), list_id.to_owned());
1426            let suppressed = self
1427                .suppressed
1428                .lock()
1429                .unwrap_or_else(std::sync::PoisonError::into_inner)
1430                .contains(&key);
1431            Ok(suppressed)
1432        })
1433    }
1434
1435    fn suppress<'a>(
1436        &'a self,
1437        subscriber: &'a str,
1438        list_id: &'a str,
1439    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1440        Box::pin(async move {
1441            let key = (subscriber.to_owned(), list_id.to_owned());
1442            self.suppressed
1443                .lock()
1444                .unwrap_or_else(std::sync::PoisonError::into_inner)
1445                .insert(key);
1446            Ok(())
1447        })
1448    }
1449}
1450
1451/// Runtime wiring for List-Unsubscribe.
1452///
1453/// Holds where to point unsubscribe links, how to sign tokens, and where
1454/// suppression lives. Shared (via `Arc`) between the [`Mailer`] that signs
1455/// links and the endpoint that verifies them so tokens always validate within a
1456/// process.
1457pub struct UnsubscribeRuntime {
1458    /// Base URL for unsubscribe links (e.g. `https://app.example.com`).
1459    pub base_url: Option<String>,
1460    /// `mailto:` fallback address for the `List-Unsubscribe` header.
1461    pub mailto: Option<String>,
1462    /// Signing keys used for token HMACs.
1463    pub signing_keys: Arc<crate::security::config::ResolvedSigningKeys>,
1464    /// Token validity window, in days.
1465    pub ttl_days: i64,
1466    /// Suppression backend (absent in pure-header configurations).
1467    pub suppression: Option<Arc<dyn SuppressionStore>>,
1468}
1469
1470impl std::fmt::Debug for UnsubscribeRuntime {
1471    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1472        f.debug_struct("UnsubscribeRuntime")
1473            .field("base_url", &self.base_url)
1474            .field("mailto", &self.mailto)
1475            .field("ttl_days", &self.ttl_days)
1476            .field("has_suppression", &self.suppression.is_some())
1477            .finish_non_exhaustive()
1478    }
1479}
1480
1481impl UnsubscribeRuntime {
1482    /// Build the `List-Unsubscribe` header value for `subscriber` on `list_id`:
1483    /// `<https://…?token=…>, <mailto:…>` per RFC 8058 §2. Returns `None` when
1484    /// neither a base URL nor a mailto is configured.
1485    #[must_use]
1486    pub fn list_unsubscribe_header(&self, subscriber: &str, list_id: &str) -> Option<String> {
1487        let mut entries: Vec<String> = Vec::new();
1488        if let Some(base) = self.base_url.as_deref().filter(|s| !s.trim().is_empty()) {
1489            let expiry = current_unix_time().saturating_add(self.ttl_days.saturating_mul(86_400));
1490            let token = unsubscribe::sign_token(&self.signing_keys, subscriber, list_id, expiry);
1491            entries.push(format!("<{}>", unsubscribe::unsubscribe_url(base, &token)));
1492        }
1493        if let Some(mailto) = self.mailto.as_deref().filter(|s| !s.trim().is_empty()) {
1494            // Accept both a bare address and a full `mailto:` URI without
1495            // double-prefixing the scheme. Render only the bare mailbox (drop any
1496            // configured `?query`) before appending the canonical subject, so a
1497            // value like `mailto:u@x?subject=a\r\nBcc: v@x` can't inject extra
1498            // headers into the raw `List-Unsubscribe` value.
1499            let trimmed = mailto.trim();
1500            let address = trimmed.strip_prefix("mailto:").unwrap_or(trimmed);
1501            let address = address.split('?').next().unwrap_or(address);
1502            entries.push(format!("<mailto:{address}?subject=unsubscribe>"));
1503        }
1504        if entries.is_empty() {
1505            None
1506        } else {
1507            Some(entries.join(", "))
1508        }
1509    }
1510
1511    /// Whether RFC 8058 one-click is available — i.e. an HTTPS unsubscribe URL is
1512    /// configured. `List-Unsubscribe-Post` is only valid alongside such a URL; a
1513    /// `mailto`-only configuration is a plain RFC 2369 unsubscribe, not one-click.
1514    #[must_use]
1515    pub fn supports_one_click(&self) -> bool {
1516        self.base_url
1517            .as_deref()
1518            .is_some_and(|s| !s.trim().is_empty())
1519    }
1520}
1521
1522fn current_unix_time() -> i64 {
1523    SystemTime::now()
1524        .duration_since(UNIX_EPOCH)
1525        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
1526}
1527
1528#[derive(Debug, Clone, Default)]
1529struct MailerDefaults {
1530    from: Option<String>,
1531    reply_to: Option<String>,
1532}
1533
1534/// Cloneable email sender. Extract it in handlers as `mailer: Mailer`.
1535#[derive(Clone)]
1536pub struct Mailer {
1537    defaults: Arc<MailerDefaults>,
1538    transport: Arc<dyn MailTransport>,
1539    delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
1540    unsubscribe: Option<Arc<UnsubscribeRuntime>>,
1541    /// Bounce/complaint suppression list consulted before transport. See
1542    /// [`suppression`]. `None` disables the check (suppression is opt-in on a
1543    /// hand-built [`Mailer`]; the framework wires a default in-memory store).
1544    suppression: Option<Arc<dyn suppression::SuppressionStore>>,
1545    /// Default for CSS inlining of HTML bodies when a message does not set its
1546    /// own [`Mail::inline_css`] override. Sourced from [`MailConfig::inline_css`].
1547    inline_css_default: bool,
1548}
1549
1550impl Mailer {
1551    /// Build a mailer manually.
1552    #[must_use]
1553    pub fn builder() -> MailerBuilder {
1554        MailerBuilder::default()
1555    }
1556
1557    /// Build a mailer from resolved config.
1558    ///
1559    /// # Errors
1560    ///
1561    /// Returns an error when SMTP or address configuration is invalid.
1562    pub fn from_config(config: &MailConfig) -> Result<Self, MailError> {
1563        Self::from_config_inner(config, None)
1564    }
1565
1566    pub(crate) fn from_config_inner(
1567        config: &MailConfig,
1568        resilience: Option<Arc<crate::config::ResilienceConfig>>,
1569    ) -> Result<Self, MailError> {
1570        let mut builder = Self::builder()
1571            .transport(config.transport)
1572            .inline_css(config.inline_css)
1573            .resilience_config(resilience);
1574        if let Some(from) = &config.from {
1575            builder = builder.from(from.clone());
1576        }
1577        if let Some(reply_to) = &config.reply_to {
1578            builder = builder.reply_to(reply_to.clone());
1579        }
1580        if config.transport == Transport::File {
1581            builder = builder.file_dir(config.file_dir.clone());
1582        }
1583        if config.transport == Transport::Smtp {
1584            builder = builder.smtp(config.smtp.clone());
1585        }
1586        builder.build()
1587    }
1588
1589    /// Build a mailer from any custom transport.
1590    #[must_use]
1591    pub fn with_transport(transport: impl MailTransport + 'static) -> Self {
1592        Self {
1593            defaults: Arc::new(MailerDefaults::default()),
1594            transport: Arc::new(transport),
1595            delivery_queue: None,
1596            unsubscribe: None,
1597            suppression: None,
1598            inline_css_default: false,
1599        }
1600    }
1601
1602    /// Attach a durable [`MailDeliveryQueue`] used by [`Self::deliver_later`].
1603    #[must_use]
1604    pub fn with_delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
1605        self.delivery_queue = Some(Arc::new(queue));
1606        self
1607    }
1608
1609    /// Attach the List-Unsubscribe runtime used to sign links, emit RFC 8058
1610    /// headers, and skip suppressed recipients.
1611    #[must_use]
1612    pub fn with_unsubscribe(mut self, runtime: Arc<UnsubscribeRuntime>) -> Self {
1613        self.unsubscribe = Some(runtime);
1614        self
1615    }
1616
1617    /// Attach the bounce/complaint [`suppression`] list consulted before
1618    /// transport. Recipients on the list are skipped (and the skip is logged +
1619    /// counted) unless the message opts out via
1620    /// [`MailBuilder::ignore_suppression`].
1621    #[must_use]
1622    pub fn with_suppression(mut self, store: suppression::SuppressionStoreHandle) -> Self {
1623        self.suppression = Some(store.into_inner());
1624        self
1625    }
1626
1627    /// Returns whether a durable [`MailDeliveryQueue`] is attached.
1628    #[must_use]
1629    pub fn has_durable_delivery_queue(&self) -> bool {
1630        self.delivery_queue.is_some()
1631    }
1632
1633    /// Returns `true` when the active transport is intentionally a no-op
1634    /// (i.e. `transport = "disabled"` in `autumn.toml`).
1635    ///
1636    /// Handlers that require mail (e.g. forgot-password) can guard against
1637    /// silently dropped messages by checking this before attempting to send.
1638    #[must_use]
1639    pub fn is_disabled(&self) -> bool {
1640        self.transport.is_disabled()
1641    }
1642
1643    /// Send mail immediately.
1644    ///
1645    /// Before transport, recipients on the bounce/complaint [`suppression`] list
1646    /// (hard bounce or complaint) are skipped — each skip emits a structured
1647    /// `outcome = "skipped_suppressed"` log line and increments
1648    /// [`suppression::suppressed_skips`]. When **every** recipient is suppressed,
1649    /// returns [`MailError::AllRecipientsSuppressed`] rather than reporting a
1650    /// phantom success. A message built with
1651    /// [`Mail::ignore_suppression`](MailBuilder::ignore_suppression) bypasses
1652    /// this check entirely (critical mail).
1653    ///
1654    /// When the message carries a [`list_unsubscribe`](Mail::list_unsubscribe)
1655    /// scope and a [`UnsubscribeRuntime`] is attached, recipients with a
1656    /// matching suppression row are skipped (with a structured log event) and
1657    /// every delivered message gains RFC 8058 `List-Unsubscribe` /
1658    /// `List-Unsubscribe-Post` headers scoped to the recipient. Such messages
1659    /// are delivered one recipient at a time so each unsubscribe link is
1660    /// personalized.
1661    ///
1662    /// # Errors
1663    ///
1664    /// Returns [`MailError::AllRecipientsSuppressed`] when every recipient is
1665    /// suppressed, an error from the selected transport, or from the suppression
1666    /// store when a suppression check fails.
1667    pub async fn send(&self, mail: Mail) -> Result<(), MailError> {
1668        let mut mail = mail.with_defaults(&self.defaults);
1669
1670        // Inline `<style>` CSS into element `style="…"` attributes before
1671        // transport, so every transport (SMTP, file, log, preview) delivers the
1672        // inlined body and it renders styled in clients that strip
1673        // `<head>`/`<style>`. Doing it here — ahead of the list-mail branch that
1674        // clones per recipient — inlines exactly once regardless of path.
1675        self.apply_css_inlining(&mut mail)?;
1676
1677        // Consult the bounce/complaint suppression list *before* transport.
1678        // Suppressed recipients are dropped from `to` (skipped, not an error)
1679        // unless the message opts out via `Mail::ignore_suppression`. When every
1680        // recipient is suppressed we return `AllRecipientsSuppressed` rather than
1681        // reporting a phantom success.
1682        if !mail.ignore_suppression
1683            && let Some(store) = self.suppression.as_ref()
1684            && !mail.to.is_empty()
1685        {
1686            let mut kept: Vec<String> = Vec::with_capacity(mail.to.len());
1687            for recipient in &mail.to {
1688                // The store canonicalizes internally, so pass the raw recipient
1689                // and only canonicalize on the (rare) suppressed path for the
1690                // log line — no allocation for delivered recipients.
1691                if store.is_suppressed(recipient).await? {
1692                    suppression::note_skip(&canonical_subscriber(recipient));
1693                } else {
1694                    kept.push(recipient.clone());
1695                }
1696            }
1697            if kept.is_empty() {
1698                return Err(MailError::AllRecipientsSuppressed);
1699            }
1700            mail.to = kept;
1701        }
1702
1703        if let Some(list_id) = mail.list_unsubscribe.clone() {
1704            if let Some(runtime) = self.unsubscribe.clone() {
1705                return self.send_list_mail(mail, list_id, &runtime).await;
1706            }
1707            // Opted into a list (e.g. via MailBuilder::list_unsubscribe) but no
1708            // unsubscribe runtime is wired — send without headers/suppression,
1709            // but make the compliance gap loud rather than silent.
1710            tracing::warn!(
1711                target: "mail",
1712                list_id = %list_id,
1713                "sending list mail without an unsubscribe runtime: no List-Unsubscribe headers or suppression applied (set mail.unsubscribe_base_url / mail.unsubscribe_mailto)"
1714            );
1715        }
1716        self.transport.send(mail).await
1717    }
1718
1719    /// Resolve the CSS-inlining decision for a message and, when enabled, inline
1720    /// its HTML body in place (issue #1254).
1721    ///
1722    /// Precedence: a per-message [`Mail::inline_css`] override wins; otherwise
1723    /// the [`Mailer`]'s configured [`MailConfig::inline_css`] default applies.
1724    /// On inliner failure `send` fails loudly: a typed [`MailError::CssInline`]
1725    /// is returned and the message is not delivered, rather than shipping a
1726    /// corrupted body. Text bodies are never touched.
1727    fn apply_css_inlining(&self, mail: &mut Mail) -> Result<(), MailError> {
1728        let enabled = mail.inline_css.unwrap_or(self.inline_css_default);
1729        if !enabled {
1730            return Ok(());
1731        }
1732        let Some(html) = mail.html.as_deref() else {
1733            return Ok(());
1734        };
1735        match inline_css_html(html) {
1736            Ok(inlined) => {
1737                mail.html = Some(inlined);
1738                Ok(())
1739            }
1740            Err(error) => {
1741                // Leave `mail.html` as the original body (not corrupted) and make
1742                // the failure loud rather than silently shipping broken HTML.
1743                tracing::warn!(
1744                    target: "mail",
1745                    error = %error,
1746                    "CSS inlining failed; HTML body left un-inlined"
1747                );
1748                Err(error)
1749            }
1750        }
1751    }
1752
1753    /// Freeze this mailer's CSS-inlining default onto a message before it is
1754    /// handed to a durable [`MailDeliveryQueue`] for deferred delivery (issue
1755    /// #1254).
1756    ///
1757    /// A worker that later dequeues the persisted job resolves
1758    /// [`Mail::inline_css`] against ITS OWN mailer's default via
1759    /// [`apply_css_inlining`](Self::apply_css_inlining), which may differ from
1760    /// (or be off by default relative to) the originating mailer. Recording the
1761    /// originating decision here makes the persisted job self-describing, so
1762    /// deferred mail inlines consistently with an immediate send. Only `None`
1763    /// is resolved — explicit `Some(true)`/`Some(false)` per-message overrides
1764    /// are preserved. The body itself is left un-inlined so the single inline
1765    /// pass still happens once at the consumer's `send()`, keeping delivery
1766    /// idempotent and avoiding a bloated persisted body.
1767    const fn freeze_inline_css_default(&self, mail: &mut Mail) {
1768        if mail.inline_css.is_none() {
1769            mail.inline_css = Some(self.inline_css_default);
1770        }
1771    }
1772
1773    /// Deliver a list mail recipient-by-recipient, applying suppression and
1774    /// per-recipient RFC 8058 headers.
1775    async fn send_list_mail(
1776        &self,
1777        mail: Mail,
1778        list_id: String,
1779        runtime: &UnsubscribeRuntime,
1780    ) -> Result<(), MailError> {
1781        // Resolve every recipient — address validity AND suppression decision —
1782        // before delivering anything. The delivery loop below sends one message
1783        // per recipient; if validation or a suppression-store lookup failed
1784        // mid-loop it could deliver to earlier recipients and then return an
1785        // error, so a caller retrying the send would duplicate those earlier
1786        // deliveries. Non-list mail builds and validates the full message before
1787        // any send — match that atomicity here.
1788        //
1789        // Each entry is `(recipient_display, canonical_subscriber)`. The canonical
1790        // bare address is used for the suppression / token key so a formatted
1791        // `Ada <ada@example.com>` recipient matches an opt-out recorded as
1792        // `ada@example.com`; the display string is preserved for actual delivery.
1793        let mut deliveries: Vec<(String, String)> = Vec::with_capacity(mail.to.len());
1794        for recipient in &mail.to {
1795            parse_mailbox(recipient)?;
1796            let subscriber = canonical_subscriber(recipient);
1797            if let Some(store) = runtime.suppression.as_ref()
1798                && store.is_suppressed(&subscriber, &list_id).await?
1799            {
1800                tracing::info!(
1801                    target: "mail",
1802                    list_id = %list_id,
1803                    outcome = "skipped_suppressed",
1804                    "skipping suppressed list-unsubscribe recipient"
1805                );
1806                continue;
1807            }
1808            deliveries.push((recipient.clone(), subscriber));
1809        }
1810
1811        for (recipient, subscriber) in deliveries {
1812            let mut per_recipient = mail.clone();
1813            per_recipient.to = vec![recipient];
1814            if let Some(value) = runtime.list_unsubscribe_header(&subscriber, &list_id) {
1815                // A migration to `#[mailer(list_unsubscribe)]` replaces, not
1816                // duplicates, any header the template set by hand: drop an existing
1817                // List-Unsubscribe / List-Unsubscribe-Post first so the generated
1818                // per-recipient one-click header is authoritative (otherwise a
1819                // stale manual header would suppress RFC 8058 compliance).
1820                per_recipient.extra_headers.retain(|(name, _)| {
1821                    !name.eq_ignore_ascii_case("List-Unsubscribe")
1822                        && !name.eq_ignore_ascii_case("List-Unsubscribe-Post")
1823                });
1824                per_recipient
1825                    .extra_headers
1826                    .push(("List-Unsubscribe".to_owned(), value));
1827                // `List-Unsubscribe-Post` is only valid with an HTTPS one-click
1828                // URL; a mailto-only header is plain RFC 2369.
1829                if runtime.supports_one_click() {
1830                    per_recipient.extra_headers.push((
1831                        "List-Unsubscribe-Post".to_owned(),
1832                        "List-Unsubscribe=One-Click".to_owned(),
1833                    ));
1834                }
1835            }
1836            self.transport.send(per_recipient).await?;
1837        }
1838        Ok(())
1839    }
1840
1841    /// Queue mail for later delivery.
1842    ///
1843    /// When called **inside a [`Db::tx`](autumn_web::db::Db::tx) block**, the
1844    /// delivery is automatically deferred until the transaction commits. On
1845    /// rollback the mail is silently dropped — no orphaned sends.
1846    ///
1847    /// This deferral is process-local. It prevents mail for rolled-back writes,
1848    /// but it does not make the post-commit mail handoff crash-safe unless the
1849    /// configured [`MailDeliveryQueue`] records a durable outbox/queue entry.
1850    ///
1851    /// When called outside any active transaction the behaviour is unchanged:
1852    /// the mail is dispatched in a background Tokio task immediately.
1853    ///
1854    /// Use [`deliver_later_eager`](Self::deliver_later_eager) when you need the
1855    /// mail to fire regardless of whether the surrounding transaction commits
1856    /// (e.g. security alerts that must go out on any code path).
1857    pub fn deliver_later(&self, mail: Mail) {
1858        if let Err(error) = self.try_deliver_later(mail) {
1859            tracing::error!(error = %error, "background mail delivery was not scheduled");
1860        }
1861    }
1862
1863    /// Queue mail for later delivery, **bypassing any active transaction**.
1864    ///
1865    /// Unlike [`deliver_later`](Self::deliver_later), this method always
1866    /// spawns the delivery immediately — it does not check for an active
1867    /// `db.tx` block. Use this when the mail must be sent even if the
1868    /// surrounding transaction rolls back (e.g. "someone tried to log in"
1869    /// security alerts, rate-limit notices).
1870    pub fn deliver_later_eager(&self, mail: Mail) {
1871        if let Err(error) = self.try_deliver_later_eager(mail) {
1872            tracing::error!(error = %error, "background mail delivery was not scheduled");
1873        }
1874    }
1875
1876    /// Queue mail for later delivery, deferring when inside a `db.tx`.
1877    ///
1878    /// # Errors
1879    ///
1880    /// Returns an error when no active Tokio runtime is available to host the
1881    /// background task.
1882    ///
1883    /// # Panics
1884    ///
1885    /// Panics if the internal after-commit registry mutex is poisoned.
1886    pub fn try_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
1887        if self.transport.is_disabled() {
1888            return Ok(());
1889        }
1890        let mut mail = mail.with_defaults(&self.defaults);
1891        // Resolve the CSS-inlining default onto the message once, at the top of
1892        // the deferred path, so BOTH the durable-queue branch (persisted for a
1893        // possibly-different worker to consume) and the in-process fallback
1894        // branch carry the originating mailer's decision. Only `None` is frozen;
1895        // explicit per-message overrides are preserved (issue #1254).
1896        self.freeze_inline_css_default(&mut mail);
1897
1898        // When inside a db.tx, push the spawn as an after-commit callback so
1899        // the mail only fires if the transaction commits successfully.
1900        #[cfg(feature = "db")]
1901        {
1902            let mailer = self.clone();
1903            let deferred = mail.clone();
1904            let mut f_opt: Option<(Self, Mail)> = Some((mailer, deferred));
1905            // Capture the caller's span now; the after-commit callback runs in a
1906            // fresh task with no request span, so spawn_mail_delivery would see an
1907            // empty span and lose trace correlation without this.
1908            let deliver_span = tracing::Span::current();
1909
1910            crate::db::AFTER_COMMIT_REGISTRY
1911                .try_with(|registry| {
1912                    #[allow(
1913                        clippy::expect_used,
1914                        reason = "unreachable: try_with closure body runs at most once"
1915                    )]
1916                    let (m, m_mail) = f_opt.take().expect("once");
1917                    let span = deliver_span.clone();
1918                    let boxed: crate::db::CommitCallback = Box::new(move || {
1919                        Box::pin(tracing::Instrument::instrument(
1920                            async move {
1921                                if let Some(queue) = m.delivery_queue.clone() {
1922                                    queue.enqueue(m_mail).await.map_err(|e| {
1923                                        crate::AutumnError::internal_server_error_msg(e.to_string())
1924                                    })
1925                                } else {
1926                                    m.spawn_mail_delivery(m_mail).map_err(|e| {
1927                                        crate::AutumnError::internal_server_error_msg(e.to_string())
1928                                    })
1929                                }
1930                            },
1931                            span,
1932                        ))
1933                    });
1934                    registry
1935                        .lock()
1936                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1937                        .push(boxed);
1938                })
1939                .ok();
1940
1941            if f_opt.is_none() {
1942                // Successfully registered for after-commit; skip the eager spawn.
1943                return Ok(());
1944            }
1945        }
1946
1947        // Outside a transaction (or `db` feature not enabled) — spawn immediately.
1948        self.spawn_mail_delivery(mail)
1949    }
1950
1951    /// Queue mail for later delivery, always spawning immediately.
1952    ///
1953    /// # Errors
1954    ///
1955    /// Returns an error when no active Tokio runtime is available.
1956    pub fn try_deliver_later_eager(&self, mail: Mail) -> Result<(), MailError> {
1957        if self.transport.is_disabled() {
1958            return Ok(());
1959        }
1960        let mut mail = mail.with_defaults(&self.defaults);
1961        self.freeze_inline_css_default(&mut mail);
1962        self.spawn_mail_delivery(mail)
1963    }
1964
1965    fn spawn_mail_delivery(&self, mail: Mail) -> Result<(), MailError> {
1966        // Honor the disabled-transport contract: if the operator turned mail off
1967        // for this profile, deliver_later must drop the message just like
1968        // immediate `send` does — even when a queue is attached.
1969        let handle = tokio::runtime::Handle::try_current().map_err(|_| {
1970            MailError::RuntimeUnavailable(
1971                "deliver_later requires an active Tokio runtime".to_owned(),
1972            )
1973        })?;
1974        let parent_span = tracing::Span::current();
1975        if let Some(queue) = self.delivery_queue.clone() {
1976            handle.spawn(tracing::Instrument::instrument(
1977                async move {
1978                    if let Err(error) = queue.enqueue(mail).await {
1979                        tracing::error!(error = %error, "durable mail enqueue failed");
1980                    }
1981                },
1982                parent_span,
1983            ));
1984        } else {
1985            let mailer = self.clone();
1986            handle.spawn(tracing::Instrument::instrument(
1987                async move {
1988                    if let Err(error) = mailer.send(mail).await {
1989                        tracing::error!(error = %error, "background mail delivery failed");
1990                    }
1991                },
1992                parent_span,
1993            ));
1994        }
1995        Ok(())
1996    }
1997}
1998
1999impl FromRequestParts<AppState> for Mailer {
2000    type Rejection = AutumnError;
2001
2002    async fn from_request_parts(
2003        _parts: &mut http::request::Parts,
2004        state: &AppState,
2005    ) -> Result<Self, Self::Rejection> {
2006        state
2007            .extension::<Self>()
2008            .as_deref()
2009            .cloned()
2010            .ok_or_else(|| AutumnError::service_unavailable_msg("Mailer is not configured"))
2011    }
2012}
2013
2014/// Builder for [`Mailer`].
2015#[derive(Clone)]
2016pub struct MailerBuilder {
2017    transport: Transport,
2018    from: Option<String>,
2019    reply_to: Option<String>,
2020    file_dir: PathBuf,
2021    smtp: Option<SmtpConfig>,
2022    delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
2023    resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2024    inline_css: bool,
2025}
2026
2027impl Default for MailerBuilder {
2028    fn default() -> Self {
2029        Self {
2030            transport: Transport::Log,
2031            from: None,
2032            reply_to: None,
2033            file_dir: default_file_dir(),
2034            smtp: None,
2035            delivery_queue: None,
2036            resilience_config: None,
2037            inline_css: false,
2038        }
2039    }
2040}
2041
2042impl MailerBuilder {
2043    /// Select the transport.
2044    #[must_use]
2045    pub const fn transport(mut self, transport: Transport) -> Self {
2046        self.transport = transport;
2047        self
2048    }
2049
2050    /// Set default From header.
2051    #[must_use]
2052    pub fn from(mut self, from: impl Into<String>) -> Self {
2053        self.from = Some(from.into());
2054        self
2055    }
2056
2057    /// Set default Reply-To header.
2058    #[must_use]
2059    pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
2060        self.reply_to = Some(reply_to.into());
2061        self
2062    }
2063
2064    /// Set file output directory.
2065    #[must_use]
2066    pub fn file_dir(mut self, dir: impl AsRef<Path>) -> Self {
2067        self.file_dir = dir.as_ref().to_path_buf();
2068        self
2069    }
2070
2071    /// Set SMTP config.
2072    #[must_use]
2073    pub fn smtp(mut self, smtp: SmtpConfig) -> Self {
2074        self.smtp = Some(smtp);
2075        self
2076    }
2077
2078    /// Attach a durable [`MailDeliveryQueue`] used by
2079    /// [`Mailer::deliver_later`].
2080    #[must_use]
2081    pub fn delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
2082        self.delivery_queue = Some(Arc::new(queue));
2083        self
2084    }
2085
2086    /// Attach an already-shared durable [`MailDeliveryQueue`].
2087    #[must_use]
2088    pub fn delivery_queue_arc(mut self, queue: Arc<dyn MailDeliveryQueue>) -> Self {
2089        self.delivery_queue = Some(queue);
2090        self
2091    }
2092
2093    #[must_use]
2094    pub fn resilience_config(mut self, rc: Option<Arc<crate::config::ResilienceConfig>>) -> Self {
2095        self.resilience_config = rc;
2096        self
2097    }
2098
2099    /// Set the default for CSS inlining of HTML bodies (issue #1254). Applied to
2100    /// every message that does not carry its own [`MailBuilder::inline_css`]
2101    /// override. Mirrors [`MailConfig::inline_css`].
2102    #[must_use]
2103    pub const fn inline_css(mut self, enabled: bool) -> Self {
2104        self.inline_css = enabled;
2105        self
2106    }
2107
2108    /// Build the mailer.
2109    ///
2110    /// # Errors
2111    ///
2112    /// Returns an error when the SMTP transport or default addresses cannot be configured.
2113    pub fn build(self) -> Result<Mailer, MailError> {
2114        if let Some(from) = &self.from {
2115            parse_mailbox(from)?;
2116        }
2117        if let Some(reply_to) = &self.reply_to {
2118            parse_mailbox(reply_to)?;
2119        }
2120
2121        let transport: Arc<dyn MailTransport> = match self.transport {
2122            Transport::Log => Arc::new(LogTransport),
2123            Transport::File => Arc::new(FileTransport { dir: self.file_dir }),
2124            Transport::Disabled => Arc::new(DisabledTransport),
2125            Transport::Smtp => Arc::new(SmtpTransport::new(
2126                self.smtp.unwrap_or_default(),
2127                self.resilience_config.clone(),
2128            )?),
2129        };
2130
2131        Ok(Mailer {
2132            defaults: Arc::new(MailerDefaults {
2133                from: self.from,
2134                reply_to: self.reply_to,
2135            }),
2136            transport,
2137            delivery_queue: self.delivery_queue,
2138            unsubscribe: None,
2139            suppression: None,
2140            inline_css_default: self.inline_css,
2141        })
2142    }
2143}
2144
2145struct DisabledTransport;
2146
2147impl MailTransport for DisabledTransport {
2148    fn send<'a>(
2149        &'a self,
2150        _mail: Mail,
2151    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2152        Box::pin(async { Ok(()) })
2153    }
2154
2155    fn is_disabled(&self) -> bool {
2156        true
2157    }
2158}
2159
2160struct LogTransport;
2161
2162impl MailTransport for LogTransport {
2163    fn send<'a>(
2164        &'a self,
2165        mail: Mail,
2166    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2167        Box::pin(async move {
2168            tracing::info!(
2169                from = ?mail.from,
2170                reply_to = ?mail.reply_to,
2171                to = ?mail.to,
2172                subject = %mail.subject,
2173                text = ?mail.text,
2174                html = ?mail.html,
2175                attachments = mail.attachments.len(),
2176                "mail captured by log transport"
2177            );
2178            Ok(())
2179        })
2180    }
2181}
2182
2183struct FileTransport {
2184    dir: PathBuf,
2185}
2186
2187static FILE_TRANSPORT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
2188
2189impl MailTransport for FileTransport {
2190    fn send<'a>(
2191        &'a self,
2192        mail: Mail,
2193    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2194        Box::pin(async move {
2195            tokio::fs::create_dir_all(&self.dir).await?;
2196            let filename = file_transport_filename(&mail);
2197            let path = self.dir.join(filename);
2198            let mut file = tokio::fs::OpenOptions::new()
2199                .write(true)
2200                .create_new(true)
2201                .open(path)
2202                .await?;
2203            let eml = render_eml(&mail);
2204            tokio::io::AsyncWriteExt::write_all(&mut file, eml.as_bytes()).await?;
2205            tokio::io::AsyncWriteExt::flush(&mut file).await?;
2206            file.sync_all().await?;
2207            Ok(())
2208        })
2209    }
2210}
2211
2212struct SmtpTransport {
2213    inner: AsyncSmtpTransport<Tokio1Executor>,
2214    resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2215}
2216
2217impl SmtpTransport {
2218    fn new(
2219        config: SmtpConfig,
2220        resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2221    ) -> Result<Self, MailError> {
2222        let host = config
2223            .host
2224            .filter(|host| !host.trim().is_empty())
2225            .ok_or_else(|| MailError::InvalidMessage("mail.smtp.host is required".to_owned()))?;
2226        let mut builder = match config.tls {
2227            TlsMode::Disabled => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host),
2228            TlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
2229            TlsMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
2230        };
2231        if let Some(port) = config.port {
2232            builder = builder.port(port);
2233        }
2234        if let Some(username) = config.username {
2235            let password_env = config.password_env.ok_or_else(|| {
2236                MailError::InvalidMessage(
2237                    "mail.smtp.password_env is required when mail.smtp.username is set".to_owned(),
2238                )
2239            })?;
2240            let password = std::env::var(&password_env)
2241                .map_err(|error| smtp_password_env_error(&password_env, &error))?;
2242            builder = builder.credentials(Credentials::new(username, password));
2243        }
2244        Ok(Self {
2245            inner: builder.build(),
2246            resilience_config,
2247        })
2248    }
2249}
2250
2251/// Builds the startup error for a failed SMTP password lookup without ever
2252/// embedding the environment variable's *value*: [`std::env::VarError`]'s
2253/// `NotUnicode` variant carries the raw contents of the variable — the SMTP
2254/// password itself — in both its `Display` and `Debug` output, so the error
2255/// kind is mapped to a static description instead of being formatted. The
2256/// variable *name* is ordinary configuration and is kept for diagnostics.
2257fn smtp_password_env_error(password_env: &str, error: &std::env::VarError) -> MailError {
2258    let reason = match error {
2259        std::env::VarError::NotPresent => "environment variable is not set",
2260        std::env::VarError::NotUnicode(_) => "environment variable contains non-unicode data",
2261    };
2262    MailError::InvalidMessage(format!(
2263        "mail.smtp.password_env={password_env:?} could not be resolved: {reason}"
2264    ))
2265}
2266
2267impl MailTransport for SmtpTransport {
2268    fn send<'a>(
2269        &'a self,
2270        mail: Mail,
2271    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2272        Box::pin(async move {
2273            let breaker = self.resilience_config.as_ref().map_or_else(
2274                || {
2275                    crate::circuit_breaker::global_registry().get_or_create(
2276                        "smtp_mailer",
2277                        crate::circuit_breaker::CircuitBreakerPolicy::default(),
2278                    )
2279                },
2280                |rc| {
2281                    let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(
2282                        rc,
2283                        "smtp_mailer",
2284                    );
2285                    crate::circuit_breaker::global_registry()
2286                        .get_or_create_with_config("smtp_mailer", policy)
2287                },
2288            );
2289
2290            if breaker.before_call().is_err() {
2291                return Err(MailError::RuntimeUnavailable(
2292                    "smtp mailer circuit breaker is open".to_owned(),
2293                ));
2294            }
2295            let guard = crate::circuit_breaker::CircuitBreakerGuard::new(breaker.clone());
2296
2297            let message = lettre_message(&mail)?;
2298            let res = self.inner.send(message).await;
2299            if res.is_ok() {
2300                guard.success();
2301            } else {
2302                guard.failure();
2303            }
2304
2305            res.map(|_| ()).map_err(Into::into)
2306        })
2307    }
2308}
2309
2310fn sanitize_filename(value: &str) -> String {
2311    value
2312        .chars()
2313        .map(|ch| {
2314            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
2315                ch
2316            } else {
2317                '_'
2318            }
2319        })
2320        .collect()
2321}
2322
2323/// RFC 2231 `attr-char`: alphanumerics plus these ASCII punctuation marks may
2324/// appear unescaped in an extended parameter value; everything else
2325/// (including all non-ASCII and control bytes) is percent-encoded.
2326const RFC2231_ATTR_CHAR: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
2327    .remove(b'!')
2328    .remove(b'#')
2329    .remove(b'$')
2330    .remove(b'&')
2331    .remove(b'+')
2332    .remove(b'-')
2333    .remove(b'.')
2334    .remove(b'^')
2335    .remove(b'_')
2336    .remove(b'`')
2337    .remove(b'|')
2338    .remove(b'~');
2339
2340/// Strips CR/LF and other ASCII/Unicode control characters from a header
2341/// value written by the hand-rolled `.eml` renderer, so untrusted `Mail`
2342/// field content (which may arrive via `Deserialize` from a durable queue,
2343/// bypassing [`MailBuilder::build`]'s validation) can never inject an extra
2344/// header line.
2345fn strip_header_controls(value: &str) -> String {
2346    value.chars().filter(|ch| !ch.is_control()).collect()
2347}
2348
2349fn quote_header_value(value: &str) -> String {
2350    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
2351    format!("\"{escaped}\"")
2352}
2353
2354/// Builds the `Content-Disposition: attachment; …` parameter section for the
2355/// hand-rolled `.eml` renderer. Always ASCII and CR/LF-free by construction:
2356/// control characters are stripped, and non-ASCII filenames are RFC 2231
2357/// percent-encoded (`filename*=UTF-8''…`) alongside an ASCII fallback
2358/// `filename="…"` for readers that don't understand extended parameters.
2359fn content_disposition_params(filename: &str) -> String {
2360    let mut clean = strip_header_controls(filename);
2361    if clean.trim().is_empty() {
2362        "attachment".clone_into(&mut clean);
2363    }
2364    if clean.is_ascii() {
2365        format!("filename={}", quote_header_value(&clean))
2366    } else {
2367        let fallback: String = clean
2368            .chars()
2369            .map(|ch| if ch.is_ascii() { ch } else { '_' })
2370            .collect();
2371        let encoded = percent_encoding::utf8_percent_encode(&clean, RFC2231_ATTR_CHAR);
2372        format!(
2373            "filename={}; filename*=UTF-8''{encoded}",
2374            quote_header_value(&fallback)
2375        )
2376    }
2377}
2378
2379/// Base64-encodes `bytes` and hard-wraps at 76 columns per RFC 2045.
2380fn base64_wrap76(bytes: &[u8]) -> String {
2381    use base64::Engine as _;
2382    let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
2383    if encoded.len() <= 76 {
2384        return encoded;
2385    }
2386    let newlines = (encoded.len() - 1) / 76;
2387    let mut wrapped = String::with_capacity(encoded.len() + newlines);
2388    for chunk in encoded.as_bytes().chunks(76) {
2389        if !wrapped.is_empty() {
2390            wrapped.push('\n');
2391        }
2392        #[allow(
2393            clippy::expect_used,
2394            reason = "infallible: base64 output is always ASCII"
2395        )]
2396        let chunk_str = std::str::from_utf8(chunk).expect("base64 output is always ASCII");
2397        wrapped.push_str(chunk_str);
2398    }
2399    wrapped
2400}
2401
2402fn file_transport_filename(mail: &Mail) -> String {
2403    let sequence = FILE_TRANSPORT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2404    format!(
2405        "{}-{}-{:016x}-{}.eml",
2406        chrono::Utc::now().format("%Y%m%d%H%M%S%6f"),
2407        std::process::id(),
2408        sequence,
2409        sanitize_filename(mail.to.first().map_or("unknown", String::as_str))
2410    )
2411}
2412
2413fn render_eml(mail: &Mail) -> String {
2414    let mut out = String::new();
2415    if let Some(from) = &mail.from {
2416        out.push_str("From: ");
2417        out.push_str(&strip_header_controls(from));
2418        out.push('\n');
2419    }
2420    for to in &mail.to {
2421        out.push_str("To: ");
2422        out.push_str(&strip_header_controls(to));
2423        out.push('\n');
2424    }
2425    if let Some(reply_to) = &mail.reply_to {
2426        out.push_str("Reply-To: ");
2427        out.push_str(&strip_header_controls(reply_to));
2428        out.push('\n');
2429    }
2430    out.push_str("Date: ");
2431    out.push_str(&chrono::Utc::now().to_rfc2822());
2432    out.push('\n');
2433    out.push_str("Message-Id: <");
2434    out.push_str(&uuid::Uuid::new_v4().to_string());
2435    out.push_str("@autumn.local>\n");
2436    out.push_str("Subject: ");
2437    out.push_str(&strip_header_controls(&mail.subject));
2438    out.push('\n');
2439    for (name, value) in &mail.extra_headers {
2440        out.push_str(&strip_header_controls(name));
2441        out.push_str(": ");
2442        out.push_str(&strip_header_controls(value));
2443        out.push('\n');
2444    }
2445    out.push_str("MIME-Version: 1.0\n");
2446    if mail.attachments.is_empty() {
2447        render_eml_bodies(mail, &mut out);
2448    } else {
2449        // Random per-message boundary: text/html bodies are caller-controlled
2450        // and may legitimately contain a line matching a fixed boundary
2451        // (e.g. `--autumn-mixed`), which would truncate or split the
2452        // rendered MIME structure. A boundary the caller cannot predict in
2453        // advance can't collide with body content.
2454        use std::fmt::Write as _;
2455        let boundary = format!("autumn-mixed-{}", uuid::Uuid::new_v4().simple());
2456        let _ = write!(
2457            out,
2458            "Content-Type: multipart/mixed; boundary=\"{boundary}\"\n\n"
2459        );
2460        let _ = writeln!(out, "--{boundary}");
2461        render_eml_bodies(mail, &mut out);
2462        for attachment in &mail.attachments {
2463            let _ = writeln!(out, "--{boundary}");
2464            out.push_str("Content-Type: ");
2465            let content_type = strip_header_controls(&attachment.content_type);
2466            if ContentType::parse(&content_type).is_ok() {
2467                out.push_str(&content_type);
2468            } else {
2469                out.push_str("application/octet-stream");
2470            }
2471            out.push('\n');
2472            out.push_str("Content-Disposition: attachment; ");
2473            out.push_str(&content_disposition_params(&attachment.filename));
2474            out.push('\n');
2475            out.push_str("Content-Transfer-Encoding: base64\n\n");
2476            out.push_str(&base64_wrap76(&attachment.bytes));
2477            out.push('\n');
2478        }
2479        let _ = writeln!(out, "--{boundary}--");
2480    }
2481    out
2482}
2483
2484/// Renders the html/text body part(s) of an `.eml` message — everything
2485/// after the `MIME-Version` header, before any `multipart/mixed` attachment
2486/// wrapper. Pulled out of [`render_eml`] so the attachment-less code path is
2487/// provably byte-identical to what it was before attachments existed.
2488fn render_eml_bodies(mail: &Mail, out: &mut String) {
2489    if mail.html.is_some() && mail.text.is_some() {
2490        out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
2491        if let Some(text) = &mail.text {
2492            out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
2493            out.push_str(text);
2494            out.push('\n');
2495        }
2496        if let Some(html) = &mail.html {
2497            out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
2498            out.push_str(html);
2499            out.push('\n');
2500        }
2501        out.push_str("--autumn-mail--\n");
2502    } else if let Some(html) = &mail.html {
2503        out.push_str("Content-Type: text/html; charset=utf-8\n\n");
2504        out.push_str(html);
2505        out.push('\n');
2506    } else if let Some(text) = &mail.text {
2507        out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
2508        out.push_str(text);
2509        out.push('\n');
2510    }
2511}
2512
2513#[derive(Debug, Clone)]
2514struct ParsedMail {
2515    headers: Vec<(String, String)>,
2516    to: Vec<String>,
2517    subject: String,
2518    date: Option<String>,
2519    html: Option<String>,
2520    text: Option<String>,
2521    attachments: Vec<ParsedAttachment>,
2522    raw: String,
2523}
2524
2525impl ParsedMail {
2526    fn header_value(&self, name: &str) -> Option<&str> {
2527        self.headers
2528            .iter()
2529            .find(|(header, _)| header.eq_ignore_ascii_case(name))
2530            .map(|(_, value)| value.as_str())
2531    }
2532}
2533
2534/// An attachment as surfaced by the dev mail preview: just enough to list it
2535/// (filename, declared content type) without decoding its body.
2536#[derive(Debug, Clone)]
2537struct ParsedAttachment {
2538    filename: String,
2539    content_type: String,
2540}
2541
2542#[derive(Debug, Clone)]
2543struct CapturedMailSummary {
2544    id: String,
2545    to: Vec<String>,
2546    subject: String,
2547    timestamp: String,
2548    modified: SystemTime,
2549}
2550
2551pub(crate) fn mail_preview_router<S>(file_dir: PathBuf) -> axum::Router<S>
2552where
2553    S: Clone + Send + Sync + 'static,
2554    AppState: axum::extract::FromRef<S>,
2555{
2556    let file_dir = Arc::new(file_dir);
2557    axum::Router::new()
2558        .route(
2559            MAIL_PREVIEW_PATH,
2560            axum::routing::get({
2561                let file_dir = Arc::clone(&file_dir);
2562                move |axum::extract::State(state): axum::extract::State<AppState>| {
2563                    let file_dir = Arc::clone(&file_dir);
2564                    async move { list_mail_preview(file_dir, state).await }
2565                }
2566            }),
2567        )
2568        .route(
2569            MAIL_PREVIEW_MESSAGE_PATH,
2570            axum::routing::get({
2571                let file_dir = Arc::clone(&file_dir);
2572                move |axum::extract::Path(message_id): axum::extract::Path<String>| {
2573                    let file_dir = Arc::clone(&file_dir);
2574                    async move { show_captured_mail(file_dir, message_id).await }
2575                }
2576            }),
2577        )
2578        .route(
2579            MAIL_PREVIEW_TEMPLATE_PATH,
2580            axum::routing::get(
2581                |axum::extract::Path((mailer, method)): axum::extract::Path<(String, String)>,
2582                 axum::extract::State(state): axum::extract::State<AppState>| async move {
2583                    show_template_preview(&state, &mailer, &method)
2584                },
2585            ),
2586        )
2587}
2588
2589async fn list_mail_preview(file_dir: Arc<PathBuf>, state: AppState) -> Response {
2590    match captured_messages(&file_dir).await {
2591        Ok(messages) => {
2592            let previews = state
2593                .extension::<MailPreviewRegistry>()
2594                .map(|registry| registry.previews().to_vec())
2595                .unwrap_or_default();
2596            html_response(render_mail_index(&messages, &previews, &file_dir))
2597        }
2598        Err(error) => preview_error_response(&error),
2599    }
2600}
2601
2602async fn show_captured_mail(file_dir: Arc<PathBuf>, message_id: String) -> Response {
2603    match read_captured_message(&file_dir, &message_id).await {
2604        Ok(parsed) => html_response(render_mail_detail(&parsed, "Captured message")),
2605        Err(error) => preview_error_response(&error),
2606    }
2607}
2608
2609fn show_template_preview(state: &AppState, mailer: &str, method: &str) -> Response {
2610    let preview = state
2611        .extension::<MailPreviewRegistry>()
2612        .and_then(|registry| registry.find(mailer, method));
2613    let Some(preview) = preview else {
2614        return preview_error_response(&MailPreviewError::NotFound(format!("{mailer}/{method}")));
2615    };
2616
2617    match preview.render() {
2618        Ok(mail) => {
2619            let mut mail = apply_preview_unsubscribe_headers(state, mailer, mail);
2620            // Match Mailer::send: inline <style> CSS so the preview reflects what
2621            // strict clients (Gmail/Outlook) actually receive. Reuses the send-time
2622            // decision (per-message override vs. the mailer's inline_css_default).
2623            if let Some(m) = state.extension::<Mailer>() {
2624                // Dev preview: degrade gracefully on inliner error (leaves html un-inlined)
2625                // rather than failing the preview; the inliner is effectively infallible here.
2626                let _ = m.apply_css_inlining(&mut mail);
2627            }
2628            let raw = render_eml(&mail);
2629            let parsed = parse_eml(&raw);
2630            html_response(render_mail_detail(&parsed, "Template preview"))
2631        }
2632        Err(error) => preview_error_response(&error),
2633    }
2634}
2635
2636/// Inject sample RFC 8058 headers into a preview so authors can confirm wiring
2637/// without sending. Uses the configured [`UnsubscribeRuntime`] when present,
2638/// otherwise a sample base URL with an ephemeral key purely for display.
2639fn apply_preview_unsubscribe_headers(state: &AppState, mailer_label: &str, mut mail: Mail) -> Mail {
2640    let scope = mail.list_unsubscribe.clone().or_else(|| {
2641        registered_list_unsubscribe_scopes()
2642            .into_iter()
2643            .find(|descriptor| descriptor.mailer == mailer_label)
2644            .map(|descriptor| descriptor.scope.to_owned())
2645    });
2646    let Some(scope) = scope else {
2647        return mail;
2648    };
2649    mail.list_unsubscribe = Some(scope.clone());
2650    let recipient = mail.to.first().map_or_else(
2651        || "subscriber@example.com".to_owned(),
2652        |to| canonical_subscriber(to),
2653    );
2654    // Use the configured runtime when present, otherwise a sample with an
2655    // ephemeral key purely for display. Compute the header inside each branch so
2656    // the sample need not outlive this expression.
2657    let (header, one_click) = state.extension::<UnsubscribeRuntime>().map_or_else(
2658        || {
2659            let sample = UnsubscribeRuntime {
2660                base_url: Some("https://example.com".to_owned()),
2661                mailto: None,
2662                signing_keys: Arc::new(crate::security::config::resolve_signing_keys(
2663                    &crate::security::config::SigningSecretConfig::default(),
2664                )),
2665                ttl_days: unsubscribe::DEFAULT_TOKEN_TTL_DAYS,
2666                suppression: None,
2667            };
2668            (
2669                sample.list_unsubscribe_header(&recipient, &scope),
2670                sample.supports_one_click(),
2671            )
2672        },
2673        |runtime| {
2674            (
2675                runtime.list_unsubscribe_header(&recipient, &scope),
2676                runtime.supports_one_click(),
2677            )
2678        },
2679    );
2680    if let Some(value) = header {
2681        // Mirror send: the generated header replaces, not duplicates, any header
2682        // the preview author set by hand, so the preview reflects what is sent.
2683        mail.extra_headers.retain(|(name, _)| {
2684            !name.eq_ignore_ascii_case("List-Unsubscribe")
2685                && !name.eq_ignore_ascii_case("List-Unsubscribe-Post")
2686        });
2687        mail.extra_headers
2688            .push(("List-Unsubscribe".to_owned(), value));
2689        if one_click {
2690            mail.extra_headers.push((
2691                "List-Unsubscribe-Post".to_owned(),
2692                "List-Unsubscribe=One-Click".to_owned(),
2693            ));
2694        }
2695    }
2696    mail
2697}
2698
2699async fn captured_messages(dir: &Path) -> Result<Vec<CapturedMailSummary>, MailPreviewError> {
2700    let mut entries = match tokio::fs::read_dir(dir).await {
2701        Ok(entries) => entries,
2702        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2703        Err(error) => return Err(error.into()),
2704    };
2705
2706    let mut messages = Vec::new();
2707    while let Some(entry) = entries.next_entry().await? {
2708        let path = entry.path();
2709        if !path
2710            .extension()
2711            .and_then(|ext| ext.to_str())
2712            .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
2713        {
2714            continue;
2715        }
2716        let Some(id) = path.file_name().and_then(|name| name.to_str()) else {
2717            continue;
2718        };
2719        let metadata = entry.metadata().await?;
2720        let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
2721        let raw = tokio::fs::read_to_string(&path).await?;
2722        let parsed = parse_eml(&raw);
2723        messages.push(CapturedMailSummary {
2724            id: id.to_owned(),
2725            to: parsed.to,
2726            subject: parsed.subject,
2727            timestamp: parsed.date.unwrap_or_else(|| format_system_time(modified)),
2728            modified,
2729        });
2730    }
2731
2732    messages.sort_by(|left, right| {
2733        right
2734            .modified
2735            .cmp(&left.modified)
2736            .then_with(|| right.id.cmp(&left.id))
2737    });
2738    Ok(messages)
2739}
2740
2741async fn read_captured_message(
2742    dir: &Path,
2743    message_id: &str,
2744) -> Result<ParsedMail, MailPreviewError> {
2745    if !valid_message_id(message_id) {
2746        return Err(MailPreviewError::InvalidMessageId(message_id.to_owned()));
2747    }
2748    let path = dir.join(message_id);
2749    let raw = match tokio::fs::read_to_string(&path).await {
2750        Ok(raw) => raw,
2751        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2752            return Err(MailPreviewError::NotFound(message_id.to_owned()));
2753        }
2754        Err(error) => return Err(error.into()),
2755    };
2756    Ok(parse_eml(&raw))
2757}
2758
2759fn valid_message_id(message_id: &str) -> bool {
2760    !message_id.is_empty()
2761        && Path::new(message_id)
2762            .extension()
2763            .and_then(|ext| ext.to_str())
2764            .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
2765        && !message_id.contains('/')
2766        && !message_id.contains('\\')
2767        && !message_id.contains("..")
2768}
2769
2770fn parse_eml(raw: &str) -> ParsedMail {
2771    let normalized = raw.replace("\r\n", "\n");
2772    let (headers, body) = split_headers_body(&normalized);
2773    let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
2774    let (html, text, attachments) = parse_mail_body(&content_type, body);
2775    let to = header_values(&headers, "To");
2776    let subject = header_value(&headers, "Subject").unwrap_or_else(|| "(no subject)".to_owned());
2777    let date = header_value(&headers, "Date");
2778
2779    ParsedMail {
2780        headers,
2781        to,
2782        subject,
2783        date,
2784        html,
2785        text,
2786        attachments,
2787        raw: raw.to_owned(),
2788    }
2789}
2790
2791fn split_headers_body(raw: &str) -> (Vec<(String, String)>, &str) {
2792    let Some((header_block, body)) = raw.split_once("\n\n") else {
2793        return (parse_header_block(raw), "");
2794    };
2795    (parse_header_block(header_block), body)
2796}
2797
2798fn parse_header_block(header_block: &str) -> Vec<(String, String)> {
2799    let mut headers = Vec::new();
2800    let mut current: Option<(String, String)> = None;
2801
2802    for line in header_block.lines() {
2803        if line.starts_with(' ') || line.starts_with('\t') {
2804            if let Some((_, value)) = current.as_mut() {
2805                value.push(' ');
2806                value.push_str(line.trim());
2807            }
2808            continue;
2809        }
2810        if let Some(header) = current.take() {
2811            headers.push(header);
2812        }
2813        if let Some((name, value)) = line.split_once(':') {
2814            current = Some((name.trim().to_owned(), value.trim().to_owned()));
2815        }
2816    }
2817    if let Some(header) = current {
2818        headers.push(header);
2819    }
2820    headers
2821}
2822
2823fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
2824    headers
2825        .iter()
2826        .find(|(header, _)| header.eq_ignore_ascii_case(name))
2827        .map(|(_, value)| value.clone())
2828}
2829
2830fn header_values(headers: &[(String, String)], name: &str) -> Vec<String> {
2831    headers
2832        .iter()
2833        .filter(|(header, _)| header.eq_ignore_ascii_case(name))
2834        .map(|(_, value)| value.clone())
2835        .collect()
2836}
2837
2838fn parse_mail_body(
2839    content_type: &str,
2840    body: &str,
2841) -> (Option<String>, Option<String>, Vec<ParsedAttachment>) {
2842    let lower = content_type.to_ascii_lowercase();
2843    if lower.contains("multipart/mixed")
2844        && let Some(boundary) = content_type_boundary(content_type)
2845    {
2846        return parse_multipart_mixed(body, &boundary);
2847    }
2848
2849    if lower.contains("multipart/alternative")
2850        && let Some(boundary) = content_type_boundary(content_type)
2851    {
2852        let (html, text) = parse_multipart_alternative(body, &boundary);
2853        return (html, text, Vec::new());
2854    }
2855
2856    if lower.contains("text/html") {
2857        (Some(trim_body(body)), None, Vec::new())
2858    } else {
2859        (None, Some(trim_body(body)), Vec::new())
2860    }
2861}
2862
2863/// Parses a `multipart/mixed` body: the first non-attachment part is
2864/// recursed into for html/text (it is itself typically a nested
2865/// `multipart/alternative`), and every part with an `attachment`
2866/// `Content-Disposition` is collected into the returned attachment list.
2867fn parse_multipart_mixed(
2868    body: &str,
2869    boundary: &str,
2870) -> (Option<String>, Option<String>, Vec<ParsedAttachment>) {
2871    let marker = format!("--{boundary}");
2872    let mut html = None;
2873    let mut text = None;
2874    let mut attachments = Vec::new();
2875
2876    for segment in body.split(&marker).skip(1) {
2877        let segment = segment.trim_start_matches(['\n', '\r']);
2878        if segment.starts_with("--") {
2879            break;
2880        }
2881        let (headers, part_body) = split_headers_body(segment);
2882        let disposition = header_value(&headers, "Content-Disposition").unwrap_or_default();
2883        let part_content_type = header_value(&headers, "Content-Type").unwrap_or_default();
2884        let disposition_type = split_mime_params(&disposition)
2885            .first()
2886            .copied()
2887            .unwrap_or("");
2888        if disposition_type.eq_ignore_ascii_case("attachment") {
2889            attachments.push(ParsedAttachment {
2890                filename: extract_attachment_filename(&disposition),
2891                content_type: content_type_without_params(&part_content_type),
2892            });
2893        } else {
2894            let (nested_html, nested_text, _) = parse_mail_body(&part_content_type, part_body);
2895            html = html.or(nested_html);
2896            text = text.or(nested_text);
2897        }
2898    }
2899
2900    (html, text, attachments)
2901}
2902
2903/// Splits a `Content-Disposition`/`Content-Type` parameter list on `;`,
2904/// respecting RFC 2045 quoted-string boundaries so a value like
2905/// `filename="a;b.txt"` is not mistaken for two parameters.
2906fn split_mime_params(value: &str) -> Vec<&str> {
2907    let mut parts = Vec::new();
2908    let mut start = 0;
2909    let mut in_quotes = false;
2910    let mut escaped = false;
2911    for (i, ch) in value.char_indices() {
2912        if escaped {
2913            escaped = false;
2914            continue;
2915        }
2916        match ch {
2917            '\\' if in_quotes => escaped = true,
2918            '"' => in_quotes = !in_quotes,
2919            ';' if !in_quotes => {
2920                parts.push(value[start..i].trim());
2921                start = i + ch.len_utf8();
2922            }
2923            _ => {}
2924        }
2925    }
2926    parts.push(value[start..].trim());
2927    parts
2928}
2929
2930/// Reverses RFC 2045 quoted-string escaping (`\\` → `\`, `\"` → `"`), the
2931/// inverse of [`quote_header_value`].
2932fn unescape_quoted_string(value: &str) -> String {
2933    let mut result = String::with_capacity(value.len());
2934    let mut chars = value.chars();
2935    while let Some(ch) = chars.next() {
2936        if ch == '\\'
2937            && let Some(next) = chars.next()
2938        {
2939            result.push(next);
2940        } else {
2941            result.push(ch);
2942        }
2943    }
2944    result
2945}
2946
2947/// Extracts a filename from a `Content-Disposition: attachment; …` header
2948/// value, preferring the RFC 2231 extended `filename*=charset'lang'…`
2949/// parameter (percent-decoded, case-insensitive charset/param name, any
2950/// language tag) over the plain `filename="…"` fallback when both are
2951/// present.
2952fn extract_attachment_filename(disposition: &str) -> String {
2953    let params = split_mime_params(disposition);
2954    if let Some(value) = params.iter().skip(1).find_map(|part| {
2955        let (key, val) = part.split_once('=')?;
2956        key.trim().eq_ignore_ascii_case("filename*").then_some(val)
2957    }) {
2958        let encoded = value.splitn(3, '\'').nth(2).unwrap_or(value);
2959        return percent_encoding::percent_decode_str(encoded)
2960            .decode_utf8_lossy()
2961            .into_owned();
2962    }
2963    if let Some(value) = params.iter().skip(1).find_map(|part| {
2964        let (key, val) = part.split_once('=')?;
2965        key.trim()
2966            .eq_ignore_ascii_case("filename")
2967            .then_some(val.trim())
2968    }) {
2969        if let Some(inner) = value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
2970            return unescape_quoted_string(inner);
2971        }
2972        return value.to_owned();
2973    }
2974    "attachment".to_owned()
2975}
2976
2977fn content_type_without_params(content_type: &str) -> String {
2978    content_type
2979        .split(';')
2980        .next()
2981        .unwrap_or(content_type)
2982        .trim()
2983        .to_owned()
2984}
2985
2986fn parse_multipart_alternative(body: &str, boundary: &str) -> (Option<String>, Option<String>) {
2987    let marker = format!("--{boundary}");
2988    let mut html = None;
2989    let mut text = None;
2990
2991    for segment in body.split(&marker).skip(1) {
2992        let segment = segment.trim_start_matches(['\n', '\r']);
2993        if segment.starts_with("--") {
2994            break;
2995        }
2996        let (headers, part_body) = split_headers_body(segment);
2997        let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
2998        if content_type.to_ascii_lowercase().contains("text/html") {
2999            html = Some(trim_body(part_body));
3000        } else if content_type.to_ascii_lowercase().contains("text/plain") {
3001            text = Some(trim_body(part_body));
3002        }
3003    }
3004
3005    (html, text)
3006}
3007
3008fn content_type_boundary(content_type: &str) -> Option<String> {
3009    content_type.split(';').find_map(|part| {
3010        let part = part.trim();
3011        let (name, value) = part.split_once('=')?;
3012        if !name.trim().eq_ignore_ascii_case("boundary") {
3013            return None;
3014        }
3015        Some(value.trim().trim_matches('"').to_owned())
3016    })
3017}
3018
3019fn trim_body(body: &str) -> String {
3020    body.trim_matches(['\r', '\n']).to_owned()
3021}
3022
3023fn format_system_time(time: SystemTime) -> String {
3024    let datetime: chrono::DateTime<chrono::Utc> = time.into();
3025    datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3026}
3027
3028fn render_mail_index(
3029    messages: &[CapturedMailSummary],
3030    previews: &[MailPreview],
3031    file_dir: &Path,
3032) -> String {
3033    let mut body = String::new();
3034    body.push_str("<h1>Autumn Mail</h1>");
3035    body.push_str("<section><h2>Captured messages</h2>");
3036    if messages.is_empty() {
3037        body.push_str("<p class=\"empty\">No captured emails yet. Set <code>mail.transport = &quot;file&quot;</code>, send an email, then refresh this page. Autumn reads <code>");
3038        body.push_str(&escape_html(&file_dir.display().to_string()));
3039        body.push_str("</code>.</p>");
3040    } else {
3041        body.push_str(
3042            "<table><thead><tr><th>Timestamp</th><th>To</th><th>Subject</th></tr></thead><tbody>",
3043        );
3044        for message in messages {
3045            body.push_str("<tr><td>");
3046            body.push_str(&escape_html(&message.timestamp));
3047            body.push_str("</td><td>");
3048            body.push_str(&escape_html(&message.to.join(", ")));
3049            body.push_str("</td><td><a href=\"");
3050            body.push_str(MAIL_PREVIEW_PATH);
3051            body.push_str("/messages/");
3052            body.push_str(&escape_html(&message.id));
3053            body.push_str("\">");
3054            body.push_str(&escape_html(&message.subject));
3055            body.push_str("</a></td></tr>");
3056        }
3057        body.push_str("</tbody></table>");
3058    }
3059    body.push_str("</section><section><h2>Template previews</h2>");
3060    if previews.is_empty() {
3061        body.push_str("<p class=\"empty\">No mailer previews registered.</p>");
3062    } else {
3063        body.push_str("<table><thead><tr><th>Mailer</th><th>Preview</th></tr></thead><tbody>");
3064        for preview in previews {
3065            body.push_str("<tr><td>");
3066            body.push_str(&escape_html(preview.mailer()));
3067            body.push_str("</td><td><a href=\"");
3068            body.push_str(MAIL_PREVIEW_PATH);
3069            body.push_str("/previews/");
3070            body.push_str(&escape_html(preview.mailer()));
3071            body.push('/');
3072            body.push_str(&escape_html(preview.method()));
3073            body.push_str("\">");
3074            body.push_str(&escape_html(preview.method()));
3075            body.push_str("</a></td></tr>");
3076        }
3077        body.push_str("</tbody></table>");
3078    }
3079    body.push_str("</section>");
3080    render_mail_preview_layout("Autumn Mail", &body)
3081}
3082
3083fn render_mail_detail(parsed: &ParsedMail, label: &str) -> String {
3084    let mut body = String::new();
3085    body.push_str("<p><a href=\"");
3086    body.push_str(MAIL_PREVIEW_PATH);
3087    body.push_str("\">Back to mail</a></p><h1>");
3088    body.push_str(&escape_html(&parsed.subject));
3089    body.push_str("</h1><p class=\"muted\">");
3090    body.push_str(&escape_html(label));
3091    body.push_str("</p>");
3092
3093    if let Some(html) = &parsed.html {
3094        body.push_str("<iframe title=\"Rendered HTML email\" sandbox srcdoc=\"");
3095        body.push_str(&escape_html(html));
3096        body.push_str("\"></iframe>");
3097    } else {
3098        body.push_str("<p class=\"empty\">No HTML body was found for this email.</p>");
3099    }
3100
3101    body.push_str("<details><summary>Plain text</summary><pre>");
3102    body.push_str(&escape_html(parsed.text.as_deref().unwrap_or("")));
3103    body.push_str("</pre></details>");
3104
3105    if !parsed.attachments.is_empty() {
3106        body.push_str("<details open><summary>Attachments (");
3107        body.push_str(&parsed.attachments.len().to_string());
3108        body.push_str(")</summary><ul>");
3109        for attachment in &parsed.attachments {
3110            body.push_str("<li>");
3111            body.push_str(&escape_html(&attachment.filename));
3112            body.push_str(" <span class=\"muted\">(");
3113            body.push_str(&escape_html(&attachment.content_type));
3114            body.push_str(")</span></li>");
3115        }
3116        body.push_str("</ul></details>");
3117    }
3118
3119    body.push_str("<details><summary>Headers</summary><dl>");
3120    for header in [
3121        "From",
3122        "To",
3123        "Reply-To",
3124        "Subject",
3125        "Date",
3126        "Message-Id",
3127        "List-Unsubscribe",
3128        "List-Unsubscribe-Post",
3129    ] {
3130        if let Some(value) = parsed.header_value(header) {
3131            body.push_str("<dt>");
3132            body.push_str(header);
3133            body.push_str("</dt><dd>");
3134            body.push_str(&escape_html(value));
3135            body.push_str("</dd>");
3136        }
3137    }
3138    body.push_str("</dl></details>");
3139
3140    body.push_str("<details><summary>Raw .eml</summary><pre>");
3141    body.push_str(&escape_html(&parsed.raw));
3142    body.push_str("</pre></details>");
3143
3144    render_mail_preview_layout(&parsed.subject, &body)
3145}
3146
3147fn render_mail_preview_layout(title: &str, body: &str) -> String {
3148    format!(
3149        "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>{}</style></head><body>{}</body></html>",
3150        escape_html(title),
3151        MAIL_PREVIEW_CSS,
3152        body
3153    )
3154}
3155
3156const MAIL_PREVIEW_CSS: &str = r#"
3157body{margin:0;padding:24px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2933;background:#f6f8fa}
3158h1{margin:0 0 16px;font-size:28px}
3159h2{margin:28px 0 12px;font-size:18px}
3160table{width:100%;border-collapse:collapse;background:white;border:1px solid #d9e2ec}
3161th,td{padding:10px 12px;border-bottom:1px solid #e5eaf0;text-align:left;font-size:14px;vertical-align:top}
3162th{background:#edf2f7;color:#394b59;font-weight:650}
3163a{color:#0b63ce;text-decoration:none}
3164a:hover{text-decoration:underline}
3165.empty,.muted{color:#52616f}
3166code,pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
3167pre{white-space:pre-wrap;background:#111827;color:#f8fafc;padding:12px;overflow:auto}
3168iframe{width:100%;min-height:420px;border:1px solid #cbd5e1;background:white}
3169details{margin-top:14px;background:white;border:1px solid #d9e2ec;padding:10px 12px}
3170summary{cursor:pointer;font-weight:650}
3171dt{font-weight:650;margin-top:8px}
3172dd{margin:2px 0 8px}
3173"#;
3174
3175fn html_response(html: String) -> Response {
3176    Html(html).into_response()
3177}
3178
3179fn preview_error_response(error: &MailPreviewError) -> Response {
3180    let status = match error {
3181        MailPreviewError::NotFound(_) | MailPreviewError::InvalidMessageId(_) => {
3182            http::StatusCode::NOT_FOUND
3183        }
3184        MailPreviewError::Io(_) | MailPreviewError::PreviewPanicked { .. } => {
3185            http::StatusCode::INTERNAL_SERVER_ERROR
3186        }
3187    };
3188    (
3189        status,
3190        Html(render_mail_preview_layout(
3191            "Mail preview error",
3192            &format!(
3193                "<h1>Mail preview error</h1><p>{}</p>",
3194                escape_html(&error.to_string())
3195            ),
3196        )),
3197    )
3198        .into_response()
3199}
3200
3201fn escape_html(value: &str) -> String {
3202    let mut escaped = String::with_capacity(value.len());
3203    for ch in value.chars() {
3204        match ch {
3205            '&' => escaped.push_str("&amp;"),
3206            '<' => escaped.push_str("&lt;"),
3207            '>' => escaped.push_str("&gt;"),
3208            '"' => escaped.push_str("&quot;"),
3209            '\'' => escaped.push_str("&#39;"),
3210            _ => escaped.push(ch),
3211        }
3212    }
3213    escaped
3214}
3215
3216fn parse_mailbox(address: &str) -> Result<Mailbox, MailError> {
3217    address.parse().map_err(|source| MailError::InvalidAddress {
3218        address: address.to_owned(),
3219        source,
3220    })
3221}
3222
3223/// Canonical, case-insensitive bare address used as the suppression / token key.
3224///
3225/// Strips any display name (`Ada <ada@example.com>` → `ada@example.com`) and
3226/// lowercases, so an opt-out matches future sends regardless of formatting.
3227/// Falls back to the trimmed, lowercased input when the address cannot be parsed.
3228fn canonical_subscriber(recipient: &str) -> String {
3229    parse_mailbox(recipient).map_or_else(
3230        |_| recipient.trim().to_ascii_lowercase(),
3231        |mailbox| mailbox.email.to_string().to_ascii_lowercase(),
3232    )
3233}
3234
3235/// The html/text body of a message, before any attachment wrapping is
3236/// decided. Kept as an enum so the attachment-less code path can hand a
3237/// `SinglePart` straight to `Message::builder().singlepart(...)` exactly as
3238/// it did before attachments existed — a `MultiPart::mixed()` wrapper is
3239/// only introduced when there is at least one attachment.
3240enum MailBodyPart {
3241    Single(SinglePart),
3242    Multi(MultiPart),
3243}
3244
3245fn lettre_body_part(mail: &Mail) -> Result<MailBodyPart, MailError> {
3246    match (&mail.text, &mail.html) {
3247        (Some(text), Some(html)) => Ok(MailBodyPart::Multi(
3248            MultiPart::alternative()
3249                .singlepart(SinglePart::plain(text.clone()))
3250                .singlepart(SinglePart::html(html.clone())),
3251        )),
3252        (Some(text), None) => Ok(MailBodyPart::Single(SinglePart::plain(text.clone()))),
3253        (None, Some(html)) => Ok(MailBodyPart::Single(SinglePart::html(html.clone()))),
3254        (None, None) => Err(MailError::InvalidMessage(
3255            "mail must include html or text body".to_owned(),
3256        )),
3257    }
3258}
3259
3260fn lettre_attachment_part(attachment: &MailAttachment) -> Result<SinglePart, MailError> {
3261    let content_type = ContentType::parse(&attachment.content_type).map_err(|error| {
3262        MailError::InvalidMessage(format!(
3263            "attachment {:?} has invalid content type {:?}: {error}",
3264            attachment.filename, attachment.content_type
3265        ))
3266    })?;
3267    // Force base64 regardless of content: lettre's automatic encoder picks
3268    // `7bit` for short ASCII byte buffers, but attachments must always carry
3269    // a `base64` Content-Transfer-Encoding per the framework's contract.
3270    #[allow(
3271        clippy::expect_used,
3272        reason = "infallible: base64 encoding is always valid for any byte buffer"
3273    )]
3274    let body =
3275        LettreBody::new_with_encoding(attachment.bytes.clone(), ContentTransferEncoding::Base64)
3276            .expect("base64 encoding is always valid for any byte buffer");
3277    Ok(LettreAttachment::new(attachment.filename.clone()).body(body, content_type))
3278}
3279
3280fn lettre_message(mail: &Mail) -> Result<Message, MailError> {
3281    let from = mail
3282        .from
3283        .as_deref()
3284        .ok_or_else(|| MailError::InvalidMessage("mail from address is required".to_owned()))?;
3285    let mut builder = Message::builder().from(parse_mailbox(from)?);
3286    for to in &mail.to {
3287        builder = builder.to(parse_mailbox(to)?);
3288    }
3289    if let Some(reply_to) = &mail.reply_to {
3290        builder = builder.reply_to(parse_mailbox(reply_to)?);
3291    }
3292    builder = builder.subject(mail.subject.clone());
3293
3294    for (name, value) in &mail.extra_headers {
3295        use lettre::message::header::{HeaderName, HeaderValue};
3296        match HeaderName::new_from_ascii(name.clone()) {
3297            Ok(header_name) => {
3298                builder = builder.raw_header(HeaderValue::new(header_name, value.clone()));
3299            }
3300            Err(error) => {
3301                tracing::warn!(
3302                    header_name = %name,
3303                    error = %error,
3304                    "skipping mail header with invalid name"
3305                );
3306            }
3307        }
3308    }
3309
3310    let body_part = lettre_body_part(mail)?;
3311
3312    if mail.attachments.is_empty() {
3313        return Ok(match body_part {
3314            MailBodyPart::Multi(multi) => builder.multipart(multi)?,
3315            MailBodyPart::Single(single) => builder.singlepart(single)?,
3316        });
3317    }
3318
3319    let mut mixed = match body_part {
3320        MailBodyPart::Multi(multi) => MultiPart::mixed().multipart(multi),
3321        MailBodyPart::Single(single) => MultiPart::mixed().singlepart(single),
3322    };
3323    for attachment in &mail.attachments {
3324        mixed = mixed.singlepart(lettre_attachment_part(attachment)?);
3325    }
3326    Ok(builder.multipart(mixed)?)
3327}
3328
3329struct InterceptedMailTransport {
3330    inner: Arc<dyn MailTransport>,
3331    interceptor: Arc<dyn crate::interceptor::MailInterceptor>,
3332}
3333
3334impl MailTransport for InterceptedMailTransport {
3335    fn send<'a>(
3336        &'a self,
3337        mail: Mail,
3338    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3339        Box::pin(async move {
3340            let inner = Arc::clone(&self.inner);
3341            let mail_for_next = mail.clone();
3342            let next = Box::pin(async move { inner.send(mail_for_next).await });
3343            self.interceptor.intercept(&mail, next).await
3344        })
3345    }
3346
3347    fn is_disabled(&self) -> bool {
3348        self.inner.is_disabled()
3349    }
3350}
3351
3352/// Install the configured mailer into app state.
3353///
3354/// Picks up a runtime-installed [`MailDeliveryQueueHandle`] from
3355/// [`AppState`] extensions when present, so plugins (Harvest, Redis-backed,
3356/// etc.) can register durable delivery before this runs. In `prod` with a
3357/// non-`Disabled` transport, startup fails when neither a durable queue nor
3358/// [`MailConfig::allow_in_process_deliver_later_in_production`] is set, unless
3359/// `enforce_durable_guard` is `false` (used by short-lived contexts like
3360/// static-site builds where `deliver_later` semantics don't apply).
3361///
3362/// # Errors
3363///
3364/// Returns an Autumn error when the configured transport cannot be created or
3365/// when the production `deliver_later` guard is not satisfied.
3366#[allow(clippy::too_many_lines)]
3367pub(crate) fn install_mailer(
3368    state: &AppState,
3369    config: &MailConfig,
3370    enforce_durable_guard: bool,
3371) -> AutumnResult<()> {
3372    let resilience = state
3373        .extension::<crate::config::AutumnConfig>()
3374        .map(|c| Arc::new(c.resilience.clone()));
3375    let mut mailer =
3376        Mailer::from_config_inner(config, resilience).map_err(AutumnError::service_unavailable)?;
3377
3378    if let Some(interceptor) = state.extension::<Arc<dyn crate::interceptor::MailInterceptor>>() {
3379        mailer.transport = Arc::new(InterceptedMailTransport {
3380            inner: Arc::clone(&mailer.transport),
3381            interceptor: (*interceptor).clone(),
3382        });
3383    }
3384
3385    let in_production = matches!(state.profile(), "prod" | "production");
3386    let transport_sends_mail = config.transport != Transport::Disabled;
3387
3388    // Honor the disabled transport contract: if the operator turned mail off
3389    // for this profile (tests, review apps, etc.), `deliver_later` must also
3390    // be a no-op — even when a durable queue was registered globally.
3391    if transport_sends_mail {
3392        let queue_handle = state.extension::<MailDeliveryQueueHandle>();
3393        if let Some(handle) = queue_handle.as_ref() {
3394            mailer.delivery_queue = Some(Arc::clone(handle.inner()));
3395        }
3396    }
3397
3398    if enforce_durable_guard && in_production && transport_sends_mail {
3399        let has_durable_queue = mailer.delivery_queue.is_some();
3400        if !has_durable_queue && !config.allow_in_process_deliver_later_in_production {
3401            return Err(AutumnError::service_unavailable_msg(
3402                "mail.deliver_later has no durable backend in prod: register a MailDeliveryQueueHandle on AppState or set mail.allow_in_process_deliver_later_in_production = true to opt into the in-process Tokio fallback",
3403            ));
3404        }
3405        if !has_durable_queue {
3406            tracing::warn!(
3407                "mail.deliver_later is using the in-process Tokio fallback in prod; this is acknowledged via mail.allow_in_process_deliver_later_in_production but is not durable across restarts or replicas"
3408            );
3409        }
3410    }
3411
3412    // ── List-Unsubscribe wiring ──────────────────────────────────────────────
3413    let base_url = config
3414        .unsubscribe_base_url
3415        .as_deref()
3416        .map(str::trim)
3417        .filter(|s| !s.is_empty());
3418    let mailto = config
3419        .unsubscribe_mailto
3420        .as_deref()
3421        .map(str::trim)
3422        .filter(|s| !s.is_empty());
3423    let unsubscribe_configured = base_url.is_some() || mailto.is_some();
3424
3425    // Resolve the suppression backend: an explicitly registered handle wins;
3426    // otherwise auto-wire a Diesel-backed store when a DB pool is available.
3427    let suppression: Option<Arc<dyn SuppressionStore>> = {
3428        let explicit = state
3429            .extension::<SuppressionStoreHandle>()
3430            .map(|handle| Arc::clone(handle.inner()));
3431        #[cfg(feature = "db")]
3432        let resolved = explicit.or_else(|| {
3433            state
3434                .pool()
3435                .map(|pool| Arc::new(db_suppression::DbSuppressionStore::new(pool.clone())) as _)
3436        });
3437        #[cfg(not(feature = "db"))]
3438        let resolved = explicit;
3439        resolved
3440    };
3441
3442    // Fail closed: any mailer that declares `list_unsubscribe` needs a place to
3443    // point the unsubscribe link/mailto, or Gmail/Yahoo will reject the mail.
3444    // Skipped when the transport is disabled — no list mail is emitted, so the
3445    // disabled-transport contract (review apps, tests) can boot without it.
3446    if transport_sends_mail
3447        && unsubscribe_config_fail_closed(
3448            enforce_durable_guard,
3449            in_production,
3450            has_list_unsubscribe_mailers(),
3451            unsubscribe_configured,
3452        )
3453    {
3454        return Err(AutumnError::service_unavailable_msg(
3455            "a #[mailer] declares list_unsubscribe but neither mail.unsubscribe_base_url nor mail.unsubscribe_mailto is configured: set at least one so RFC 8058 List-Unsubscribe headers can be emitted",
3456        ));
3457    }
3458
3459    // Fail closed: when we will actually emit one-click links (active transport,
3460    // a list mailer, and a base URL), the endpoint must be able to record
3461    // opt-outs — otherwise a successful unsubscribe POST is a silent no-op.
3462    if enforce_durable_guard
3463        && in_production
3464        && transport_sends_mail
3465        && has_list_unsubscribe_mailers()
3466        && base_url.is_some()
3467        && suppression.is_none()
3468    {
3469        return Err(AutumnError::service_unavailable_msg(
3470            "mail.unsubscribe_base_url is set but no suppression backend is available: configure a database pool or register a SuppressionStore so one-click unsubscribes can be persisted",
3471        ));
3472    }
3473
3474    // Warn (don't fail — a custom route is a valid choice) when one-click links
3475    // will be advertised but the built-in endpoint is not opted in. We can't see
3476    // app-registered routes here, so this is a heads-up, not a hard gate.
3477    if in_production
3478        && transport_sends_mail
3479        && has_list_unsubscribe_mailers()
3480        && base_url.is_some()
3481        && !config.mount_unsubscribe_endpoint
3482    {
3483        tracing::warn!(
3484            target: "mail",
3485            path = UNSUBSCRIBE_PATH,
3486            "list mail will advertise one-click unsubscribe URLs but the default endpoint is not mounted; call AppBuilder::mount_unsubscribe_endpoint() or serve the path yourself"
3487        );
3488    }
3489
3490    if unsubscribe_configured || suppression.is_some() {
3491        let signing_keys = Arc::new(crate::security::config::resolve_signing_keys(
3492            &state
3493                .extension::<crate::config::AutumnConfig>()
3494                .map(|c| c.security.signing_secret.clone())
3495                .unwrap_or_default(),
3496        ));
3497        let ttl_days = config.unsubscribe_token_ttl_days;
3498        let make_runtime = || UnsubscribeRuntime {
3499            base_url: base_url.map(str::to_owned),
3500            mailto: mailto.map(str::to_owned),
3501            signing_keys: Arc::clone(&signing_keys),
3502            ttl_days,
3503            suppression: suppression.clone(),
3504        };
3505        // Always share the wiring with the endpoint handler (mounted whenever an
3506        // unsubscribe destination is configured, independent of transport) so a
3507        // live unsubscribe link never 404s. Only the *sender* skips when the
3508        // transport is intentionally a no-op.
3509        state.insert_extension(make_runtime());
3510        if transport_sends_mail {
3511            mailer.unsubscribe = Some(Arc::new(make_runtime()));
3512        }
3513    }
3514
3515    // ── Bounce/complaint suppression wiring (issue #1247) ────────────────────
3516    // Zero-config: default to an in-memory store so the detect→suppress loop
3517    // works out of the box on a single instance. An explicitly registered
3518    // handle (e.g. a Postgres-backed `PgSuppressionStore` via
3519    // `AppBuilder::with_mail_suppression_store`) wins. Unlike List-Unsubscribe
3520    // suppression, no db-backed store is auto-wired: `send()` consults this on
3521    // *every* message, so silently pointing it at a table that may not exist
3522    // would break all outbound mail — durable backends are opt-in.
3523    //
3524    // The resolved handle is registered on `AppState` so inbound bounce/complaint
3525    // handlers can share the exact store the `Mailer` consults.
3526    if transport_sends_mail {
3527        let handle = state
3528            .extension::<suppression::SuppressionStoreHandle>()
3529            .map_or_else(
3530                || {
3531                    let handle = suppression::SuppressionStoreHandle::new(
3532                        suppression::InMemorySuppressionStore::new(),
3533                    );
3534                    state.insert_extension(handle.clone());
3535                    handle
3536                },
3537                |arc| (*arc).clone(),
3538            );
3539        mailer.suppression = Some(Arc::clone(handle.inner()));
3540    }
3541
3542    state.insert_extension(mailer);
3543    Ok(())
3544}
3545
3546/// Run the optional [`MailDeliveryQueue`] factory and install the configured
3547/// mailer.
3548///
3549/// Centralizes the wiring used by every [`AppBuilder`](crate::app::AppBuilder)
3550/// build path: optionally invoke `queue_factory` against the live `AppState`,
3551/// register the resulting [`MailDeliveryQueueHandle`], then call
3552/// [`install_mailer`]. The factory is skipped entirely when
3553/// `enforce_durable_guard` is `false` (static-site builds), since the queue
3554/// may capture infrastructure (Redis, Harvest, etc.) that isn't available in
3555/// the asset-build environment.
3556///
3557/// # Errors
3558///
3559/// Propagates errors from the queue factory and from [`install_mailer`].
3560pub(crate) fn install_mailer_with_factory<F>(
3561    state: &AppState,
3562    config: &MailConfig,
3563    queue_factory: Option<F>,
3564    enforce_durable_guard: bool,
3565) -> AutumnResult<()>
3566where
3567    F: FnOnce(&AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>,
3568{
3569    // Honor the disabled transport contract: a profile that turned mail off
3570    // (tests, review apps, etc.) must not open queue infrastructure either,
3571    // since all sends — immediate and deferred — are supposed to be no-ops.
3572    let transport_sends_mail = config.transport != Transport::Disabled;
3573    if enforce_durable_guard
3574        && transport_sends_mail
3575        && let Some(factory) = queue_factory
3576    {
3577        let queue = factory(state)?;
3578        state.insert_extension(MailDeliveryQueueHandle::from_arc(queue));
3579    }
3580    install_mailer(state, config, enforce_durable_guard)
3581}
3582
3583// ── Default one-click unsubscribe endpoint ───────────────────────────────────
3584
3585#[derive(Deserialize)]
3586struct UnsubscribeParams {
3587    #[serde(default)]
3588    token: String,
3589}
3590
3591/// Router for the framework's default unsubscribe endpoint.
3592///
3593/// Mounted automatically when `mail.unsubscribe_base_url` or
3594/// `mail.unsubscribe_mailto` is configured, unless the app registers its own
3595/// route at [`UNSUBSCRIBE_PATH`] (the documented override hook). Requires no
3596/// end-user auth; the global rate-limit layer applies.
3597pub(crate) fn unsubscribe_router() -> axum::Router<AppState> {
3598    axum::Router::new().route(
3599        UNSUBSCRIBE_PATH,
3600        axum::routing::get(unsubscribe_get_handler).post(unsubscribe_post_handler),
3601    )
3602}
3603
3604/// RFC 8058 one-click POST: verify the token and record the suppression.
3605async fn unsubscribe_post_handler(
3606    axum::extract::State(state): axum::extract::State<AppState>,
3607    axum::extract::Query(params): axum::extract::Query<UnsubscribeParams>,
3608    body: String,
3609) -> Response {
3610    // RFC 8058 §3.1: the one-click POST carries `List-Unsubscribe=One-Click`.
3611    // Requiring it avoids recording opt-outs from arbitrary POSTs to the URL
3612    // (e.g. link scanners that don't send the body).
3613    if !is_one_click_body(&body) {
3614        return (
3615            axum::http::StatusCode::BAD_REQUEST,
3616            "expected List-Unsubscribe=One-Click body",
3617        )
3618            .into_response();
3619    }
3620    let Some(runtime) = state.extension::<UnsubscribeRuntime>() else {
3621        return (
3622            axum::http::StatusCode::NOT_FOUND,
3623            "unsubscribe is not configured",
3624        )
3625            .into_response();
3626    };
3627    match unsubscribe::verify_token(&runtime.signing_keys, &params.token, current_unix_time()) {
3628        Ok(decoded) => {
3629            let Some(store) = runtime.suppression.as_ref() else {
3630                // No backend to record the opt-out — never confirm an unsubscribe
3631                // we cannot actually honor.
3632                tracing::error!(
3633                    target: "mail",
3634                    "unsubscribe POST received but no suppression backend is configured"
3635                );
3636                return (
3637                    axum::http::StatusCode::SERVICE_UNAVAILABLE,
3638                    "unsubscribe storage is not configured",
3639                )
3640                    .into_response();
3641            };
3642            if let Err(error) = store.suppress(&decoded.subscriber, &decoded.list_id).await {
3643                tracing::error!(error = %error, "failed to record unsubscribe suppression");
3644                return (
3645                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
3646                    "could not process unsubscribe",
3647                )
3648                    .into_response();
3649            }
3650            tracing::info!(
3651                target: "mail",
3652                list_id = %decoded.list_id,
3653                outcome = "unsubscribed",
3654                "recorded one-click unsubscribe"
3655            );
3656            (
3657                axum::http::StatusCode::OK,
3658                Html(unsubscribe_confirmation_html(&decoded.list_id)),
3659            )
3660                .into_response()
3661        }
3662        Err(error) => (
3663            axum::http::StatusCode::BAD_REQUEST,
3664            Html(unsubscribe_error_html(&error.to_string())),
3665        )
3666            .into_response(),
3667    }
3668}
3669
3670/// Whether a urlencoded body contains `List-Unsubscribe=One-Click` (RFC 8058).
3671fn is_one_click_body(body: &str) -> bool {
3672    body.split('&').any(|pair| {
3673        let mut kv = pair.splitn(2, '=');
3674        let key = kv.next().unwrap_or("");
3675        let value = kv.next().unwrap_or("");
3676        key.eq_ignore_ascii_case("List-Unsubscribe") && value.eq_ignore_ascii_case("One-Click")
3677    })
3678}
3679
3680/// Click-through GET: render a minimal confirmation page with a one-click form.
3681async fn unsubscribe_get_handler(
3682    axum::extract::State(state): axum::extract::State<AppState>,
3683    axum::extract::Query(params): axum::extract::Query<UnsubscribeParams>,
3684) -> Response {
3685    let Some(runtime) = state.extension::<UnsubscribeRuntime>() else {
3686        return (
3687            axum::http::StatusCode::NOT_FOUND,
3688            "unsubscribe is not configured",
3689        )
3690            .into_response();
3691    };
3692    match unsubscribe::verify_token(&runtime.signing_keys, &params.token, current_unix_time()) {
3693        Ok(decoded) => Html(unsubscribe_form_html(&decoded.list_id, &params.token)).into_response(),
3694        Err(error) => (
3695            axum::http::StatusCode::BAD_REQUEST,
3696            Html(unsubscribe_error_html(&error.to_string())),
3697        )
3698            .into_response(),
3699    }
3700}
3701
3702fn unsubscribe_form_html(list_id: &str, token: &str) -> String {
3703    // Relative action (`?token=…`) posts back to the current URL, preserving any
3704    // base-path prefix added by a reverse proxy.
3705    format!(
3706        "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribe</title></head>\
3707         <body><h1>Unsubscribe</h1>\
3708         <p>Stop receiving <strong>{}</strong> emails?</p>\
3709         <form method=\"post\" action=\"?token={}\">\
3710         <input type=\"hidden\" name=\"List-Unsubscribe\" value=\"One-Click\">\
3711         <button type=\"submit\">Unsubscribe</button></form></body></html>",
3712        escape_html(list_id),
3713        escape_html(token),
3714    )
3715}
3716
3717fn unsubscribe_confirmation_html(list_id: &str) -> String {
3718    format!(
3719        "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribed</title></head>\
3720         <body><h1>You're unsubscribed</h1>\
3721         <p>You will no longer receive <strong>{}</strong> emails.</p></body></html>",
3722        escape_html(list_id),
3723    )
3724}
3725
3726fn unsubscribe_error_html(detail: &str) -> String {
3727    format!(
3728        "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribe</title></head>\
3729         <body><h1>Unsubscribe link is not valid</h1><p>{}</p></body></html>",
3730        escape_html(detail),
3731    )
3732}
3733
3734/// Bounce/complaint mail suppression list (issue #1247).
3735///
3736/// Autumn already *detects* delivery failure: `inbound_mail` parses provider
3737/// bounce signals and spam complaints. This module closes the loop — it records
3738/// the addresses that hard-bounced or complained and has [`Mailer::send`] skip
3739/// them before transport, so a sending domain's reputation survives contact
3740/// with real recipients.
3741///
3742/// This is distinct from the recipient-initiated List-Unsubscribe suppression
3743/// in [`crate::mail::unsubscribe`] (issue #838): that keys on
3744/// `(subscriber, list_id)` and is driven by a user clicking "unsubscribe";
3745/// this keys on a bare address and is driven by a *provider-reported* failure.
3746///
3747/// # Backends
3748///
3749/// [`InMemorySuppressionStore`] is the zero-config default (process-local,
3750/// lost on restart — perfect for a single instance, tests, and review apps).
3751/// [`PgSuppressionStore`](suppression::PgSuppressionStore) (feature `db`) persists to a `mail_suppressions`
3752/// table for multi-instance deploys, mirroring the memory/durable split used
3753/// by sessions and jobs. That table is **not** auto-created — provision it
3754/// yourself (see [`PgSuppressionStore`](suppression::PgSuppressionStore)).
3755///
3756/// # Closing the loop
3757///
3758/// Wire the provided `record_inbound` handler into the inbound router's
3759/// `on_bounce` hook (or call [`SuppressionStore::suppress`] yourself) to turn a
3760/// parsed provider bounce into a suppression entry. autumn's `on_spam` signal
3761/// is an *inbound spam verdict*, not an outbound FBL complaint — see
3762/// `record_inbound` for why routing it here is a safe no-op rather than
3763/// suppressing the wrong address.
3764pub mod suppression {
3765    use std::future::Future;
3766    use std::pin::Pin;
3767    use std::sync::Arc;
3768    use std::sync::atomic::{AtomicU64, Ordering};
3769
3770    use super::{MailError, canonical_subscriber};
3771
3772    /// Why an address is on the suppression list.
3773    #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
3774    #[serde(rename_all = "snake_case")]
3775    pub enum SuppressionReason {
3776        /// A permanent delivery failure (5xx SMTP / DSN hard bounce).
3777        HardBounce,
3778        /// A spam complaint / feedback-loop (FBL) report.
3779        Complaint,
3780        /// Added by an operator, not by a provider signal.
3781        Manual,
3782    }
3783
3784    impl SuppressionReason {
3785        /// Stable lowercase token used in storage rows and log lines.
3786        #[must_use]
3787        pub const fn as_str(self) -> &'static str {
3788            match self {
3789                Self::HardBounce => "hard_bounce",
3790                Self::Complaint => "complaint",
3791                Self::Manual => "manual",
3792            }
3793        }
3794    }
3795
3796    impl std::fmt::Display for SuppressionReason {
3797        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3798            f.write_str(self.as_str())
3799        }
3800    }
3801
3802    /// Persistent set of addresses that must not receive mail because they
3803    /// hard-bounced or filed a spam complaint.
3804    ///
3805    /// All three methods canonicalize the address (strip any display name and
3806    /// lowercase) so a suppression recorded as `Bounced@X.com` matches a later
3807    /// send to `Ada <bounced@x.com>`.
3808    pub trait SuppressionStore: Send + Sync {
3809        /// Returns `true` when `address` must not be delivered to.
3810        fn is_suppressed<'a>(
3811            &'a self,
3812            address: &'a str,
3813        ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>>;
3814
3815        /// Record `address` on the suppression list (idempotent). A repeat call
3816        /// with a different `reason` updates the recorded reason.
3817        fn suppress<'a>(
3818            &'a self,
3819            address: &'a str,
3820            reason: SuppressionReason,
3821        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
3822
3823        /// Remove `address` from the suppression list — the manual escape hatch
3824        /// (e.g. a recipient fixed their mailbox). No-op when absent.
3825        fn unsuppress<'a>(
3826            &'a self,
3827            address: &'a str,
3828        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
3829    }
3830
3831    /// Cloneable handle to a [`SuppressionStore`] for storage on `AppState` and
3832    /// attachment to a [`Mailer`](crate::mail::Mailer).
3833    #[derive(Clone)]
3834    pub struct SuppressionStoreHandle(Arc<dyn SuppressionStore>);
3835
3836    impl SuppressionStoreHandle {
3837        /// Wrap a store implementation.
3838        #[must_use]
3839        pub fn new(store: impl SuppressionStore + 'static) -> Self {
3840            Self(Arc::new(store))
3841        }
3842
3843        /// Wrap an already-shared store implementation.
3844        #[must_use]
3845        pub fn from_arc(store: Arc<dyn SuppressionStore>) -> Self {
3846            Self(store)
3847        }
3848
3849        /// Borrow the inner store.
3850        #[must_use]
3851        pub fn inner(&self) -> &Arc<dyn SuppressionStore> {
3852            &self.0
3853        }
3854
3855        /// Consume the handle, yielding the shared store.
3856        #[must_use]
3857        pub fn into_inner(self) -> Arc<dyn SuppressionStore> {
3858            self.0
3859        }
3860    }
3861
3862    impl std::fmt::Debug for SuppressionStoreHandle {
3863        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3864            f.debug_struct("SuppressionStoreHandle")
3865                .finish_non_exhaustive()
3866        }
3867    }
3868
3869    /// In-memory [`SuppressionStore`] — the zero-config default.
3870    ///
3871    /// State is process-local and lost on restart; use [`PgSuppressionStore`]
3872    /// for multi-instance deploys that must share suppression across replicas.
3873    #[derive(Debug, Default, Clone)]
3874    pub struct InMemorySuppressionStore {
3875        entries: Arc<std::sync::Mutex<std::collections::HashMap<String, SuppressionReason>>>,
3876    }
3877
3878    impl InMemorySuppressionStore {
3879        /// Create an empty in-memory store.
3880        #[must_use]
3881        pub fn new() -> Self {
3882            Self::default()
3883        }
3884    }
3885
3886    impl SuppressionStore for InMemorySuppressionStore {
3887        fn is_suppressed<'a>(
3888            &'a self,
3889            address: &'a str,
3890        ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
3891            Box::pin(async move {
3892                let key = canonical_subscriber(address);
3893                Ok(self
3894                    .entries
3895                    .lock()
3896                    .unwrap_or_else(std::sync::PoisonError::into_inner)
3897                    .contains_key(&key))
3898            })
3899        }
3900
3901        fn suppress<'a>(
3902            &'a self,
3903            address: &'a str,
3904            reason: SuppressionReason,
3905        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3906            Box::pin(async move {
3907                let key = canonical_subscriber(address);
3908                self.entries
3909                    .lock()
3910                    .unwrap_or_else(std::sync::PoisonError::into_inner)
3911                    .insert(key, reason);
3912                Ok(())
3913            })
3914        }
3915
3916        fn unsuppress<'a>(
3917            &'a self,
3918            address: &'a str,
3919        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3920            Box::pin(async move {
3921                let key = canonical_subscriber(address);
3922                self.entries
3923                    .lock()
3924                    .unwrap_or_else(std::sync::PoisonError::into_inner)
3925                    .remove(&key);
3926                Ok(())
3927            })
3928        }
3929    }
3930
3931    // ── Observability: a suppressed drop is never truly silent ───────────────
3932    static SUPPRESSED_SKIPS: AtomicU64 = AtomicU64::new(0);
3933
3934    /// Recipients [`Mailer::send`](crate::mail::Mailer::send) has skipped as suppressed, process-wide.
3935    ///
3936    /// Counted since startup. Pair with the structured `outcome =
3937    /// "skipped_suppressed"` log line emitted per skip.
3938    #[must_use]
3939    pub fn suppressed_skips() -> u64 {
3940        SUPPRESSED_SKIPS.load(Ordering::Relaxed)
3941    }
3942
3943    /// Record and log a skip. Internal to the `send` path.
3944    pub(crate) fn note_skip(canonical_address: &str) {
3945        SUPPRESSED_SKIPS.fetch_add(1, Ordering::Relaxed);
3946        tracing::info!(
3947            target: "mail",
3948            outcome = "skipped_suppressed",
3949            address = %canonical_address,
3950            "skipping suppressed recipient (hard bounce or complaint); \
3951             pass Mail::ignore_suppression() to override for critical mail"
3952        );
3953    }
3954
3955    /// Provided inbound handler: turn a parsed provider bounce/complaint webhook
3956    /// into a suppression entry, closing the detect→suppress loop in one call.
3957    ///
3958    /// It only ever suppresses the *provider-reported failed/complaining
3959    /// address*, never `email.to` — on an inbound webhook `to` is the app's own
3960    /// inbound address, so suppressing it would let anyone who can POST to the
3961    /// endpoint knock arbitrary recipients off future sends.
3962    ///
3963    /// - A bounce (`email.is_bounce`) suppresses the provider-reported
3964    ///   [`bounced_address`](crate::inbound_mail::InboundEmail::bounced_address)
3965    ///   with [`SuppressionReason::HardBounce`]. A bounce flagged with no
3966    ///   address is logged and dropped (nothing suppressed).
3967    /// - A complaint suppresses
3968    ///   [`complained_address`](crate::inbound_mail::InboundEmail::complained_address)
3969    ///   with [`SuppressionReason::Complaint`] — populated only by parsers that
3970    ///   surface a genuine FBL complainant. autumn's built-in `on_spam` signal
3971    ///   is an *inbound spam verdict* (`X-Mailgun-Sflag`), not an outbound FBL
3972    ///   complaint, and carries no complainant address, so wiring `on_spam`
3973    ///   here is a safe no-op (logged) rather than suppressing the wrong party.
3974    ///
3975    /// Wire it into the inbound router (see the crate `suppression` module docs
3976    /// for the full shared-store example):
3977    ///
3978    /// ```rust,ignore
3979    /// InboundMailRouter::new()
3980    ///     .endpoint(InboundMailEndpointConfig::mailgun("/mail/inbound", key))
3981    ///     .on_bounce(|email| Box::pin(async move {
3982    ///         record_inbound(SUPPRESSION.get().unwrap().inner().as_ref(), &email).await?;
3983    ///         Ok(())
3984    ///     }));
3985    /// ```
3986    ///
3987    /// # Errors
3988    ///
3989    /// Propagates any [`MailError`] returned by the store while recording the
3990    /// suppression (e.g. a database backend being unavailable).
3991    #[cfg(feature = "inbound-mail")]
3992    pub async fn record_inbound(
3993        store: &dyn SuppressionStore,
3994        email: &crate::inbound_mail::InboundEmail,
3995    ) -> Result<(), MailError> {
3996        if email.is_bounce {
3997            // Only the provider-reported bounced address is the failed
3998            // recipient; `email.to` on a bounce webhook is the app's own
3999            // inbound address, so never suppress that.
4000            if let Some(addr) = email.bounced_address.as_deref() {
4001                store.suppress(addr, SuppressionReason::HardBounce).await?;
4002            } else {
4003                tracing::warn!(
4004                    target: "mail",
4005                    "inbound bounce webhook set is_bounce with no bounced_address; nothing suppressed"
4006                );
4007            }
4008            return Ok(());
4009        }
4010        // Complaint / FBL: suppress the genuine complainant only. Never fall
4011        // back to `email.to`. autumn's `on_spam` is an inbound spam verdict, not
4012        // an outbound complaint, so `complained_address` is `None` there and we
4013        // log rather than suppress the wrong address.
4014        if let Some(addr) = email.complained_address.as_deref() {
4015            store.suppress(addr, SuppressionReason::Complaint).await?;
4016        } else if email
4017            .spam_report
4018            .as_ref()
4019            .and_then(|r| r.verdict.as_deref())
4020            .is_some_and(|v| v.eq_ignore_ascii_case("yes"))
4021        {
4022            tracing::warn!(
4023                target: "mail",
4024                "inbound spam verdict carries no outbound complainant address; \
4025                 nothing suppressed (wire a real FBL/complaint source that \
4026                 populates InboundEmail::complained_address)"
4027            );
4028        }
4029        Ok(())
4030    }
4031
4032    #[cfg(feature = "db")]
4033    pub use pg::PgSuppressionStore;
4034
4035    #[cfg(feature = "db")]
4036    mod pg {
4037        use std::future::Future;
4038        use std::pin::Pin;
4039
4040        use diesel::prelude::*;
4041        use diesel_async::AsyncPgConnection;
4042        use diesel_async::RunQueryDsl;
4043        use diesel_async::pooled_connection::deadpool::Pool;
4044
4045        use super::super::canonical_subscriber;
4046        use super::{MailError, SuppressionReason, SuppressionStore};
4047
4048        diesel::table! {
4049            mail_suppressions (address) {
4050                address -> Text,
4051                reason -> Text,
4052                suppressed_at -> Timestamptz,
4053            }
4054        }
4055
4056        #[derive(Insertable)]
4057        #[diesel(table_name = mail_suppressions)]
4058        struct NewSuppression<'a> {
4059            address: &'a str,
4060            reason: &'a str,
4061        }
4062
4063        /// Postgres-backed bounce/complaint [`SuppressionStore`].
4064        ///
4065        /// Suppression is shared across every instance that points at the same
4066        /// database.
4067        ///
4068        /// # Required table (no migration is shipped)
4069        ///
4070        /// This store does **not** create or migrate its table — provision it
4071        /// yourself (same convention as the List-Unsubscribe `mail_unsubscribes`
4072        /// store). Every `send` errors on the suppression lookup until it
4073        /// exists:
4074        ///
4075        /// ```sql
4076        /// CREATE TABLE mail_suppressions (
4077        ///     address       TEXT PRIMARY KEY,
4078        ///     reason        TEXT NOT NULL,
4079        ///     suppressed_at TIMESTAMPTZ NOT NULL DEFAULT now()
4080        /// );
4081        /// ```
4082        #[derive(Clone)]
4083        pub struct PgSuppressionStore {
4084            pool: Pool<AsyncPgConnection>,
4085        }
4086
4087        impl PgSuppressionStore {
4088            /// Create a store backed by `pool`.
4089            #[must_use]
4090            pub const fn new(pool: Pool<AsyncPgConnection>) -> Self {
4091                Self { pool }
4092            }
4093        }
4094
4095        impl SuppressionStore for PgSuppressionStore {
4096            fn is_suppressed<'a>(
4097                &'a self,
4098                address: &'a str,
4099            ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
4100                Box::pin(async move {
4101                    let key = canonical_subscriber(address);
4102                    let mut conn = self.pool.get().await.map_err(|e| {
4103                        MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4104                    })?;
4105                    let count: i64 = mail_suppressions::table
4106                        .filter(mail_suppressions::address.eq(&key))
4107                        .count()
4108                        .get_result(&mut conn)
4109                        .await
4110                        .map_err(|e| {
4111                            MailError::RuntimeUnavailable(format!("suppression query: {e}"))
4112                        })?;
4113                    Ok(count > 0)
4114                })
4115            }
4116
4117            fn suppress<'a>(
4118                &'a self,
4119                address: &'a str,
4120                reason: SuppressionReason,
4121            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4122                Box::pin(async move {
4123                    let key = canonical_subscriber(address);
4124                    let reason_str = reason.as_str();
4125                    let mut conn = self.pool.get().await.map_err(|e| {
4126                        MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4127                    })?;
4128                    diesel::insert_into(mail_suppressions::table)
4129                        .values(NewSuppression {
4130                            address: &key,
4131                            reason: reason_str,
4132                        })
4133                        .on_conflict(mail_suppressions::address)
4134                        .do_update()
4135                        // Refresh both the reason and the timestamp so a
4136                        // re-suppression (e.g. an old hard bounce now also a
4137                        // complaint) reflects the latest event, not stale data.
4138                        .set((
4139                            mail_suppressions::reason.eq(reason_str),
4140                            mail_suppressions::suppressed_at.eq(diesel::dsl::now),
4141                        ))
4142                        .execute(&mut conn)
4143                        .await
4144                        .map_err(|e| {
4145                            MailError::RuntimeUnavailable(format!("suppression insert: {e}"))
4146                        })?;
4147                    Ok(())
4148                })
4149            }
4150
4151            fn unsuppress<'a>(
4152                &'a self,
4153                address: &'a str,
4154            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4155                Box::pin(async move {
4156                    let key = canonical_subscriber(address);
4157                    let mut conn = self.pool.get().await.map_err(|e| {
4158                        MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4159                    })?;
4160                    diesel::delete(
4161                        mail_suppressions::table.filter(mail_suppressions::address.eq(&key)),
4162                    )
4163                    .execute(&mut conn)
4164                    .await
4165                    .map_err(|e| {
4166                        MailError::RuntimeUnavailable(format!("suppression delete: {e}"))
4167                    })?;
4168                    Ok(())
4169                })
4170            }
4171        }
4172    }
4173}
4174
4175/// Diesel-backed [`SuppressionStore`].
4176#[cfg(feature = "db")]
4177pub mod db_suppression {
4178    use std::future::Future;
4179    use std::pin::Pin;
4180
4181    use diesel::prelude::*;
4182    use diesel_async::AsyncPgConnection;
4183    use diesel_async::RunQueryDsl;
4184    use diesel_async::pooled_connection::deadpool::Pool;
4185
4186    use super::{MailError, SuppressionStore};
4187
4188    diesel::table! {
4189        mail_unsubscribes (id) {
4190            id -> Int8,
4191            subscriber -> Text,
4192            list_id -> Text,
4193            unsubscribed_at -> Timestamptz,
4194        }
4195    }
4196
4197    #[derive(Insertable)]
4198    #[diesel(table_name = mail_unsubscribes)]
4199    struct NewUnsubscribe<'a> {
4200        subscriber: &'a str,
4201        list_id: &'a str,
4202    }
4203
4204    /// Postgres-backed suppression list keyed by `(subscriber, list_id)`.
4205    ///
4206    /// Backed by the `mail_unsubscribes` table provisioned by the migration that
4207    /// `autumn generate mailer --list-unsubscribe` writes into the app.
4208    #[derive(Clone)]
4209    pub struct DbSuppressionStore {
4210        pool: Pool<AsyncPgConnection>,
4211    }
4212
4213    impl DbSuppressionStore {
4214        /// Create a store backed by `pool`.
4215        #[must_use]
4216        pub const fn new(pool: Pool<AsyncPgConnection>) -> Self {
4217            Self { pool }
4218        }
4219    }
4220
4221    impl SuppressionStore for DbSuppressionStore {
4222        fn is_suppressed<'a>(
4223            &'a self,
4224            subscriber: &'a str,
4225            list_id: &'a str,
4226        ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
4227            Box::pin(async move {
4228                let mut conn =
4229                    self.pool.get().await.map_err(|e| {
4230                        MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4231                    })?;
4232                let count: i64 = mail_unsubscribes::table
4233                    .filter(mail_unsubscribes::subscriber.eq(subscriber))
4234                    .filter(mail_unsubscribes::list_id.eq(list_id))
4235                    .count()
4236                    .get_result(&mut conn)
4237                    .await
4238                    .map_err(|e| {
4239                        MailError::RuntimeUnavailable(format!("suppression query: {e}"))
4240                    })?;
4241                Ok(count > 0)
4242            })
4243        }
4244
4245        fn suppress<'a>(
4246            &'a self,
4247            subscriber: &'a str,
4248            list_id: &'a str,
4249        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4250            Box::pin(async move {
4251                let mut conn =
4252                    self.pool.get().await.map_err(|e| {
4253                        MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4254                    })?;
4255                diesel::insert_into(mail_unsubscribes::table)
4256                    .values(NewUnsubscribe {
4257                        subscriber,
4258                        list_id,
4259                    })
4260                    .on_conflict((mail_unsubscribes::subscriber, mail_unsubscribes::list_id))
4261                    .do_nothing()
4262                    .execute(&mut conn)
4263                    .await
4264                    .map_err(|e| {
4265                        MailError::RuntimeUnavailable(format!("suppression insert: {e}"))
4266                    })?;
4267                Ok(())
4268            })
4269        }
4270    }
4271}
4272
4273#[cfg(test)]
4274mod tests {
4275    use super::*;
4276
4277    // ── CSS inlining (issue #1254) ────────────────────────────────────────
4278
4279    #[test]
4280    fn html_contains_style_block_is_case_insensitive() {
4281        assert!(html_contains_style_block("<STYLE>.a{}</STYLE>"));
4282        assert!(html_contains_style_block("<p>x</p><style>.a{}</style>"));
4283        assert!(!html_contains_style_block("<p style=\"color:red\">x</p>"));
4284        assert!(!html_contains_style_block("just plain text, no tags"));
4285    }
4286
4287    #[test]
4288    fn inline_css_applies_class_style_to_anchor() {
4289        // AC1: a `<style>` block + a class-styled `<a>` yields an equivalent
4290        // inline `style="…"` on the anchor.
4291        let html = r#"<style>.btn{color:#fff;background:#06c}</style><a class="btn">Go</a>"#;
4292        let out = inline_css_html(html).expect("inlining succeeds");
4293        // Inspect the `<a …>` opening tag specifically so every assertion is
4294        // discriminating: `#fff`/`#06c` also appear in the retained `<style>`
4295        // block, so a no-op would pass a bare `out.contains(...)`.
4296        let anchor = out
4297            .split("<a")
4298            .nth(1)
4299            .expect("an <a> tag is present in the output");
4300        let anchor_open = &anchor[..anchor.find('>').expect("anchor tag closes")];
4301        assert!(
4302            anchor_open.contains("style="),
4303            "anchor must gain an inline style attribute; got tag: {anchor_open}"
4304        );
4305        assert!(
4306            anchor_open.contains("#fff"),
4307            "anchor's inline style must carry the color rule; got tag: {anchor_open}"
4308        );
4309        assert!(
4310            anchor_open.contains("#06c") || anchor_open.contains("background"),
4311            "anchor's inline style must carry the background rule; got tag: {anchor_open}"
4312        );
4313    }
4314
4315    #[test]
4316    fn inline_css_applies_class_style_to_table() {
4317        // AC7: a class-styled `<table>` gains the expected inline style.
4318        let html = r#"<style>.wrap{width:600px;background:#eee}</style><table class="wrap"><tr><td>x</td></tr></table>"#;
4319        let out = inline_css_html(html).expect("inlining succeeds");
4320        let table = out
4321            .split("<table")
4322            .nth(1)
4323            .expect("a <table> tag is present in the output");
4324        let table_open = &table[..table.find('>').expect("table tag closes")];
4325        assert!(
4326            table_open.contains("style=") && table_open.contains("600px"),
4327            "table must carry an inline style with the width rule; got tag: {table_open}"
4328        );
4329    }
4330
4331    #[test]
4332    #[allow(
4333        clippy::literal_string_with_formatting_args,
4334        reason = "CSS rule braces are literal HTML, not format placeholders"
4335    )]
4336    fn inline_css_emits_outlook_width_height_attributes() {
4337        // Outlook-family clients ignore CSS `width`/`height`, so the inliner must
4338        // also emit the presentational HTML `width`/`height` attributes on the
4339        // supported elements (`table`/`td`/`th`/`img`) — not only the CSS `style=`.
4340        let html = r#"<style>table{width:600px}img{height:40px}</style><table><tr><td><img src="/x.png"></td></tr></table>"#;
4341        let out = inline_css_html(html).expect("inlining succeeds");
4342
4343        let table_open = {
4344            let table = out
4345                .split("<table")
4346                .nth(1)
4347                .expect("a <table> tag is present in the output");
4348            &table[..table.find('>').expect("table tag closes")]
4349        };
4350        // Discriminating: the CSS style must be present AND the HTML attribute too.
4351        assert!(
4352            table_open.contains("style=") && table_open.contains("600px"),
4353            "table must still carry the inline CSS width; got tag: {table_open}"
4354        );
4355        assert!(
4356            table_open.contains(r#"width="600""#),
4357            "table must gain the presentational HTML width attribute Outlook needs; got tag: {table_open}"
4358        );
4359
4360        let img_open = {
4361            let img = out
4362                .split("<img")
4363                .nth(1)
4364                .expect("an <img> tag is present in the output");
4365            &img[..img.find('>').expect("img tag closes")]
4366        };
4367        assert!(
4368            img_open.contains("style=") && img_open.contains("40px"),
4369            "img must still carry the inline CSS height; got tag: {img_open}"
4370        );
4371        assert!(
4372            img_open.contains(r#"height="40""#),
4373            "img must gain the presentational HTML height attribute Outlook needs; got tag: {img_open}"
4374        );
4375    }
4376
4377    #[test]
4378    fn inline_css_passthrough_without_style_block_is_byte_identical() {
4379        // AC3: bodies already fully inlined (no `<style>`) pass through unchanged.
4380        let html = r#"<p style="color:red">Hello</p><a href="/x">link</a>"#;
4381        let out = inline_css_html(html).expect("no-op inlining succeeds");
4382        assert_eq!(out, html, "no-<style> body must be returned unchanged");
4383    }
4384
4385    #[test]
4386    #[allow(
4387        clippy::literal_string_with_formatting_args,
4388        reason = "CSS rule braces are literal HTML, not format placeholders"
4389    )]
4390    fn inline_css_retains_link_stylesheet_tags() {
4391        // We never fetch `<link>` stylesheets, so the `<link rel="stylesheet">`
4392        // tag must survive inlining rather than being silently dropped from the
4393        // delivered body — otherwise a message combining an embedded `<style>`
4394        // with a linked stylesheet would lose the linked CSS. The embedded rule
4395        // is still inlined onto the element.
4396        let html = r#"<style>.x{color:red}</style><link rel="stylesheet" href="https://example.com/app.css"><p class="x">Hi</p>"#;
4397        let out = inline_css_html(html).expect("inlining succeeds");
4398        assert!(
4399            out.contains("<link") && out.contains(r#"rel="stylesheet""#),
4400            "the <link rel=\"stylesheet\"> tag must be preserved; got: {out}"
4401        );
4402        assert!(
4403            out.contains("app.css"),
4404            "the linked stylesheet href must be preserved; got: {out}"
4405        );
4406        let para = out
4407            .split("<p")
4408            .nth(1)
4409            .expect("a <p> tag is present in the output");
4410        let para_open = &para[..para.find('>').expect("paragraph tag closes")];
4411        assert!(
4412            para_open.contains("style=") && para_open.contains("red"),
4413            "the embedded rule must still be inlined onto the paragraph; got tag: {para_open}"
4414        );
4415    }
4416
4417    #[test]
4418    #[allow(
4419        clippy::literal_string_with_formatting_args,
4420        reason = "CSS rule braces are literal HTML, not format placeholders"
4421    )]
4422    fn inline_css_is_idempotent() {
4423        // AC3: inlining twice equals inlining once.
4424        let html = r#"<style>.btn{color:#fff}p{margin:0}</style><a class="btn">Go</a><p>hi</p>"#;
4425        let once = inline_css_html(html).expect("first pass");
4426        let twice = inline_css_html(&once).expect("second pass");
4427        assert_eq!(once, twice, "inlining must be idempotent");
4428    }
4429
4430    #[test]
4431    #[allow(
4432        clippy::literal_string_with_formatting_args,
4433        reason = "CSS rule braces are literal HTML, not format placeholders"
4434    )]
4435    fn inline_css_retains_uninlinable_media_queries() {
4436        // AC5: `@media` rules that cannot be inlined survive in a retained
4437        // `<style>` block rather than being dropped.
4438        let html = r#"<style>.btn{color:#fff}@media (max-width:600px){.btn{color:#000}}</style><a class="btn">Go</a>"#;
4439        let out = inline_css_html(html).expect("inlining succeeds");
4440        assert!(
4441            out.contains("@media") && out.contains("max-width"),
4442            "the @media rule must be preserved in a retained <style> block; got: {out}"
4443        );
4444        // And the inlinable rule was still applied to the element.
4445        assert!(
4446            out.contains("<a") && out.contains("style="),
4447            "the inlinable rule must still be inlined onto the anchor; got: {out}"
4448        );
4449    }
4450
4451    #[test]
4452    #[allow(
4453        clippy::literal_string_with_formatting_args,
4454        reason = "CSS rule braces are literal HTML, not format placeholders"
4455    )]
4456    fn inline_css_fragment_body_stays_a_fragment() {
4457        // A no-layout FRAGMENT body must stay a fragment after inlining:
4458        // opting into CSS inlining must not promote it into a full document by
4459        // introducing synthetic `<html>`/`<head>`/`<body>` wrappers. The class
4460        // rule is still inlined onto the element. See issue #1254 / PR #1681.
4461        let html = r#"<style>.x{color:red}</style><p class="x">Hi</p>"#;
4462        let out = inline_css_html(html).expect("inlining succeeds");
4463        assert!(
4464            !out.to_ascii_lowercase().contains("<html")
4465                && !out.to_ascii_lowercase().contains("<body")
4466                && !out.to_ascii_lowercase().contains("<head"),
4467            "fragment body must not gain document wrappers; got: {out}"
4468        );
4469        let para = out
4470            .split("<p")
4471            .nth(1)
4472            .expect("a <p> tag is present in the output");
4473        let para_open = &para[..para.find('>').expect("paragraph tag closes")];
4474        assert!(
4475            para_open.contains("style=") && para_open.contains("red"),
4476            "the class rule must be inlined onto the paragraph; got tag: {para_open}"
4477        );
4478    }
4479
4480    #[test]
4481    #[allow(
4482        clippy::literal_string_with_formatting_args,
4483        reason = "CSS rule braces are literal HTML, not format placeholders"
4484    )]
4485    fn inline_css_fragment_body_retains_media_query_without_wrapping() {
4486        // AC5 + fragment: a FRAGMENT body with an un-inlinable `@media` rule
4487        // must stay a fragment (no synthetic wrappers) yet still carry the
4488        // retained `@media` block. Document-mode inlining hoists that retained
4489        // `<style>` into the synthetic `<head>`; the unwrap must fold it back
4490        // into the fragment rather than dropping it. See PR #1681.
4491        let html = r#"<style>.btn{color:#fff}@media (max-width:600px){.btn{color:#000}}</style><a class="btn">Go</a>"#;
4492        let out = inline_css_html(html).expect("inlining succeeds");
4493        assert!(
4494            !out.to_ascii_lowercase().contains("<html")
4495                && !out.to_ascii_lowercase().contains("<body")
4496                && !out.to_ascii_lowercase().contains("<head"),
4497            "fragment body must not gain document wrappers; got: {out}"
4498        );
4499        assert!(
4500            out.contains("@media") && out.contains("max-width"),
4501            "the retained @media block must survive the unwrap; got: {out}"
4502        );
4503        let anchor = out
4504            .split("<a")
4505            .nth(1)
4506            .expect("an <a> tag is present in the output");
4507        let anchor_open = &anchor[..anchor.find('>').expect("anchor tag closes")];
4508        assert!(
4509            anchor_open.contains("style=") && anchor_open.contains("#fff"),
4510            "the inlinable rule must still be inlined onto the anchor; got tag: {anchor_open}"
4511        );
4512    }
4513
4514    #[test]
4515    #[allow(
4516        clippy::literal_string_with_formatting_args,
4517        reason = "CSS rule braces are literal HTML, not format placeholders"
4518    )]
4519    fn inline_css_full_document_body_stays_a_document() {
4520        // A FULL-DOCUMENT body keeps document-mode handling: its authored
4521        // `<html>`/`<body>` structure survives and the class rule is inlined.
4522        let html = r#"<html><head><style>.x{color:red}</style></head><body><p class="x">Hi</p></body></html>"#;
4523        let out = inline_css_html(html).expect("inlining succeeds");
4524        assert!(
4525            out.to_ascii_lowercase().contains("<html")
4526                && out.to_ascii_lowercase().contains("<body"),
4527            "full-document body must retain its structure; got: {out}"
4528        );
4529        let para = out
4530            .split("<p")
4531            .nth(1)
4532            .expect("a <p> tag is present in the output");
4533        let para_open = &para[..para.find('>').expect("paragraph tag closes")];
4534        assert!(
4535            para_open.contains("style=") && para_open.contains("red"),
4536            "the class rule must be inlined onto the paragraph; got tag: {para_open}"
4537        );
4538    }
4539
4540    #[test]
4541    fn inline_css_stripped_style_renders_same_computed_styling() {
4542        // AC7: a `<style>`-stripped copy of the inlined output renders the same
4543        // computed styling — i.e. the visual styling lives in the inline
4544        // `style="…"` attribute, independent of any `<head>`/`<style>` the
4545        // client might drop.
4546        let html = r#"<style>.btn{color:#fff;padding:8px}</style><a class="btn">Go</a>"#;
4547        let inlined = inline_css_html(html).expect("inlining succeeds");
4548
4549        // Strip every <style>…</style> block (what Gmail/Outlook effectively do).
4550        let mut stripped = String::new();
4551        let mut rest = inlined.as_str();
4552        while let Some(start) = rest.to_ascii_lowercase().find("<style") {
4553            stripped.push_str(&rest[..start]);
4554            let after = &rest[start..];
4555            let end = after
4556                .to_ascii_lowercase()
4557                .find("</style>")
4558                .map_or(after.len(), |e| e + "</style>".len());
4559            rest = &after[end..];
4560        }
4561        stripped.push_str(rest);
4562
4563        // The anchor's inline style survives the strip, so styling is unchanged.
4564        let anchor = stripped
4565            .split("<a")
4566            .nth(1)
4567            .expect("anchor present after stripping <style>");
4568        let anchor_open = &anchor[..anchor.find('>').expect("anchor closes")];
4569        assert!(
4570            anchor_open.contains("style=") && anchor_open.contains("#fff"),
4571            "computed styling must be carried inline so a style-stripped copy looks identical; got: {anchor_open}"
4572        );
4573    }
4574
4575    // ── Preview honours send-time CSS inlining (issue #1254) ──────────────
4576
4577    /// Render `show_template_preview` for a single registered preview and return
4578    /// the full response body as a string. A [`Mailer`] is installed on the
4579    /// state so the handler can reuse the send-time inlining decision — mirrors
4580    /// the app build, where the mailer is always present before the preview
4581    /// registry.
4582    async fn preview_body_for(preview: MailPreview) -> String {
4583        let state = crate::AppState::for_test();
4584        state.insert_extension(MailPreviewRegistry::new(vec![preview]));
4585        let mailer = Mailer::builder()
4586            .build()
4587            .expect("log-transport mailer builds");
4588        state.insert_extension(mailer);
4589
4590        let response = show_template_preview(&state, "test", "styled");
4591        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
4592            .await
4593            .expect("preview body collects");
4594        String::from_utf8(bytes.to_vec()).expect("preview body is utf-8")
4595    }
4596
4597    /// Escaped opening `<a …>` tag of the email body as it appears in the
4598    /// preview page (the email HTML is HTML-escaped into an `<iframe srcdoc>`).
4599    /// Isolating the anchor keeps assertions discriminating: the colour rule
4600    /// also lives in the retained/original `<style>` block.
4601    fn escaped_anchor_open_tag(body: &str) -> String {
4602        let after = body
4603            .split("&lt;a")
4604            .nth(1)
4605            .expect("an <a> tag is present in the escaped preview body");
4606        let open = &after[..after.find("&gt;").expect("anchor tag closes")];
4607        open.to_owned()
4608    }
4609
4610    #[tokio::test]
4611    #[allow(
4612        clippy::literal_string_with_formatting_args,
4613        reason = "CSS rule braces are literal HTML, not format placeholders"
4614    )]
4615    async fn preview_inlines_style_block_when_inlining_enabled() {
4616        // A preview whose Mail opts into inlining must reflect what strict
4617        // clients receive: the `.btn` class rule inlined onto the anchor.
4618        let preview = MailPreview::new("test", "styled", || {
4619            Mail::builder()
4620                .to("user@example.com")
4621                .subject("Styled")
4622                .html(r#"<style>.btn{color:#ff0000}</style><a class="btn">Go</a>"#)
4623                .inline_css(true)
4624                .build()
4625                .expect("preview mail builds")
4626        });
4627
4628        let body = preview_body_for(preview).await;
4629        let anchor = escaped_anchor_open_tag(&body);
4630        assert!(
4631            anchor.contains("style="),
4632            "preview must inline the <style> block onto the anchor; got tag: {anchor}"
4633        );
4634        assert!(
4635            anchor.contains("#ff0000"),
4636            "the .btn colour rule must be carried inline; got tag: {anchor}"
4637        );
4638    }
4639
4640    #[tokio::test]
4641    #[allow(
4642        clippy::literal_string_with_formatting_args,
4643        reason = "CSS rule braces are literal HTML, not format placeholders"
4644    )]
4645    async fn preview_leaves_style_block_raw_when_inlining_disabled() {
4646        // The discriminating counterpart: with inlining off the anchor keeps no
4647        // inline style and the raw `<style>` block survives untouched.
4648        let preview = MailPreview::new("test", "styled", || {
4649            Mail::builder()
4650                .to("user@example.com")
4651                .subject("Styled")
4652                .html(r#"<style>.btn{color:#ff0000}</style><a class="btn">Go</a>"#)
4653                .inline_css(false)
4654                .build()
4655                .expect("preview mail builds")
4656        });
4657
4658        let body = preview_body_for(preview).await;
4659        let anchor = escaped_anchor_open_tag(&body);
4660        assert!(
4661            !anchor.contains("style="),
4662            "inlining is off, so the anchor must not gain an inline style; got tag: {anchor}"
4663        );
4664        assert!(
4665            body.contains("&lt;style&gt;"),
4666            "the raw <style> block must survive when inlining is off"
4667        );
4668    }
4669
4670    #[test]
4671    fn mail_builder_inline_css_sets_per_message_override() {
4672        let on = Mail::builder()
4673            .to("a@example.com")
4674            .subject("s")
4675            .html("<p>x</p>")
4676            .inline_css(true)
4677            .build()
4678            .expect("valid mail");
4679        assert_eq!(on.inline_css, Some(true));
4680
4681        let off = Mail::builder()
4682            .to("a@example.com")
4683            .subject("s")
4684            .html("<p>x</p>")
4685            .inline_css(false)
4686            .build()
4687            .expect("valid mail");
4688        assert_eq!(off.inline_css, Some(false));
4689
4690        let unset = Mail::builder()
4691            .to("a@example.com")
4692            .subject("s")
4693            .html("<p>x</p>")
4694            .build()
4695            .expect("valid mail");
4696        assert_eq!(
4697            unset.inline_css, None,
4698            "unset builder must defer to the mailer/config default"
4699        );
4700    }
4701
4702    #[test]
4703    fn mail_config_inline_css_defaults_off() {
4704        assert!(
4705            !MailConfig::default().inline_css,
4706            "inlining must default off so existing apps are unaffected"
4707        );
4708    }
4709
4710    // ── Attachments (issue #1256): pinning tests ──────────────────────────
4711    //
4712    // These prove attachment support introduces zero byte-for-byte regression
4713    // to attachment-less mail. `pinned_render_eml_no_attachments` is a frozen
4714    // copy of `render_eml`'s pre-attachment body captured before any
4715    // attachment code was added. Do not "fix" drift here — a diff against
4716    // this function IS the regression signal (AC: "pure additive, no
4717    // regression to existing email output").
4718
4719    fn pinned_render_eml_no_attachments(mail: &Mail) -> String {
4720        let mut out = String::new();
4721        if let Some(from) = &mail.from {
4722            out.push_str("From: ");
4723            out.push_str(from);
4724            out.push('\n');
4725        }
4726        for to in &mail.to {
4727            out.push_str("To: ");
4728            out.push_str(to);
4729            out.push('\n');
4730        }
4731        if let Some(reply_to) = &mail.reply_to {
4732            out.push_str("Reply-To: ");
4733            out.push_str(reply_to);
4734            out.push('\n');
4735        }
4736        out.push_str("Date: ");
4737        out.push_str("PINNED-DATE");
4738        out.push('\n');
4739        out.push_str("Message-Id: <");
4740        out.push_str("PINNED-ID");
4741        out.push_str("@autumn.local>\n");
4742        out.push_str("Subject: ");
4743        out.push_str(&mail.subject);
4744        out.push('\n');
4745        for (name, value) in &mail.extra_headers {
4746            out.push_str(name);
4747            out.push_str(": ");
4748            out.push_str(value);
4749            out.push('\n');
4750        }
4751        out.push_str("MIME-Version: 1.0\n");
4752        if mail.html.is_some() && mail.text.is_some() {
4753            out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
4754            if let Some(text) = &mail.text {
4755                out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
4756                out.push_str(text);
4757                out.push('\n');
4758            }
4759            if let Some(html) = &mail.html {
4760                out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
4761                out.push_str(html);
4762                out.push('\n');
4763            }
4764            out.push_str("--autumn-mail--\n");
4765        } else if let Some(html) = &mail.html {
4766            out.push_str("Content-Type: text/html; charset=utf-8\n\n");
4767            out.push_str(html);
4768            out.push('\n');
4769        } else if let Some(text) = &mail.text {
4770            out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
4771            out.push_str(text);
4772            out.push('\n');
4773        }
4774        out
4775    }
4776
4777    fn mask_nondeterministic(eml: &str) -> String {
4778        eml.lines()
4779            .map(|line| {
4780                if line.starts_with("Date: ") {
4781                    "Date: PINNED-DATE"
4782                } else if line.starts_with("Message-Id: ") {
4783                    "Message-Id: <PINNED-ID@autumn.local>"
4784                } else {
4785                    line
4786                }
4787            })
4788            .collect::<Vec<_>>()
4789            .join("\n")
4790    }
4791
4792    #[test]
4793    fn render_eml_without_attachments_matches_pinned_shape() {
4794        let mails = [
4795            Mail::builder()
4796                .from("from@example.com")
4797                .to("user@example.com")
4798                .subject("Hi")
4799                .text("hello text")
4800                .html("<p>hello html</p>")
4801                .build()
4802                .expect("mail should build"),
4803            Mail::builder()
4804                .from("from@example.com")
4805                .to("user@example.com")
4806                .subject("Hi")
4807                .text("hello text only")
4808                .build()
4809                .expect("mail should build"),
4810            Mail::builder()
4811                .from("from@example.com")
4812                .to("user@example.com")
4813                .subject("Hi")
4814                .html("<p>hello html only</p>")
4815                .build()
4816                .expect("mail should build"),
4817        ];
4818        for mail in mails {
4819            let actual = mask_nondeterministic(&render_eml(&mail));
4820            let pinned = mask_nondeterministic(&pinned_render_eml_no_attachments(&mail));
4821            assert_eq!(
4822                actual, pinned,
4823                "render_eml must be byte-identical for attachment-less mail"
4824            );
4825            assert!(!actual.contains("multipart/mixed"));
4826        }
4827    }
4828
4829    #[test]
4830    fn lettre_message_without_attachments_has_no_mixed_part() {
4831        let mail = Mail::builder()
4832            .from("from@example.com")
4833            .to("user@example.com")
4834            .subject("Hi")
4835            .text("hello")
4836            .html("<p>hello</p>")
4837            .build()
4838            .expect("mail should build");
4839        let message = lettre_message(&mail).expect("lettre message should build");
4840        let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
4841        assert!(formatted.contains("multipart/alternative"));
4842        assert!(!formatted.contains("multipart/mixed"));
4843    }
4844
4845    // ── Attachments (issue #1256): model & builder ────────────────────────
4846
4847    #[test]
4848    fn mail_builder_attach_preserves_order_and_count() {
4849        let mail = Mail::builder()
4850            .to("user@example.com")
4851            .subject("Hi")
4852            .text("hello")
4853            .attach("a.txt", "text/plain", b"aaa".to_vec())
4854            .attach("b.txt", "text/plain", b"bbb".to_vec())
4855            .attach("c.txt", "text/plain", b"ccc".to_vec())
4856            .build()
4857            .expect("mail should build");
4858        assert_eq!(mail.attachments.len(), 3);
4859        assert_eq!(
4860            mail.attachments
4861                .iter()
4862                .map(|a| a.filename.as_str())
4863                .collect::<Vec<_>>(),
4864            vec!["a.txt", "b.txt", "c.txt"]
4865        );
4866        assert_eq!(mail.attachments[1].content_type, "text/plain");
4867        assert_eq!(mail.attachments[1].bytes, b"bbb".to_vec());
4868    }
4869
4870    #[test]
4871    fn mail_serde_round_trips_attachments() {
4872        let mail = Mail::builder()
4873            .from("from@example.com")
4874            .to("user@example.com")
4875            .subject("Hi")
4876            .text("hello")
4877            .attach("invoice.pdf", "application/pdf", vec![0_u8, 1, 2, 255])
4878            .build()
4879            .expect("mail should build");
4880        let json = serde_json::to_string(&mail).expect("mail should serialize");
4881        let round_tripped: Mail = serde_json::from_str(&json).expect("mail should deserialize");
4882        assert_eq!(round_tripped, mail);
4883    }
4884
4885    #[test]
4886    fn mail_builder_rejects_control_chars_in_attachment_filename() {
4887        let err = Mail::builder()
4888            .to("user@example.com")
4889            .subject("Hi")
4890            .text("hello")
4891            .attach(
4892                "evil\r\nX-Injected: 1.pdf",
4893                "application/pdf",
4894                b"x".to_vec(),
4895            )
4896            .build()
4897            .expect_err("CRLF in filename should be rejected");
4898        assert!(err.to_string().contains("filename"));
4899
4900        let err = Mail::builder()
4901            .to("user@example.com")
4902            .subject("Hi")
4903            .text("hello")
4904            .attach("\0evil.pdf", "application/pdf", b"x".to_vec())
4905            .build()
4906            .expect_err("NUL in filename should be rejected");
4907        assert!(err.to_string().contains("filename"));
4908
4909        let err = Mail::builder()
4910            .to("user@example.com")
4911            .subject("Hi")
4912            .text("hello")
4913            .attach("   ", "application/pdf", b"x".to_vec())
4914            .build()
4915            .expect_err("empty filename should be rejected");
4916        assert!(err.to_string().contains("filename"));
4917    }
4918
4919    #[test]
4920    fn mail_builder_rejects_invalid_attachment_content_type() {
4921        let err = Mail::builder()
4922            .to("user@example.com")
4923            .subject("Hi")
4924            .text("hello")
4925            .attach("a.pdf", "not a mime type", b"x".to_vec())
4926            .build()
4927            .expect_err("invalid content type should be rejected");
4928        assert!(err.to_string().contains("content type"));
4929    }
4930
4931    #[test]
4932    fn mail_attachment_debug_hides_bytes() {
4933        let attachment = MailAttachment {
4934            filename: "secret.bin".to_owned(),
4935            content_type: "application/octet-stream".to_owned(),
4936            bytes: vec![1, 2, 3, 4, 5],
4937        };
4938        let debug = format!("{attachment:?}");
4939        assert!(debug.contains("secret.bin"));
4940        assert!(debug.contains('5'), "byte length should appear: {debug}");
4941        assert!(
4942            !debug.contains("[1, 2, 3, 4, 5]"),
4943            "raw byte values must not appear: {debug}"
4944        );
4945    }
4946
4947    // ── Attachments (issue #1256): render_eml (file transport) ───────────
4948
4949    fn blob_all_byte_values() -> Vec<u8> {
4950        (0_u8..=255).cycle().take(4096).collect()
4951    }
4952
4953    fn sha256_hex(bytes: &[u8]) -> String {
4954        use sha2::Digest as _;
4955        let mut hasher = sha2::Sha256::new();
4956        hasher.update(bytes);
4957        format!("{:x}", hasher.finalize())
4958    }
4959
4960    /// Extracts the random `multipart/mixed` boundary rendered by
4961    /// `render_eml` for an attachment message, so tests can assert against
4962    /// it without depending on a fixed boundary string.
4963    fn mixed_boundary(eml: &str) -> String {
4964        let line = eml
4965            .lines()
4966            .find(|line| line.starts_with("Content-Type: multipart/mixed;"))
4967            .expect("multipart/mixed Content-Type header present");
4968        content_type_boundary(line).expect("boundary parameter present")
4969    }
4970
4971    #[test]
4972    fn render_eml_with_attachment_emits_multipart_mixed() {
4973        let mail = Mail::builder()
4974            .from("from@example.com")
4975            .to("user@example.com")
4976            .subject("Invoice")
4977            .text("see attached")
4978            .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
4979            .build()
4980            .expect("mail should build");
4981        let eml = render_eml(&mail);
4982        let boundary = mixed_boundary(&eml);
4983        assert!(eml.contains(&format!(
4984            "Content-Type: multipart/mixed; boundary=\"{boundary}\""
4985        )));
4986        assert!(eml.contains("Content-Disposition: attachment; filename=\"invoice.pdf\""));
4987        assert!(eml.contains("Content-Type: application/pdf"));
4988        assert!(eml.contains("Content-Transfer-Encoding: base64"));
4989        assert!(eml.contains(&format!("--{boundary}--")));
4990    }
4991
4992    #[test]
4993    fn render_eml_boundary_is_unpredictable_and_body_cannot_forge_it() {
4994        // A fixed boundary (e.g. a literal `"autumn-mixed"`) lets a body
4995        // containing a `--autumn-mixed` line be mistaken for a real MIME
4996        // delimiter, truncating or splitting the message. The boundary must
4997        // vary per render and not be derivable from body content alone.
4998        let mail = Mail::builder()
4999            .from("from@example.com")
5000            .to("user@example.com")
5001            .subject("Spoof attempt")
5002            .text("line one\n--autumn-mixed--\nX-Spoofed: header\nline two")
5003            .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5004            .build()
5005            .expect("mail should build");
5006        let eml = render_eml(&mail);
5007        let parsed = parse_eml(&eml);
5008        assert_eq!(
5009            parsed.text.as_deref(),
5010            Some("line one\n--autumn-mixed--\nX-Spoofed: header\nline two"),
5011            "body content resembling the old fixed boundary must not truncate the message"
5012        );
5013        assert_eq!(parsed.attachments.len(), 1);
5014
5015        let other = render_eml(
5016            &Mail::builder()
5017                .from("from@example.com")
5018                .to("user@example.com")
5019                .subject("Second message")
5020                .text("hi")
5021                .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5022                .build()
5023                .expect("mail should build"),
5024        );
5025        assert_ne!(
5026            mixed_boundary(&eml),
5027            mixed_boundary(&other),
5028            "boundary must vary per message, not be a fixed/predictable string"
5029        );
5030    }
5031
5032    #[test]
5033    fn render_eml_attachment_bytes_round_trip_sha256() {
5034        use base64::Engine as _;
5035        let blob = blob_all_byte_values();
5036        let expected_digest = sha256_hex(&blob);
5037        let mail = Mail::builder()
5038            .from("from@example.com")
5039            .to("user@example.com")
5040            .subject("Blob")
5041            .text("see attached")
5042            .attach("blob.bin", "application/octet-stream", blob)
5043            .build()
5044            .expect("mail should build");
5045        let eml = render_eml(&mail);
5046        let boundary = mixed_boundary(&eml);
5047
5048        let start = eml
5049            .find("Content-Transfer-Encoding: base64\n\n")
5050            .expect("base64 section present")
5051            + "Content-Transfer-Encoding: base64\n\n".len();
5052        let rest = &eml[start..];
5053        let end = rest
5054            .find(&format!("--{boundary}"))
5055            .expect("closing boundary present");
5056        let encoded: String = rest[..end].chars().filter(|c| !c.is_whitespace()).collect();
5057
5058        let decoded = base64::engine::general_purpose::STANDARD
5059            .decode(encoded)
5060            .expect("attachment body should be valid base64");
5061        assert_eq!(sha256_hex(&decoded), expected_digest);
5062    }
5063
5064    #[test]
5065    fn render_eml_preserves_attachment_order() {
5066        let mail = Mail::builder()
5067            .from("from@example.com")
5068            .to("user@example.com")
5069            .subject("Multi")
5070            .text("see attached")
5071            .attach("a.txt", "text/plain", b"a".to_vec())
5072            .attach("b.txt", "text/plain", b"b".to_vec())
5073            .build()
5074            .expect("mail should build");
5075        let eml = render_eml(&mail);
5076        let a_pos = eml.find("filename=\"a.txt\"").expect("a.txt present");
5077        let b_pos = eml.find("filename=\"b.txt\"").expect("b.txt present");
5078        assert!(a_pos < b_pos, "attachments must render in declared order");
5079    }
5080
5081    #[test]
5082    fn render_eml_with_attachment_nests_alternative_body() {
5083        let mail = Mail::builder()
5084            .from("from@example.com")
5085            .to("user@example.com")
5086            .subject("Both bodies")
5087            .text("plain")
5088            .html("<p>html</p>")
5089            .attach("a.txt", "text/plain", b"a".to_vec())
5090            .build()
5091            .expect("mail should build");
5092        let eml = render_eml(&mail);
5093        assert!(eml.contains("Content-Type: multipart/alternative; boundary=\"autumn-mail\""));
5094        assert!(eml.contains("plain"));
5095        assert!(eml.contains("<p>html</p>"));
5096    }
5097
5098    #[test]
5099    fn render_eml_blocks_filename_header_injection() {
5100        // Hand-built Mail bypasses `build()`'s validation entirely — a `Mail`
5101        // can also arrive via `Deserialize` from a durable queue, so the
5102        // render layer must be injection-proof independent of the builder.
5103        let mail = Mail {
5104            from: Some("from@example.com".to_owned()),
5105            reply_to: None,
5106            to: vec!["user@example.com".to_owned()],
5107            subject: "Hi".to_owned(),
5108            html: None,
5109            text: Some("hello".to_owned()),
5110            list_unsubscribe: None,
5111            extra_headers: Vec::new(),
5112            attachments: vec![MailAttachment {
5113                filename: "evil\r\nX-Injected: 1.pdf".to_owned(),
5114                content_type: "application/pdf".to_owned(),
5115                bytes: b"x".to_vec(),
5116            }],
5117            ignore_suppression: false,
5118            inline_css: None,
5119        };
5120        let eml = render_eml(&mail);
5121        assert!(
5122            !eml.lines().any(|line| line.starts_with("X-Injected")),
5123            "CRLF in filename must not inject a header: {eml}"
5124        );
5125        assert!(!eml.contains('\r'));
5126    }
5127
5128    #[test]
5129    fn render_eml_blocks_header_injection_in_all_deserialized_fields() {
5130        // Same threat model as `render_eml_blocks_filename_header_injection`,
5131        // but for the pre-existing `subject`/`to`/`from`/`reply_to`/
5132        // `extra_headers` fields — these are just as reachable via an
5133        // untrusted `Deserialize`d `Mail` as the attachment filename is.
5134        let mail = Mail {
5135            from: Some("from@example.com\r\nX-From-Injected: 1".to_owned()),
5136            reply_to: Some("reply@example.com\r\nX-Reply-Injected: 1".to_owned()),
5137            to: vec!["user@example.com\r\nX-To-Injected: 1".to_owned()],
5138            subject: "Hi\r\nX-Subject-Injected: 1".to_owned(),
5139            html: None,
5140            text: Some("hello".to_owned()),
5141            list_unsubscribe: None,
5142            extra_headers: vec![(
5143                "X-Custom\r\nX-Header-Injected".to_owned(),
5144                "1\r\nX-Value-Injected: 1".to_owned(),
5145            )],
5146            attachments: Vec::new(),
5147            ignore_suppression: false,
5148            inline_css: None,
5149        };
5150        let eml = render_eml(&mail);
5151        assert!(
5152            !eml.lines().any(|line| line.starts_with("X-From-Injected")
5153                || line.starts_with("X-Reply-Injected")
5154                || line.starts_with("X-To-Injected")
5155                || line.starts_with("X-Subject-Injected")
5156                || line.starts_with("X-Header-Injected")
5157                || line.starts_with("X-Value-Injected")),
5158            "CRLF in any header-bound field must not inject a standalone header line: {eml}"
5159        );
5160        assert!(!eml.contains('\r'));
5161    }
5162
5163    #[test]
5164    fn render_eml_falls_back_to_octet_stream_for_invalid_content_type() {
5165        // A `Mail` bypassing `build()` could carry a syntactically invalid
5166        // content type; the file transport must not write it verbatim, to
5167        // stay consistent with the SMTP transport (which rejects it).
5168        let mail = Mail {
5169            from: Some("from@example.com".to_owned()),
5170            reply_to: None,
5171            to: vec!["user@example.com".to_owned()],
5172            subject: "Hi".to_owned(),
5173            html: None,
5174            text: Some("hello".to_owned()),
5175            list_unsubscribe: None,
5176            extra_headers: Vec::new(),
5177            attachments: vec![MailAttachment {
5178                filename: "file.bin".to_owned(),
5179                content_type: "not a mime type".to_owned(),
5180                bytes: b"x".to_vec(),
5181            }],
5182            ignore_suppression: false,
5183            inline_css: None,
5184        };
5185        let eml = render_eml(&mail);
5186        assert!(eml.contains("Content-Type: application/octet-stream"));
5187        assert!(!eml.contains("not a mime type"));
5188    }
5189
5190    #[test]
5191    fn render_eml_encodes_non_ascii_filename_rfc2231() {
5192        let mail = Mail {
5193            from: Some("from@example.com".to_owned()),
5194            reply_to: None,
5195            to: vec!["user@example.com".to_owned()],
5196            subject: "Hi".to_owned(),
5197            html: None,
5198            text: Some("hello".to_owned()),
5199            list_unsubscribe: None,
5200            extra_headers: Vec::new(),
5201            attachments: vec![MailAttachment {
5202                filename: "Résumé façade.pdf".to_owned(),
5203                content_type: "application/pdf".to_owned(),
5204                bytes: b"x".to_vec(),
5205            }],
5206            ignore_suppression: false,
5207            inline_css: None,
5208        };
5209        let eml = render_eml(&mail);
5210        assert!(eml.contains("filename*=UTF-8''"));
5211        let disposition_line = eml
5212            .lines()
5213            .find(|line| line.starts_with("Content-Disposition:"))
5214            .expect("Content-Disposition header present");
5215        assert!(disposition_line.is_ascii());
5216    }
5217
5218    #[test]
5219    fn content_disposition_params_table() {
5220        assert_eq!(content_disposition_params("a.txt"), "filename=\"a.txt\"");
5221        assert_eq!(
5222            content_disposition_params("weird\"na\\me.txt"),
5223            "filename=\"weird\\\"na\\\\me.txt\""
5224        );
5225        assert_eq!(
5226            content_disposition_params("evil\r\nX: 1"),
5227            "filename=\"evilX: 1\""
5228        );
5229        assert_eq!(content_disposition_params(""), "filename=\"attachment\"");
5230        assert_eq!(content_disposition_params("   "), "filename=\"attachment\"");
5231        let non_ascii = content_disposition_params("café.txt");
5232        assert!(non_ascii.contains("filename*=UTF-8''caf%C3%A9.txt"));
5233        assert!(non_ascii.is_ascii());
5234    }
5235
5236    #[test]
5237    fn render_eml_base64_lines_wrap_at_76() {
5238        let blob = blob_all_byte_values();
5239        let mail = Mail::builder()
5240            .from("from@example.com")
5241            .to("user@example.com")
5242            .subject("Blob")
5243            .text("see attached")
5244            .attach("blob.bin", "application/octet-stream", blob)
5245            .build()
5246            .expect("mail should build");
5247        let eml = render_eml(&mail);
5248        let boundary = mixed_boundary(&eml);
5249        let start = eml
5250            .find("Content-Transfer-Encoding: base64\n\n")
5251            .expect("base64 section present")
5252            + "Content-Transfer-Encoding: base64\n\n".len();
5253        let rest = &eml[start..];
5254        let end = rest
5255            .find(&format!("--{boundary}"))
5256            .expect("closing boundary present");
5257        for line in rest[..end].lines() {
5258            assert!(
5259                line.len() <= 76,
5260                "base64 line too long: {} chars",
5261                line.len()
5262            );
5263        }
5264    }
5265
5266    // ── Attachments (issue #1256): lettre_message (SMTP transport) ───────
5267
5268    #[test]
5269    fn lettre_message_with_attachment_is_multipart_mixed() {
5270        let mail = Mail::builder()
5271            .from("from@example.com")
5272            .to("user@example.com")
5273            .subject("Invoice")
5274            .text("see attached")
5275            .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5276            .build()
5277            .expect("mail should build");
5278        let message = lettre_message(&mail).expect("lettre message should build");
5279        let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5280        assert!(formatted.contains("multipart/mixed"));
5281        assert!(formatted.contains("Content-Disposition: attachment"));
5282        assert!(formatted.contains("invoice.pdf"));
5283        assert!(formatted.contains("base64"));
5284    }
5285
5286    fn extract_boundary(text: &str) -> String {
5287        let marker = "boundary=\"";
5288        let start = text.find(marker).expect("boundary present") + marker.len();
5289        let rest = &text[start..];
5290        let end = rest.find('"').expect("boundary closing quote");
5291        rest[..end].to_owned()
5292    }
5293
5294    fn extract_attachment_base64(formatted_lf: &str, boundary: &str) -> String {
5295        let marker = format!("--{boundary}");
5296        for segment in formatted_lf.split(&marker).skip(1) {
5297            let segment = segment.trim_start_matches(['\n', '\r']);
5298            if segment.starts_with("--") {
5299                break;
5300            }
5301            let (headers, body) = split_headers_body(segment);
5302            if header_value(&headers, "Content-Disposition")
5303                .unwrap_or_default()
5304                .to_ascii_lowercase()
5305                .contains("attachment")
5306            {
5307                return body.chars().filter(|c| !c.is_whitespace()).collect();
5308            }
5309        }
5310        panic!("attachment part not found in: {formatted_lf}");
5311    }
5312
5313    #[test]
5314    fn lettre_message_attachment_round_trips_sha256() {
5315        use base64::Engine as _;
5316        let blob = blob_all_byte_values();
5317        let expected_digest = sha256_hex(&blob);
5318        let mail = Mail::builder()
5319            .from("from@example.com")
5320            .to("user@example.com")
5321            .subject("Blob")
5322            .text("see attached")
5323            .attach("blob.bin", "application/octet-stream", blob)
5324            .build()
5325            .expect("mail should build");
5326        let message = lettre_message(&mail).expect("lettre message should build");
5327        let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5328        let normalized = formatted.replace("\r\n", "\n");
5329        let boundary = extract_boundary(&normalized);
5330        let encoded = extract_attachment_base64(&normalized, &boundary);
5331
5332        let decoded = base64::engine::general_purpose::STANDARD
5333            .decode(encoded)
5334            .expect("attachment body should be valid base64");
5335        assert_eq!(sha256_hex(&decoded), expected_digest);
5336    }
5337
5338    #[test]
5339    fn lettre_message_attachment_headers_ascii_and_injection_free() {
5340        for filename in ["evil\r\nX-Injected: 1.pdf", "Résumé façade.pdf"] {
5341            let mail = Mail {
5342                from: Some("from@example.com".to_owned()),
5343                reply_to: None,
5344                to: vec!["user@example.com".to_owned()],
5345                subject: "Hi".to_owned(),
5346                html: None,
5347                text: Some("hello".to_owned()),
5348                list_unsubscribe: None,
5349                extra_headers: Vec::new(),
5350                attachments: vec![MailAttachment {
5351                    filename: filename.to_owned(),
5352                    content_type: "application/pdf".to_owned(),
5353                    bytes: b"x".to_vec(),
5354                }],
5355                ignore_suppression: false,
5356                inline_css: None,
5357            };
5358            let message = lettre_message(&mail).expect("lettre message should build");
5359            let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5360            let header_section = formatted
5361                .split("\r\n\r\n")
5362                .next()
5363                .expect("header section present");
5364            assert!(
5365                header_section.is_ascii(),
5366                "headers must stay ASCII for filename {filename:?}: {header_section}"
5367            );
5368            assert!(
5369                !formatted.lines().any(|line| line.starts_with("X-Injected")),
5370                "CRLF in filename must not inject a header for {filename:?}"
5371            );
5372        }
5373    }
5374
5375    #[test]
5376    fn lettre_message_attachment_with_invalid_content_type_errors() {
5377        let mail = Mail {
5378            from: Some("from@example.com".to_owned()),
5379            reply_to: None,
5380            to: vec!["user@example.com".to_owned()],
5381            subject: "Hi".to_owned(),
5382            html: None,
5383            text: Some("hello".to_owned()),
5384            list_unsubscribe: None,
5385            extra_headers: Vec::new(),
5386            attachments: vec![MailAttachment {
5387                filename: "a.bin".to_owned(),
5388                content_type: "not a mime type".to_owned(),
5389                bytes: b"x".to_vec(),
5390            }],
5391            ignore_suppression: false,
5392            inline_css: None,
5393        };
5394        let err = lettre_message(&mail).expect_err("invalid content type should error");
5395        assert!(matches!(err, MailError::InvalidMessage(_)));
5396    }
5397
5398    // ── Attachments (issue #1256): dev preview ────────────────────────────
5399
5400    #[test]
5401    fn parse_eml_extracts_attachment_list() {
5402        let mail = Mail::builder()
5403            .from("from@example.com")
5404            .to("user@example.com")
5405            .subject("Invoice")
5406            .text("plain")
5407            .html("<p>html</p>")
5408            .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5409            .attach("receipt.csv", "text/csv", b"a,b,c".to_vec())
5410            .build()
5411            .expect("mail should build");
5412        let eml = render_eml(&mail);
5413        let parsed = parse_eml(&eml);
5414        assert_eq!(parsed.attachments.len(), 2);
5415        assert_eq!(parsed.attachments[0].filename, "invoice.pdf");
5416        assert_eq!(parsed.attachments[0].content_type, "application/pdf");
5417        assert_eq!(parsed.attachments[1].filename, "receipt.csv");
5418        assert_eq!(parsed.html.as_deref(), Some("<p>html</p>"));
5419        assert_eq!(parsed.text.as_deref(), Some("plain"));
5420    }
5421
5422    #[test]
5423    fn extract_attachment_filename_handles_semicolon_in_quoted_filename() {
5424        // Naive `disposition.split(';')` would truncate this at "invoice".
5425        assert_eq!(
5426            extract_attachment_filename(r#"attachment; filename="invoice;2026.pdf""#),
5427            "invoice;2026.pdf"
5428        );
5429    }
5430
5431    #[test]
5432    fn extract_attachment_filename_unescapes_quoted_pairs() {
5433        assert_eq!(
5434            extract_attachment_filename(r#"attachment; filename="weird\"na\\me.txt""#),
5435            "weird\"na\\me.txt"
5436        );
5437    }
5438
5439    #[test]
5440    fn extract_attachment_filename_is_case_insensitive_and_handles_language_tag() {
5441        assert_eq!(
5442            extract_attachment_filename("attachment; filename*=utf-8'en'r%C3%A9sum%C3%A9.pdf"),
5443            "résumé.pdf"
5444        );
5445    }
5446
5447    #[test]
5448    fn extract_attachment_filename_round_trips_through_dev_preview() {
5449        let mail = Mail::builder()
5450            .from("from@example.com")
5451            .to("user@example.com")
5452            .subject("Invoice")
5453            .text("plain")
5454            .attach(r#"a;b"c\d.txt"#, "text/plain", b"x".to_vec())
5455            .build()
5456            .expect("mail should build");
5457        let eml = render_eml(&mail);
5458        let parsed = parse_eml(&eml);
5459        assert_eq!(parsed.attachments.len(), 1);
5460        assert_eq!(parsed.attachments[0].filename, r#"a;b"c\d.txt"#);
5461    }
5462
5463    #[test]
5464    fn parse_multipart_mixed_does_not_misclassify_inline_as_attachment() {
5465        let (_, _, attachments) = parse_multipart_mixed(
5466            "--b\nContent-Disposition: inline; filename=\"my-attachment-notes.pdf\"\nContent-Type: text/plain\n\nhi\n--b--\n",
5467            "b",
5468        );
5469        assert!(
5470            attachments.is_empty(),
5471            "an `inline` disposition must not be classified as an attachment: {attachments:?}"
5472        );
5473    }
5474
5475    #[test]
5476    fn mail_deserializes_from_pre_attachments_json_shape() {
5477        // `Mail::attachments` must default on a missing key so a `Mail`
5478        // serialized by an older binary (before this field existed) still
5479        // deserializes from a durable delivery queue during a rolling
5480        // deploy.
5481        let json = r#"{"from":null,"reply_to":null,"to":["a@example.com"],"subject":"hi","html":null,"text":"hello","list_unsubscribe":null,"extra_headers":[]}"#;
5482        let mail: Mail =
5483            serde_json::from_str(json).expect("pre-attachments JSON should deserialize");
5484        assert!(mail.attachments.is_empty());
5485    }
5486
5487    #[test]
5488    fn render_mail_detail_lists_attachments() {
5489        let mail = Mail::builder()
5490            .from("from@example.com")
5491            .to("user@example.com")
5492            .subject("Invoice")
5493            .text("plain")
5494            .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5495            .attach("receipt.csv", "text/csv", b"a,b,c".to_vec())
5496            .build()
5497            .expect("mail should build");
5498        let parsed = parse_eml(&render_eml(&mail));
5499        let detail = render_mail_detail(&parsed, "captured");
5500        assert!(detail.contains("Attachments (2)"));
5501        assert!(detail.contains("invoice.pdf"));
5502        assert!(detail.contains("receipt.csv"));
5503    }
5504
5505    #[test]
5506    fn render_mail_detail_without_attachments_omits_section() {
5507        let mail = Mail::builder()
5508            .from("from@example.com")
5509            .to("user@example.com")
5510            .subject("Plain")
5511            .text("plain")
5512            .build()
5513            .expect("mail should build");
5514        let parsed = parse_eml(&render_eml(&mail));
5515        let detail = render_mail_detail(&parsed, "captured");
5516        assert!(!detail.contains("Attachments"));
5517    }
5518
5519    #[test]
5520    fn mail_builder_rejects_missing_body() {
5521        let err = Mail::builder()
5522            .to("user@example.com")
5523            .subject("Hello")
5524            .build()
5525            .expect_err("body should be required");
5526        assert!(err.to_string().contains("html or text"));
5527    }
5528
5529    #[test]
5530    fn filename_sanitizer_keeps_safe_characters() {
5531        assert_eq!(
5532            sanitize_filename("Ada Lovelace <ada@example.com>"),
5533            "Ada_Lovelace__ada_example.com_"
5534        );
5535    }
5536
5537    #[test]
5538    fn transport_default_is_disabled() {
5539        assert_eq!(Transport::default(), Transport::Disabled);
5540    }
5541
5542    // ── List-Unsubscribe: Mail surface (Component 1) ─────────────────────────
5543
5544    #[test]
5545    fn mail_defaults_have_no_unsubscribe_or_extra_headers() {
5546        let mail = Mail::builder()
5547            .to("user@example.com")
5548            .subject("Hi")
5549            .text("hello")
5550            .build()
5551            .expect("mail should build");
5552        assert_eq!(mail.list_unsubscribe, None);
5553        assert!(mail.extra_headers.is_empty());
5554        assert!(mail.attachments.is_empty());
5555    }
5556
5557    #[test]
5558    fn mail_builder_sets_list_unsubscribe_and_headers() {
5559        let mail = Mail::builder()
5560            .to("user@example.com")
5561            .subject("Hi")
5562            .text("hello")
5563            .list_unsubscribe("weekly_digest")
5564            .header("X-Custom", "1")
5565            .build()
5566            .expect("mail should build");
5567        assert_eq!(mail.list_unsubscribe.as_deref(), Some("weekly_digest"));
5568        assert_eq!(
5569            mail.extra_headers,
5570            vec![("X-Custom".to_owned(), "1".to_owned())]
5571        );
5572    }
5573
5574    // ── List-Unsubscribe: token signing (Component 2) ────────────────────────
5575
5576    fn test_keys() -> crate::security::config::ResolvedSigningKeys {
5577        crate::security::config::ResolvedSigningKeys::new(
5578            b"unit-test-signing-key-0123456789".to_vec(),
5579            vec![],
5580        )
5581    }
5582
5583    #[test]
5584    fn token_roundtrips_and_hides_subscriber() {
5585        use base64::Engine as _;
5586        let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
5587        let keys = test_keys();
5588        let token =
5589            unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 4_000_000_000);
5590        assert!(
5591            !token.contains("ada@example.com"),
5592            "raw subscriber must not appear in the token: {token}"
5593        );
5594        // The address is encrypted, not merely base64-encoded: its base64url form
5595        // (which the old signed-token format embedded) must not appear either.
5596        assert!(
5597            !token.contains(&engine.encode("ada@example.com")),
5598            "base64 of subscriber must not appear — the payload must be encrypted: {token}"
5599        );
5600        let decoded = unsubscribe::verify_token(&keys, &token, 1_000).expect("token should verify");
5601        assert_eq!(decoded.subscriber, "ada@example.com");
5602        assert_eq!(decoded.list_id, "weekly_digest");
5603    }
5604
5605    #[test]
5606    fn token_rejects_tamper_and_expiry() {
5607        use base64::Engine as _;
5608        let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
5609        let keys = test_keys();
5610        let token =
5611            unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 4_000_000_000);
5612        // Flip a bit in the trailing GCM tag: AES-GCM authentication must reject it.
5613        let mut blob = engine.decode(&token).expect("token is base64");
5614        let last = blob.len() - 1;
5615        blob[last] ^= 0x01;
5616        let tampered = engine.encode(&blob);
5617        assert_eq!(
5618            unsubscribe::verify_token(&keys, &tampered, 1_000),
5619            Err(unsubscribe::TokenError::BadSignature)
5620        );
5621        // Expired (now > expiry).
5622        let short = unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 100);
5623        assert_eq!(
5624            unsubscribe::verify_token(&keys, &short, 200),
5625            Err(unsubscribe::TokenError::Expired)
5626        );
5627    }
5628
5629    #[test]
5630    fn token_verifies_under_rotated_previous_key() {
5631        let signer = crate::security::config::ResolvedSigningKeys::new(
5632            b"old-key-old-key-old-key-old-key!".to_vec(),
5633            vec![],
5634        );
5635        let token = unsubscribe::sign_token(&signer, "ada@example.com", "list", 4_000_000_000);
5636        let rotated = crate::security::config::ResolvedSigningKeys::new(
5637            b"new-key-new-key-new-key-new-key!".to_vec(),
5638            vec![b"old-key-old-key-old-key-old-key!".to_vec()],
5639        );
5640        assert!(unsubscribe::verify_token(&rotated, &token, 1_000).is_ok());
5641    }
5642
5643    #[test]
5644    fn unsubscribe_url_includes_token_and_path() {
5645        let url = unsubscribe::unsubscribe_url("https://app.example.com/", "TOK");
5646        assert_eq!(url, "https://app.example.com/_autumn/unsubscribe?token=TOK");
5647    }
5648
5649    // ── List-Unsubscribe: suppression store (Component 3) ────────────────────
5650
5651    #[tokio::test]
5652    async fn in_memory_suppression_transitions() {
5653        let store = InMemorySuppressionStore::new();
5654        assert!(!store.is_suppressed("a@x.com", "list").await.unwrap());
5655        store.suppress("a@x.com", "list").await.unwrap();
5656        assert!(store.is_suppressed("a@x.com", "list").await.unwrap());
5657        // Scoped to (subscriber, list).
5658        assert!(!store.is_suppressed("a@x.com", "other").await.unwrap());
5659        assert!(!store.is_suppressed("b@x.com", "list").await.unwrap());
5660    }
5661
5662    // ── List-Unsubscribe: header emission + send (Component 4) ───────────────
5663
5664    #[test]
5665    fn render_eml_emits_extra_headers() {
5666        let mail = Mail::builder()
5667            .from("from@example.com")
5668            .to("user@example.com")
5669            .subject("Hi")
5670            .text("hello")
5671            .header("List-Unsubscribe", "<https://x/u?token=t>, <mailto:u@x>")
5672            .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
5673            .build()
5674            .expect("mail should build");
5675        let eml = render_eml(&mail);
5676        assert!(eml.contains("List-Unsubscribe: <https://x/u?token=t>, <mailto:u@x>"));
5677        assert!(eml.contains("List-Unsubscribe-Post: List-Unsubscribe=One-Click"));
5678    }
5679
5680    #[test]
5681    fn render_eml_without_headers_has_no_unsubscribe() {
5682        let mail = Mail::builder()
5683            .from("from@example.com")
5684            .to("user@example.com")
5685            .subject("Hi")
5686            .text("hello")
5687            .build()
5688            .expect("mail should build");
5689        assert!(!render_eml(&mail).contains("List-Unsubscribe"));
5690    }
5691
5692    #[derive(Clone)]
5693    struct CapturingTransport {
5694        sent: Arc<std::sync::Mutex<Vec<Mail>>>,
5695    }
5696
5697    impl MailTransport for CapturingTransport {
5698        fn send<'a>(
5699            &'a self,
5700            mail: Mail,
5701        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
5702            Box::pin(async move {
5703                self.sent.lock().expect("sent lock").push(mail);
5704                Ok(())
5705            })
5706        }
5707    }
5708
5709    fn unsubscribe_runtime(
5710        suppression: Option<Arc<dyn SuppressionStore>>,
5711    ) -> Arc<UnsubscribeRuntime> {
5712        Arc::new(UnsubscribeRuntime {
5713            base_url: Some("https://app.example.com".to_owned()),
5714            mailto: Some("unsub@example.com".to_owned()),
5715            signing_keys: Arc::new(test_keys()),
5716            ttl_days: 30,
5717            suppression,
5718        })
5719    }
5720
5721    #[tokio::test]
5722    #[allow(clippy::significant_drop_tightening)]
5723    async fn send_adds_headers_for_list_mail() {
5724        let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5725        let transport = CapturingTransport { sent: sent.clone() };
5726        let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
5727        let mail = Mail::builder()
5728            .from("from@example.com")
5729            .to("user@example.com")
5730            .subject("Digest")
5731            .text("hello")
5732            .list_unsubscribe("weekly_digest")
5733            .build()
5734            .unwrap();
5735        mailer.send(mail).await.unwrap();
5736        let captured = sent.lock().unwrap();
5737        assert_eq!(captured.len(), 1);
5738        let headers = &captured[0].extra_headers;
5739        assert!(headers.iter().any(|(n, v)| n == "List-Unsubscribe"
5740            && v.contains("/_autumn/unsubscribe?token=")
5741            && v.contains("mailto:unsub@example.com")));
5742        assert!(
5743            headers
5744                .iter()
5745                .any(|(n, v)| n == "List-Unsubscribe-Post" && v == "List-Unsubscribe=One-Click")
5746        );
5747    }
5748
5749    #[tokio::test]
5750    #[allow(clippy::significant_drop_tightening)]
5751    async fn send_replaces_manual_list_unsubscribe_with_generated_one_click() {
5752        // A template that opts into list_unsubscribe but also set a hand-rolled
5753        // List-Unsubscribe must end up with the generated per-recipient one-click
5754        // header (replace, not suppress), so RFC 8058 compliance isn't lost.
5755        let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5756        let transport = CapturingTransport { sent: sent.clone() };
5757        let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
5758        let mail = Mail::builder()
5759            .from("from@example.com")
5760            .to("user@example.com")
5761            .subject("Digest")
5762            .text("hello")
5763            .header("List-Unsubscribe", "<mailto:old@example.com>")
5764            .list_unsubscribe("weekly_digest")
5765            .build()
5766            .unwrap();
5767        mailer.send(mail).await.unwrap();
5768        let captured = sent.lock().unwrap();
5769        assert_eq!(captured.len(), 1);
5770        let headers = &captured[0].extra_headers;
5771        // Exactly one List-Unsubscribe, and it's the generated one-click (not the
5772        // stale manual value).
5773        let unsub: Vec<&String> = headers
5774            .iter()
5775            .filter(|(n, _)| n == "List-Unsubscribe")
5776            .map(|(_, v)| v)
5777            .collect();
5778        assert_eq!(unsub.len(), 1);
5779        assert!(unsub[0].contains("/_autumn/unsubscribe?token="));
5780        assert!(!unsub[0].contains("old@example.com"));
5781        assert!(
5782            headers
5783                .iter()
5784                .any(|(n, v)| n == "List-Unsubscribe-Post" && v == "List-Unsubscribe=One-Click")
5785        );
5786    }
5787
5788    #[tokio::test]
5789    async fn send_list_mail_rejects_invalid_recipient_before_delivery() {
5790        let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5791        let transport = CapturingTransport { sent: sent.clone() };
5792        let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
5793        // Second recipient is syntactically invalid. The send must fail before
5794        // delivering to the first, so a retry cannot duplicate that send.
5795        let mail = Mail::builder()
5796            .from("from@example.com")
5797            .to("good@example.com")
5798            .to("not a valid address")
5799            .subject("Digest")
5800            .text("hello")
5801            .list_unsubscribe("weekly_digest")
5802            .build()
5803            .unwrap();
5804        let result = mailer.send(mail).await;
5805        assert!(result.is_err(), "invalid recipient must fail the send");
5806        assert!(
5807            sent.lock().unwrap().is_empty(),
5808            "no recipient may be delivered when the list contains an invalid address"
5809        );
5810    }
5811
5812    #[tokio::test]
5813    async fn send_list_mail_suppression_error_fails_before_any_delivery() {
5814        // A suppression store that errors for one specific subscriber.
5815        struct FailingStore {
5816            fail_for: String,
5817        }
5818        impl SuppressionStore for FailingStore {
5819            fn is_suppressed<'a>(
5820                &'a self,
5821                subscriber: &'a str,
5822                _list_id: &'a str,
5823            ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
5824                let fails = subscriber == self.fail_for;
5825                Box::pin(async move {
5826                    if fails {
5827                        Err(MailError::RuntimeUnavailable(
5828                            "store unavailable".to_owned(),
5829                        ))
5830                    } else {
5831                        Ok(false)
5832                    }
5833                })
5834            }
5835            fn suppress<'a>(
5836                &'a self,
5837                _subscriber: &'a str,
5838                _list_id: &'a str,
5839            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
5840                Box::pin(async move { Ok(()) })
5841            }
5842        }
5843
5844        let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5845        let transport = CapturingTransport { sent: sent.clone() };
5846        let store: Arc<dyn SuppressionStore> = Arc::new(FailingStore {
5847            fail_for: "second@example.com".to_owned(),
5848        });
5849        let mailer =
5850            Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(Some(store)));
5851        // The second recipient's suppression lookup errors. The whole send must
5852        // fail before the first recipient is delivered, so a retry can't duplicate
5853        // that delivery.
5854        let mail = Mail::builder()
5855            .from("from@example.com")
5856            .to("first@example.com")
5857            .to("second@example.com")
5858            .subject("Digest")
5859            .text("hello")
5860            .list_unsubscribe("weekly_digest")
5861            .build()
5862            .unwrap();
5863        let result = mailer.send(mail).await;
5864        assert!(
5865            result.is_err(),
5866            "suppression-store error must fail the send"
5867        );
5868        assert!(
5869            sent.lock().unwrap().is_empty(),
5870            "no recipient may be delivered when a later suppression lookup fails"
5871        );
5872    }
5873
5874    #[tokio::test]
5875    async fn send_skips_suppressed_recipient() {
5876        let store = Arc::new(InMemorySuppressionStore::new());
5877        store
5878            .suppress("user@example.com", "weekly_digest")
5879            .await
5880            .unwrap();
5881        let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5882        let transport = CapturingTransport { sent: sent.clone() };
5883        let mailer =
5884            Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(Some(store)));
5885        let mail = Mail::builder()
5886            .from("from@example.com")
5887            .to("user@example.com")
5888            .subject("Digest")
5889            .text("hello")
5890            .list_unsubscribe("weekly_digest")
5891            .build()
5892            .unwrap();
5893        mailer.send(mail).await.unwrap();
5894        assert!(
5895            sent.lock().unwrap().is_empty(),
5896            "suppressed recipient must be skipped"
5897        );
5898    }
5899
5900    #[tokio::test]
5901    #[allow(clippy::significant_drop_tightening)]
5902    async fn send_without_scope_is_unchanged() {
5903        let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5904        let transport = CapturingTransport { sent: sent.clone() };
5905        let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
5906        let mail = Mail::builder()
5907            .from("from@example.com")
5908            .to("user@example.com")
5909            .subject("Reset")
5910            .text("hello")
5911            .build()
5912            .unwrap();
5913        mailer.send(mail).await.unwrap();
5914        let captured = sent.lock().unwrap();
5915        assert_eq!(captured.len(), 1);
5916        assert!(
5917            captured[0].extra_headers.is_empty(),
5918            "non-list mail must not gain headers"
5919        );
5920    }
5921
5922    // ── List-Unsubscribe: startup fail-closed (Component 6) ──────────────────
5923
5924    #[test]
5925    fn fail_closed_only_in_prod_with_mailers_and_no_config() {
5926        assert!(unsubscribe_config_fail_closed(true, true, true, false));
5927        // configured → ok
5928        assert!(!unsubscribe_config_fail_closed(true, true, true, true));
5929        // no list mailers → ok
5930        assert!(!unsubscribe_config_fail_closed(true, true, false, false));
5931        // not production → ok
5932        assert!(!unsubscribe_config_fail_closed(true, false, true, false));
5933        // not enforced (static build) → ok
5934        assert!(!unsubscribe_config_fail_closed(false, true, true, false));
5935    }
5936
5937    #[test]
5938    fn validate_rejects_non_positive_unsubscribe_ttl() {
5939        let ttl = |days: i64| MailConfig {
5940            unsubscribe_token_ttl_days: days,
5941            ..MailConfig::default()
5942        };
5943        assert!(ttl(0).validate(Some("dev")).is_err());
5944        assert!(ttl(-1).validate(Some("dev")).is_err());
5945        assert!(ttl(30).validate(Some("dev")).is_ok());
5946    }
5947
5948    #[test]
5949    fn unsubscribe_base_url_set_tracks_config() {
5950        let with = |base: Option<&str>, mailto: Option<&str>| MailConfig {
5951            unsubscribe_base_url: base.map(str::to_owned),
5952            unsubscribe_mailto: mailto.map(str::to_owned),
5953            ..MailConfig::default()
5954        };
5955        assert!(!with(None, None).unsubscribe_base_url_set());
5956        // mailto-only is not a base URL (RFC 2369, not one-click).
5957        assert!(!with(None, Some("u@example.com")).unsubscribe_base_url_set());
5958        assert!(with(Some("https://x"), None).unsubscribe_base_url_set());
5959        assert!(!with(Some("   "), None).unsubscribe_base_url_set());
5960    }
5961
5962    #[test]
5963    fn should_mount_unsubscribe_endpoint_requires_opt_in_and_base_url() {
5964        let cfg = |base: Option<&str>, opt_in: bool| MailConfig {
5965            unsubscribe_base_url: base.map(str::to_owned),
5966            mount_unsubscribe_endpoint: opt_in,
5967            ..MailConfig::default()
5968        };
5969        // base URL alone does not mount — opt-in is required.
5970        assert!(!cfg(Some("https://x"), false).should_mount_unsubscribe_endpoint());
5971        assert!(cfg(Some("https://x"), true).should_mount_unsubscribe_endpoint());
5972        // opt-in without a base URL does not mount.
5973        assert!(!cfg(None, true).should_mount_unsubscribe_endpoint());
5974    }
5975
5976    #[test]
5977    fn validate_rejects_malformed_mailto_in_prod() {
5978        let cfg = |mailto: &str| MailConfig {
5979            unsubscribe_mailto: Some(mailto.to_owned()),
5980            ..MailConfig::default()
5981        };
5982        assert!(
5983            cfg("unsubscribe example.com")
5984                .validate(Some("prod"))
5985                .is_err()
5986        );
5987        assert!(cfg("not-an-email").validate(Some("prod")).is_err());
5988        assert!(cfg("unsub@example.com").validate(Some("prod")).is_ok());
5989        // a full mailto: URI is accepted too.
5990        assert!(
5991            cfg("mailto:unsub@example.com")
5992                .validate(Some("prod"))
5993                .is_ok()
5994        );
5995        // dev is lenient.
5996        assert!(cfg("whatever").validate(Some("dev")).is_ok());
5997    }
5998
5999    #[test]
6000    fn validate_requires_https_base_url_in_prod() {
6001        let cfg = |url: &str| MailConfig {
6002            unsubscribe_base_url: Some(url.to_owned()),
6003            ..MailConfig::default()
6004        };
6005        assert!(
6006            cfg("http://app.example.com")
6007                .validate(Some("prod"))
6008                .is_err()
6009        );
6010        assert!(
6011            cfg("https://app.example.com")
6012                .validate(Some("prod"))
6013                .is_ok()
6014        );
6015        // dev allows http for local testing.
6016        assert!(cfg("http://localhost:3000").validate(Some("dev")).is_ok());
6017        // https prefix without a real host is rejected in prod.
6018        assert!(cfg("https://").validate(Some("prod")).is_err());
6019        assert!(cfg("https:///path").validate(Some("prod")).is_err());
6020        // query/fragment bases would break the appended ?token=… link.
6021        assert!(
6022            cfg("https://app.example.com?t=acme")
6023                .validate(Some("prod"))
6024                .is_err()
6025        );
6026        assert!(
6027            cfg("https://app.example.com#x")
6028                .validate(Some("prod"))
6029                .is_err()
6030        );
6031        assert!(
6032            cfg("https://app.example.com/base")
6033                .validate(Some("prod"))
6034                .is_ok()
6035        );
6036    }
6037
6038    #[test]
6039    fn canonical_subscriber_strips_name_and_lowercases() {
6040        assert_eq!(
6041            canonical_subscriber("Ada Lovelace <Ada@Example.com>"),
6042            "ada@example.com"
6043        );
6044        assert_eq!(canonical_subscriber("USER@EXAMPLE.COM"), "user@example.com");
6045    }
6046
6047    #[test]
6048    fn mailto_only_runtime_does_not_support_one_click() {
6049        let runtime = UnsubscribeRuntime {
6050            base_url: None,
6051            mailto: Some("u@example.com".to_owned()),
6052            signing_keys: Arc::new(test_keys()),
6053            ttl_days: 30,
6054            suppression: None,
6055        };
6056        assert!(!runtime.supports_one_click());
6057        let header = runtime
6058            .list_unsubscribe_header("a@x.com", "list")
6059            .expect("mailto header");
6060        assert!(header.contains("mailto:u@example.com"));
6061        assert!(!header.contains("token="));
6062    }
6063
6064    #[test]
6065    fn mailto_value_with_scheme_is_not_double_prefixed() {
6066        let runtime = UnsubscribeRuntime {
6067            base_url: None,
6068            mailto: Some("mailto:u@example.com".to_owned()),
6069            signing_keys: Arc::new(test_keys()),
6070            ttl_days: 30,
6071            suppression: None,
6072        };
6073        let header = runtime
6074            .list_unsubscribe_header("a@x.com", "list")
6075            .expect("mailto header");
6076        assert!(header.contains("<mailto:u@example.com?subject=unsubscribe>"));
6077        assert!(!header.contains("mailto:mailto:"));
6078    }
6079
6080    #[test]
6081    fn one_click_body_detection() {
6082        assert!(is_one_click_body("List-Unsubscribe=One-Click"));
6083        assert!(is_one_click_body("foo=bar&List-Unsubscribe=One-Click"));
6084        assert!(is_one_click_body("list-unsubscribe=one-click")); // case-insensitive
6085        assert!(!is_one_click_body(""));
6086        assert!(!is_one_click_body("List-Unsubscribe=Nope"));
6087        assert!(!is_one_click_body("something=else"));
6088    }
6089
6090    #[test]
6091    fn smtp_config_validation_rejects_whitespace_only_host() {
6092        let config = MailConfig {
6093            transport: Transport::Smtp,
6094            smtp: SmtpConfig {
6095                host: Some("   ".to_owned()),
6096                ..Default::default()
6097            },
6098            ..Default::default()
6099        };
6100
6101        let error = config
6102            .validate(Some("dev"))
6103            .expect_err("whitespace SMTP host should be rejected");
6104
6105        assert!(error.to_string().contains("mail.smtp.host is required"));
6106    }
6107
6108    #[test]
6109    fn transport_env_value_is_trimmed_and_case_insensitive() {
6110        assert_eq!(Transport::from_env_value(" SMTP "), Some(Transport::Smtp));
6111        assert_eq!(Transport::from_env_value(" LoG "), Some(Transport::Log));
6112    }
6113
6114    #[test]
6115    fn tls_mode_env_value_is_trimmed_and_case_insensitive() {
6116        assert_eq!(TlsMode::from_env_value(" TLS "), Some(TlsMode::Tls));
6117        assert_eq!(
6118            TlsMode::from_env_value(" START_TLS "),
6119            Some(TlsMode::StartTls)
6120        );
6121        assert_eq!(
6122            TlsMode::from_env_value(" disabled "),
6123            Some(TlsMode::Disabled)
6124        );
6125    }
6126
6127    #[test]
6128    fn file_transport_filename_is_unique_for_same_recipient() {
6129        let mail = Mail::builder()
6130            .to("Ada Lovelace <ada@example.com>")
6131            .subject("Hello")
6132            .text("body")
6133            .build()
6134            .expect("mail should build");
6135
6136        let first = file_transport_filename(&mail);
6137        let second = file_transport_filename(&mail);
6138
6139        assert_ne!(first, second);
6140        assert!(
6141            Path::new(&first)
6142                .extension()
6143                .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
6144        );
6145        assert!(
6146            Path::new(&second)
6147                .extension()
6148                .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
6149        );
6150    }
6151
6152    #[test]
6153    fn smtp_transport_rejects_missing_password_env_when_username_is_set() {
6154        let missing_key = format!(
6155            "AUTUMN_TEST_MISSING_SMTP_PASSWORD_{}_{}",
6156            std::process::id(),
6157            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
6158        );
6159        let Err(error) = SmtpTransport::new(
6160            SmtpConfig {
6161                host: Some("smtp.example.com".to_owned()),
6162                port: Some(587),
6163                username: Some("mailer".to_owned()),
6164                password_env: Some(missing_key.clone()),
6165                tls: TlsMode::StartTls,
6166            },
6167            None,
6168        ) else {
6169            panic!("missing password env should fail at startup");
6170        };
6171
6172        let displayed = error.to_string();
6173        assert!(displayed.contains(&missing_key));
6174        assert!(displayed.contains("environment variable is not set"));
6175    }
6176
6177    #[test]
6178    fn smtp_password_env_error_never_embeds_the_secret_value() {
6179        // `std::env::VarError::NotUnicode` carries the raw contents of the
6180        // environment variable — i.e. the SMTP password itself. Formatting
6181        // that error directly (`{error}` or `{error:?}`) would leak the
6182        // secret into startup logs, so the redacting helper must map it to a
6183        // static reason instead.
6184        let secret = "hunter2-super-secret-password";
6185        let error = std::env::VarError::NotUnicode(std::ffi::OsString::from(secret));
6186        // Sanity check: the raw VarError does expose the value, which is
6187        // exactly why it must never be formatted into a MailError.
6188        assert!(error.to_string().contains(secret));
6189        assert!(format!("{error:?}").contains(secret));
6190
6191        let mail_error = smtp_password_env_error("APP_SMTP_PASSWORD", &error);
6192        let displayed = mail_error.to_string();
6193        let debugged = format!("{mail_error:?}");
6194        assert!(
6195            !displayed.contains(secret),
6196            "Display output leaked the SMTP password: {displayed}"
6197        );
6198        assert!(
6199            !debugged.contains(secret),
6200            "Debug output leaked the SMTP password: {debugged}"
6201        );
6202        // The env var *name* is ordinary configuration and stays in the
6203        // message so operators can tell which variable is misconfigured.
6204        assert!(displayed.contains("APP_SMTP_PASSWORD"));
6205        assert!(displayed.contains("environment variable contains non-unicode data"));
6206    }
6207
6208    #[test]
6209    fn smtp_password_env_error_redacts_missing_variable_details() {
6210        let error = std::env::VarError::NotPresent;
6211        let mail_error = smtp_password_env_error("APP_SMTP_PASSWORD", &error);
6212        let displayed = mail_error.to_string();
6213        assert!(displayed.contains("APP_SMTP_PASSWORD"));
6214        assert!(displayed.contains("environment variable is not set"));
6215    }
6216
6217    #[test]
6218    fn smtp_transport_rejects_missing_password_env_key_when_username_is_set() {
6219        let Err(error) = SmtpTransport::new(
6220            SmtpConfig {
6221                host: Some("smtp.example.com".to_owned()),
6222                port: Some(587),
6223                username: Some("mailer".to_owned()),
6224                password_env: None,
6225                tls: TlsMode::StartTls,
6226            },
6227            None,
6228        ) else {
6229            panic!("missing password_env setting should fail at startup");
6230        };
6231
6232        assert!(error.to_string().contains("mail.smtp.password_env"));
6233    }
6234
6235    #[test]
6236    fn mailer_builder_rejects_invalid_default_from_address() {
6237        let Err(error) = Mailer::builder().from("not an email address").build() else {
6238            panic!("invalid default from should fail fast");
6239        };
6240
6241        match error {
6242            MailError::InvalidAddress { address, .. } => {
6243                assert_eq!(address, "not an email address");
6244            }
6245            other => panic!("expected invalid address error, got {other:?}"),
6246        }
6247    }
6248
6249    #[test]
6250    fn mailer_from_config_rejects_invalid_default_reply_to_address() {
6251        let config = MailConfig {
6252            transport: Transport::Smtp,
6253            from: Some("Autumn <noreply@example.com>".to_owned()),
6254            reply_to: Some("definitely not an address".to_owned()),
6255            smtp: SmtpConfig {
6256                host: Some("smtp.example.com".to_owned()),
6257                ..Default::default()
6258            },
6259            ..Default::default()
6260        };
6261
6262        let Err(error) = Mailer::from_config(&config) else {
6263            panic!("invalid configured reply-to should fail at construction");
6264        };
6265
6266        match error {
6267            MailError::InvalidAddress { address, .. } => {
6268                assert_eq!(address, "definitely not an address");
6269            }
6270            other => panic!("expected invalid address error, got {other:?}"),
6271        }
6272    }
6273
6274    #[test]
6275    fn try_deliver_later_returns_error_without_runtime() {
6276        let mailer = Mailer::builder().build().expect("mailer should build");
6277        let mail = Mail::builder()
6278            .to("user@example.com")
6279            .subject("Hello")
6280            .text("hello")
6281            .build()
6282            .expect("mail should build");
6283
6284        let error = mailer
6285            .try_deliver_later(mail)
6286            .expect_err("missing runtime should return an error");
6287
6288        assert!(error.to_string().contains("active Tokio runtime"));
6289    }
6290
6291    #[test]
6292    fn deliver_later_does_not_panic_without_runtime() {
6293        let mailer = Mailer::builder().build().expect("mailer should build");
6294        let mail = Mail::builder()
6295            .to("user@example.com")
6296            .subject("Hello")
6297            .text("hello")
6298            .build()
6299            .expect("mail should build");
6300
6301        mailer.deliver_later(mail);
6302    }
6303
6304    fn sample_smtp_config() -> MailConfig {
6305        MailConfig {
6306            transport: Transport::Smtp,
6307            from: Some("Autumn <noreply@example.com>".to_owned()),
6308            smtp: SmtpConfig {
6309                host: Some("smtp.example.com".to_owned()),
6310                ..Default::default()
6311            },
6312            ..Default::default()
6313        }
6314    }
6315
6316    fn sample_mail() -> Mail {
6317        Mail::builder()
6318            .to("user@example.com")
6319            .subject("Hi")
6320            .text("hello")
6321            .build()
6322            .expect("mail should build")
6323    }
6324
6325    struct NoopQueue;
6326
6327    impl MailDeliveryQueue for NoopQueue {
6328        fn enqueue<'a>(
6329            &'a self,
6330            _mail: Mail,
6331        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6332            Box::pin(async { Ok(()) })
6333        }
6334    }
6335
6336    #[test]
6337    fn install_mailer_rejects_in_process_fallback_in_prod_without_ack() {
6338        let state = crate::AppState::for_test().with_profile("prod");
6339        let config = sample_smtp_config();
6340
6341        let error = install_mailer(&state, &config, true)
6342            .expect_err("prod must reject in-process deliver_later fallback without ack");
6343
6344        let message = error.to_string();
6345        assert!(
6346            message.contains("allow_in_process_deliver_later_in_production"),
6347            "error should explain how to opt in: {message}"
6348        );
6349    }
6350
6351    #[test]
6352    fn install_mailer_allows_in_process_fallback_in_prod_with_explicit_ack() {
6353        let state = crate::AppState::for_test().with_profile("prod");
6354        let config = MailConfig {
6355            allow_in_process_deliver_later_in_production: true,
6356            ..sample_smtp_config()
6357        };
6358
6359        install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
6360    }
6361
6362    #[test]
6363    fn install_mailer_allows_durable_queue_in_prod_without_ack() {
6364        let state = crate::AppState::for_test().with_profile("prod");
6365        state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
6366        let config = sample_smtp_config();
6367
6368        install_mailer(&state, &config, true)
6369            .expect("a registered durable queue should satisfy the prod guard");
6370    }
6371
6372    #[test]
6373    fn install_mailer_does_not_require_ack_outside_production() {
6374        let state = crate::AppState::for_test().with_profile("dev");
6375        let config = sample_smtp_config();
6376
6377        install_mailer(&state, &config, true).expect("non-prod profiles should not require an ack");
6378    }
6379
6380    #[test]
6381    fn install_mailer_does_not_require_ack_when_transport_is_disabled() {
6382        let state = crate::AppState::for_test().with_profile("prod");
6383        let config = MailConfig::default();
6384
6385        install_mailer(&state, &config, true)
6386            .expect("disabled transport never sends mail so it should not need an ack");
6387    }
6388
6389    struct CapturingQueue {
6390        tx: tokio::sync::mpsc::UnboundedSender<Mail>,
6391    }
6392
6393    impl MailDeliveryQueue for CapturingQueue {
6394        fn enqueue<'a>(
6395            &'a self,
6396            mail: Mail,
6397        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6398            let tx = self.tx.clone();
6399            Box::pin(async move {
6400                tx.send(mail)
6401                    .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
6402                Ok(())
6403            })
6404        }
6405    }
6406
6407    #[cfg(feature = "db")]
6408    struct FailingQueue {
6409        tx: tokio::sync::mpsc::UnboundedSender<Mail>,
6410    }
6411
6412    #[cfg(feature = "db")]
6413    impl MailDeliveryQueue for FailingQueue {
6414        fn enqueue<'a>(
6415            &'a self,
6416            mail: Mail,
6417        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6418            let tx = self.tx.clone();
6419            Box::pin(async move {
6420                tx.send(mail)
6421                    .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
6422                Err(MailError::RuntimeUnavailable("queue offline".to_owned()))
6423            })
6424        }
6425    }
6426
6427    #[cfg(feature = "db")]
6428    async fn drain_after_commit_callbacks_for_test(
6429        registry: &std::sync::Arc<std::sync::Mutex<Vec<crate::db::CommitCallback>>>,
6430    ) {
6431        let callbacks: Vec<crate::db::CommitCallback> = {
6432            let mut reg = registry
6433                .lock()
6434                .unwrap_or_else(std::sync::PoisonError::into_inner);
6435            std::mem::take(&mut *reg)
6436        };
6437
6438        for cb in callbacks {
6439            if let Err(error) = cb().await {
6440                crate::db::record_after_commit_failure();
6441                tracing::error!("test drain: after_commit callback failed: {error}");
6442            }
6443        }
6444    }
6445
6446    #[cfg(feature = "db")]
6447    #[tokio::test]
6448    async fn deferred_deliver_later_queue_failure_increments_after_commit_counter() {
6449        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6450        let mailer = Mailer::builder()
6451            .delivery_queue(FailingQueue { tx })
6452            .build()
6453            .expect("mailer should build");
6454        let registry = std::sync::Arc::new(std::sync::Mutex::new(
6455            Vec::<crate::db::CommitCallback>::new(),
6456        ));
6457        let before =
6458            crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
6459
6460        crate::db::AFTER_COMMIT_REGISTRY
6461            .scope(registry.clone(), async {
6462                mailer
6463                    .try_deliver_later(sample_mail())
6464                    .expect("registering deferred mail should succeed");
6465            })
6466            .await;
6467
6468        drain_after_commit_callbacks_for_test(&registry).await;
6469
6470        let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6471            .await
6472            .expect("queue should be called within 1s")
6473            .expect("queue should receive the mail");
6474        assert_eq!(received.subject, "Hi");
6475
6476        let after =
6477            crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
6478        assert!(
6479            after > before,
6480            "deferred durable mail handoff failures should count as after_commit failures"
6481        );
6482    }
6483
6484    #[tokio::test]
6485    async fn deliver_later_routes_through_configured_queue() {
6486        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6487
6488        let mailer = Mailer::builder()
6489            .delivery_queue(CapturingQueue { tx })
6490            .build()
6491            .expect("mailer should build");
6492
6493        mailer
6494            .try_deliver_later(sample_mail())
6495            .expect("scheduling onto the queue should succeed");
6496
6497        let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6498            .await
6499            .expect("queue should receive within 1s")
6500            .expect("queue should receive the mail");
6501
6502        assert_eq!(received.subject, "Hi");
6503    }
6504
6505    #[tokio::test]
6506    async fn deliver_later_preserves_attachments_through_queue() {
6507        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6508
6509        let mailer = Mailer::builder()
6510            .delivery_queue(CapturingQueue { tx })
6511            .build()
6512            .expect("mailer should build");
6513
6514        let mail = Mail::builder()
6515            .to("user@example.com")
6516            .subject("Hi")
6517            .text("hello")
6518            .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
6519            .build()
6520            .expect("mail should build");
6521
6522        mailer
6523            .try_deliver_later(mail.clone())
6524            .expect("scheduling onto the queue should succeed");
6525
6526        let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6527            .await
6528            .expect("queue should receive within 1s")
6529            .expect("queue should receive the mail");
6530
6531        // The deferred path freezes the originating mailer's CSS-inlining default
6532        // onto the message before enqueue (issue #1254); this mailer defaults
6533        // inlining off, so the enqueued job carries `Some(false)` where the
6534        // source had `None`. Everything else (notably attachments) is untouched.
6535        let mut expected = mail;
6536        expected.inline_css = Some(false);
6537        assert_eq!(received, expected);
6538    }
6539
6540    #[tokio::test]
6541    async fn deferred_enqueue_freezes_originating_inline_css_default() {
6542        // A mailer whose config defaults CSS inlining ON must record that
6543        // decision on the persisted job when the message carries no explicit
6544        // override, so a worker consuming the durable queue (with a possibly
6545        // different/off default) still inlines. Only the flag is frozen — the
6546        // body is left un-inlined so the single inline pass happens once at the
6547        // consumer's send() (issue #1254).
6548        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6549        let mailer = Mailer::builder()
6550            .inline_css(true)
6551            .delivery_queue(CapturingQueue { tx })
6552            .build()
6553            .expect("mailer should build");
6554
6555        let mail = Mail::builder()
6556            .to("user@example.com")
6557            .subject("Hi")
6558            .html(
6559                "<html><head><style>p { color: red; }</style></head><body><p>hi</p></body></html>",
6560            )
6561            .build()
6562            .expect("mail should build");
6563        assert_eq!(mail.inline_css, None, "sample relies on the mailer default");
6564
6565        mailer
6566            .try_deliver_later(mail)
6567            .expect("scheduling onto the queue should succeed");
6568
6569        let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6570            .await
6571            .expect("queue should receive within 1s")
6572            .expect("queue should receive the mail");
6573
6574        assert_eq!(
6575            received.inline_css,
6576            Some(true),
6577            "the originating mailer's inlining default must be frozen onto the enqueued job"
6578        );
6579        assert!(
6580            received
6581                .html
6582                .as_deref()
6583                .expect("html body")
6584                .contains("<style>"),
6585            "the body must be left un-inlined at enqueue time; inlining happens once at the consumer's send()"
6586        );
6587    }
6588
6589    #[tokio::test]
6590    async fn deferred_enqueue_preserves_explicit_inline_css_override() {
6591        // An explicit per-message `inline_css(false)` opt-out must survive the
6592        // durable-queue handoff and never be clobbered by the mailer default.
6593        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6594        let mailer = Mailer::builder()
6595            .inline_css(true)
6596            .delivery_queue(CapturingQueue { tx })
6597            .build()
6598            .expect("mailer should build");
6599
6600        let mail = Mail::builder()
6601            .to("user@example.com")
6602            .subject("Hi")
6603            .html(
6604                "<html><head><style>p { color: red; }</style></head><body><p>hi</p></body></html>",
6605            )
6606            .inline_css(false)
6607            .build()
6608            .expect("mail should build");
6609
6610        mailer
6611            .try_deliver_later(mail)
6612            .expect("scheduling onto the queue should succeed");
6613
6614        let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6615            .await
6616            .expect("queue should receive within 1s")
6617            .expect("queue should receive the mail");
6618
6619        assert_eq!(
6620            received.inline_css,
6621            Some(false),
6622            "an explicit per-message override must be preserved through the queue, not overwritten by the mailer default"
6623        );
6624    }
6625
6626    #[tokio::test]
6627    async fn deliver_later_without_queue_sends_via_transport_directly() {
6628        // When no delivery queue is configured, `spawn_mail_delivery` falls back to
6629        // calling `mailer.send()` in a background task.
6630        use std::sync::Arc;
6631        use std::sync::atomic::{AtomicBool, Ordering};
6632
6633        struct TrackingSend(Arc<AtomicBool>);
6634        impl MailTransport for TrackingSend {
6635            fn send<'a>(
6636                &'a self,
6637                _mail: Mail,
6638            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6639                self.0.store(true, Ordering::SeqCst);
6640                Box::pin(async { Ok(()) })
6641            }
6642        }
6643
6644        let sent = Arc::new(AtomicBool::new(false));
6645        let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
6646
6647        mailer
6648            .try_deliver_later(sample_mail())
6649            .expect("should succeed without queue");
6650
6651        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6652        assert!(
6653            sent.load(Ordering::SeqCst),
6654            "mail should have been sent directly via transport"
6655        );
6656    }
6657
6658    #[cfg(feature = "db")]
6659    #[tokio::test]
6660    async fn deferred_deliver_later_without_queue_sends_after_commit() {
6661        // After-commit callback with no queue falls back to `spawn_mail_delivery`
6662        // which calls `mailer.send()` in a spawned task.
6663        use std::sync::Arc;
6664        use std::sync::atomic::{AtomicBool, Ordering};
6665
6666        struct TrackingSend(Arc<AtomicBool>);
6667        impl MailTransport for TrackingSend {
6668            fn send<'a>(
6669                &'a self,
6670                _mail: Mail,
6671            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6672                self.0.store(true, Ordering::SeqCst);
6673                Box::pin(async { Ok(()) })
6674            }
6675        }
6676
6677        let sent = Arc::new(AtomicBool::new(false));
6678        let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
6679        let registry = std::sync::Arc::new(std::sync::Mutex::new(
6680            Vec::<crate::db::CommitCallback>::new(),
6681        ));
6682
6683        crate::db::AFTER_COMMIT_REGISTRY
6684            .scope(registry.clone(), async {
6685                mailer
6686                    .try_deliver_later(sample_mail())
6687                    .expect("should succeed");
6688            })
6689            .await;
6690
6691        drain_after_commit_callbacks_for_test(&registry).await;
6692        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6693
6694        assert!(
6695            sent.load(Ordering::SeqCst),
6696            "mail should have been sent after commit via direct transport"
6697        );
6698    }
6699
6700    #[tokio::test]
6701    async fn mailer_with_transport_starts_without_delivery_queue() {
6702        let mailer = Mailer::with_transport(NoopTransport);
6703        assert!(
6704            !mailer.has_durable_delivery_queue(),
6705            "with_transport should default to no durable queue"
6706        );
6707        // Exercise NoopTransport::send so its body is also covered.
6708        mailer
6709            .send(sample_mail())
6710            .await
6711            .expect("noop transport should always succeed");
6712    }
6713
6714    struct NoopTransport;
6715    impl MailTransport for NoopTransport {
6716        fn send<'a>(
6717            &'a self,
6718            _mail: Mail,
6719        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6720            Box::pin(async { Ok(()) })
6721        }
6722    }
6723
6724    #[tokio::test]
6725    async fn deliver_later_is_noop_when_transport_disabled_even_with_queue() {
6726        // The Mailer-level builder lets callers attach a queue *and* pick
6727        // Transport::Disabled. The disabled-transport contract requires
6728        // deliver_later to drop the message in that case — the queue must
6729        // not persist mail when the operator has turned mail off entirely.
6730        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6731        let mailer = Mailer::builder()
6732            .transport(Transport::Disabled)
6733            .delivery_queue(CapturingQueue { tx })
6734            .build()
6735            .expect("mailer should build");
6736
6737        mailer
6738            .try_deliver_later(sample_mail())
6739            .expect("disabled transport should succeed as a no-op");
6740
6741        // Wait briefly for any spawn that might erroneously fire to land.
6742        let received = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
6743        assert!(
6744            received.is_err(),
6745            "queue must not be invoked when transport is disabled"
6746        );
6747    }
6748
6749    #[tokio::test]
6750    async fn deliver_later_uses_in_process_fallback_when_no_queue() {
6751        // The default Mailer has no durable queue, so deliver_later should
6752        // still spawn the in-process Tokio task and not call any queue.
6753        let mailer = Mailer::builder().build().expect("mailer should build");
6754
6755        mailer
6756            .try_deliver_later(sample_mail())
6757            .expect("in-process fallback should still schedule");
6758    }
6759
6760    #[test]
6761    fn mail_delivery_queue_handle_round_trips_via_from_arc_and_inner() {
6762        let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
6763        let handle = MailDeliveryQueueHandle::from_arc(Arc::clone(&arc));
6764
6765        assert!(Arc::ptr_eq(handle.inner(), &arc));
6766    }
6767
6768    #[test]
6769    fn mail_delivery_queue_handle_debug_does_not_panic() {
6770        let handle = MailDeliveryQueueHandle::new(NoopQueue);
6771        let rendered = format!("{handle:?}");
6772        assert!(rendered.contains("MailDeliveryQueueHandle"));
6773    }
6774
6775    #[test]
6776    fn mailer_has_durable_delivery_queue_reflects_attachment() {
6777        let plain = Mailer::builder().build().expect("mailer should build");
6778        assert!(!plain.has_durable_delivery_queue());
6779
6780        let with_queue = Mailer::builder()
6781            .delivery_queue(NoopQueue)
6782            .build()
6783            .expect("mailer should build");
6784        assert!(with_queue.has_durable_delivery_queue());
6785    }
6786
6787    #[test]
6788    fn mailer_with_delivery_queue_post_build_attaches_queue() {
6789        let mailer = Mailer::builder()
6790            .build()
6791            .expect("mailer should build")
6792            .with_delivery_queue(NoopQueue);
6793
6794        assert!(mailer.has_durable_delivery_queue());
6795    }
6796
6797    #[test]
6798    fn mailer_builder_delivery_queue_arc_attaches_shared_queue() {
6799        let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
6800        let mailer = Mailer::builder()
6801            .delivery_queue_arc(arc)
6802            .build()
6803            .expect("mailer should build");
6804
6805        assert!(mailer.has_durable_delivery_queue());
6806    }
6807
6808    #[test]
6809    fn install_mailer_warns_but_succeeds_with_explicit_ack_in_prod() {
6810        // Same as the explicit-ack test, but also asserts the mailer was
6811        // actually inserted and has no durable queue attached.
6812        let state = crate::AppState::for_test().with_profile("prod");
6813        let config = MailConfig {
6814            allow_in_process_deliver_later_in_production: true,
6815            ..sample_smtp_config()
6816        };
6817
6818        install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
6819
6820        let installed = state
6821            .extension::<Mailer>()
6822            .expect("install_mailer should store a Mailer extension");
6823        assert!(
6824            !installed.has_durable_delivery_queue(),
6825            "no queue was registered, so installed mailer should fall back in-process"
6826        );
6827    }
6828
6829    #[test]
6830    fn install_mailer_attaches_registered_queue_to_mailer() {
6831        let state = crate::AppState::for_test().with_profile("prod");
6832        state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
6833        let config = sample_smtp_config();
6834
6835        install_mailer(&state, &config, true).expect("durable queue should permit prod startup");
6836
6837        let installed = state
6838            .extension::<Mailer>()
6839            .expect("install_mailer should store a Mailer extension");
6840        assert!(
6841            installed.has_durable_delivery_queue(),
6842            "registered queue handle should be attached to the installed mailer"
6843        );
6844    }
6845
6846    #[test]
6847    fn install_mailer_with_factory_runs_factory_and_attaches_queue() {
6848        let state = crate::AppState::for_test().with_profile("prod");
6849        let config = sample_smtp_config();
6850        let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
6851        let captured = Arc::clone(&factory_called);
6852
6853        let factory = move |_state: &crate::AppState| {
6854            captured.store(true, std::sync::atomic::Ordering::SeqCst);
6855            Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
6856        };
6857
6858        install_mailer_with_factory(&state, &config, Some(factory), true)
6859            .expect("factory should produce a queue and satisfy the prod guard");
6860
6861        assert!(
6862            factory_called.load(std::sync::atomic::Ordering::SeqCst),
6863            "factory must run when enforce_durable_guard is true"
6864        );
6865        let installed = state
6866            .extension::<Mailer>()
6867            .expect("install_mailer should store a Mailer extension");
6868        assert!(
6869            installed.has_durable_delivery_queue(),
6870            "factory's queue should be wired into the installed Mailer"
6871        );
6872    }
6873
6874    #[test]
6875    fn install_mailer_with_factory_skips_factory_when_not_enforced() {
6876        let state = crate::AppState::for_test().with_profile("prod");
6877        let config = sample_smtp_config();
6878        let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
6879        let captured = Arc::clone(&factory_called);
6880
6881        let factory = move |_state: &crate::AppState| {
6882            captured.store(true, std::sync::atomic::Ordering::SeqCst);
6883            Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
6884        };
6885
6886        install_mailer_with_factory(&state, &config, Some(factory), false)
6887            .expect("static-build path should skip factory and install cleanly");
6888
6889        assert!(
6890            !factory_called.load(std::sync::atomic::Ordering::SeqCst),
6891            "factory must be skipped when enforce_durable_guard is false"
6892        );
6893    }
6894
6895    #[test]
6896    fn install_mailer_with_factory_propagates_factory_errors() {
6897        let state = crate::AppState::for_test().with_profile("prod");
6898        let config = sample_smtp_config();
6899
6900        let factory = |_state: &crate::AppState| {
6901            Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
6902                "queue offline",
6903            ))
6904        };
6905
6906        let error = install_mailer_with_factory(&state, &config, Some(factory), true)
6907            .expect_err("factory error should propagate");
6908        assert!(error.to_string().contains("queue offline"));
6909    }
6910
6911    #[test]
6912    fn install_mailer_with_factory_skips_factory_when_transport_disabled() {
6913        // Even when enforce_durable_guard=true (normal server path), a
6914        // profile with transport=disabled must not run the factory: the
6915        // factory might open Redis/Harvest/DB connections, but all mail in
6916        // this profile is supposed to be a no-op.
6917        let state = crate::AppState::for_test().with_profile("dev");
6918        let config = MailConfig::default(); // transport = Disabled
6919        let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
6920        let captured = Arc::clone(&factory_called);
6921
6922        let factory = move |_state: &crate::AppState| {
6923            captured.store(true, std::sync::atomic::Ordering::SeqCst);
6924            Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
6925                "queue must not be reached",
6926            ))
6927        };
6928
6929        install_mailer_with_factory(&state, &config, Some(factory), true)
6930            .expect("disabled transport should bypass the factory entirely");
6931        assert!(
6932            !factory_called.load(std::sync::atomic::Ordering::SeqCst),
6933            "factory must not run when transport = disabled"
6934        );
6935    }
6936
6937    #[test]
6938    fn install_mailer_with_factory_works_without_factory() {
6939        type FactoryFn = fn(&crate::AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>;
6940        let state = crate::AppState::for_test().with_profile("dev");
6941        let config = sample_smtp_config();
6942        let no_factory: Option<FactoryFn> = None;
6943
6944        install_mailer_with_factory(&state, &config, no_factory, true)
6945            .expect("absent factory should be fine in non-prod");
6946    }
6947
6948    #[test]
6949    fn install_mailer_does_not_run_factory_when_not_enforced_and_no_handle() {
6950        // Mirrors run_build_mode: queue factory is intentionally skipped, so
6951        // no MailDeliveryQueueHandle is on AppState. install_mailer must
6952        // tolerate this and not try to enforce or warn about a missing queue.
6953        let state = crate::AppState::for_test().with_profile("prod");
6954        let config = sample_smtp_config();
6955
6956        install_mailer(&state, &config, false)
6957            .expect("static-build mode should install cleanly with no queue handle");
6958
6959        let installed = state
6960            .extension::<Mailer>()
6961            .expect("install_mailer should store a Mailer extension");
6962        assert!(
6963            !installed.has_durable_delivery_queue(),
6964            "no queue is expected when run_build_mode skips the factory"
6965        );
6966    }
6967
6968    #[test]
6969    fn install_mailer_skips_production_guard_when_not_enforced() {
6970        // Static-site builds (run_build_mode) call install_mailer with
6971        // enforce_durable_guard=false because they don't run the request
6972        // loop and don't actually defer mail. Even with a prod profile,
6973        // an active SMTP transport, no queue, and no ack flag, install
6974        // must succeed in this mode.
6975        let state = crate::AppState::for_test().with_profile("prod");
6976        let config = sample_smtp_config();
6977
6978        install_mailer(&state, &config, false)
6979            .expect("static-build mode should not enforce the deliver_later guard");
6980    }
6981
6982    #[test]
6983    fn spawn_mail_delivery_inherits_parent_span() {
6984        use std::future::Future;
6985        use std::pin::Pin;
6986        use std::sync::{Arc, Mutex};
6987
6988        struct CapturingQueue(Arc<Mutex<Option<tracing::span::Id>>>);
6989        impl MailDeliveryQueue for CapturingQueue {
6990            fn enqueue<'a>(
6991                &'a self,
6992                _mail: Mail,
6993            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6994                let captured = self.0.clone();
6995                Box::pin(async move {
6996                    *captured.lock().unwrap() = tracing::Span::current().id();
6997                    Ok(())
6998                })
6999            }
7000        }
7001
7002        let captured_span_id: Arc<Mutex<Option<tracing::span::Id>>> = Arc::new(Mutex::new(None));
7003
7004        let mailer = Mailer::builder()
7005            .delivery_queue(CapturingQueue(captured_span_id.clone()))
7006            .build()
7007            .expect("mailer with queue should build");
7008        let mail = sample_mail();
7009
7010        // The subscriber must remain active for the entire duration — spanning
7011        // both the enqueue call and the spawned task's execution — so that
7012        // `tracing::Span::current()` inside the task sees the same span tree
7013        // that was active when `try_deliver_later` was called.
7014        tracing::subscriber::with_default(tracing_subscriber::registry(), || {
7015            let rt = tokio::runtime::Builder::new_current_thread()
7016                .enable_all()
7017                .build()
7018                .expect("build runtime");
7019
7020            let outer = tracing::info_span!("deliver_later_outer");
7021            let outer_id = outer.id();
7022
7023            rt.block_on(async {
7024                {
7025                    let _guard = outer.enter();
7026                    mailer
7027                        .try_deliver_later(mail)
7028                        .expect("deliver_later must not fail");
7029                }
7030
7031                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7032            });
7033
7034            let in_task = captured_span_id.lock().unwrap().clone();
7035            assert_eq!(
7036                in_task, outer_id,
7037                "delivery task must run inside the span that called deliver_later"
7038            );
7039        });
7040    }
7041
7042    #[tokio::test]
7043    async fn spawn_mail_delivery_logs_error_when_queue_fails() {
7044        use std::future::Future;
7045        use std::pin::Pin;
7046
7047        struct AlwaysFailQueue;
7048        impl MailDeliveryQueue for AlwaysFailQueue {
7049            fn enqueue<'a>(
7050                &'a self,
7051                _mail: Mail,
7052            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7053                Box::pin(async { Err(MailError::RuntimeUnavailable("always fails".to_owned())) })
7054            }
7055        }
7056
7057        let mailer = Mailer::builder()
7058            .delivery_queue(AlwaysFailQueue)
7059            .build()
7060            .expect("build");
7061
7062        mailer
7063            .try_deliver_later(sample_mail())
7064            .expect("should schedule");
7065
7066        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7067    }
7068
7069    #[tokio::test]
7070    async fn spawn_mail_delivery_logs_error_when_transport_fails() {
7071        use std::future::Future;
7072        use std::pin::Pin;
7073
7074        struct AlwaysFailTransport;
7075        impl MailTransport for AlwaysFailTransport {
7076            fn send<'a>(
7077                &'a self,
7078                _mail: Mail,
7079            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7080                Box::pin(async {
7081                    Err(MailError::RuntimeUnavailable(
7082                        "transport offline".to_owned(),
7083                    ))
7084                })
7085            }
7086        }
7087
7088        let mailer = Mailer::with_transport(AlwaysFailTransport);
7089
7090        mailer
7091            .try_deliver_later(sample_mail())
7092            .expect("should schedule");
7093
7094        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7095    }
7096
7097    #[test]
7098    fn install_mailer_does_not_attach_queue_when_transport_disabled() {
7099        // When mail.transport = "disabled" the operator has explicitly turned
7100        // mail off for this profile (tests, review apps, etc.). A globally
7101        // registered queue must not turn deliver_later back into a durable
7102        // persist; it should remain a no-op.
7103        let state = crate::AppState::for_test().with_profile("dev");
7104        state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
7105        let config = MailConfig::default(); // transport = Disabled
7106
7107        install_mailer(&state, &config, true).expect("disabled transport should install cleanly");
7108
7109        let installed = state
7110            .extension::<Mailer>()
7111            .expect("install_mailer should store a Mailer extension");
7112        assert!(
7113            !installed.has_durable_delivery_queue(),
7114            "disabled transport must suppress queue attachment so deliver_later is a no-op"
7115        );
7116    }
7117
7118    #[tokio::test]
7119    async fn intercepted_mail_transport_short_circuit_prevents_sync_execution() {
7120        use std::future::Future;
7121        use std::pin::Pin;
7122        use std::sync::atomic::{AtomicU32, Ordering};
7123
7124        static TRANSPORT_CALLS: AtomicU32 = AtomicU32::new(0);
7125
7126        struct CountingTransport;
7127        impl MailTransport for CountingTransport {
7128            fn send<'a>(
7129                &'a self,
7130                _mail: Mail,
7131            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7132                TRANSPORT_CALLS.fetch_add(1, Ordering::SeqCst);
7133                Box::pin(async move { Ok(()) })
7134            }
7135
7136            fn is_disabled(&self) -> bool {
7137                false
7138            }
7139        }
7140
7141        struct ShortCircuitMailInterceptor;
7142        impl crate::interceptor::MailInterceptor for ShortCircuitMailInterceptor {
7143            fn intercept<'a>(
7144                &'a self,
7145                _mail: &'a Mail,
7146                _next: Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>,
7147            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7148                Box::pin(async move {
7149                    Err(MailError::RuntimeUnavailable(
7150                        "blocked by interceptor".to_owned(),
7151                    ))
7152                })
7153            }
7154        }
7155
7156        let transport = Arc::new(CountingTransport);
7157        let interceptor = Arc::new(ShortCircuitMailInterceptor);
7158        let intercepted = InterceptedMailTransport {
7159            inner: transport,
7160            interceptor,
7161        };
7162
7163        let mail = Mail::builder()
7164            .to("test@example.com")
7165            .subject("test")
7166            .text("body")
7167            .build()
7168            .unwrap();
7169
7170        TRANSPORT_CALLS.store(0, Ordering::SeqCst);
7171
7172        let res = intercepted.send(mail).await;
7173        assert!(res.is_err());
7174        assert_eq!(TRANSPORT_CALLS.load(Ordering::SeqCst), 0);
7175    }
7176
7177    #[tokio::test]
7178    #[allow(clippy::await_holding_lock)]
7179    async fn test_smtp_transport_circuit_breaker() {
7180        let _lock = crate::circuit_breaker::TEST_LOCK
7181            .lock()
7182            .unwrap_or_else(std::sync::PoisonError::into_inner);
7183        crate::circuit_breaker::global_registry().clear();
7184        let policy = crate::circuit_breaker::CircuitBreakerPolicy {
7185            failure_ratio_threshold: 0.5,
7186            sample_window: std::time::Duration::from_secs(10),
7187            minimum_sample_count: 3,
7188            open_duration: std::time::Duration::from_secs(60),
7189            half_open_trial_count: 2,
7190        };
7191        let breaker =
7192            crate::circuit_breaker::global_registry().get_or_create("smtp_mailer", policy);
7193
7194        // Ensure it is closed initially
7195        assert_eq!(
7196            breaker.state(),
7197            crate::circuit_breaker::CircuitState::Closed
7198        );
7199
7200        // Build an SMTP transport pointing to a bogus localhost port so it fails
7201        let config = SmtpConfig {
7202            host: Some("127.0.0.1".to_string()),
7203            port: Some(9999), // Bogus port
7204            tls: TlsMode::Disabled,
7205            username: None,
7206            password_env: None,
7207        };
7208        let transport = SmtpTransport::new(config, None).unwrap();
7209
7210        let mail = Mail::builder()
7211            .from("sender@example.com")
7212            .to("test@example.com")
7213            .subject("test")
7214            .text("body")
7215            .build()
7216            .unwrap();
7217
7218        // Send 3 times — all should fail and trip the breaker
7219        for _ in 0..3 {
7220            let res = transport.send(mail.clone()).await;
7221            assert!(res.is_err());
7222        }
7223
7224        assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
7225
7226        // 4th send should fail fast with a circuit breaker error
7227        let res = transport.send(mail.clone()).await;
7228        assert!(res.is_err());
7229        let err_str = res.err().unwrap().to_string();
7230        assert!(
7231            err_str.contains("circuit breaker")
7232                || err_str.contains("open")
7233                || err_str.contains("Open")
7234                || err_str.contains("runtime unavailable")
7235        );
7236
7237        crate::circuit_breaker::global_registry().clear();
7238    }
7239
7240    #[test]
7241    fn validate_log_transport_in_prod_fails() {
7242        let cfg = MailConfig {
7243            transport: Transport::Log,
7244            ..MailConfig::default()
7245        };
7246        assert!(cfg.validate(Some("prod")).is_err());
7247        assert!(cfg.validate(Some("production")).is_err());
7248        // allow flag lifts the restriction.
7249        let allowed = MailConfig {
7250            transport: Transport::Log,
7251            allow_log_in_production: true,
7252            ..MailConfig::default()
7253        };
7254        assert!(allowed.validate(Some("prod")).is_ok());
7255    }
7256
7257    #[test]
7258    fn validate_preview_outside_dev_fails() {
7259        let cfg = MailConfig {
7260            preview: true,
7261            ..MailConfig::default()
7262        };
7263        assert!(cfg.validate(Some("prod")).is_err());
7264        assert!(cfg.validate(Some("dev")).is_ok());
7265        assert!(cfg.validate(Some("development")).is_ok());
7266    }
7267
7268    #[test]
7269    fn is_valid_https_base_url_edge_cases() {
7270        assert!(is_valid_https_base_url("https://app.example.com"));
7271        assert!(is_valid_https_base_url("https://app.example.com/base"));
7272        assert!(!is_valid_https_base_url("http://app.example.com"));
7273        assert!(!is_valid_https_base_url("https://"));
7274        assert!(!is_valid_https_base_url("https:///path"));
7275        assert!(!is_valid_https_base_url("https://app.example.com?q=1"));
7276        assert!(!is_valid_https_base_url("https://app.example.com#frag"));
7277        assert!(!is_valid_https_base_url("https://host name.com"));
7278        // Malformed authorities that a naive `/`-split would wrongly accept.
7279        assert!(!is_valid_https_base_url("https://app.example.com:abc"));
7280        assert!(!is_valid_https_base_url("https://@/base"));
7281        assert!(!is_valid_https_base_url("https://user@app.example.com"));
7282        // A valid explicit port is fine.
7283        assert!(is_valid_https_base_url("https://app.example.com:8443"));
7284        // Characters unsafe inside an RFC 2369 angle-bracket URI are rejected,
7285        // even though `Url::parse` would percent-encode them.
7286        assert!(!is_valid_https_base_url("https://example.com/<x>"));
7287        assert!(!is_valid_https_base_url("https://example.com/a b"));
7288        assert!(!is_valid_https_base_url("https://example.com/a\r\nb"));
7289        // Missing/short authority that `Url::parse` would normalize to a valid
7290        // host — rejected so prod can't advertise an unusable one-click URL.
7291        assert!(!is_valid_https_base_url("https:/app.example.com"));
7292        assert!(!is_valid_https_base_url("https:app.example.com"));
7293    }
7294
7295    #[test]
7296    fn is_valid_mailto_address_edge_cases() {
7297        assert!(is_valid_mailto_address("unsub@example.com"));
7298        assert!(is_valid_mailto_address("mailto:unsub@example.com"));
7299        assert!(is_valid_mailto_address(
7300            "mailto:unsub@example.com?subject=hi"
7301        ));
7302        assert!(!is_valid_mailto_address("not-an-email"));
7303        assert!(!is_valid_mailto_address("missing@dot"));
7304        assert!(!is_valid_mailto_address("space @example.com"));
7305        assert!(!is_valid_mailto_address(""));
7306        assert!(!is_valid_mailto_address("@example.com")); // empty local
7307        assert!(!is_valid_mailto_address("local@")); // empty domain
7308        // Other URI schemes must be rejected, not coerced into <mailto:…>.
7309        assert!(!is_valid_mailto_address("https://unsub@example.com"));
7310        assert!(!is_valid_mailto_address("mailto:https://unsub@example.com"));
7311        assert!(!is_valid_mailto_address("unsub@https://example.com"));
7312        // CRLF / control characters (header-injection attempt) are rejected even
7313        // when hidden behind a `?query` the address check would otherwise drop.
7314        assert!(!is_valid_mailto_address(
7315            "mailto:unsub@example.com?subject=x\r\nBcc: victim@example.com"
7316        ));
7317        assert!(!is_valid_mailto_address("unsub@example.com\nBcc: v@x.com"));
7318        // RFC 2369 delimiters (`<`/`>`/`,`) would close the angle-bracket entry
7319        // and inject an extra List-Unsubscribe target.
7320        assert!(!is_valid_mailto_address(
7321            "unsub@example.com>,<bogus@example.com"
7322        ));
7323        assert!(!is_valid_mailto_address("a@x.com,b@x.com"));
7324    }
7325
7326    #[test]
7327    fn unsubscribe_header_mailto_drops_configured_query_no_injection() {
7328        // Even if a malformed value slipped past validation (e.g. set outside
7329        // prod), the rendered header must carry only the bare mailbox plus the
7330        // canonical subject — never an injected CRLF/Bcc.
7331        let runtime = UnsubscribeRuntime {
7332            base_url: None,
7333            mailto: Some("mailto:u@example.com?subject=x\r\nBcc: v@x.com".to_owned()),
7334            signing_keys: Arc::new(test_keys()),
7335            ttl_days: 30,
7336            suppression: None,
7337        };
7338        let header = runtime
7339            .list_unsubscribe_header("a@x.com", "list")
7340            .expect("mailto header");
7341        assert_eq!(header, "<mailto:u@example.com?subject=unsubscribe>");
7342        assert!(!header.contains('\r') && !header.contains('\n'));
7343        assert!(!header.contains("Bcc"));
7344    }
7345
7346    #[test]
7347    fn unsubscribe_runtime_header_both_base_url_and_mailto() {
7348        let runtime = UnsubscribeRuntime {
7349            base_url: Some("https://app.example.com".to_owned()),
7350            mailto: Some("u@example.com".to_owned()),
7351            signing_keys: Arc::new(test_keys()),
7352            ttl_days: 30,
7353            suppression: None,
7354        };
7355        let header = runtime
7356            .list_unsubscribe_header("a@x.com", "list")
7357            .expect("header with both");
7358        assert!(header.contains("https://app.example.com/_autumn/unsubscribe?token="));
7359        assert!(header.contains("mailto:u@example.com?subject=unsubscribe"));
7360        assert!(runtime.supports_one_click());
7361    }
7362
7363    #[test]
7364    fn unsubscribe_runtime_header_neither_configured_returns_none() {
7365        let runtime = UnsubscribeRuntime {
7366            base_url: None,
7367            mailto: None,
7368            signing_keys: Arc::new(test_keys()),
7369            ttl_days: 30,
7370            suppression: None,
7371        };
7372        assert!(runtime.list_unsubscribe_header("a@x.com", "list").is_none());
7373        assert!(!runtime.supports_one_click());
7374    }
7375}