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