#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
)
)]
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use axum::extract::FromRequestParts;
use axum::response::{Html, IntoResponse, Response};
use lettre::message::header::{ContentTransferEncoding, ContentType};
use lettre::message::{
Attachment as LettreAttachment, Body as LettreBody, Mailbox, MultiPart, SinglePart,
};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{AppState, AutumnError, AutumnResult};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
Log,
File,
Smtp,
#[default]
Disabled,
}
impl Transport {
pub(crate) fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"log" => Some(Self::Log),
"file" => Some(Self::File),
"smtp" => Some(Self::Smtp),
"disabled" => Some(Self::Disabled),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TlsMode {
Disabled,
#[default]
StartTls,
Tls,
}
impl TlsMode {
pub(crate) fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"disabled" => Some(Self::Disabled),
"starttls" | "start_tls" => Some(Self::StartTls),
"tls" => Some(Self::Tls),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct SmtpConfig {
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password_env: Option<String>,
#[serde(default)]
pub tls: TlsMode,
}
impl Default for SmtpConfig {
fn default() -> Self {
Self {
host: None,
port: None,
username: None,
password_env: None,
tls: TlsMode::StartTls,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[allow(clippy::struct_excessive_bools)] pub struct MailConfig {
#[serde(default)]
pub transport: Transport,
#[serde(default)]
pub from: Option<String>,
#[serde(default)]
pub reply_to: Option<String>,
#[serde(default)]
pub allow_log_in_production: bool,
#[serde(default)]
pub allow_in_process_deliver_later_in_production: bool,
#[serde(default = "default_file_dir")]
pub file_dir: PathBuf,
#[serde(default)]
pub preview: bool,
#[serde(default)]
pub unsubscribe_base_url: Option<String>,
#[serde(default)]
pub unsubscribe_mailto: Option<String>,
#[serde(default = "default_unsubscribe_ttl_days")]
pub unsubscribe_token_ttl_days: i64,
#[serde(default)]
pub mount_unsubscribe_endpoint: bool,
#[serde(default)]
pub inline_css: bool,
#[serde(default)]
pub smtp: SmtpConfig,
}
fn is_valid_https_base_url(url: &str) -> bool {
if url
.chars()
.any(|c| c.is_control() || c.is_whitespace() || matches!(c, '<' | '>'))
{
return false;
}
match url.strip_prefix("https://") {
Some(rest) if !rest.is_empty() && !rest.starts_with('/') => {}
_ => return false,
}
let Ok(parsed) = ::url::Url::parse(url) else {
return false;
};
parsed.scheme() == "https"
&& parsed.host_str().is_some_and(|h| !h.is_empty())
&& parsed.username().is_empty()
&& parsed.password().is_none()
&& parsed.query().is_none()
&& parsed.fragment().is_none()
}
fn is_valid_mailto_address(value: &str) -> bool {
if value
.chars()
.any(|c| c.is_control() || matches!(c, '<' | '>' | ','))
{
return false;
}
let address = value
.trim()
.strip_prefix("mailto:")
.unwrap_or_else(|| value.trim());
let address = address.split('?').next().unwrap_or("");
match address.split_once('@') {
Some((local, domain)) => {
!local.is_empty()
&& !domain.is_empty()
&& domain.contains('.')
&& !address.contains(char::is_whitespace)
&& !address.contains([':', '/'])
}
None => false,
}
}
const fn default_unsubscribe_ttl_days() -> i64 {
crate::mail::unsubscribe::DEFAULT_TOKEN_TTL_DAYS
}
impl Default for MailConfig {
fn default() -> Self {
Self {
transport: Transport::Disabled,
from: None,
reply_to: None,
allow_log_in_production: false,
allow_in_process_deliver_later_in_production: false,
file_dir: default_file_dir(),
preview: false,
unsubscribe_base_url: None,
unsubscribe_mailto: None,
unsubscribe_token_ttl_days: default_unsubscribe_ttl_days(),
mount_unsubscribe_endpoint: false,
inline_css: false,
smtp: SmtpConfig::default(),
}
}
}
impl MailConfig {
pub fn validate(&self, profile: Option<&str>) -> Result<(), crate::config::ConfigError> {
if matches!(profile, Some("prod" | "production"))
&& self.transport == Transport::Log
&& !self.allow_log_in_production
{
return Err(crate::config::ConfigError::Validation(
"mail.transport = \"log\" is disabled in prod; set mail.allow_log_in_production = true to acknowledge this explicitly".to_owned(),
));
}
if self.transport == Transport::Smtp
&& self.smtp.host.as_deref().map_or("", str::trim).is_empty()
{
return Err(crate::config::ConfigError::Validation(
"mail.smtp.host is required when mail.transport = \"smtp\"".to_owned(),
));
}
if self.preview && !matches!(profile, Some("dev" | "development")) {
return Err(crate::config::ConfigError::Validation(
"mail.preview = true is only allowed in dev; refusing to mount /_autumn/mail outside the dev profile".to_owned(),
));
}
if self.unsubscribe_token_ttl_days <= 0 {
return Err(crate::config::ConfigError::Validation(
"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(),
));
}
if matches!(profile, Some("prod" | "production"))
&& let Some(base) = self.unsubscribe_base_url.as_deref().map(str::trim)
&& !base.is_empty()
&& !is_valid_https_base_url(base)
{
return Err(crate::config::ConfigError::Validation(
"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(),
));
}
if matches!(profile, Some("prod" | "production"))
&& let Some(mailto) = self.unsubscribe_mailto.as_deref().map(str::trim)
&& !mailto.is_empty()
&& !is_valid_mailto_address(mailto)
{
return Err(crate::config::ConfigError::Validation(
"mail.unsubscribe_mailto must be a bare mailbox address (or mailto: URI) like unsubscribe@example.com".to_owned(),
));
}
Ok(())
}
pub(crate) fn preview_routes_enabled(&self, profile: Option<&str>) -> bool {
matches!(profile, Some("dev" | "development"))
&& (self.preview || self.transport == Transport::File)
}
pub(crate) fn unsubscribe_base_url_set(&self) -> bool {
self.unsubscribe_base_url
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
}
pub(crate) fn should_mount_unsubscribe_endpoint(&self) -> bool {
self.mount_unsubscribe_endpoint && self.unsubscribe_base_url_set()
}
}
fn default_file_dir() -> PathBuf {
PathBuf::from("target/mail")
}
pub trait IntoMailBody {
fn into_mail_body(self) -> String;
}
impl IntoMailBody for String {
fn into_mail_body(self) -> String {
self
}
}
impl IntoMailBody for &str {
fn into_mail_body(self) -> String {
self.to_owned()
}
}
impl IntoMailBody for maud::Markup {
fn into_mail_body(self) -> String {
self.into_string()
}
}
pub const MAIL_LAYOUT_CONTENT_MARKER: &str = "{{ content }}";
#[must_use]
pub fn compose_layout(layout: &str, body: &str) -> String {
if layout.contains(MAIL_LAYOUT_CONTENT_MARKER) {
layout.replace(MAIL_LAYOUT_CONTENT_MARKER, body)
} else {
body.to_owned()
}
}
fn html_contains_style_block(html: &str) -> bool {
html.as_bytes()
.windows(6)
.any(|window| window.eq_ignore_ascii_case(b"<style"))
}
fn html_is_full_document(html: &str) -> bool {
let bytes = html.as_bytes();
bytes.windows(5).any(|w| w.eq_ignore_ascii_case(b"<html"))
|| bytes.windows(5).any(|w| w.eq_ignore_ascii_case(b"<body"))
|| bytes
.windows(9)
.any(|w| w.eq_ignore_ascii_case(b"<!doctype"))
}
fn unwrap_synthetic_document(doc: &str) -> String {
let inner = doc
.strip_prefix("<html>")
.and_then(|rest| rest.strip_suffix("</html>"))
.unwrap_or(doc);
let (head, after_head) = match inner.strip_prefix("<head>") {
Some(rest) => match rest.split_once("</head>") {
Some(split) => split,
None => return doc.to_owned(),
},
None => ("", inner),
};
let body = after_head
.strip_prefix("<body>")
.map_or(after_head, |rest| {
rest.strip_suffix("</body>").unwrap_or(rest)
});
format!("{head}{body}")
}
fn inline_css_html(html: &str) -> Result<String, MailError> {
if !html_contains_style_block(html) {
return Ok(html.to_owned());
}
let inliner = css_inline::CSSInliner::options()
.keep_style_tags(true)
.keep_at_rules(true)
.remove_inlined_selectors(true)
.load_remote_stylesheets(false)
.keep_link_tags(true)
.apply_width_attributes(true)
.apply_height_attributes(true)
.build();
let is_fragment = !html_is_full_document(html);
let rendered = inliner
.inline(html)
.map_err(|error| MailError::CssInline(error.to_string()))?;
Ok(if is_fragment {
unwrap_synthetic_document(&rendered)
} else {
rendered
})
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MailAttachment {
pub filename: String,
pub content_type: String,
pub bytes: Vec<u8>,
}
impl std::fmt::Debug for MailAttachment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MailAttachment")
.field("filename", &self.filename)
.field("content_type", &self.content_type)
.field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mail {
pub from: Option<String>,
pub reply_to: Option<String>,
pub to: Vec<String>,
pub subject: String,
pub html: Option<String>,
pub text: Option<String>,
pub list_unsubscribe: Option<String>,
pub extra_headers: Vec<(String, String)>,
#[serde(default)]
pub attachments: Vec<MailAttachment>,
#[serde(default)]
pub ignore_suppression: bool,
#[serde(default)]
pub inline_css: Option<bool>,
}
pub const MAIL_PREVIEW_PATH: &str = "/_autumn/mail";
const MAIL_PREVIEW_MESSAGE_PATH: &str = "/_autumn/mail/messages/{message_id}";
const MAIL_PREVIEW_TEMPLATE_PATH: &str = "/_autumn/mail/previews/{mailer}/{method}";
#[derive(Clone)]
pub struct MailPreview {
mailer: &'static str,
method: &'static str,
render: fn() -> Mail,
}
impl std::fmt::Debug for MailPreview {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MailPreview")
.field("mailer", &self.mailer)
.field("method", &self.method)
.finish_non_exhaustive()
}
}
impl MailPreview {
#[must_use]
pub const fn new(mailer: &'static str, method: &'static str, render: fn() -> Mail) -> Self {
Self {
mailer,
method,
render,
}
}
#[must_use]
pub const fn mailer(&self) -> &'static str {
self.mailer
}
#[must_use]
pub const fn method(&self) -> &'static str {
self.method
}
pub fn render(&self) -> Result<Mail, MailPreviewError> {
std::panic::catch_unwind(|| (self.render)()).map_err(|_| {
MailPreviewError::PreviewPanicked {
mailer: self.mailer,
method: self.method,
}
})
}
}
#[derive(Debug, Clone, Default)]
pub struct MailPreviewRegistry {
previews: Arc<Vec<MailPreview>>,
}
impl MailPreviewRegistry {
#[must_use]
pub fn new(previews: Vec<MailPreview>) -> Self {
Self {
previews: Arc::new(previews),
}
}
#[must_use]
pub fn previews(&self) -> &[MailPreview] {
&self.previews
}
fn find(&self, mailer: &str, method: &str) -> Option<MailPreview> {
self.previews
.iter()
.find(|preview| preview.mailer == mailer && preview.method == method)
.cloned()
}
}
#[derive(Debug, Error)]
pub enum MailPreviewError {
#[error("mail preview file IO failed: {0}")]
Io(#[from] std::io::Error),
#[error("captured mail message not found: {0}")]
NotFound(String),
#[error("invalid captured mail message id: {0}")]
InvalidMessageId(String),
#[error("mail preview {mailer}::{method} panicked while rendering")]
PreviewPanicked {
mailer: &'static str,
method: &'static str,
},
}
impl Mail {
#[must_use]
pub fn builder() -> MailBuilder {
MailBuilder::default()
}
fn with_defaults(mut self, defaults: &MailerDefaults) -> Self {
if self.from.is_none() {
self.from.clone_from(&defaults.from);
}
if self.reply_to.is_none() {
self.reply_to.clone_from(&defaults.reply_to);
}
self
}
}
#[derive(Debug, Clone, Default)]
pub struct MailBuilder {
from: Option<String>,
reply_to: Option<String>,
to: Vec<String>,
subject: Option<String>,
html: Option<String>,
text: Option<String>,
html_layout: Option<String>,
text_layout: Option<String>,
list_unsubscribe: Option<String>,
extra_headers: Vec<(String, String)>,
attachments: Vec<MailAttachment>,
ignore_suppression: bool,
inline_css: Option<bool>,
}
impl MailBuilder {
#[must_use]
pub fn from(mut self, from: impl Into<String>) -> Self {
self.from = Some(from.into());
self
}
#[must_use]
pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
self.reply_to = Some(reply_to.into());
self
}
#[must_use]
pub fn to(mut self, to: impl Into<String>) -> Self {
self.to.push(to.into());
self
}
#[must_use]
pub fn subject(mut self, subject: impl Into<String>) -> Self {
self.subject = Some(subject.into());
self
}
#[must_use]
pub fn html(mut self, html: impl IntoMailBody) -> Self {
self.html = Some(html.into_mail_body());
self
}
#[must_use]
pub fn text(mut self, text: impl IntoMailBody) -> Self {
self.text = Some(text.into_mail_body());
self
}
#[must_use]
pub fn list_unsubscribe(mut self, scope: impl Into<String>) -> Self {
self.list_unsubscribe = Some(scope.into());
self
}
#[must_use]
pub const fn ignore_suppression(mut self) -> Self {
self.ignore_suppression = true;
self
}
#[must_use]
pub const fn inline_css(mut self, enabled: bool) -> Self {
self.inline_css = Some(enabled);
self
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_headers.push((name.into(), value.into()));
self
}
#[must_use]
pub fn attach(
mut self,
filename: impl Into<String>,
content_type: impl Into<String>,
bytes: impl Into<Vec<u8>>,
) -> Self {
self.attachments.push(MailAttachment {
filename: filename.into(),
content_type: content_type.into(),
bytes: bytes.into(),
});
self
}
#[must_use]
pub fn layout(
mut self,
html_layout: impl IntoMailBody,
text_layout: impl IntoMailBody,
) -> Self {
self.html_layout = Some(html_layout.into_mail_body());
self.text_layout = Some(text_layout.into_mail_body());
self
}
pub fn build(self) -> Result<Mail, MailError> {
if self.to.is_empty() {
return Err(MailError::InvalidMessage(
"mail must have at least one recipient".to_owned(),
));
}
let subject = self
.subject
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| MailError::InvalidMessage("mail subject is required".to_owned()))?;
if self.html.is_none() && self.text.is_none() {
return Err(MailError::InvalidMessage(
"mail must include html or text body".to_owned(),
));
}
for attachment in &self.attachments {
if attachment.filename.trim().is_empty()
|| attachment.filename.chars().any(char::is_control)
{
return Err(MailError::InvalidMessage(format!(
"attachment filename {:?} must be non-empty and free of control characters",
attachment.filename
)));
}
if let Err(error) = ContentType::parse(&attachment.content_type) {
return Err(MailError::InvalidMessage(format!(
"attachment {:?} has invalid content type {:?}: {error}",
attachment.filename, attachment.content_type
)));
}
}
let html = match (self.html, self.html_layout) {
(Some(body), Some(layout)) => Some(compose_layout(&layout, &body)),
(html, _) => html, };
let text = match (self.text, self.text_layout) {
(Some(body), Some(layout)) => Some(compose_layout(&layout, &body)),
(text, _) => text, };
Ok(Mail {
from: self.from,
reply_to: self.reply_to,
to: self.to,
subject,
html,
text,
list_unsubscribe: self.list_unsubscribe,
extra_headers: self.extra_headers,
attachments: self.attachments,
ignore_suppression: self.ignore_suppression,
inline_css: self.inline_css,
})
}
}
#[derive(Debug, Error)]
pub enum MailError {
#[error("invalid mail message: {0}")]
InvalidMessage(String),
#[error("mail runtime unavailable: {0}")]
RuntimeUnavailable(String),
#[error("invalid mail address {address:?}: {source}")]
InvalidAddress {
address: String,
source: lettre::address::AddressError,
},
#[error("failed to build mail message: {0}")]
Build(#[from] lettre::error::Error),
#[error("smtp send failed: {0}")]
Smtp(#[from] lettre::transport::smtp::Error),
#[error("file mail transport failed: {0}")]
Io(#[from] std::io::Error),
#[error("all recipients are on the mail suppression list; nothing was sent")]
AllRecipientsSuppressed,
#[error("failed to inline CSS into HTML mail body: {0}")]
CssInline(String),
}
pub trait MailTransport: Send + Sync {
fn send<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
fn is_disabled(&self) -> bool {
false
}
}
pub trait MailDeliveryQueue: Send + Sync {
fn enqueue<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
}
#[derive(Clone)]
pub struct MailDeliveryQueueHandle(Arc<dyn MailDeliveryQueue>);
impl MailDeliveryQueueHandle {
#[must_use]
pub fn new(queue: impl MailDeliveryQueue + 'static) -> Self {
Self(Arc::new(queue))
}
#[must_use]
pub fn from_arc(queue: Arc<dyn MailDeliveryQueue>) -> Self {
Self(queue)
}
#[must_use]
pub fn inner(&self) -> &Arc<dyn MailDeliveryQueue> {
&self.0
}
}
impl std::fmt::Debug for MailDeliveryQueueHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MailDeliveryQueueHandle").finish()
}
}
pub const UNSUBSCRIBE_PATH: &str = "/_autumn/unsubscribe";
#[derive(Debug)]
pub struct MailerListUnsubscribeDescriptor {
pub mailer: &'static str,
pub scope: &'static str,
}
inventory::collect!(MailerListUnsubscribeDescriptor);
#[must_use]
pub fn registered_list_unsubscribe_scopes() -> Vec<&'static MailerListUnsubscribeDescriptor> {
inventory::iter::<MailerListUnsubscribeDescriptor>
.into_iter()
.collect()
}
#[must_use]
pub fn has_list_unsubscribe_mailers() -> bool {
inventory::iter::<MailerListUnsubscribeDescriptor>
.into_iter()
.next()
.is_some()
}
#[must_use]
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) const fn unsubscribe_config_fail_closed(
enforce: bool,
in_production: bool,
has_list_mailers: bool,
unsubscribe_configured: bool,
) -> bool {
enforce && in_production && has_list_mailers && !unsubscribe_configured
}
pub mod unsubscribe {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine as _;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::security::config::ResolvedSigningKeys;
pub const DEFAULT_TOKEN_TTL_DAYS: i64 = 30;
const ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
const TOKEN_VERSION: u8 = 1;
const NONCE_LEN: usize = 12;
const KEY_CONTEXT: &[u8] = b"autumn:unsubscribe-token:v1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unsubscribed {
pub subscriber: String,
pub list_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TokenError {
#[error("unsubscribe token is malformed")]
Malformed,
#[error("unsubscribe token signature is invalid")]
BadSignature,
#[error("unsubscribe token has expired")]
Expired,
}
#[allow(
clippy::expect_used,
reason = "infallible: HMAC accepts any key length"
)]
fn derive_key(signing_key: &[u8]) -> [u8; 32] {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(signing_key)
.expect("HMAC accepts any key length");
mac.update(KEY_CONTEXT);
let bytes = mac.finalize().into_bytes();
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
key
}
fn plaintext(subscriber: &str, list_id: &str, expiry_unix: i64) -> String {
format!(
"{}.{}.{expiry_unix}",
ENGINE.encode(subscriber.as_bytes()),
ENGINE.encode(list_id.as_bytes()),
)
}
#[must_use]
#[allow(
clippy::expect_used,
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"
)]
pub fn sign_token(
keys: &ResolvedSigningKeys,
subscriber: &str,
list_id: &str,
expiry_unix: i64,
) -> String {
let key = derive_key(&keys.current);
let cipher = Aes256Gcm::new_from_slice(&key).expect("derived key is always 32 bytes");
let mut nonce_bytes = [0u8; NONCE_LEN];
getrandom::getrandom(&mut nonce_bytes).expect("OS RNG failed");
let ciphertext = cipher
.encrypt(
Nonce::from_slice(&nonce_bytes),
plaintext(subscriber, list_id, expiry_unix).as_bytes(),
)
.expect("AES-GCM encryption cannot fail for valid inputs");
let mut blob = Vec::with_capacity(1 + NONCE_LEN + ciphertext.len());
blob.push(TOKEN_VERSION);
blob.extend_from_slice(&nonce_bytes);
blob.extend_from_slice(&ciphertext);
ENGINE.encode(blob)
}
#[allow(
clippy::indexing_slicing,
reason = "blob.len() is checked to be >= 1 + NONCE_LEN above, so these indices are in bounds"
)]
pub fn verify_token(
keys: &ResolvedSigningKeys,
token: &str,
now_unix: i64,
) -> Result<Unsubscribed, TokenError> {
let blob = ENGINE.decode(token).map_err(|_| TokenError::Malformed)?;
if blob.len() < 1 + NONCE_LEN {
return Err(TokenError::Malformed);
}
if blob[0] != TOKEN_VERSION {
return Err(TokenError::Malformed);
}
let nonce = Nonce::from_slice(&blob[1..=NONCE_LEN]);
let ciphertext = &blob[1 + NONCE_LEN..];
let payload = std::iter::once(&keys.current)
.chain(keys.previous.iter())
.find_map(|signing_key| {
let key = derive_key(signing_key);
let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
cipher.decrypt(nonce, ciphertext).ok()
})
.ok_or(TokenError::BadSignature)?;
let payload = String::from_utf8(payload).map_err(|_| TokenError::Malformed)?;
let mut parts = payload.split('.');
let subscriber_b64 = parts.next().ok_or(TokenError::Malformed)?;
let list_b64 = parts.next().ok_or(TokenError::Malformed)?;
let expiry_s = parts.next().ok_or(TokenError::Malformed)?;
if parts.next().is_some() {
return Err(TokenError::Malformed);
}
let expiry: i64 = expiry_s.parse().map_err(|_| TokenError::Malformed)?;
if now_unix > expiry {
return Err(TokenError::Expired);
}
let subscriber = decode_field(subscriber_b64)?;
let list_id = decode_field(list_b64)?;
Ok(Unsubscribed {
subscriber,
list_id,
})
}
fn decode_field(encoded: &str) -> Result<String, TokenError> {
let bytes = ENGINE.decode(encoded).map_err(|_| TokenError::Malformed)?;
String::from_utf8(bytes).map_err(|_| TokenError::Malformed)
}
#[must_use]
pub fn unsubscribe_url(base_url: &str, token: &str) -> String {
format!(
"{}{}?token={token}",
base_url.trim_end_matches('/'),
super::UNSUBSCRIBE_PATH,
)
}
}
pub trait SuppressionStore: Send + Sync {
fn is_suppressed<'a>(
&'a self,
subscriber: &'a str,
list_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>>;
fn suppress<'a>(
&'a self,
subscriber: &'a str,
list_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
}
#[derive(Clone)]
pub struct SuppressionStoreHandle(Arc<dyn SuppressionStore>);
impl SuppressionStoreHandle {
#[must_use]
pub fn new(store: impl SuppressionStore + 'static) -> Self {
Self(Arc::new(store))
}
#[must_use]
pub fn from_arc(store: Arc<dyn SuppressionStore>) -> Self {
Self(store)
}
#[must_use]
pub fn inner(&self) -> &Arc<dyn SuppressionStore> {
&self.0
}
}
impl std::fmt::Debug for SuppressionStoreHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SuppressionStoreHandle").finish()
}
}
#[derive(Debug, Default, Clone)]
pub struct InMemorySuppressionStore {
suppressed: Arc<std::sync::Mutex<std::collections::HashSet<(String, String)>>>,
}
impl InMemorySuppressionStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl SuppressionStore for InMemorySuppressionStore {
fn is_suppressed<'a>(
&'a self,
subscriber: &'a str,
list_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
Box::pin(async move {
let key = (subscriber.to_owned(), list_id.to_owned());
let suppressed = self
.suppressed
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(&key);
Ok(suppressed)
})
}
fn suppress<'a>(
&'a self,
subscriber: &'a str,
list_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
let key = (subscriber.to_owned(), list_id.to_owned());
self.suppressed
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(key);
Ok(())
})
}
}
pub struct UnsubscribeRuntime {
pub base_url: Option<String>,
pub mailto: Option<String>,
pub signing_keys: Arc<crate::security::config::ResolvedSigningKeys>,
pub ttl_days: i64,
pub suppression: Option<Arc<dyn SuppressionStore>>,
}
impl std::fmt::Debug for UnsubscribeRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UnsubscribeRuntime")
.field("base_url", &self.base_url)
.field("mailto", &self.mailto)
.field("ttl_days", &self.ttl_days)
.field("has_suppression", &self.suppression.is_some())
.finish_non_exhaustive()
}
}
impl UnsubscribeRuntime {
#[must_use]
pub fn list_unsubscribe_header(&self, subscriber: &str, list_id: &str) -> Option<String> {
let mut entries: Vec<String> = Vec::new();
if let Some(base) = self.base_url.as_deref().filter(|s| !s.trim().is_empty()) {
let expiry = current_unix_time().saturating_add(self.ttl_days.saturating_mul(86_400));
let token = unsubscribe::sign_token(&self.signing_keys, subscriber, list_id, expiry);
entries.push(format!("<{}>", unsubscribe::unsubscribe_url(base, &token)));
}
if let Some(mailto) = self.mailto.as_deref().filter(|s| !s.trim().is_empty()) {
let trimmed = mailto.trim();
let address = trimmed.strip_prefix("mailto:").unwrap_or(trimmed);
let address = address.split('?').next().unwrap_or(address);
entries.push(format!("<mailto:{address}?subject=unsubscribe>"));
}
if entries.is_empty() {
None
} else {
Some(entries.join(", "))
}
}
#[must_use]
pub fn supports_one_click(&self) -> bool {
self.base_url
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
}
}
fn current_unix_time() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
#[derive(Debug, Clone, Default)]
struct MailerDefaults {
from: Option<String>,
reply_to: Option<String>,
}
#[derive(Clone)]
pub struct Mailer {
defaults: Arc<MailerDefaults>,
transport: Arc<dyn MailTransport>,
delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
unsubscribe: Option<Arc<UnsubscribeRuntime>>,
suppression: Option<Arc<dyn suppression::SuppressionStore>>,
inline_css_default: bool,
}
impl Mailer {
#[must_use]
pub fn builder() -> MailerBuilder {
MailerBuilder::default()
}
pub fn from_config(config: &MailConfig) -> Result<Self, MailError> {
Self::from_config_inner(config, None)
}
pub(crate) fn from_config_inner(
config: &MailConfig,
resilience: Option<Arc<crate::config::ResilienceConfig>>,
) -> Result<Self, MailError> {
let mut builder = Self::builder()
.transport(config.transport)
.inline_css(config.inline_css)
.resilience_config(resilience);
if let Some(from) = &config.from {
builder = builder.from(from.clone());
}
if let Some(reply_to) = &config.reply_to {
builder = builder.reply_to(reply_to.clone());
}
if config.transport == Transport::File {
builder = builder.file_dir(config.file_dir.clone());
}
if config.transport == Transport::Smtp {
builder = builder.smtp(config.smtp.clone());
}
builder.build()
}
#[must_use]
pub fn with_transport(transport: impl MailTransport + 'static) -> Self {
Self {
defaults: Arc::new(MailerDefaults::default()),
transport: Arc::new(transport),
delivery_queue: None,
unsubscribe: None,
suppression: None,
inline_css_default: false,
}
}
#[must_use]
pub fn with_delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
self.delivery_queue = Some(Arc::new(queue));
self
}
#[must_use]
pub fn with_unsubscribe(mut self, runtime: Arc<UnsubscribeRuntime>) -> Self {
self.unsubscribe = Some(runtime);
self
}
#[must_use]
pub fn with_suppression(mut self, store: suppression::SuppressionStoreHandle) -> Self {
self.suppression = Some(store.into_inner());
self
}
#[must_use]
pub fn has_durable_delivery_queue(&self) -> bool {
self.delivery_queue.is_some()
}
#[must_use]
pub fn is_disabled(&self) -> bool {
self.transport.is_disabled()
}
pub async fn send(&self, mail: Mail) -> Result<(), MailError> {
let mut mail = mail.with_defaults(&self.defaults);
self.apply_css_inlining(&mut mail)?;
if !mail.ignore_suppression
&& let Some(store) = self.suppression.as_ref()
&& !mail.to.is_empty()
{
let mut kept: Vec<String> = Vec::with_capacity(mail.to.len());
for recipient in &mail.to {
if store.is_suppressed(recipient).await? {
suppression::note_skip(&canonical_subscriber(recipient));
} else {
kept.push(recipient.clone());
}
}
if kept.is_empty() {
return Err(MailError::AllRecipientsSuppressed);
}
mail.to = kept;
}
if let Some(list_id) = mail.list_unsubscribe.clone() {
if let Some(runtime) = self.unsubscribe.clone() {
return self.send_list_mail(mail, list_id, &runtime).await;
}
tracing::warn!(
target: "mail",
list_id = %list_id,
"sending list mail without an unsubscribe runtime: no List-Unsubscribe headers or suppression applied (set mail.unsubscribe_base_url / mail.unsubscribe_mailto)"
);
}
self.transport.send(mail).await
}
fn apply_css_inlining(&self, mail: &mut Mail) -> Result<(), MailError> {
let enabled = mail.inline_css.unwrap_or(self.inline_css_default);
if !enabled {
return Ok(());
}
let Some(html) = mail.html.as_deref() else {
return Ok(());
};
match inline_css_html(html) {
Ok(inlined) => {
mail.html = Some(inlined);
Ok(())
}
Err(error) => {
tracing::warn!(
target: "mail",
error = %error,
"CSS inlining failed; HTML body left un-inlined"
);
Err(error)
}
}
}
const fn freeze_inline_css_default(&self, mail: &mut Mail) {
if mail.inline_css.is_none() {
mail.inline_css = Some(self.inline_css_default);
}
}
async fn send_list_mail(
&self,
mail: Mail,
list_id: String,
runtime: &UnsubscribeRuntime,
) -> Result<(), MailError> {
let mut deliveries: Vec<(String, String)> = Vec::with_capacity(mail.to.len());
for recipient in &mail.to {
parse_mailbox(recipient)?;
let subscriber = canonical_subscriber(recipient);
if let Some(store) = runtime.suppression.as_ref()
&& store.is_suppressed(&subscriber, &list_id).await?
{
tracing::info!(
target: "mail",
list_id = %list_id,
outcome = "skipped_suppressed",
"skipping suppressed list-unsubscribe recipient"
);
continue;
}
deliveries.push((recipient.clone(), subscriber));
}
for (recipient, subscriber) in deliveries {
let mut per_recipient = mail.clone();
per_recipient.to = vec![recipient];
if let Some(value) = runtime.list_unsubscribe_header(&subscriber, &list_id) {
per_recipient.extra_headers.retain(|(name, _)| {
!name.eq_ignore_ascii_case("List-Unsubscribe")
&& !name.eq_ignore_ascii_case("List-Unsubscribe-Post")
});
per_recipient
.extra_headers
.push(("List-Unsubscribe".to_owned(), value));
if runtime.supports_one_click() {
per_recipient.extra_headers.push((
"List-Unsubscribe-Post".to_owned(),
"List-Unsubscribe=One-Click".to_owned(),
));
}
}
self.transport.send(per_recipient).await?;
}
Ok(())
}
pub fn deliver_later(&self, mail: Mail) {
if let Err(error) = self.try_deliver_later(mail) {
tracing::error!(error = %error, "background mail delivery was not scheduled");
}
}
pub fn deliver_later_eager(&self, mail: Mail) {
if let Err(error) = self.try_deliver_later_eager(mail) {
tracing::error!(error = %error, "background mail delivery was not scheduled");
}
}
pub fn try_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
if self.transport.is_disabled() {
return Ok(());
}
let mut mail = mail.with_defaults(&self.defaults);
self.freeze_inline_css_default(&mut mail);
#[cfg(feature = "db")]
{
let mailer = self.clone();
let deferred = mail.clone();
let mut f_opt: Option<(Self, Mail)> = Some((mailer, deferred));
let deliver_span = tracing::Span::current();
crate::db::AFTER_COMMIT_REGISTRY
.try_with(|registry| {
#[allow(
clippy::expect_used,
reason = "unreachable: try_with closure body runs at most once"
)]
let (m, m_mail) = f_opt.take().expect("once");
let span = deliver_span.clone();
let boxed: crate::db::CommitCallback = Box::new(move || {
Box::pin(tracing::Instrument::instrument(
async move {
if let Some(queue) = m.delivery_queue.clone() {
queue.enqueue(m_mail).await.map_err(|e| {
crate::AutumnError::internal_server_error_msg(e.to_string())
})
} else {
m.spawn_mail_delivery(m_mail).map_err(|e| {
crate::AutumnError::internal_server_error_msg(e.to_string())
})
}
},
span,
))
});
registry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(boxed);
})
.ok();
if f_opt.is_none() {
return Ok(());
}
}
self.spawn_mail_delivery(mail)
}
pub fn try_deliver_later_eager(&self, mail: Mail) -> Result<(), MailError> {
if self.transport.is_disabled() {
return Ok(());
}
let mut mail = mail.with_defaults(&self.defaults);
self.freeze_inline_css_default(&mut mail);
self.spawn_mail_delivery(mail)
}
fn spawn_mail_delivery(&self, mail: Mail) -> Result<(), MailError> {
let handle = tokio::runtime::Handle::try_current().map_err(|_| {
MailError::RuntimeUnavailable(
"deliver_later requires an active Tokio runtime".to_owned(),
)
})?;
let parent_span = tracing::Span::current();
if let Some(queue) = self.delivery_queue.clone() {
handle.spawn(tracing::Instrument::instrument(
async move {
if let Err(error) = queue.enqueue(mail).await {
tracing::error!(error = %error, "durable mail enqueue failed");
}
},
parent_span,
));
} else {
let mailer = self.clone();
handle.spawn(tracing::Instrument::instrument(
async move {
if let Err(error) = mailer.send(mail).await {
tracing::error!(error = %error, "background mail delivery failed");
}
},
parent_span,
));
}
Ok(())
}
}
impl FromRequestParts<AppState> for Mailer {
type Rejection = AutumnError;
async fn from_request_parts(
_parts: &mut http::request::Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
state
.extension::<Self>()
.as_deref()
.cloned()
.ok_or_else(|| AutumnError::service_unavailable_msg("Mailer is not configured"))
}
}
#[derive(Clone)]
pub struct MailerBuilder {
transport: Transport,
from: Option<String>,
reply_to: Option<String>,
file_dir: PathBuf,
smtp: Option<SmtpConfig>,
delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
inline_css: bool,
}
impl Default for MailerBuilder {
fn default() -> Self {
Self {
transport: Transport::Log,
from: None,
reply_to: None,
file_dir: default_file_dir(),
smtp: None,
delivery_queue: None,
resilience_config: None,
inline_css: false,
}
}
}
impl MailerBuilder {
#[must_use]
pub const fn transport(mut self, transport: Transport) -> Self {
self.transport = transport;
self
}
#[must_use]
pub fn from(mut self, from: impl Into<String>) -> Self {
self.from = Some(from.into());
self
}
#[must_use]
pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
self.reply_to = Some(reply_to.into());
self
}
#[must_use]
pub fn file_dir(mut self, dir: impl AsRef<Path>) -> Self {
self.file_dir = dir.as_ref().to_path_buf();
self
}
#[must_use]
pub fn smtp(mut self, smtp: SmtpConfig) -> Self {
self.smtp = Some(smtp);
self
}
#[must_use]
pub fn delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
self.delivery_queue = Some(Arc::new(queue));
self
}
#[must_use]
pub fn delivery_queue_arc(mut self, queue: Arc<dyn MailDeliveryQueue>) -> Self {
self.delivery_queue = Some(queue);
self
}
#[must_use]
pub fn resilience_config(mut self, rc: Option<Arc<crate::config::ResilienceConfig>>) -> Self {
self.resilience_config = rc;
self
}
#[must_use]
pub const fn inline_css(mut self, enabled: bool) -> Self {
self.inline_css = enabled;
self
}
pub fn build(self) -> Result<Mailer, MailError> {
if let Some(from) = &self.from {
parse_mailbox(from)?;
}
if let Some(reply_to) = &self.reply_to {
parse_mailbox(reply_to)?;
}
let transport: Arc<dyn MailTransport> = match self.transport {
Transport::Log => Arc::new(LogTransport),
Transport::File => Arc::new(FileTransport { dir: self.file_dir }),
Transport::Disabled => Arc::new(DisabledTransport),
Transport::Smtp => Arc::new(SmtpTransport::new(
self.smtp.unwrap_or_default(),
self.resilience_config.clone(),
)?),
};
Ok(Mailer {
defaults: Arc::new(MailerDefaults {
from: self.from,
reply_to: self.reply_to,
}),
transport,
delivery_queue: self.delivery_queue,
unsubscribe: None,
suppression: None,
inline_css_default: self.inline_css,
})
}
}
struct DisabledTransport;
impl MailTransport for DisabledTransport {
fn send<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
fn is_disabled(&self) -> bool {
true
}
}
struct LogTransport;
impl MailTransport for LogTransport {
fn send<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
tracing::info!(
from = ?mail.from,
reply_to = ?mail.reply_to,
to = ?mail.to,
subject = %mail.subject,
text = ?mail.text,
html = ?mail.html,
attachments = mail.attachments.len(),
"mail captured by log transport"
);
Ok(())
})
}
}
struct FileTransport {
dir: PathBuf,
}
static FILE_TRANSPORT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
impl MailTransport for FileTransport {
fn send<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
tokio::fs::create_dir_all(&self.dir).await?;
let filename = file_transport_filename(&mail);
let path = self.dir.join(filename);
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.await?;
let eml = render_eml(&mail);
tokio::io::AsyncWriteExt::write_all(&mut file, eml.as_bytes()).await?;
tokio::io::AsyncWriteExt::flush(&mut file).await?;
file.sync_all().await?;
Ok(())
})
}
}
struct SmtpTransport {
inner: AsyncSmtpTransport<Tokio1Executor>,
resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
}
impl SmtpTransport {
fn new(
config: SmtpConfig,
resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
) -> Result<Self, MailError> {
let host = config
.host
.filter(|host| !host.trim().is_empty())
.ok_or_else(|| MailError::InvalidMessage("mail.smtp.host is required".to_owned()))?;
let mut builder = match config.tls {
TlsMode::Disabled => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host),
TlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
TlsMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
};
if let Some(port) = config.port {
builder = builder.port(port);
}
if let Some(username) = config.username {
let password_env = config.password_env.ok_or_else(|| {
MailError::InvalidMessage(
"mail.smtp.password_env is required when mail.smtp.username is set".to_owned(),
)
})?;
let password = std::env::var(&password_env)
.map_err(|error| smtp_password_env_error(&password_env, &error))?;
builder = builder.credentials(Credentials::new(username, password));
}
Ok(Self {
inner: builder.build(),
resilience_config,
})
}
}
fn smtp_password_env_error(password_env: &str, error: &std::env::VarError) -> MailError {
let reason = match error {
std::env::VarError::NotPresent => "environment variable is not set",
std::env::VarError::NotUnicode(_) => "environment variable contains non-unicode data",
};
MailError::InvalidMessage(format!(
"mail.smtp.password_env={password_env:?} could not be resolved: {reason}"
))
}
impl MailTransport for SmtpTransport {
fn send<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
let breaker = self.resilience_config.as_ref().map_or_else(
|| {
crate::circuit_breaker::global_registry().get_or_create(
"smtp_mailer",
crate::circuit_breaker::CircuitBreakerPolicy::default(),
)
},
|rc| {
let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(
rc,
"smtp_mailer",
);
crate::circuit_breaker::global_registry()
.get_or_create_with_config("smtp_mailer", policy)
},
);
if breaker.before_call().is_err() {
return Err(MailError::RuntimeUnavailable(
"smtp mailer circuit breaker is open".to_owned(),
));
}
let guard = crate::circuit_breaker::CircuitBreakerGuard::new(breaker.clone());
let message = lettre_message(&mail)?;
let res = self.inner.send(message).await;
if res.is_ok() {
guard.success();
} else {
guard.failure();
}
res.map(|_| ()).map_err(Into::into)
})
}
}
fn sanitize_filename(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect()
}
const RFC2231_ATTR_CHAR: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'!')
.remove(b'#')
.remove(b'$')
.remove(b'&')
.remove(b'+')
.remove(b'-')
.remove(b'.')
.remove(b'^')
.remove(b'_')
.remove(b'`')
.remove(b'|')
.remove(b'~');
fn strip_header_controls(value: &str) -> String {
value.chars().filter(|ch| !ch.is_control()).collect()
}
fn quote_header_value(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn content_disposition_params(filename: &str) -> String {
let mut clean = strip_header_controls(filename);
if clean.trim().is_empty() {
"attachment".clone_into(&mut clean);
}
if clean.is_ascii() {
format!("filename={}", quote_header_value(&clean))
} else {
let fallback: String = clean
.chars()
.map(|ch| if ch.is_ascii() { ch } else { '_' })
.collect();
let encoded = percent_encoding::utf8_percent_encode(&clean, RFC2231_ATTR_CHAR);
format!(
"filename={}; filename*=UTF-8''{encoded}",
quote_header_value(&fallback)
)
}
}
fn base64_wrap76(bytes: &[u8]) -> String {
use base64::Engine as _;
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
if encoded.len() <= 76 {
return encoded;
}
let newlines = (encoded.len() - 1) / 76;
let mut wrapped = String::with_capacity(encoded.len() + newlines);
for chunk in encoded.as_bytes().chunks(76) {
if !wrapped.is_empty() {
wrapped.push('\n');
}
#[allow(
clippy::expect_used,
reason = "infallible: base64 output is always ASCII"
)]
let chunk_str = std::str::from_utf8(chunk).expect("base64 output is always ASCII");
wrapped.push_str(chunk_str);
}
wrapped
}
fn file_transport_filename(mail: &Mail) -> String {
let sequence = FILE_TRANSPORT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
format!(
"{}-{}-{:016x}-{}.eml",
chrono::Utc::now().format("%Y%m%d%H%M%S%6f"),
std::process::id(),
sequence,
sanitize_filename(mail.to.first().map_or("unknown", String::as_str))
)
}
fn render_eml(mail: &Mail) -> String {
let mut out = String::new();
if let Some(from) = &mail.from {
out.push_str("From: ");
out.push_str(&strip_header_controls(from));
out.push('\n');
}
for to in &mail.to {
out.push_str("To: ");
out.push_str(&strip_header_controls(to));
out.push('\n');
}
if let Some(reply_to) = &mail.reply_to {
out.push_str("Reply-To: ");
out.push_str(&strip_header_controls(reply_to));
out.push('\n');
}
out.push_str("Date: ");
out.push_str(&chrono::Utc::now().to_rfc2822());
out.push('\n');
out.push_str("Message-Id: <");
out.push_str(&uuid::Uuid::new_v4().to_string());
out.push_str("@autumn.local>\n");
out.push_str("Subject: ");
out.push_str(&strip_header_controls(&mail.subject));
out.push('\n');
for (name, value) in &mail.extra_headers {
out.push_str(&strip_header_controls(name));
out.push_str(": ");
out.push_str(&strip_header_controls(value));
out.push('\n');
}
out.push_str("MIME-Version: 1.0\n");
if mail.attachments.is_empty() {
render_eml_bodies(mail, &mut out);
} else {
use std::fmt::Write as _;
let boundary = format!("autumn-mixed-{}", uuid::Uuid::new_v4().simple());
let _ = write!(
out,
"Content-Type: multipart/mixed; boundary=\"{boundary}\"\n\n"
);
let _ = writeln!(out, "--{boundary}");
render_eml_bodies(mail, &mut out);
for attachment in &mail.attachments {
let _ = writeln!(out, "--{boundary}");
out.push_str("Content-Type: ");
let content_type = strip_header_controls(&attachment.content_type);
if ContentType::parse(&content_type).is_ok() {
out.push_str(&content_type);
} else {
out.push_str("application/octet-stream");
}
out.push('\n');
out.push_str("Content-Disposition: attachment; ");
out.push_str(&content_disposition_params(&attachment.filename));
out.push('\n');
out.push_str("Content-Transfer-Encoding: base64\n\n");
out.push_str(&base64_wrap76(&attachment.bytes));
out.push('\n');
}
let _ = writeln!(out, "--{boundary}--");
}
out
}
fn render_eml_bodies(mail: &Mail, out: &mut String) {
if mail.html.is_some() && mail.text.is_some() {
out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
if let Some(text) = &mail.text {
out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
out.push_str(text);
out.push('\n');
}
if let Some(html) = &mail.html {
out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
out.push_str(html);
out.push('\n');
}
out.push_str("--autumn-mail--\n");
} else if let Some(html) = &mail.html {
out.push_str("Content-Type: text/html; charset=utf-8\n\n");
out.push_str(html);
out.push('\n');
} else if let Some(text) = &mail.text {
out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
out.push_str(text);
out.push('\n');
}
}
#[derive(Debug, Clone)]
struct ParsedMail {
headers: Vec<(String, String)>,
to: Vec<String>,
subject: String,
date: Option<String>,
html: Option<String>,
text: Option<String>,
attachments: Vec<ParsedAttachment>,
raw: String,
}
impl ParsedMail {
fn header_value(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
#[derive(Debug, Clone)]
struct ParsedAttachment {
filename: String,
content_type: String,
}
#[derive(Debug, Clone)]
struct CapturedMailSummary {
id: String,
to: Vec<String>,
subject: String,
timestamp: String,
modified: SystemTime,
}
pub(crate) fn mail_preview_router<S>(file_dir: PathBuf) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
AppState: axum::extract::FromRef<S>,
{
let file_dir = Arc::new(file_dir);
axum::Router::new()
.route(
MAIL_PREVIEW_PATH,
axum::routing::get({
let file_dir = Arc::clone(&file_dir);
move |axum::extract::State(state): axum::extract::State<AppState>| {
let file_dir = Arc::clone(&file_dir);
async move { list_mail_preview(file_dir, state).await }
}
}),
)
.route(
MAIL_PREVIEW_MESSAGE_PATH,
axum::routing::get({
let file_dir = Arc::clone(&file_dir);
move |axum::extract::Path(message_id): axum::extract::Path<String>| {
let file_dir = Arc::clone(&file_dir);
async move { show_captured_mail(file_dir, message_id).await }
}
}),
)
.route(
MAIL_PREVIEW_TEMPLATE_PATH,
axum::routing::get(
|axum::extract::Path((mailer, method)): axum::extract::Path<(String, String)>,
axum::extract::State(state): axum::extract::State<AppState>| async move {
show_template_preview(&state, &mailer, &method)
},
),
)
}
async fn list_mail_preview(file_dir: Arc<PathBuf>, state: AppState) -> Response {
match captured_messages(&file_dir).await {
Ok(messages) => {
let previews = state
.extension::<MailPreviewRegistry>()
.map(|registry| registry.previews().to_vec())
.unwrap_or_default();
html_response(render_mail_index(&messages, &previews, &file_dir))
}
Err(error) => preview_error_response(&error),
}
}
async fn show_captured_mail(file_dir: Arc<PathBuf>, message_id: String) -> Response {
match read_captured_message(&file_dir, &message_id).await {
Ok(parsed) => html_response(render_mail_detail(&parsed, "Captured message")),
Err(error) => preview_error_response(&error),
}
}
fn show_template_preview(state: &AppState, mailer: &str, method: &str) -> Response {
let preview = state
.extension::<MailPreviewRegistry>()
.and_then(|registry| registry.find(mailer, method));
let Some(preview) = preview else {
return preview_error_response(&MailPreviewError::NotFound(format!("{mailer}/{method}")));
};
match preview.render() {
Ok(mail) => {
let mut mail = apply_preview_unsubscribe_headers(state, mailer, mail);
if let Some(m) = state.extension::<Mailer>() {
let _ = m.apply_css_inlining(&mut mail);
}
let raw = render_eml(&mail);
let parsed = parse_eml(&raw);
html_response(render_mail_detail(&parsed, "Template preview"))
}
Err(error) => preview_error_response(&error),
}
}
fn apply_preview_unsubscribe_headers(state: &AppState, mailer_label: &str, mut mail: Mail) -> Mail {
let scope = mail.list_unsubscribe.clone().or_else(|| {
registered_list_unsubscribe_scopes()
.into_iter()
.find(|descriptor| descriptor.mailer == mailer_label)
.map(|descriptor| descriptor.scope.to_owned())
});
let Some(scope) = scope else {
return mail;
};
mail.list_unsubscribe = Some(scope.clone());
let recipient = mail.to.first().map_or_else(
|| "subscriber@example.com".to_owned(),
|to| canonical_subscriber(to),
);
let (header, one_click) = state.extension::<UnsubscribeRuntime>().map_or_else(
|| {
let sample = UnsubscribeRuntime {
base_url: Some("https://example.com".to_owned()),
mailto: None,
signing_keys: Arc::new(crate::security::config::resolve_signing_keys(
&crate::security::config::SigningSecretConfig::default(),
)),
ttl_days: unsubscribe::DEFAULT_TOKEN_TTL_DAYS,
suppression: None,
};
(
sample.list_unsubscribe_header(&recipient, &scope),
sample.supports_one_click(),
)
},
|runtime| {
(
runtime.list_unsubscribe_header(&recipient, &scope),
runtime.supports_one_click(),
)
},
);
if let Some(value) = header {
mail.extra_headers.retain(|(name, _)| {
!name.eq_ignore_ascii_case("List-Unsubscribe")
&& !name.eq_ignore_ascii_case("List-Unsubscribe-Post")
});
mail.extra_headers
.push(("List-Unsubscribe".to_owned(), value));
if one_click {
mail.extra_headers.push((
"List-Unsubscribe-Post".to_owned(),
"List-Unsubscribe=One-Click".to_owned(),
));
}
}
mail
}
async fn captured_messages(dir: &Path) -> Result<Vec<CapturedMailSummary>, MailPreviewError> {
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
let mut messages = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
{
continue;
}
let Some(id) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let metadata = entry.metadata().await?;
let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
let raw = tokio::fs::read_to_string(&path).await?;
let parsed = parse_eml(&raw);
messages.push(CapturedMailSummary {
id: id.to_owned(),
to: parsed.to,
subject: parsed.subject,
timestamp: parsed.date.unwrap_or_else(|| format_system_time(modified)),
modified,
});
}
messages.sort_by(|left, right| {
right
.modified
.cmp(&left.modified)
.then_with(|| right.id.cmp(&left.id))
});
Ok(messages)
}
async fn read_captured_message(
dir: &Path,
message_id: &str,
) -> Result<ParsedMail, MailPreviewError> {
if !valid_message_id(message_id) {
return Err(MailPreviewError::InvalidMessageId(message_id.to_owned()));
}
let path = dir.join(message_id);
let raw = match tokio::fs::read_to_string(&path).await {
Ok(raw) => raw,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(MailPreviewError::NotFound(message_id.to_owned()));
}
Err(error) => return Err(error.into()),
};
Ok(parse_eml(&raw))
}
fn valid_message_id(message_id: &str) -> bool {
!message_id.is_empty()
&& Path::new(message_id)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
&& !message_id.contains('/')
&& !message_id.contains('\\')
&& !message_id.contains("..")
}
fn parse_eml(raw: &str) -> ParsedMail {
let normalized = raw.replace("\r\n", "\n");
let (headers, body) = split_headers_body(&normalized);
let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
let (html, text, attachments) = parse_mail_body(&content_type, body);
let to = header_values(&headers, "To");
let subject = header_value(&headers, "Subject").unwrap_or_else(|| "(no subject)".to_owned());
let date = header_value(&headers, "Date");
ParsedMail {
headers,
to,
subject,
date,
html,
text,
attachments,
raw: raw.to_owned(),
}
}
fn split_headers_body(raw: &str) -> (Vec<(String, String)>, &str) {
let Some((header_block, body)) = raw.split_once("\n\n") else {
return (parse_header_block(raw), "");
};
(parse_header_block(header_block), body)
}
fn parse_header_block(header_block: &str) -> Vec<(String, String)> {
let mut headers = Vec::new();
let mut current: Option<(String, String)> = None;
for line in header_block.lines() {
if line.starts_with(' ') || line.starts_with('\t') {
if let Some((_, value)) = current.as_mut() {
value.push(' ');
value.push_str(line.trim());
}
continue;
}
if let Some(header) = current.take() {
headers.push(header);
}
if let Some((name, value)) = line.split_once(':') {
current = Some((name.trim().to_owned(), value.trim().to_owned()));
}
}
if let Some(header) = current {
headers.push(header);
}
headers
}
fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.clone())
}
fn header_values(headers: &[(String, String)], name: &str) -> Vec<String> {
headers
.iter()
.filter(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.clone())
.collect()
}
fn parse_mail_body(
content_type: &str,
body: &str,
) -> (Option<String>, Option<String>, Vec<ParsedAttachment>) {
let lower = content_type.to_ascii_lowercase();
if lower.contains("multipart/mixed")
&& let Some(boundary) = content_type_boundary(content_type)
{
return parse_multipart_mixed(body, &boundary);
}
if lower.contains("multipart/alternative")
&& let Some(boundary) = content_type_boundary(content_type)
{
let (html, text) = parse_multipart_alternative(body, &boundary);
return (html, text, Vec::new());
}
if lower.contains("text/html") {
(Some(trim_body(body)), None, Vec::new())
} else {
(None, Some(trim_body(body)), Vec::new())
}
}
fn parse_multipart_mixed(
body: &str,
boundary: &str,
) -> (Option<String>, Option<String>, Vec<ParsedAttachment>) {
let marker = format!("--{boundary}");
let mut html = None;
let mut text = None;
let mut attachments = Vec::new();
for segment in body.split(&marker).skip(1) {
let segment = segment.trim_start_matches(['\n', '\r']);
if segment.starts_with("--") {
break;
}
let (headers, part_body) = split_headers_body(segment);
let disposition = header_value(&headers, "Content-Disposition").unwrap_or_default();
let part_content_type = header_value(&headers, "Content-Type").unwrap_or_default();
let disposition_type = split_mime_params(&disposition)
.first()
.copied()
.unwrap_or("");
if disposition_type.eq_ignore_ascii_case("attachment") {
attachments.push(ParsedAttachment {
filename: extract_attachment_filename(&disposition),
content_type: content_type_without_params(&part_content_type),
});
} else {
let (nested_html, nested_text, _) = parse_mail_body(&part_content_type, part_body);
html = html.or(nested_html);
text = text.or(nested_text);
}
}
(html, text, attachments)
}
fn split_mime_params(value: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let mut escaped = false;
for (i, ch) in value.char_indices() {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' if in_quotes => escaped = true,
'"' => in_quotes = !in_quotes,
';' if !in_quotes => {
parts.push(value[start..i].trim());
start = i + ch.len_utf8();
}
_ => {}
}
}
parts.push(value[start..].trim());
parts
}
fn unescape_quoted_string(value: &str) -> String {
let mut result = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(ch) = chars.next() {
if ch == '\\'
&& let Some(next) = chars.next()
{
result.push(next);
} else {
result.push(ch);
}
}
result
}
fn extract_attachment_filename(disposition: &str) -> String {
let params = split_mime_params(disposition);
if let Some(value) = params.iter().skip(1).find_map(|part| {
let (key, val) = part.split_once('=')?;
key.trim().eq_ignore_ascii_case("filename*").then_some(val)
}) {
let encoded = value.splitn(3, '\'').nth(2).unwrap_or(value);
return percent_encoding::percent_decode_str(encoded)
.decode_utf8_lossy()
.into_owned();
}
if let Some(value) = params.iter().skip(1).find_map(|part| {
let (key, val) = part.split_once('=')?;
key.trim()
.eq_ignore_ascii_case("filename")
.then_some(val.trim())
}) {
if let Some(inner) = value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
return unescape_quoted_string(inner);
}
return value.to_owned();
}
"attachment".to_owned()
}
fn content_type_without_params(content_type: &str) -> String {
content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim()
.to_owned()
}
fn parse_multipart_alternative(body: &str, boundary: &str) -> (Option<String>, Option<String>) {
let marker = format!("--{boundary}");
let mut html = None;
let mut text = None;
for segment in body.split(&marker).skip(1) {
let segment = segment.trim_start_matches(['\n', '\r']);
if segment.starts_with("--") {
break;
}
let (headers, part_body) = split_headers_body(segment);
let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
if content_type.to_ascii_lowercase().contains("text/html") {
html = Some(trim_body(part_body));
} else if content_type.to_ascii_lowercase().contains("text/plain") {
text = Some(trim_body(part_body));
}
}
(html, text)
}
fn content_type_boundary(content_type: &str) -> Option<String> {
content_type.split(';').find_map(|part| {
let part = part.trim();
let (name, value) = part.split_once('=')?;
if !name.trim().eq_ignore_ascii_case("boundary") {
return None;
}
Some(value.trim().trim_matches('"').to_owned())
})
}
fn trim_body(body: &str) -> String {
body.trim_matches(['\r', '\n']).to_owned()
}
fn format_system_time(time: SystemTime) -> String {
let datetime: chrono::DateTime<chrono::Utc> = time.into();
datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn render_mail_index(
messages: &[CapturedMailSummary],
previews: &[MailPreview],
file_dir: &Path,
) -> String {
let mut body = String::new();
body.push_str("<h1>Autumn Mail</h1>");
body.push_str("<section><h2>Captured messages</h2>");
if messages.is_empty() {
body.push_str("<p class=\"empty\">No captured emails yet. Set <code>mail.transport = "file"</code>, send an email, then refresh this page. Autumn reads <code>");
body.push_str(&escape_html(&file_dir.display().to_string()));
body.push_str("</code>.</p>");
} else {
body.push_str(
"<table><thead><tr><th>Timestamp</th><th>To</th><th>Subject</th></tr></thead><tbody>",
);
for message in messages {
body.push_str("<tr><td>");
body.push_str(&escape_html(&message.timestamp));
body.push_str("</td><td>");
body.push_str(&escape_html(&message.to.join(", ")));
body.push_str("</td><td><a href=\"");
body.push_str(MAIL_PREVIEW_PATH);
body.push_str("/messages/");
body.push_str(&escape_html(&message.id));
body.push_str("\">");
body.push_str(&escape_html(&message.subject));
body.push_str("</a></td></tr>");
}
body.push_str("</tbody></table>");
}
body.push_str("</section><section><h2>Template previews</h2>");
if previews.is_empty() {
body.push_str("<p class=\"empty\">No mailer previews registered.</p>");
} else {
body.push_str("<table><thead><tr><th>Mailer</th><th>Preview</th></tr></thead><tbody>");
for preview in previews {
body.push_str("<tr><td>");
body.push_str(&escape_html(preview.mailer()));
body.push_str("</td><td><a href=\"");
body.push_str(MAIL_PREVIEW_PATH);
body.push_str("/previews/");
body.push_str(&escape_html(preview.mailer()));
body.push('/');
body.push_str(&escape_html(preview.method()));
body.push_str("\">");
body.push_str(&escape_html(preview.method()));
body.push_str("</a></td></tr>");
}
body.push_str("</tbody></table>");
}
body.push_str("</section>");
render_mail_preview_layout("Autumn Mail", &body)
}
fn render_mail_detail(parsed: &ParsedMail, label: &str) -> String {
let mut body = String::new();
body.push_str("<p><a href=\"");
body.push_str(MAIL_PREVIEW_PATH);
body.push_str("\">Back to mail</a></p><h1>");
body.push_str(&escape_html(&parsed.subject));
body.push_str("</h1><p class=\"muted\">");
body.push_str(&escape_html(label));
body.push_str("</p>");
if let Some(html) = &parsed.html {
body.push_str("<iframe title=\"Rendered HTML email\" sandbox srcdoc=\"");
body.push_str(&escape_html(html));
body.push_str("\"></iframe>");
} else {
body.push_str("<p class=\"empty\">No HTML body was found for this email.</p>");
}
body.push_str("<details><summary>Plain text</summary><pre>");
body.push_str(&escape_html(parsed.text.as_deref().unwrap_or("")));
body.push_str("</pre></details>");
if !parsed.attachments.is_empty() {
body.push_str("<details open><summary>Attachments (");
body.push_str(&parsed.attachments.len().to_string());
body.push_str(")</summary><ul>");
for attachment in &parsed.attachments {
body.push_str("<li>");
body.push_str(&escape_html(&attachment.filename));
body.push_str(" <span class=\"muted\">(");
body.push_str(&escape_html(&attachment.content_type));
body.push_str(")</span></li>");
}
body.push_str("</ul></details>");
}
body.push_str("<details><summary>Headers</summary><dl>");
for header in [
"From",
"To",
"Reply-To",
"Subject",
"Date",
"Message-Id",
"List-Unsubscribe",
"List-Unsubscribe-Post",
] {
if let Some(value) = parsed.header_value(header) {
body.push_str("<dt>");
body.push_str(header);
body.push_str("</dt><dd>");
body.push_str(&escape_html(value));
body.push_str("</dd>");
}
}
body.push_str("</dl></details>");
body.push_str("<details><summary>Raw .eml</summary><pre>");
body.push_str(&escape_html(&parsed.raw));
body.push_str("</pre></details>");
render_mail_preview_layout(&parsed.subject, &body)
}
fn render_mail_preview_layout(title: &str, body: &str) -> String {
format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>{}</style></head><body>{}</body></html>",
escape_html(title),
MAIL_PREVIEW_CSS,
body
)
}
const MAIL_PREVIEW_CSS: &str = r#"
body{margin:0;padding:24px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2933;background:#f6f8fa}
h1{margin:0 0 16px;font-size:28px}
h2{margin:28px 0 12px;font-size:18px}
table{width:100%;border-collapse:collapse;background:white;border:1px solid #d9e2ec}
th,td{padding:10px 12px;border-bottom:1px solid #e5eaf0;text-align:left;font-size:14px;vertical-align:top}
th{background:#edf2f7;color:#394b59;font-weight:650}
a{color:#0b63ce;text-decoration:none}
a:hover{text-decoration:underline}
.empty,.muted{color:#52616f}
code,pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
pre{white-space:pre-wrap;background:#111827;color:#f8fafc;padding:12px;overflow:auto}
iframe{width:100%;min-height:420px;border:1px solid #cbd5e1;background:white}
details{margin-top:14px;background:white;border:1px solid #d9e2ec;padding:10px 12px}
summary{cursor:pointer;font-weight:650}
dt{font-weight:650;margin-top:8px}
dd{margin:2px 0 8px}
"#;
fn html_response(html: String) -> Response {
Html(html).into_response()
}
fn preview_error_response(error: &MailPreviewError) -> Response {
let status = match error {
MailPreviewError::NotFound(_) | MailPreviewError::InvalidMessageId(_) => {
http::StatusCode::NOT_FOUND
}
MailPreviewError::Io(_) | MailPreviewError::PreviewPanicked { .. } => {
http::StatusCode::INTERNAL_SERVER_ERROR
}
};
(
status,
Html(render_mail_preview_layout(
"Mail preview error",
&format!(
"<h1>Mail preview error</h1><p>{}</p>",
escape_html(&error.to_string())
),
)),
)
.into_response()
}
fn escape_html(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
_ => escaped.push(ch),
}
}
escaped
}
fn parse_mailbox(address: &str) -> Result<Mailbox, MailError> {
address.parse().map_err(|source| MailError::InvalidAddress {
address: address.to_owned(),
source,
})
}
fn canonical_subscriber(recipient: &str) -> String {
parse_mailbox(recipient).map_or_else(
|_| recipient.trim().to_ascii_lowercase(),
|mailbox| mailbox.email.to_string().to_ascii_lowercase(),
)
}
enum MailBodyPart {
Single(SinglePart),
Multi(MultiPart),
}
fn lettre_body_part(mail: &Mail) -> Result<MailBodyPart, MailError> {
match (&mail.text, &mail.html) {
(Some(text), Some(html)) => Ok(MailBodyPart::Multi(
MultiPart::alternative()
.singlepart(SinglePart::plain(text.clone()))
.singlepart(SinglePart::html(html.clone())),
)),
(Some(text), None) => Ok(MailBodyPart::Single(SinglePart::plain(text.clone()))),
(None, Some(html)) => Ok(MailBodyPart::Single(SinglePart::html(html.clone()))),
(None, None) => Err(MailError::InvalidMessage(
"mail must include html or text body".to_owned(),
)),
}
}
fn lettre_attachment_part(attachment: &MailAttachment) -> Result<SinglePart, MailError> {
let content_type = ContentType::parse(&attachment.content_type).map_err(|error| {
MailError::InvalidMessage(format!(
"attachment {:?} has invalid content type {:?}: {error}",
attachment.filename, attachment.content_type
))
})?;
#[allow(
clippy::expect_used,
reason = "infallible: base64 encoding is always valid for any byte buffer"
)]
let body =
LettreBody::new_with_encoding(attachment.bytes.clone(), ContentTransferEncoding::Base64)
.expect("base64 encoding is always valid for any byte buffer");
Ok(LettreAttachment::new(attachment.filename.clone()).body(body, content_type))
}
fn lettre_message(mail: &Mail) -> Result<Message, MailError> {
let from = mail
.from
.as_deref()
.ok_or_else(|| MailError::InvalidMessage("mail from address is required".to_owned()))?;
let mut builder = Message::builder().from(parse_mailbox(from)?);
for to in &mail.to {
builder = builder.to(parse_mailbox(to)?);
}
if let Some(reply_to) = &mail.reply_to {
builder = builder.reply_to(parse_mailbox(reply_to)?);
}
builder = builder.subject(mail.subject.clone());
for (name, value) in &mail.extra_headers {
use lettre::message::header::{HeaderName, HeaderValue};
match HeaderName::new_from_ascii(name.clone()) {
Ok(header_name) => {
builder = builder.raw_header(HeaderValue::new(header_name, value.clone()));
}
Err(error) => {
tracing::warn!(
header_name = %name,
error = %error,
"skipping mail header with invalid name"
);
}
}
}
let body_part = lettre_body_part(mail)?;
if mail.attachments.is_empty() {
return Ok(match body_part {
MailBodyPart::Multi(multi) => builder.multipart(multi)?,
MailBodyPart::Single(single) => builder.singlepart(single)?,
});
}
let mut mixed = match body_part {
MailBodyPart::Multi(multi) => MultiPart::mixed().multipart(multi),
MailBodyPart::Single(single) => MultiPart::mixed().singlepart(single),
};
for attachment in &mail.attachments {
mixed = mixed.singlepart(lettre_attachment_part(attachment)?);
}
Ok(builder.multipart(mixed)?)
}
struct InterceptedMailTransport {
inner: Arc<dyn MailTransport>,
interceptor: Arc<dyn crate::interceptor::MailInterceptor>,
}
impl MailTransport for InterceptedMailTransport {
fn send<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
let inner = Arc::clone(&self.inner);
let mail_for_next = mail.clone();
let next = Box::pin(async move { inner.send(mail_for_next).await });
self.interceptor.intercept(&mail, next).await
})
}
fn is_disabled(&self) -> bool {
self.inner.is_disabled()
}
}
#[allow(clippy::too_many_lines)]
pub(crate) fn install_mailer(
state: &AppState,
config: &MailConfig,
enforce_durable_guard: bool,
) -> AutumnResult<()> {
let resilience = state
.extension::<crate::config::AutumnConfig>()
.map(|c| Arc::new(c.resilience.clone()));
let mut mailer =
Mailer::from_config_inner(config, resilience).map_err(AutumnError::service_unavailable)?;
if let Some(interceptor) = state.extension::<Arc<dyn crate::interceptor::MailInterceptor>>() {
mailer.transport = Arc::new(InterceptedMailTransport {
inner: Arc::clone(&mailer.transport),
interceptor: (*interceptor).clone(),
});
}
let in_production = matches!(state.profile(), "prod" | "production");
let transport_sends_mail = config.transport != Transport::Disabled;
if transport_sends_mail {
let queue_handle = state.extension::<MailDeliveryQueueHandle>();
if let Some(handle) = queue_handle.as_ref() {
mailer.delivery_queue = Some(Arc::clone(handle.inner()));
}
}
if enforce_durable_guard && in_production && transport_sends_mail {
let has_durable_queue = mailer.delivery_queue.is_some();
if !has_durable_queue && !config.allow_in_process_deliver_later_in_production {
return Err(AutumnError::service_unavailable_msg(
"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",
));
}
if !has_durable_queue {
tracing::warn!(
"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"
);
}
}
let base_url = config
.unsubscribe_base_url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let mailto = config
.unsubscribe_mailto
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let unsubscribe_configured = base_url.is_some() || mailto.is_some();
let suppression: Option<Arc<dyn SuppressionStore>> = {
let explicit = state
.extension::<SuppressionStoreHandle>()
.map(|handle| Arc::clone(handle.inner()));
#[cfg(feature = "db")]
let resolved = explicit.or_else(|| {
state
.pool()
.map(|pool| Arc::new(db_suppression::DbSuppressionStore::new(pool.clone())) as _)
});
#[cfg(not(feature = "db"))]
let resolved = explicit;
resolved
};
if transport_sends_mail
&& unsubscribe_config_fail_closed(
enforce_durable_guard,
in_production,
has_list_unsubscribe_mailers(),
unsubscribe_configured,
)
{
return Err(AutumnError::service_unavailable_msg(
"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",
));
}
if enforce_durable_guard
&& in_production
&& transport_sends_mail
&& has_list_unsubscribe_mailers()
&& base_url.is_some()
&& suppression.is_none()
{
return Err(AutumnError::service_unavailable_msg(
"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",
));
}
if in_production
&& transport_sends_mail
&& has_list_unsubscribe_mailers()
&& base_url.is_some()
&& !config.mount_unsubscribe_endpoint
{
tracing::warn!(
target: "mail",
path = UNSUBSCRIBE_PATH,
"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"
);
}
if unsubscribe_configured || suppression.is_some() {
let signing_keys = Arc::new(crate::security::config::resolve_signing_keys(
&state
.extension::<crate::config::AutumnConfig>()
.map(|c| c.security.signing_secret.clone())
.unwrap_or_default(),
));
let ttl_days = config.unsubscribe_token_ttl_days;
let make_runtime = || UnsubscribeRuntime {
base_url: base_url.map(str::to_owned),
mailto: mailto.map(str::to_owned),
signing_keys: Arc::clone(&signing_keys),
ttl_days,
suppression: suppression.clone(),
};
state.insert_extension(make_runtime());
if transport_sends_mail {
mailer.unsubscribe = Some(Arc::new(make_runtime()));
}
}
if transport_sends_mail {
let handle = state
.extension::<suppression::SuppressionStoreHandle>()
.map_or_else(
|| {
let handle = suppression::SuppressionStoreHandle::new(
suppression::InMemorySuppressionStore::new(),
);
state.insert_extension(handle.clone());
handle
},
|arc| (*arc).clone(),
);
mailer.suppression = Some(Arc::clone(handle.inner()));
}
state.insert_extension(mailer);
Ok(())
}
pub(crate) fn install_mailer_with_factory<F>(
state: &AppState,
config: &MailConfig,
queue_factory: Option<F>,
enforce_durable_guard: bool,
) -> AutumnResult<()>
where
F: FnOnce(&AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>,
{
let transport_sends_mail = config.transport != Transport::Disabled;
if enforce_durable_guard
&& transport_sends_mail
&& let Some(factory) = queue_factory
{
let queue = factory(state)?;
state.insert_extension(MailDeliveryQueueHandle::from_arc(queue));
}
install_mailer(state, config, enforce_durable_guard)
}
#[derive(Deserialize)]
struct UnsubscribeParams {
#[serde(default)]
token: String,
}
pub(crate) fn unsubscribe_router() -> axum::Router<AppState> {
axum::Router::new().route(
UNSUBSCRIBE_PATH,
axum::routing::get(unsubscribe_get_handler).post(unsubscribe_post_handler),
)
}
async fn unsubscribe_post_handler(
axum::extract::State(state): axum::extract::State<AppState>,
axum::extract::Query(params): axum::extract::Query<UnsubscribeParams>,
body: String,
) -> Response {
if !is_one_click_body(&body) {
return (
axum::http::StatusCode::BAD_REQUEST,
"expected List-Unsubscribe=One-Click body",
)
.into_response();
}
let Some(runtime) = state.extension::<UnsubscribeRuntime>() else {
return (
axum::http::StatusCode::NOT_FOUND,
"unsubscribe is not configured",
)
.into_response();
};
match unsubscribe::verify_token(&runtime.signing_keys, ¶ms.token, current_unix_time()) {
Ok(decoded) => {
let Some(store) = runtime.suppression.as_ref() else {
tracing::error!(
target: "mail",
"unsubscribe POST received but no suppression backend is configured"
);
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"unsubscribe storage is not configured",
)
.into_response();
};
if let Err(error) = store.suppress(&decoded.subscriber, &decoded.list_id).await {
tracing::error!(error = %error, "failed to record unsubscribe suppression");
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"could not process unsubscribe",
)
.into_response();
}
tracing::info!(
target: "mail",
list_id = %decoded.list_id,
outcome = "unsubscribed",
"recorded one-click unsubscribe"
);
(
axum::http::StatusCode::OK,
Html(unsubscribe_confirmation_html(&decoded.list_id)),
)
.into_response()
}
Err(error) => (
axum::http::StatusCode::BAD_REQUEST,
Html(unsubscribe_error_html(&error.to_string())),
)
.into_response(),
}
}
fn is_one_click_body(body: &str) -> bool {
body.split('&').any(|pair| {
let mut kv = pair.splitn(2, '=');
let key = kv.next().unwrap_or("");
let value = kv.next().unwrap_or("");
key.eq_ignore_ascii_case("List-Unsubscribe") && value.eq_ignore_ascii_case("One-Click")
})
}
async fn unsubscribe_get_handler(
axum::extract::State(state): axum::extract::State<AppState>,
axum::extract::Query(params): axum::extract::Query<UnsubscribeParams>,
) -> Response {
let Some(runtime) = state.extension::<UnsubscribeRuntime>() else {
return (
axum::http::StatusCode::NOT_FOUND,
"unsubscribe is not configured",
)
.into_response();
};
match unsubscribe::verify_token(&runtime.signing_keys, ¶ms.token, current_unix_time()) {
Ok(decoded) => Html(unsubscribe_form_html(&decoded.list_id, ¶ms.token)).into_response(),
Err(error) => (
axum::http::StatusCode::BAD_REQUEST,
Html(unsubscribe_error_html(&error.to_string())),
)
.into_response(),
}
}
fn unsubscribe_form_html(list_id: &str, token: &str) -> String {
format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribe</title></head>\
<body><h1>Unsubscribe</h1>\
<p>Stop receiving <strong>{}</strong> emails?</p>\
<form method=\"post\" action=\"?token={}\">\
<input type=\"hidden\" name=\"List-Unsubscribe\" value=\"One-Click\">\
<button type=\"submit\">Unsubscribe</button></form></body></html>",
escape_html(list_id),
escape_html(token),
)
}
fn unsubscribe_confirmation_html(list_id: &str) -> String {
format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribed</title></head>\
<body><h1>You're unsubscribed</h1>\
<p>You will no longer receive <strong>{}</strong> emails.</p></body></html>",
escape_html(list_id),
)
}
fn unsubscribe_error_html(detail: &str) -> String {
format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribe</title></head>\
<body><h1>Unsubscribe link is not valid</h1><p>{}</p></body></html>",
escape_html(detail),
)
}
pub mod suppression {
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use super::{MailError, canonical_subscriber};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SuppressionReason {
HardBounce,
Complaint,
Manual,
}
impl SuppressionReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::HardBounce => "hard_bounce",
Self::Complaint => "complaint",
Self::Manual => "manual",
}
}
}
impl std::fmt::Display for SuppressionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub trait SuppressionStore: Send + Sync {
fn is_suppressed<'a>(
&'a self,
address: &'a str,
) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>>;
fn suppress<'a>(
&'a self,
address: &'a str,
reason: SuppressionReason,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
fn unsuppress<'a>(
&'a self,
address: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
}
#[derive(Clone)]
pub struct SuppressionStoreHandle(Arc<dyn SuppressionStore>);
impl SuppressionStoreHandle {
#[must_use]
pub fn new(store: impl SuppressionStore + 'static) -> Self {
Self(Arc::new(store))
}
#[must_use]
pub fn from_arc(store: Arc<dyn SuppressionStore>) -> Self {
Self(store)
}
#[must_use]
pub fn inner(&self) -> &Arc<dyn SuppressionStore> {
&self.0
}
#[must_use]
pub fn into_inner(self) -> Arc<dyn SuppressionStore> {
self.0
}
}
impl std::fmt::Debug for SuppressionStoreHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SuppressionStoreHandle")
.finish_non_exhaustive()
}
}
#[derive(Debug, Default, Clone)]
pub struct InMemorySuppressionStore {
entries: Arc<std::sync::Mutex<std::collections::HashMap<String, SuppressionReason>>>,
}
impl InMemorySuppressionStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl SuppressionStore for InMemorySuppressionStore {
fn is_suppressed<'a>(
&'a self,
address: &'a str,
) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
Box::pin(async move {
let key = canonical_subscriber(address);
Ok(self
.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(&key))
})
}
fn suppress<'a>(
&'a self,
address: &'a str,
reason: SuppressionReason,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
let key = canonical_subscriber(address);
self.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(key, reason);
Ok(())
})
}
fn unsuppress<'a>(
&'a self,
address: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
let key = canonical_subscriber(address);
self.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&key);
Ok(())
})
}
}
static SUPPRESSED_SKIPS: AtomicU64 = AtomicU64::new(0);
#[must_use]
pub fn suppressed_skips() -> u64 {
SUPPRESSED_SKIPS.load(Ordering::Relaxed)
}
pub(crate) fn note_skip(canonical_address: &str) {
SUPPRESSED_SKIPS.fetch_add(1, Ordering::Relaxed);
tracing::info!(
target: "mail",
outcome = "skipped_suppressed",
address = %canonical_address,
"skipping suppressed recipient (hard bounce or complaint); \
pass Mail::ignore_suppression() to override for critical mail"
);
}
#[cfg(feature = "inbound-mail")]
pub async fn record_inbound(
store: &dyn SuppressionStore,
email: &crate::inbound_mail::InboundEmail,
) -> Result<(), MailError> {
if email.is_bounce {
if let Some(addr) = email.bounced_address.as_deref() {
store.suppress(addr, SuppressionReason::HardBounce).await?;
} else {
tracing::warn!(
target: "mail",
"inbound bounce webhook set is_bounce with no bounced_address; nothing suppressed"
);
}
return Ok(());
}
if let Some(addr) = email.complained_address.as_deref() {
store.suppress(addr, SuppressionReason::Complaint).await?;
} else if email
.spam_report
.as_ref()
.and_then(|r| r.verdict.as_deref())
.is_some_and(|v| v.eq_ignore_ascii_case("yes"))
{
tracing::warn!(
target: "mail",
"inbound spam verdict carries no outbound complainant address; \
nothing suppressed (wire a real FBL/complaint source that \
populates InboundEmail::complained_address)"
);
}
Ok(())
}
#[cfg(feature = "db")]
pub use pg::PgSuppressionStore;
#[cfg(feature = "db")]
mod pg {
use std::future::Future;
use std::pin::Pin;
use diesel::prelude::*;
use diesel_async::AsyncPgConnection;
use diesel_async::RunQueryDsl;
use diesel_async::pooled_connection::deadpool::Pool;
use super::super::canonical_subscriber;
use super::{MailError, SuppressionReason, SuppressionStore};
diesel::table! {
mail_suppressions (address) {
address -> Text,
reason -> Text,
suppressed_at -> Timestamptz,
}
}
#[derive(Insertable)]
#[diesel(table_name = mail_suppressions)]
struct NewSuppression<'a> {
address: &'a str,
reason: &'a str,
}
#[derive(Clone)]
pub struct PgSuppressionStore {
pool: Pool<AsyncPgConnection>,
}
impl PgSuppressionStore {
#[must_use]
pub const fn new(pool: Pool<AsyncPgConnection>) -> Self {
Self { pool }
}
}
impl SuppressionStore for PgSuppressionStore {
fn is_suppressed<'a>(
&'a self,
address: &'a str,
) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
Box::pin(async move {
let key = canonical_subscriber(address);
let mut conn = self.pool.get().await.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
})?;
let count: i64 = mail_suppressions::table
.filter(mail_suppressions::address.eq(&key))
.count()
.get_result(&mut conn)
.await
.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression query: {e}"))
})?;
Ok(count > 0)
})
}
fn suppress<'a>(
&'a self,
address: &'a str,
reason: SuppressionReason,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
let key = canonical_subscriber(address);
let reason_str = reason.as_str();
let mut conn = self.pool.get().await.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
})?;
diesel::insert_into(mail_suppressions::table)
.values(NewSuppression {
address: &key,
reason: reason_str,
})
.on_conflict(mail_suppressions::address)
.do_update()
.set((
mail_suppressions::reason.eq(reason_str),
mail_suppressions::suppressed_at.eq(diesel::dsl::now),
))
.execute(&mut conn)
.await
.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression insert: {e}"))
})?;
Ok(())
})
}
fn unsuppress<'a>(
&'a self,
address: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
let key = canonical_subscriber(address);
let mut conn = self.pool.get().await.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
})?;
diesel::delete(
mail_suppressions::table.filter(mail_suppressions::address.eq(&key)),
)
.execute(&mut conn)
.await
.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression delete: {e}"))
})?;
Ok(())
})
}
}
}
}
#[cfg(feature = "db")]
pub mod db_suppression {
use std::future::Future;
use std::pin::Pin;
use diesel::prelude::*;
use diesel_async::AsyncPgConnection;
use diesel_async::RunQueryDsl;
use diesel_async::pooled_connection::deadpool::Pool;
use super::{MailError, SuppressionStore};
diesel::table! {
mail_unsubscribes (id) {
id -> Int8,
subscriber -> Text,
list_id -> Text,
unsubscribed_at -> Timestamptz,
}
}
#[derive(Insertable)]
#[diesel(table_name = mail_unsubscribes)]
struct NewUnsubscribe<'a> {
subscriber: &'a str,
list_id: &'a str,
}
#[derive(Clone)]
pub struct DbSuppressionStore {
pool: Pool<AsyncPgConnection>,
}
impl DbSuppressionStore {
#[must_use]
pub const fn new(pool: Pool<AsyncPgConnection>) -> Self {
Self { pool }
}
}
impl SuppressionStore for DbSuppressionStore {
fn is_suppressed<'a>(
&'a self,
subscriber: &'a str,
list_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
Box::pin(async move {
let mut conn =
self.pool.get().await.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
})?;
let count: i64 = mail_unsubscribes::table
.filter(mail_unsubscribes::subscriber.eq(subscriber))
.filter(mail_unsubscribes::list_id.eq(list_id))
.count()
.get_result(&mut conn)
.await
.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression query: {e}"))
})?;
Ok(count > 0)
})
}
fn suppress<'a>(
&'a self,
subscriber: &'a str,
list_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
let mut conn =
self.pool.get().await.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
})?;
diesel::insert_into(mail_unsubscribes::table)
.values(NewUnsubscribe {
subscriber,
list_id,
})
.on_conflict((mail_unsubscribes::subscriber, mail_unsubscribes::list_id))
.do_nothing()
.execute(&mut conn)
.await
.map_err(|e| {
MailError::RuntimeUnavailable(format!("suppression insert: {e}"))
})?;
Ok(())
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_contains_style_block_is_case_insensitive() {
assert!(html_contains_style_block("<STYLE>.a{}</STYLE>"));
assert!(html_contains_style_block("<p>x</p><style>.a{}</style>"));
assert!(!html_contains_style_block("<p style=\"color:red\">x</p>"));
assert!(!html_contains_style_block("just plain text, no tags"));
}
#[test]
fn inline_css_applies_class_style_to_anchor() {
let html = r#"<style>.btn{color:#fff;background:#06c}</style><a class="btn">Go</a>"#;
let out = inline_css_html(html).expect("inlining succeeds");
let anchor = out
.split("<a")
.nth(1)
.expect("an <a> tag is present in the output");
let anchor_open = &anchor[..anchor.find('>').expect("anchor tag closes")];
assert!(
anchor_open.contains("style="),
"anchor must gain an inline style attribute; got tag: {anchor_open}"
);
assert!(
anchor_open.contains("#fff"),
"anchor's inline style must carry the color rule; got tag: {anchor_open}"
);
assert!(
anchor_open.contains("#06c") || anchor_open.contains("background"),
"anchor's inline style must carry the background rule; got tag: {anchor_open}"
);
}
#[test]
fn inline_css_applies_class_style_to_table() {
let html = r#"<style>.wrap{width:600px;background:#eee}</style><table class="wrap"><tr><td>x</td></tr></table>"#;
let out = inline_css_html(html).expect("inlining succeeds");
let table = out
.split("<table")
.nth(1)
.expect("a <table> tag is present in the output");
let table_open = &table[..table.find('>').expect("table tag closes")];
assert!(
table_open.contains("style=") && table_open.contains("600px"),
"table must carry an inline style with the width rule; got tag: {table_open}"
);
}
#[test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
fn inline_css_emits_outlook_width_height_attributes() {
let html = r#"<style>table{width:600px}img{height:40px}</style><table><tr><td><img src="/x.png"></td></tr></table>"#;
let out = inline_css_html(html).expect("inlining succeeds");
let table_open = {
let table = out
.split("<table")
.nth(1)
.expect("a <table> tag is present in the output");
&table[..table.find('>').expect("table tag closes")]
};
assert!(
table_open.contains("style=") && table_open.contains("600px"),
"table must still carry the inline CSS width; got tag: {table_open}"
);
assert!(
table_open.contains(r#"width="600""#),
"table must gain the presentational HTML width attribute Outlook needs; got tag: {table_open}"
);
let img_open = {
let img = out
.split("<img")
.nth(1)
.expect("an <img> tag is present in the output");
&img[..img.find('>').expect("img tag closes")]
};
assert!(
img_open.contains("style=") && img_open.contains("40px"),
"img must still carry the inline CSS height; got tag: {img_open}"
);
assert!(
img_open.contains(r#"height="40""#),
"img must gain the presentational HTML height attribute Outlook needs; got tag: {img_open}"
);
}
#[test]
fn inline_css_passthrough_without_style_block_is_byte_identical() {
let html = r#"<p style="color:red">Hello</p><a href="/x">link</a>"#;
let out = inline_css_html(html).expect("no-op inlining succeeds");
assert_eq!(out, html, "no-<style> body must be returned unchanged");
}
#[test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
fn inline_css_retains_link_stylesheet_tags() {
let html = r#"<style>.x{color:red}</style><link rel="stylesheet" href="https://example.com/app.css"><p class="x">Hi</p>"#;
let out = inline_css_html(html).expect("inlining succeeds");
assert!(
out.contains("<link") && out.contains(r#"rel="stylesheet""#),
"the <link rel=\"stylesheet\"> tag must be preserved; got: {out}"
);
assert!(
out.contains("app.css"),
"the linked stylesheet href must be preserved; got: {out}"
);
let para = out
.split("<p")
.nth(1)
.expect("a <p> tag is present in the output");
let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
assert!(
para_open.contains("style=") && para_open.contains("red"),
"the embedded rule must still be inlined onto the paragraph; got tag: {para_open}"
);
}
#[test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
fn inline_css_is_idempotent() {
let html = r#"<style>.btn{color:#fff}p{margin:0}</style><a class="btn">Go</a><p>hi</p>"#;
let once = inline_css_html(html).expect("first pass");
let twice = inline_css_html(&once).expect("second pass");
assert_eq!(once, twice, "inlining must be idempotent");
}
#[test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
fn inline_css_retains_uninlinable_media_queries() {
let html = r#"<style>.btn{color:#fff}@media (max-width:600px){.btn{color:#000}}</style><a class="btn">Go</a>"#;
let out = inline_css_html(html).expect("inlining succeeds");
assert!(
out.contains("@media") && out.contains("max-width"),
"the @media rule must be preserved in a retained <style> block; got: {out}"
);
assert!(
out.contains("<a") && out.contains("style="),
"the inlinable rule must still be inlined onto the anchor; got: {out}"
);
}
#[test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
fn inline_css_fragment_body_stays_a_fragment() {
let html = r#"<style>.x{color:red}</style><p class="x">Hi</p>"#;
let out = inline_css_html(html).expect("inlining succeeds");
assert!(
!out.to_ascii_lowercase().contains("<html")
&& !out.to_ascii_lowercase().contains("<body")
&& !out.to_ascii_lowercase().contains("<head"),
"fragment body must not gain document wrappers; got: {out}"
);
let para = out
.split("<p")
.nth(1)
.expect("a <p> tag is present in the output");
let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
assert!(
para_open.contains("style=") && para_open.contains("red"),
"the class rule must be inlined onto the paragraph; got tag: {para_open}"
);
}
#[test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
fn inline_css_fragment_body_retains_media_query_without_wrapping() {
let html = r#"<style>.btn{color:#fff}@media (max-width:600px){.btn{color:#000}}</style><a class="btn">Go</a>"#;
let out = inline_css_html(html).expect("inlining succeeds");
assert!(
!out.to_ascii_lowercase().contains("<html")
&& !out.to_ascii_lowercase().contains("<body")
&& !out.to_ascii_lowercase().contains("<head"),
"fragment body must not gain document wrappers; got: {out}"
);
assert!(
out.contains("@media") && out.contains("max-width"),
"the retained @media block must survive the unwrap; got: {out}"
);
let anchor = out
.split("<a")
.nth(1)
.expect("an <a> tag is present in the output");
let anchor_open = &anchor[..anchor.find('>').expect("anchor tag closes")];
assert!(
anchor_open.contains("style=") && anchor_open.contains("#fff"),
"the inlinable rule must still be inlined onto the anchor; got tag: {anchor_open}"
);
}
#[test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
fn inline_css_full_document_body_stays_a_document() {
let html = r#"<html><head><style>.x{color:red}</style></head><body><p class="x">Hi</p></body></html>"#;
let out = inline_css_html(html).expect("inlining succeeds");
assert!(
out.to_ascii_lowercase().contains("<html")
&& out.to_ascii_lowercase().contains("<body"),
"full-document body must retain its structure; got: {out}"
);
let para = out
.split("<p")
.nth(1)
.expect("a <p> tag is present in the output");
let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
assert!(
para_open.contains("style=") && para_open.contains("red"),
"the class rule must be inlined onto the paragraph; got tag: {para_open}"
);
}
#[test]
fn inline_css_stripped_style_renders_same_computed_styling() {
let html = r#"<style>.btn{color:#fff;padding:8px}</style><a class="btn">Go</a>"#;
let inlined = inline_css_html(html).expect("inlining succeeds");
let mut stripped = String::new();
let mut rest = inlined.as_str();
while let Some(start) = rest.to_ascii_lowercase().find("<style") {
stripped.push_str(&rest[..start]);
let after = &rest[start..];
let end = after
.to_ascii_lowercase()
.find("</style>")
.map_or(after.len(), |e| e + "</style>".len());
rest = &after[end..];
}
stripped.push_str(rest);
let anchor = stripped
.split("<a")
.nth(1)
.expect("anchor present after stripping <style>");
let anchor_open = &anchor[..anchor.find('>').expect("anchor closes")];
assert!(
anchor_open.contains("style=") && anchor_open.contains("#fff"),
"computed styling must be carried inline so a style-stripped copy looks identical; got: {anchor_open}"
);
}
async fn preview_body_for(preview: MailPreview) -> String {
let state = crate::AppState::for_test();
state.insert_extension(MailPreviewRegistry::new(vec![preview]));
let mailer = Mailer::builder()
.build()
.expect("log-transport mailer builds");
state.insert_extension(mailer);
let response = show_template_preview(&state, "test", "styled");
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("preview body collects");
String::from_utf8(bytes.to_vec()).expect("preview body is utf-8")
}
fn escaped_anchor_open_tag(body: &str) -> String {
let after = body
.split("<a")
.nth(1)
.expect("an <a> tag is present in the escaped preview body");
let open = &after[..after.find(">").expect("anchor tag closes")];
open.to_owned()
}
#[tokio::test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
async fn preview_inlines_style_block_when_inlining_enabled() {
let preview = MailPreview::new("test", "styled", || {
Mail::builder()
.to("user@example.com")
.subject("Styled")
.html(r#"<style>.btn{color:#ff0000}</style><a class="btn">Go</a>"#)
.inline_css(true)
.build()
.expect("preview mail builds")
});
let body = preview_body_for(preview).await;
let anchor = escaped_anchor_open_tag(&body);
assert!(
anchor.contains("style="),
"preview must inline the <style> block onto the anchor; got tag: {anchor}"
);
assert!(
anchor.contains("#ff0000"),
"the .btn colour rule must be carried inline; got tag: {anchor}"
);
}
#[tokio::test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "CSS rule braces are literal HTML, not format placeholders"
)]
async fn preview_leaves_style_block_raw_when_inlining_disabled() {
let preview = MailPreview::new("test", "styled", || {
Mail::builder()
.to("user@example.com")
.subject("Styled")
.html(r#"<style>.btn{color:#ff0000}</style><a class="btn">Go</a>"#)
.inline_css(false)
.build()
.expect("preview mail builds")
});
let body = preview_body_for(preview).await;
let anchor = escaped_anchor_open_tag(&body);
assert!(
!anchor.contains("style="),
"inlining is off, so the anchor must not gain an inline style; got tag: {anchor}"
);
assert!(
body.contains("<style>"),
"the raw <style> block must survive when inlining is off"
);
}
#[test]
fn mail_builder_inline_css_sets_per_message_override() {
let on = Mail::builder()
.to("a@example.com")
.subject("s")
.html("<p>x</p>")
.inline_css(true)
.build()
.expect("valid mail");
assert_eq!(on.inline_css, Some(true));
let off = Mail::builder()
.to("a@example.com")
.subject("s")
.html("<p>x</p>")
.inline_css(false)
.build()
.expect("valid mail");
assert_eq!(off.inline_css, Some(false));
let unset = Mail::builder()
.to("a@example.com")
.subject("s")
.html("<p>x</p>")
.build()
.expect("valid mail");
assert_eq!(
unset.inline_css, None,
"unset builder must defer to the mailer/config default"
);
}
#[test]
fn mail_config_inline_css_defaults_off() {
assert!(
!MailConfig::default().inline_css,
"inlining must default off so existing apps are unaffected"
);
}
fn pinned_render_eml_no_attachments(mail: &Mail) -> String {
let mut out = String::new();
if let Some(from) = &mail.from {
out.push_str("From: ");
out.push_str(from);
out.push('\n');
}
for to in &mail.to {
out.push_str("To: ");
out.push_str(to);
out.push('\n');
}
if let Some(reply_to) = &mail.reply_to {
out.push_str("Reply-To: ");
out.push_str(reply_to);
out.push('\n');
}
out.push_str("Date: ");
out.push_str("PINNED-DATE");
out.push('\n');
out.push_str("Message-Id: <");
out.push_str("PINNED-ID");
out.push_str("@autumn.local>\n");
out.push_str("Subject: ");
out.push_str(&mail.subject);
out.push('\n');
for (name, value) in &mail.extra_headers {
out.push_str(name);
out.push_str(": ");
out.push_str(value);
out.push('\n');
}
out.push_str("MIME-Version: 1.0\n");
if mail.html.is_some() && mail.text.is_some() {
out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
if let Some(text) = &mail.text {
out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
out.push_str(text);
out.push('\n');
}
if let Some(html) = &mail.html {
out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
out.push_str(html);
out.push('\n');
}
out.push_str("--autumn-mail--\n");
} else if let Some(html) = &mail.html {
out.push_str("Content-Type: text/html; charset=utf-8\n\n");
out.push_str(html);
out.push('\n');
} else if let Some(text) = &mail.text {
out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
out.push_str(text);
out.push('\n');
}
out
}
fn mask_nondeterministic(eml: &str) -> String {
eml.lines()
.map(|line| {
if line.starts_with("Date: ") {
"Date: PINNED-DATE"
} else if line.starts_with("Message-Id: ") {
"Message-Id: <PINNED-ID@autumn.local>"
} else {
line
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn render_eml_without_attachments_matches_pinned_shape() {
let mails = [
Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Hi")
.text("hello text")
.html("<p>hello html</p>")
.build()
.expect("mail should build"),
Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Hi")
.text("hello text only")
.build()
.expect("mail should build"),
Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Hi")
.html("<p>hello html only</p>")
.build()
.expect("mail should build"),
];
for mail in mails {
let actual = mask_nondeterministic(&render_eml(&mail));
let pinned = mask_nondeterministic(&pinned_render_eml_no_attachments(&mail));
assert_eq!(
actual, pinned,
"render_eml must be byte-identical for attachment-less mail"
);
assert!(!actual.contains("multipart/mixed"));
}
}
#[test]
fn lettre_message_without_attachments_has_no_mixed_part() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Hi")
.text("hello")
.html("<p>hello</p>")
.build()
.expect("mail should build");
let message = lettre_message(&mail).expect("lettre message should build");
let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
assert!(formatted.contains("multipart/alternative"));
assert!(!formatted.contains("multipart/mixed"));
}
#[test]
fn mail_builder_attach_preserves_order_and_count() {
let mail = Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.attach("a.txt", "text/plain", b"aaa".to_vec())
.attach("b.txt", "text/plain", b"bbb".to_vec())
.attach("c.txt", "text/plain", b"ccc".to_vec())
.build()
.expect("mail should build");
assert_eq!(mail.attachments.len(), 3);
assert_eq!(
mail.attachments
.iter()
.map(|a| a.filename.as_str())
.collect::<Vec<_>>(),
vec!["a.txt", "b.txt", "c.txt"]
);
assert_eq!(mail.attachments[1].content_type, "text/plain");
assert_eq!(mail.attachments[1].bytes, b"bbb".to_vec());
}
#[test]
fn mail_serde_round_trips_attachments() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Hi")
.text("hello")
.attach("invoice.pdf", "application/pdf", vec![0_u8, 1, 2, 255])
.build()
.expect("mail should build");
let json = serde_json::to_string(&mail).expect("mail should serialize");
let round_tripped: Mail = serde_json::from_str(&json).expect("mail should deserialize");
assert_eq!(round_tripped, mail);
}
#[test]
fn mail_builder_rejects_control_chars_in_attachment_filename() {
let err = Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.attach(
"evil\r\nX-Injected: 1.pdf",
"application/pdf",
b"x".to_vec(),
)
.build()
.expect_err("CRLF in filename should be rejected");
assert!(err.to_string().contains("filename"));
let err = Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.attach("\0evil.pdf", "application/pdf", b"x".to_vec())
.build()
.expect_err("NUL in filename should be rejected");
assert!(err.to_string().contains("filename"));
let err = Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.attach(" ", "application/pdf", b"x".to_vec())
.build()
.expect_err("empty filename should be rejected");
assert!(err.to_string().contains("filename"));
}
#[test]
fn mail_builder_rejects_invalid_attachment_content_type() {
let err = Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.attach("a.pdf", "not a mime type", b"x".to_vec())
.build()
.expect_err("invalid content type should be rejected");
assert!(err.to_string().contains("content type"));
}
#[test]
fn mail_attachment_debug_hides_bytes() {
let attachment = MailAttachment {
filename: "secret.bin".to_owned(),
content_type: "application/octet-stream".to_owned(),
bytes: vec![1, 2, 3, 4, 5],
};
let debug = format!("{attachment:?}");
assert!(debug.contains("secret.bin"));
assert!(debug.contains('5'), "byte length should appear: {debug}");
assert!(
!debug.contains("[1, 2, 3, 4, 5]"),
"raw byte values must not appear: {debug}"
);
}
fn blob_all_byte_values() -> Vec<u8> {
(0_u8..=255).cycle().take(4096).collect()
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn mixed_boundary(eml: &str) -> String {
let line = eml
.lines()
.find(|line| line.starts_with("Content-Type: multipart/mixed;"))
.expect("multipart/mixed Content-Type header present");
content_type_boundary(line).expect("boundary parameter present")
}
#[test]
fn render_eml_with_attachment_emits_multipart_mixed() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Invoice")
.text("see attached")
.attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
.build()
.expect("mail should build");
let eml = render_eml(&mail);
let boundary = mixed_boundary(&eml);
assert!(eml.contains(&format!(
"Content-Type: multipart/mixed; boundary=\"{boundary}\""
)));
assert!(eml.contains("Content-Disposition: attachment; filename=\"invoice.pdf\""));
assert!(eml.contains("Content-Type: application/pdf"));
assert!(eml.contains("Content-Transfer-Encoding: base64"));
assert!(eml.contains(&format!("--{boundary}--")));
}
#[test]
fn render_eml_boundary_is_unpredictable_and_body_cannot_forge_it() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Spoof attempt")
.text("line one\n--autumn-mixed--\nX-Spoofed: header\nline two")
.attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
.build()
.expect("mail should build");
let eml = render_eml(&mail);
let parsed = parse_eml(&eml);
assert_eq!(
parsed.text.as_deref(),
Some("line one\n--autumn-mixed--\nX-Spoofed: header\nline two"),
"body content resembling the old fixed boundary must not truncate the message"
);
assert_eq!(parsed.attachments.len(), 1);
let other = render_eml(
&Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Second message")
.text("hi")
.attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
.build()
.expect("mail should build"),
);
assert_ne!(
mixed_boundary(&eml),
mixed_boundary(&other),
"boundary must vary per message, not be a fixed/predictable string"
);
}
#[test]
fn render_eml_attachment_bytes_round_trip_sha256() {
use base64::Engine as _;
let blob = blob_all_byte_values();
let expected_digest = sha256_hex(&blob);
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Blob")
.text("see attached")
.attach("blob.bin", "application/octet-stream", blob)
.build()
.expect("mail should build");
let eml = render_eml(&mail);
let boundary = mixed_boundary(&eml);
let start = eml
.find("Content-Transfer-Encoding: base64\n\n")
.expect("base64 section present")
+ "Content-Transfer-Encoding: base64\n\n".len();
let rest = &eml[start..];
let end = rest
.find(&format!("--{boundary}"))
.expect("closing boundary present");
let encoded: String = rest[..end].chars().filter(|c| !c.is_whitespace()).collect();
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("attachment body should be valid base64");
assert_eq!(sha256_hex(&decoded), expected_digest);
}
#[test]
fn render_eml_preserves_attachment_order() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Multi")
.text("see attached")
.attach("a.txt", "text/plain", b"a".to_vec())
.attach("b.txt", "text/plain", b"b".to_vec())
.build()
.expect("mail should build");
let eml = render_eml(&mail);
let a_pos = eml.find("filename=\"a.txt\"").expect("a.txt present");
let b_pos = eml.find("filename=\"b.txt\"").expect("b.txt present");
assert!(a_pos < b_pos, "attachments must render in declared order");
}
#[test]
fn render_eml_with_attachment_nests_alternative_body() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Both bodies")
.text("plain")
.html("<p>html</p>")
.attach("a.txt", "text/plain", b"a".to_vec())
.build()
.expect("mail should build");
let eml = render_eml(&mail);
assert!(eml.contains("Content-Type: multipart/alternative; boundary=\"autumn-mail\""));
assert!(eml.contains("plain"));
assert!(eml.contains("<p>html</p>"));
}
#[test]
fn render_eml_blocks_filename_header_injection() {
let mail = Mail {
from: Some("from@example.com".to_owned()),
reply_to: None,
to: vec!["user@example.com".to_owned()],
subject: "Hi".to_owned(),
html: None,
text: Some("hello".to_owned()),
list_unsubscribe: None,
extra_headers: Vec::new(),
attachments: vec![MailAttachment {
filename: "evil\r\nX-Injected: 1.pdf".to_owned(),
content_type: "application/pdf".to_owned(),
bytes: b"x".to_vec(),
}],
ignore_suppression: false,
inline_css: None,
};
let eml = render_eml(&mail);
assert!(
!eml.lines().any(|line| line.starts_with("X-Injected")),
"CRLF in filename must not inject a header: {eml}"
);
assert!(!eml.contains('\r'));
}
#[test]
fn render_eml_blocks_header_injection_in_all_deserialized_fields() {
let mail = Mail {
from: Some("from@example.com\r\nX-From-Injected: 1".to_owned()),
reply_to: Some("reply@example.com\r\nX-Reply-Injected: 1".to_owned()),
to: vec!["user@example.com\r\nX-To-Injected: 1".to_owned()],
subject: "Hi\r\nX-Subject-Injected: 1".to_owned(),
html: None,
text: Some("hello".to_owned()),
list_unsubscribe: None,
extra_headers: vec![(
"X-Custom\r\nX-Header-Injected".to_owned(),
"1\r\nX-Value-Injected: 1".to_owned(),
)],
attachments: Vec::new(),
ignore_suppression: false,
inline_css: None,
};
let eml = render_eml(&mail);
assert!(
!eml.lines().any(|line| line.starts_with("X-From-Injected")
|| line.starts_with("X-Reply-Injected")
|| line.starts_with("X-To-Injected")
|| line.starts_with("X-Subject-Injected")
|| line.starts_with("X-Header-Injected")
|| line.starts_with("X-Value-Injected")),
"CRLF in any header-bound field must not inject a standalone header line: {eml}"
);
assert!(!eml.contains('\r'));
}
#[test]
fn render_eml_falls_back_to_octet_stream_for_invalid_content_type() {
let mail = Mail {
from: Some("from@example.com".to_owned()),
reply_to: None,
to: vec!["user@example.com".to_owned()],
subject: "Hi".to_owned(),
html: None,
text: Some("hello".to_owned()),
list_unsubscribe: None,
extra_headers: Vec::new(),
attachments: vec![MailAttachment {
filename: "file.bin".to_owned(),
content_type: "not a mime type".to_owned(),
bytes: b"x".to_vec(),
}],
ignore_suppression: false,
inline_css: None,
};
let eml = render_eml(&mail);
assert!(eml.contains("Content-Type: application/octet-stream"));
assert!(!eml.contains("not a mime type"));
}
#[test]
fn render_eml_encodes_non_ascii_filename_rfc2231() {
let mail = Mail {
from: Some("from@example.com".to_owned()),
reply_to: None,
to: vec!["user@example.com".to_owned()],
subject: "Hi".to_owned(),
html: None,
text: Some("hello".to_owned()),
list_unsubscribe: None,
extra_headers: Vec::new(),
attachments: vec![MailAttachment {
filename: "Résumé façade.pdf".to_owned(),
content_type: "application/pdf".to_owned(),
bytes: b"x".to_vec(),
}],
ignore_suppression: false,
inline_css: None,
};
let eml = render_eml(&mail);
assert!(eml.contains("filename*=UTF-8''"));
let disposition_line = eml
.lines()
.find(|line| line.starts_with("Content-Disposition:"))
.expect("Content-Disposition header present");
assert!(disposition_line.is_ascii());
}
#[test]
fn content_disposition_params_table() {
assert_eq!(content_disposition_params("a.txt"), "filename=\"a.txt\"");
assert_eq!(
content_disposition_params("weird\"na\\me.txt"),
"filename=\"weird\\\"na\\\\me.txt\""
);
assert_eq!(
content_disposition_params("evil\r\nX: 1"),
"filename=\"evilX: 1\""
);
assert_eq!(content_disposition_params(""), "filename=\"attachment\"");
assert_eq!(content_disposition_params(" "), "filename=\"attachment\"");
let non_ascii = content_disposition_params("café.txt");
assert!(non_ascii.contains("filename*=UTF-8''caf%C3%A9.txt"));
assert!(non_ascii.is_ascii());
}
#[test]
fn render_eml_base64_lines_wrap_at_76() {
let blob = blob_all_byte_values();
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Blob")
.text("see attached")
.attach("blob.bin", "application/octet-stream", blob)
.build()
.expect("mail should build");
let eml = render_eml(&mail);
let boundary = mixed_boundary(&eml);
let start = eml
.find("Content-Transfer-Encoding: base64\n\n")
.expect("base64 section present")
+ "Content-Transfer-Encoding: base64\n\n".len();
let rest = &eml[start..];
let end = rest
.find(&format!("--{boundary}"))
.expect("closing boundary present");
for line in rest[..end].lines() {
assert!(
line.len() <= 76,
"base64 line too long: {} chars",
line.len()
);
}
}
#[test]
fn lettre_message_with_attachment_is_multipart_mixed() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Invoice")
.text("see attached")
.attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
.build()
.expect("mail should build");
let message = lettre_message(&mail).expect("lettre message should build");
let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
assert!(formatted.contains("multipart/mixed"));
assert!(formatted.contains("Content-Disposition: attachment"));
assert!(formatted.contains("invoice.pdf"));
assert!(formatted.contains("base64"));
}
fn extract_boundary(text: &str) -> String {
let marker = "boundary=\"";
let start = text.find(marker).expect("boundary present") + marker.len();
let rest = &text[start..];
let end = rest.find('"').expect("boundary closing quote");
rest[..end].to_owned()
}
fn extract_attachment_base64(formatted_lf: &str, boundary: &str) -> String {
let marker = format!("--{boundary}");
for segment in formatted_lf.split(&marker).skip(1) {
let segment = segment.trim_start_matches(['\n', '\r']);
if segment.starts_with("--") {
break;
}
let (headers, body) = split_headers_body(segment);
if header_value(&headers, "Content-Disposition")
.unwrap_or_default()
.to_ascii_lowercase()
.contains("attachment")
{
return body.chars().filter(|c| !c.is_whitespace()).collect();
}
}
panic!("attachment part not found in: {formatted_lf}");
}
#[test]
fn lettre_message_attachment_round_trips_sha256() {
use base64::Engine as _;
let blob = blob_all_byte_values();
let expected_digest = sha256_hex(&blob);
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Blob")
.text("see attached")
.attach("blob.bin", "application/octet-stream", blob)
.build()
.expect("mail should build");
let message = lettre_message(&mail).expect("lettre message should build");
let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
let normalized = formatted.replace("\r\n", "\n");
let boundary = extract_boundary(&normalized);
let encoded = extract_attachment_base64(&normalized, &boundary);
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("attachment body should be valid base64");
assert_eq!(sha256_hex(&decoded), expected_digest);
}
#[test]
fn lettre_message_attachment_headers_ascii_and_injection_free() {
for filename in ["evil\r\nX-Injected: 1.pdf", "Résumé façade.pdf"] {
let mail = Mail {
from: Some("from@example.com".to_owned()),
reply_to: None,
to: vec!["user@example.com".to_owned()],
subject: "Hi".to_owned(),
html: None,
text: Some("hello".to_owned()),
list_unsubscribe: None,
extra_headers: Vec::new(),
attachments: vec![MailAttachment {
filename: filename.to_owned(),
content_type: "application/pdf".to_owned(),
bytes: b"x".to_vec(),
}],
ignore_suppression: false,
inline_css: None,
};
let message = lettre_message(&mail).expect("lettre message should build");
let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
let header_section = formatted
.split("\r\n\r\n")
.next()
.expect("header section present");
assert!(
header_section.is_ascii(),
"headers must stay ASCII for filename {filename:?}: {header_section}"
);
assert!(
!formatted.lines().any(|line| line.starts_with("X-Injected")),
"CRLF in filename must not inject a header for {filename:?}"
);
}
}
#[test]
fn lettre_message_attachment_with_invalid_content_type_errors() {
let mail = Mail {
from: Some("from@example.com".to_owned()),
reply_to: None,
to: vec!["user@example.com".to_owned()],
subject: "Hi".to_owned(),
html: None,
text: Some("hello".to_owned()),
list_unsubscribe: None,
extra_headers: Vec::new(),
attachments: vec![MailAttachment {
filename: "a.bin".to_owned(),
content_type: "not a mime type".to_owned(),
bytes: b"x".to_vec(),
}],
ignore_suppression: false,
inline_css: None,
};
let err = lettre_message(&mail).expect_err("invalid content type should error");
assert!(matches!(err, MailError::InvalidMessage(_)));
}
#[test]
fn parse_eml_extracts_attachment_list() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Invoice")
.text("plain")
.html("<p>html</p>")
.attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
.attach("receipt.csv", "text/csv", b"a,b,c".to_vec())
.build()
.expect("mail should build");
let eml = render_eml(&mail);
let parsed = parse_eml(&eml);
assert_eq!(parsed.attachments.len(), 2);
assert_eq!(parsed.attachments[0].filename, "invoice.pdf");
assert_eq!(parsed.attachments[0].content_type, "application/pdf");
assert_eq!(parsed.attachments[1].filename, "receipt.csv");
assert_eq!(parsed.html.as_deref(), Some("<p>html</p>"));
assert_eq!(parsed.text.as_deref(), Some("plain"));
}
#[test]
fn extract_attachment_filename_handles_semicolon_in_quoted_filename() {
assert_eq!(
extract_attachment_filename(r#"attachment; filename="invoice;2026.pdf""#),
"invoice;2026.pdf"
);
}
#[test]
fn extract_attachment_filename_unescapes_quoted_pairs() {
assert_eq!(
extract_attachment_filename(r#"attachment; filename="weird\"na\\me.txt""#),
"weird\"na\\me.txt"
);
}
#[test]
fn extract_attachment_filename_is_case_insensitive_and_handles_language_tag() {
assert_eq!(
extract_attachment_filename("attachment; filename*=utf-8'en'r%C3%A9sum%C3%A9.pdf"),
"résumé.pdf"
);
}
#[test]
fn extract_attachment_filename_round_trips_through_dev_preview() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Invoice")
.text("plain")
.attach(r#"a;b"c\d.txt"#, "text/plain", b"x".to_vec())
.build()
.expect("mail should build");
let eml = render_eml(&mail);
let parsed = parse_eml(&eml);
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(parsed.attachments[0].filename, r#"a;b"c\d.txt"#);
}
#[test]
fn parse_multipart_mixed_does_not_misclassify_inline_as_attachment() {
let (_, _, attachments) = parse_multipart_mixed(
"--b\nContent-Disposition: inline; filename=\"my-attachment-notes.pdf\"\nContent-Type: text/plain\n\nhi\n--b--\n",
"b",
);
assert!(
attachments.is_empty(),
"an `inline` disposition must not be classified as an attachment: {attachments:?}"
);
}
#[test]
fn mail_deserializes_from_pre_attachments_json_shape() {
let json = r#"{"from":null,"reply_to":null,"to":["a@example.com"],"subject":"hi","html":null,"text":"hello","list_unsubscribe":null,"extra_headers":[]}"#;
let mail: Mail =
serde_json::from_str(json).expect("pre-attachments JSON should deserialize");
assert!(mail.attachments.is_empty());
}
#[test]
fn render_mail_detail_lists_attachments() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Invoice")
.text("plain")
.attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
.attach("receipt.csv", "text/csv", b"a,b,c".to_vec())
.build()
.expect("mail should build");
let parsed = parse_eml(&render_eml(&mail));
let detail = render_mail_detail(&parsed, "captured");
assert!(detail.contains("Attachments (2)"));
assert!(detail.contains("invoice.pdf"));
assert!(detail.contains("receipt.csv"));
}
#[test]
fn render_mail_detail_without_attachments_omits_section() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Plain")
.text("plain")
.build()
.expect("mail should build");
let parsed = parse_eml(&render_eml(&mail));
let detail = render_mail_detail(&parsed, "captured");
assert!(!detail.contains("Attachments"));
}
#[test]
fn mail_builder_rejects_missing_body() {
let err = Mail::builder()
.to("user@example.com")
.subject("Hello")
.build()
.expect_err("body should be required");
assert!(err.to_string().contains("html or text"));
}
#[test]
fn filename_sanitizer_keeps_safe_characters() {
assert_eq!(
sanitize_filename("Ada Lovelace <ada@example.com>"),
"Ada_Lovelace__ada_example.com_"
);
}
#[test]
fn transport_default_is_disabled() {
assert_eq!(Transport::default(), Transport::Disabled);
}
#[test]
fn mail_defaults_have_no_unsubscribe_or_extra_headers() {
let mail = Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.build()
.expect("mail should build");
assert_eq!(mail.list_unsubscribe, None);
assert!(mail.extra_headers.is_empty());
assert!(mail.attachments.is_empty());
}
#[test]
fn mail_builder_sets_list_unsubscribe_and_headers() {
let mail = Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.list_unsubscribe("weekly_digest")
.header("X-Custom", "1")
.build()
.expect("mail should build");
assert_eq!(mail.list_unsubscribe.as_deref(), Some("weekly_digest"));
assert_eq!(
mail.extra_headers,
vec![("X-Custom".to_owned(), "1".to_owned())]
);
}
fn test_keys() -> crate::security::config::ResolvedSigningKeys {
crate::security::config::ResolvedSigningKeys::new(
b"unit-test-signing-key-0123456789".to_vec(),
vec![],
)
}
#[test]
fn token_roundtrips_and_hides_subscriber() {
use base64::Engine as _;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let keys = test_keys();
let token =
unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 4_000_000_000);
assert!(
!token.contains("ada@example.com"),
"raw subscriber must not appear in the token: {token}"
);
assert!(
!token.contains(&engine.encode("ada@example.com")),
"base64 of subscriber must not appear — the payload must be encrypted: {token}"
);
let decoded = unsubscribe::verify_token(&keys, &token, 1_000).expect("token should verify");
assert_eq!(decoded.subscriber, "ada@example.com");
assert_eq!(decoded.list_id, "weekly_digest");
}
#[test]
fn token_rejects_tamper_and_expiry() {
use base64::Engine as _;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let keys = test_keys();
let token =
unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 4_000_000_000);
let mut blob = engine.decode(&token).expect("token is base64");
let last = blob.len() - 1;
blob[last] ^= 0x01;
let tampered = engine.encode(&blob);
assert_eq!(
unsubscribe::verify_token(&keys, &tampered, 1_000),
Err(unsubscribe::TokenError::BadSignature)
);
let short = unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 100);
assert_eq!(
unsubscribe::verify_token(&keys, &short, 200),
Err(unsubscribe::TokenError::Expired)
);
}
#[test]
fn token_verifies_under_rotated_previous_key() {
let signer = crate::security::config::ResolvedSigningKeys::new(
b"old-key-old-key-old-key-old-key!".to_vec(),
vec![],
);
let token = unsubscribe::sign_token(&signer, "ada@example.com", "list", 4_000_000_000);
let rotated = crate::security::config::ResolvedSigningKeys::new(
b"new-key-new-key-new-key-new-key!".to_vec(),
vec![b"old-key-old-key-old-key-old-key!".to_vec()],
);
assert!(unsubscribe::verify_token(&rotated, &token, 1_000).is_ok());
}
#[test]
fn unsubscribe_url_includes_token_and_path() {
let url = unsubscribe::unsubscribe_url("https://app.example.com/", "TOK");
assert_eq!(url, "https://app.example.com/_autumn/unsubscribe?token=TOK");
}
#[tokio::test]
async fn in_memory_suppression_transitions() {
let store = InMemorySuppressionStore::new();
assert!(!store.is_suppressed("a@x.com", "list").await.unwrap());
store.suppress("a@x.com", "list").await.unwrap();
assert!(store.is_suppressed("a@x.com", "list").await.unwrap());
assert!(!store.is_suppressed("a@x.com", "other").await.unwrap());
assert!(!store.is_suppressed("b@x.com", "list").await.unwrap());
}
#[test]
fn render_eml_emits_extra_headers() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Hi")
.text("hello")
.header("List-Unsubscribe", "<https://x/u?token=t>, <mailto:u@x>")
.header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
.build()
.expect("mail should build");
let eml = render_eml(&mail);
assert!(eml.contains("List-Unsubscribe: <https://x/u?token=t>, <mailto:u@x>"));
assert!(eml.contains("List-Unsubscribe-Post: List-Unsubscribe=One-Click"));
}
#[test]
fn render_eml_without_headers_has_no_unsubscribe() {
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Hi")
.text("hello")
.build()
.expect("mail should build");
assert!(!render_eml(&mail).contains("List-Unsubscribe"));
}
#[derive(Clone)]
struct CapturingTransport {
sent: Arc<std::sync::Mutex<Vec<Mail>>>,
}
impl MailTransport for CapturingTransport {
fn send<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
self.sent.lock().expect("sent lock").push(mail);
Ok(())
})
}
}
fn unsubscribe_runtime(
suppression: Option<Arc<dyn SuppressionStore>>,
) -> Arc<UnsubscribeRuntime> {
Arc::new(UnsubscribeRuntime {
base_url: Some("https://app.example.com".to_owned()),
mailto: Some("unsub@example.com".to_owned()),
signing_keys: Arc::new(test_keys()),
ttl_days: 30,
suppression,
})
}
#[tokio::test]
#[allow(clippy::significant_drop_tightening)]
async fn send_adds_headers_for_list_mail() {
let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = CapturingTransport { sent: sent.clone() };
let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Digest")
.text("hello")
.list_unsubscribe("weekly_digest")
.build()
.unwrap();
mailer.send(mail).await.unwrap();
let captured = sent.lock().unwrap();
assert_eq!(captured.len(), 1);
let headers = &captured[0].extra_headers;
assert!(headers.iter().any(|(n, v)| n == "List-Unsubscribe"
&& v.contains("/_autumn/unsubscribe?token=")
&& v.contains("mailto:unsub@example.com")));
assert!(
headers
.iter()
.any(|(n, v)| n == "List-Unsubscribe-Post" && v == "List-Unsubscribe=One-Click")
);
}
#[tokio::test]
#[allow(clippy::significant_drop_tightening)]
async fn send_replaces_manual_list_unsubscribe_with_generated_one_click() {
let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = CapturingTransport { sent: sent.clone() };
let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Digest")
.text("hello")
.header("List-Unsubscribe", "<mailto:old@example.com>")
.list_unsubscribe("weekly_digest")
.build()
.unwrap();
mailer.send(mail).await.unwrap();
let captured = sent.lock().unwrap();
assert_eq!(captured.len(), 1);
let headers = &captured[0].extra_headers;
let unsub: Vec<&String> = headers
.iter()
.filter(|(n, _)| n == "List-Unsubscribe")
.map(|(_, v)| v)
.collect();
assert_eq!(unsub.len(), 1);
assert!(unsub[0].contains("/_autumn/unsubscribe?token="));
assert!(!unsub[0].contains("old@example.com"));
assert!(
headers
.iter()
.any(|(n, v)| n == "List-Unsubscribe-Post" && v == "List-Unsubscribe=One-Click")
);
}
#[tokio::test]
async fn send_list_mail_rejects_invalid_recipient_before_delivery() {
let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = CapturingTransport { sent: sent.clone() };
let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
let mail = Mail::builder()
.from("from@example.com")
.to("good@example.com")
.to("not a valid address")
.subject("Digest")
.text("hello")
.list_unsubscribe("weekly_digest")
.build()
.unwrap();
let result = mailer.send(mail).await;
assert!(result.is_err(), "invalid recipient must fail the send");
assert!(
sent.lock().unwrap().is_empty(),
"no recipient may be delivered when the list contains an invalid address"
);
}
#[tokio::test]
async fn send_list_mail_suppression_error_fails_before_any_delivery() {
struct FailingStore {
fail_for: String,
}
impl SuppressionStore for FailingStore {
fn is_suppressed<'a>(
&'a self,
subscriber: &'a str,
_list_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
let fails = subscriber == self.fail_for;
Box::pin(async move {
if fails {
Err(MailError::RuntimeUnavailable(
"store unavailable".to_owned(),
))
} else {
Ok(false)
}
})
}
fn suppress<'a>(
&'a self,
_subscriber: &'a str,
_list_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move { Ok(()) })
}
}
let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = CapturingTransport { sent: sent.clone() };
let store: Arc<dyn SuppressionStore> = Arc::new(FailingStore {
fail_for: "second@example.com".to_owned(),
});
let mailer =
Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(Some(store)));
let mail = Mail::builder()
.from("from@example.com")
.to("first@example.com")
.to("second@example.com")
.subject("Digest")
.text("hello")
.list_unsubscribe("weekly_digest")
.build()
.unwrap();
let result = mailer.send(mail).await;
assert!(
result.is_err(),
"suppression-store error must fail the send"
);
assert!(
sent.lock().unwrap().is_empty(),
"no recipient may be delivered when a later suppression lookup fails"
);
}
#[tokio::test]
async fn send_skips_suppressed_recipient() {
let store = Arc::new(InMemorySuppressionStore::new());
store
.suppress("user@example.com", "weekly_digest")
.await
.unwrap();
let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = CapturingTransport { sent: sent.clone() };
let mailer =
Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(Some(store)));
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Digest")
.text("hello")
.list_unsubscribe("weekly_digest")
.build()
.unwrap();
mailer.send(mail).await.unwrap();
assert!(
sent.lock().unwrap().is_empty(),
"suppressed recipient must be skipped"
);
}
#[tokio::test]
#[allow(clippy::significant_drop_tightening)]
async fn send_without_scope_is_unchanged() {
let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = CapturingTransport { sent: sent.clone() };
let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
let mail = Mail::builder()
.from("from@example.com")
.to("user@example.com")
.subject("Reset")
.text("hello")
.build()
.unwrap();
mailer.send(mail).await.unwrap();
let captured = sent.lock().unwrap();
assert_eq!(captured.len(), 1);
assert!(
captured[0].extra_headers.is_empty(),
"non-list mail must not gain headers"
);
}
#[test]
fn fail_closed_only_in_prod_with_mailers_and_no_config() {
assert!(unsubscribe_config_fail_closed(true, true, true, false));
assert!(!unsubscribe_config_fail_closed(true, true, true, true));
assert!(!unsubscribe_config_fail_closed(true, true, false, false));
assert!(!unsubscribe_config_fail_closed(true, false, true, false));
assert!(!unsubscribe_config_fail_closed(false, true, true, false));
}
#[test]
fn validate_rejects_non_positive_unsubscribe_ttl() {
let ttl = |days: i64| MailConfig {
unsubscribe_token_ttl_days: days,
..MailConfig::default()
};
assert!(ttl(0).validate(Some("dev")).is_err());
assert!(ttl(-1).validate(Some("dev")).is_err());
assert!(ttl(30).validate(Some("dev")).is_ok());
}
#[test]
fn unsubscribe_base_url_set_tracks_config() {
let with = |base: Option<&str>, mailto: Option<&str>| MailConfig {
unsubscribe_base_url: base.map(str::to_owned),
unsubscribe_mailto: mailto.map(str::to_owned),
..MailConfig::default()
};
assert!(!with(None, None).unsubscribe_base_url_set());
assert!(!with(None, Some("u@example.com")).unsubscribe_base_url_set());
assert!(with(Some("https://x"), None).unsubscribe_base_url_set());
assert!(!with(Some(" "), None).unsubscribe_base_url_set());
}
#[test]
fn should_mount_unsubscribe_endpoint_requires_opt_in_and_base_url() {
let cfg = |base: Option<&str>, opt_in: bool| MailConfig {
unsubscribe_base_url: base.map(str::to_owned),
mount_unsubscribe_endpoint: opt_in,
..MailConfig::default()
};
assert!(!cfg(Some("https://x"), false).should_mount_unsubscribe_endpoint());
assert!(cfg(Some("https://x"), true).should_mount_unsubscribe_endpoint());
assert!(!cfg(None, true).should_mount_unsubscribe_endpoint());
}
#[test]
fn validate_rejects_malformed_mailto_in_prod() {
let cfg = |mailto: &str| MailConfig {
unsubscribe_mailto: Some(mailto.to_owned()),
..MailConfig::default()
};
assert!(
cfg("unsubscribe example.com")
.validate(Some("prod"))
.is_err()
);
assert!(cfg("not-an-email").validate(Some("prod")).is_err());
assert!(cfg("unsub@example.com").validate(Some("prod")).is_ok());
assert!(
cfg("mailto:unsub@example.com")
.validate(Some("prod"))
.is_ok()
);
assert!(cfg("whatever").validate(Some("dev")).is_ok());
}
#[test]
fn validate_requires_https_base_url_in_prod() {
let cfg = |url: &str| MailConfig {
unsubscribe_base_url: Some(url.to_owned()),
..MailConfig::default()
};
assert!(
cfg("http://app.example.com")
.validate(Some("prod"))
.is_err()
);
assert!(
cfg("https://app.example.com")
.validate(Some("prod"))
.is_ok()
);
assert!(cfg("http://localhost:3000").validate(Some("dev")).is_ok());
assert!(cfg("https://").validate(Some("prod")).is_err());
assert!(cfg("https:///path").validate(Some("prod")).is_err());
assert!(
cfg("https://app.example.com?t=acme")
.validate(Some("prod"))
.is_err()
);
assert!(
cfg("https://app.example.com#x")
.validate(Some("prod"))
.is_err()
);
assert!(
cfg("https://app.example.com/base")
.validate(Some("prod"))
.is_ok()
);
}
#[test]
fn canonical_subscriber_strips_name_and_lowercases() {
assert_eq!(
canonical_subscriber("Ada Lovelace <Ada@Example.com>"),
"ada@example.com"
);
assert_eq!(canonical_subscriber("USER@EXAMPLE.COM"), "user@example.com");
}
#[test]
fn mailto_only_runtime_does_not_support_one_click() {
let runtime = UnsubscribeRuntime {
base_url: None,
mailto: Some("u@example.com".to_owned()),
signing_keys: Arc::new(test_keys()),
ttl_days: 30,
suppression: None,
};
assert!(!runtime.supports_one_click());
let header = runtime
.list_unsubscribe_header("a@x.com", "list")
.expect("mailto header");
assert!(header.contains("mailto:u@example.com"));
assert!(!header.contains("token="));
}
#[test]
fn mailto_value_with_scheme_is_not_double_prefixed() {
let runtime = UnsubscribeRuntime {
base_url: None,
mailto: Some("mailto:u@example.com".to_owned()),
signing_keys: Arc::new(test_keys()),
ttl_days: 30,
suppression: None,
};
let header = runtime
.list_unsubscribe_header("a@x.com", "list")
.expect("mailto header");
assert!(header.contains("<mailto:u@example.com?subject=unsubscribe>"));
assert!(!header.contains("mailto:mailto:"));
}
#[test]
fn one_click_body_detection() {
assert!(is_one_click_body("List-Unsubscribe=One-Click"));
assert!(is_one_click_body("foo=bar&List-Unsubscribe=One-Click"));
assert!(is_one_click_body("list-unsubscribe=one-click")); assert!(!is_one_click_body(""));
assert!(!is_one_click_body("List-Unsubscribe=Nope"));
assert!(!is_one_click_body("something=else"));
}
#[test]
fn smtp_config_validation_rejects_whitespace_only_host() {
let config = MailConfig {
transport: Transport::Smtp,
smtp: SmtpConfig {
host: Some(" ".to_owned()),
..Default::default()
},
..Default::default()
};
let error = config
.validate(Some("dev"))
.expect_err("whitespace SMTP host should be rejected");
assert!(error.to_string().contains("mail.smtp.host is required"));
}
#[test]
fn transport_env_value_is_trimmed_and_case_insensitive() {
assert_eq!(Transport::from_env_value(" SMTP "), Some(Transport::Smtp));
assert_eq!(Transport::from_env_value(" LoG "), Some(Transport::Log));
}
#[test]
fn tls_mode_env_value_is_trimmed_and_case_insensitive() {
assert_eq!(TlsMode::from_env_value(" TLS "), Some(TlsMode::Tls));
assert_eq!(
TlsMode::from_env_value(" START_TLS "),
Some(TlsMode::StartTls)
);
assert_eq!(
TlsMode::from_env_value(" disabled "),
Some(TlsMode::Disabled)
);
}
#[test]
fn file_transport_filename_is_unique_for_same_recipient() {
let mail = Mail::builder()
.to("Ada Lovelace <ada@example.com>")
.subject("Hello")
.text("body")
.build()
.expect("mail should build");
let first = file_transport_filename(&mail);
let second = file_transport_filename(&mail);
assert_ne!(first, second);
assert!(
Path::new(&first)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
);
assert!(
Path::new(&second)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
);
}
#[test]
fn smtp_transport_rejects_missing_password_env_when_username_is_set() {
let missing_key = format!(
"AUTUMN_TEST_MISSING_SMTP_PASSWORD_{}_{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
);
let Err(error) = SmtpTransport::new(
SmtpConfig {
host: Some("smtp.example.com".to_owned()),
port: Some(587),
username: Some("mailer".to_owned()),
password_env: Some(missing_key.clone()),
tls: TlsMode::StartTls,
},
None,
) else {
panic!("missing password env should fail at startup");
};
let displayed = error.to_string();
assert!(displayed.contains(&missing_key));
assert!(displayed.contains("environment variable is not set"));
}
#[test]
fn smtp_password_env_error_never_embeds_the_secret_value() {
let secret = "hunter2-super-secret-password";
let error = std::env::VarError::NotUnicode(std::ffi::OsString::from(secret));
assert!(error.to_string().contains(secret));
assert!(format!("{error:?}").contains(secret));
let mail_error = smtp_password_env_error("APP_SMTP_PASSWORD", &error);
let displayed = mail_error.to_string();
let debugged = format!("{mail_error:?}");
assert!(
!displayed.contains(secret),
"Display output leaked the SMTP password: {displayed}"
);
assert!(
!debugged.contains(secret),
"Debug output leaked the SMTP password: {debugged}"
);
assert!(displayed.contains("APP_SMTP_PASSWORD"));
assert!(displayed.contains("environment variable contains non-unicode data"));
}
#[test]
fn smtp_password_env_error_redacts_missing_variable_details() {
let error = std::env::VarError::NotPresent;
let mail_error = smtp_password_env_error("APP_SMTP_PASSWORD", &error);
let displayed = mail_error.to_string();
assert!(displayed.contains("APP_SMTP_PASSWORD"));
assert!(displayed.contains("environment variable is not set"));
}
#[test]
fn smtp_transport_rejects_missing_password_env_key_when_username_is_set() {
let Err(error) = SmtpTransport::new(
SmtpConfig {
host: Some("smtp.example.com".to_owned()),
port: Some(587),
username: Some("mailer".to_owned()),
password_env: None,
tls: TlsMode::StartTls,
},
None,
) else {
panic!("missing password_env setting should fail at startup");
};
assert!(error.to_string().contains("mail.smtp.password_env"));
}
#[test]
fn mailer_builder_rejects_invalid_default_from_address() {
let Err(error) = Mailer::builder().from("not an email address").build() else {
panic!("invalid default from should fail fast");
};
match error {
MailError::InvalidAddress { address, .. } => {
assert_eq!(address, "not an email address");
}
other => panic!("expected invalid address error, got {other:?}"),
}
}
#[test]
fn mailer_from_config_rejects_invalid_default_reply_to_address() {
let config = MailConfig {
transport: Transport::Smtp,
from: Some("Autumn <noreply@example.com>".to_owned()),
reply_to: Some("definitely not an address".to_owned()),
smtp: SmtpConfig {
host: Some("smtp.example.com".to_owned()),
..Default::default()
},
..Default::default()
};
let Err(error) = Mailer::from_config(&config) else {
panic!("invalid configured reply-to should fail at construction");
};
match error {
MailError::InvalidAddress { address, .. } => {
assert_eq!(address, "definitely not an address");
}
other => panic!("expected invalid address error, got {other:?}"),
}
}
#[test]
fn try_deliver_later_returns_error_without_runtime() {
let mailer = Mailer::builder().build().expect("mailer should build");
let mail = Mail::builder()
.to("user@example.com")
.subject("Hello")
.text("hello")
.build()
.expect("mail should build");
let error = mailer
.try_deliver_later(mail)
.expect_err("missing runtime should return an error");
assert!(error.to_string().contains("active Tokio runtime"));
}
#[test]
fn deliver_later_does_not_panic_without_runtime() {
let mailer = Mailer::builder().build().expect("mailer should build");
let mail = Mail::builder()
.to("user@example.com")
.subject("Hello")
.text("hello")
.build()
.expect("mail should build");
mailer.deliver_later(mail);
}
fn sample_smtp_config() -> MailConfig {
MailConfig {
transport: Transport::Smtp,
from: Some("Autumn <noreply@example.com>".to_owned()),
smtp: SmtpConfig {
host: Some("smtp.example.com".to_owned()),
..Default::default()
},
..Default::default()
}
}
fn sample_mail() -> Mail {
Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.build()
.expect("mail should build")
}
struct NoopQueue;
impl MailDeliveryQueue for NoopQueue {
fn enqueue<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
}
#[test]
fn install_mailer_rejects_in_process_fallback_in_prod_without_ack() {
let state = crate::AppState::for_test().with_profile("prod");
let config = sample_smtp_config();
let error = install_mailer(&state, &config, true)
.expect_err("prod must reject in-process deliver_later fallback without ack");
let message = error.to_string();
assert!(
message.contains("allow_in_process_deliver_later_in_production"),
"error should explain how to opt in: {message}"
);
}
#[test]
fn install_mailer_allows_in_process_fallback_in_prod_with_explicit_ack() {
let state = crate::AppState::for_test().with_profile("prod");
let config = MailConfig {
allow_in_process_deliver_later_in_production: true,
..sample_smtp_config()
};
install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
}
#[test]
fn install_mailer_allows_durable_queue_in_prod_without_ack() {
let state = crate::AppState::for_test().with_profile("prod");
state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
let config = sample_smtp_config();
install_mailer(&state, &config, true)
.expect("a registered durable queue should satisfy the prod guard");
}
#[test]
fn install_mailer_does_not_require_ack_outside_production() {
let state = crate::AppState::for_test().with_profile("dev");
let config = sample_smtp_config();
install_mailer(&state, &config, true).expect("non-prod profiles should not require an ack");
}
#[test]
fn install_mailer_does_not_require_ack_when_transport_is_disabled() {
let state = crate::AppState::for_test().with_profile("prod");
let config = MailConfig::default();
install_mailer(&state, &config, true)
.expect("disabled transport never sends mail so it should not need an ack");
}
struct CapturingQueue {
tx: tokio::sync::mpsc::UnboundedSender<Mail>,
}
impl MailDeliveryQueue for CapturingQueue {
fn enqueue<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
let tx = self.tx.clone();
Box::pin(async move {
tx.send(mail)
.map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
Ok(())
})
}
}
#[cfg(feature = "db")]
struct FailingQueue {
tx: tokio::sync::mpsc::UnboundedSender<Mail>,
}
#[cfg(feature = "db")]
impl MailDeliveryQueue for FailingQueue {
fn enqueue<'a>(
&'a self,
mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
let tx = self.tx.clone();
Box::pin(async move {
tx.send(mail)
.map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
Err(MailError::RuntimeUnavailable("queue offline".to_owned()))
})
}
}
#[cfg(feature = "db")]
async fn drain_after_commit_callbacks_for_test(
registry: &std::sync::Arc<std::sync::Mutex<Vec<crate::db::CommitCallback>>>,
) {
let callbacks: Vec<crate::db::CommitCallback> = {
let mut reg = registry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::take(&mut *reg)
};
for cb in callbacks {
if let Err(error) = cb().await {
crate::db::record_after_commit_failure();
tracing::error!("test drain: after_commit callback failed: {error}");
}
}
}
#[cfg(feature = "db")]
#[tokio::test]
async fn deferred_deliver_later_queue_failure_increments_after_commit_counter() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
let mailer = Mailer::builder()
.delivery_queue(FailingQueue { tx })
.build()
.expect("mailer should build");
let registry = std::sync::Arc::new(std::sync::Mutex::new(
Vec::<crate::db::CommitCallback>::new(),
));
let before =
crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
crate::db::AFTER_COMMIT_REGISTRY
.scope(registry.clone(), async {
mailer
.try_deliver_later(sample_mail())
.expect("registering deferred mail should succeed");
})
.await;
drain_after_commit_callbacks_for_test(®istry).await;
let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("queue should be called within 1s")
.expect("queue should receive the mail");
assert_eq!(received.subject, "Hi");
let after =
crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
assert!(
after > before,
"deferred durable mail handoff failures should count as after_commit failures"
);
}
#[tokio::test]
async fn deliver_later_routes_through_configured_queue() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
let mailer = Mailer::builder()
.delivery_queue(CapturingQueue { tx })
.build()
.expect("mailer should build");
mailer
.try_deliver_later(sample_mail())
.expect("scheduling onto the queue should succeed");
let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("queue should receive within 1s")
.expect("queue should receive the mail");
assert_eq!(received.subject, "Hi");
}
#[tokio::test]
async fn deliver_later_preserves_attachments_through_queue() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
let mailer = Mailer::builder()
.delivery_queue(CapturingQueue { tx })
.build()
.expect("mailer should build");
let mail = Mail::builder()
.to("user@example.com")
.subject("Hi")
.text("hello")
.attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
.build()
.expect("mail should build");
mailer
.try_deliver_later(mail.clone())
.expect("scheduling onto the queue should succeed");
let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("queue should receive within 1s")
.expect("queue should receive the mail");
let mut expected = mail;
expected.inline_css = Some(false);
assert_eq!(received, expected);
}
#[tokio::test]
async fn deferred_enqueue_freezes_originating_inline_css_default() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
let mailer = Mailer::builder()
.inline_css(true)
.delivery_queue(CapturingQueue { tx })
.build()
.expect("mailer should build");
let mail = Mail::builder()
.to("user@example.com")
.subject("Hi")
.html(
"<html><head><style>p { color: red; }</style></head><body><p>hi</p></body></html>",
)
.build()
.expect("mail should build");
assert_eq!(mail.inline_css, None, "sample relies on the mailer default");
mailer
.try_deliver_later(mail)
.expect("scheduling onto the queue should succeed");
let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("queue should receive within 1s")
.expect("queue should receive the mail");
assert_eq!(
received.inline_css,
Some(true),
"the originating mailer's inlining default must be frozen onto the enqueued job"
);
assert!(
received
.html
.as_deref()
.expect("html body")
.contains("<style>"),
"the body must be left un-inlined at enqueue time; inlining happens once at the consumer's send()"
);
}
#[tokio::test]
async fn deferred_enqueue_preserves_explicit_inline_css_override() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
let mailer = Mailer::builder()
.inline_css(true)
.delivery_queue(CapturingQueue { tx })
.build()
.expect("mailer should build");
let mail = Mail::builder()
.to("user@example.com")
.subject("Hi")
.html(
"<html><head><style>p { color: red; }</style></head><body><p>hi</p></body></html>",
)
.inline_css(false)
.build()
.expect("mail should build");
mailer
.try_deliver_later(mail)
.expect("scheduling onto the queue should succeed");
let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("queue should receive within 1s")
.expect("queue should receive the mail");
assert_eq!(
received.inline_css,
Some(false),
"an explicit per-message override must be preserved through the queue, not overwritten by the mailer default"
);
}
#[tokio::test]
async fn deliver_later_without_queue_sends_via_transport_directly() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct TrackingSend(Arc<AtomicBool>);
impl MailTransport for TrackingSend {
fn send<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
self.0.store(true, Ordering::SeqCst);
Box::pin(async { Ok(()) })
}
}
let sent = Arc::new(AtomicBool::new(false));
let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
mailer
.try_deliver_later(sample_mail())
.expect("should succeed without queue");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
sent.load(Ordering::SeqCst),
"mail should have been sent directly via transport"
);
}
#[cfg(feature = "db")]
#[tokio::test]
async fn deferred_deliver_later_without_queue_sends_after_commit() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct TrackingSend(Arc<AtomicBool>);
impl MailTransport for TrackingSend {
fn send<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
self.0.store(true, Ordering::SeqCst);
Box::pin(async { Ok(()) })
}
}
let sent = Arc::new(AtomicBool::new(false));
let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
let registry = std::sync::Arc::new(std::sync::Mutex::new(
Vec::<crate::db::CommitCallback>::new(),
));
crate::db::AFTER_COMMIT_REGISTRY
.scope(registry.clone(), async {
mailer
.try_deliver_later(sample_mail())
.expect("should succeed");
})
.await;
drain_after_commit_callbacks_for_test(®istry).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
sent.load(Ordering::SeqCst),
"mail should have been sent after commit via direct transport"
);
}
#[tokio::test]
async fn mailer_with_transport_starts_without_delivery_queue() {
let mailer = Mailer::with_transport(NoopTransport);
assert!(
!mailer.has_durable_delivery_queue(),
"with_transport should default to no durable queue"
);
mailer
.send(sample_mail())
.await
.expect("noop transport should always succeed");
}
struct NoopTransport;
impl MailTransport for NoopTransport {
fn send<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
}
#[tokio::test]
async fn deliver_later_is_noop_when_transport_disabled_even_with_queue() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
let mailer = Mailer::builder()
.transport(Transport::Disabled)
.delivery_queue(CapturingQueue { tx })
.build()
.expect("mailer should build");
mailer
.try_deliver_later(sample_mail())
.expect("disabled transport should succeed as a no-op");
let received = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
assert!(
received.is_err(),
"queue must not be invoked when transport is disabled"
);
}
#[tokio::test]
async fn deliver_later_uses_in_process_fallback_when_no_queue() {
let mailer = Mailer::builder().build().expect("mailer should build");
mailer
.try_deliver_later(sample_mail())
.expect("in-process fallback should still schedule");
}
#[test]
fn mail_delivery_queue_handle_round_trips_via_from_arc_and_inner() {
let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
let handle = MailDeliveryQueueHandle::from_arc(Arc::clone(&arc));
assert!(Arc::ptr_eq(handle.inner(), &arc));
}
#[test]
fn mail_delivery_queue_handle_debug_does_not_panic() {
let handle = MailDeliveryQueueHandle::new(NoopQueue);
let rendered = format!("{handle:?}");
assert!(rendered.contains("MailDeliveryQueueHandle"));
}
#[test]
fn mailer_has_durable_delivery_queue_reflects_attachment() {
let plain = Mailer::builder().build().expect("mailer should build");
assert!(!plain.has_durable_delivery_queue());
let with_queue = Mailer::builder()
.delivery_queue(NoopQueue)
.build()
.expect("mailer should build");
assert!(with_queue.has_durable_delivery_queue());
}
#[test]
fn mailer_with_delivery_queue_post_build_attaches_queue() {
let mailer = Mailer::builder()
.build()
.expect("mailer should build")
.with_delivery_queue(NoopQueue);
assert!(mailer.has_durable_delivery_queue());
}
#[test]
fn mailer_builder_delivery_queue_arc_attaches_shared_queue() {
let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
let mailer = Mailer::builder()
.delivery_queue_arc(arc)
.build()
.expect("mailer should build");
assert!(mailer.has_durable_delivery_queue());
}
#[test]
fn install_mailer_warns_but_succeeds_with_explicit_ack_in_prod() {
let state = crate::AppState::for_test().with_profile("prod");
let config = MailConfig {
allow_in_process_deliver_later_in_production: true,
..sample_smtp_config()
};
install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
let installed = state
.extension::<Mailer>()
.expect("install_mailer should store a Mailer extension");
assert!(
!installed.has_durable_delivery_queue(),
"no queue was registered, so installed mailer should fall back in-process"
);
}
#[test]
fn install_mailer_attaches_registered_queue_to_mailer() {
let state = crate::AppState::for_test().with_profile("prod");
state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
let config = sample_smtp_config();
install_mailer(&state, &config, true).expect("durable queue should permit prod startup");
let installed = state
.extension::<Mailer>()
.expect("install_mailer should store a Mailer extension");
assert!(
installed.has_durable_delivery_queue(),
"registered queue handle should be attached to the installed mailer"
);
}
#[test]
fn install_mailer_with_factory_runs_factory_and_attaches_queue() {
let state = crate::AppState::for_test().with_profile("prod");
let config = sample_smtp_config();
let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
let captured = Arc::clone(&factory_called);
let factory = move |_state: &crate::AppState| {
captured.store(true, std::sync::atomic::Ordering::SeqCst);
Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
};
install_mailer_with_factory(&state, &config, Some(factory), true)
.expect("factory should produce a queue and satisfy the prod guard");
assert!(
factory_called.load(std::sync::atomic::Ordering::SeqCst),
"factory must run when enforce_durable_guard is true"
);
let installed = state
.extension::<Mailer>()
.expect("install_mailer should store a Mailer extension");
assert!(
installed.has_durable_delivery_queue(),
"factory's queue should be wired into the installed Mailer"
);
}
#[test]
fn install_mailer_with_factory_skips_factory_when_not_enforced() {
let state = crate::AppState::for_test().with_profile("prod");
let config = sample_smtp_config();
let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
let captured = Arc::clone(&factory_called);
let factory = move |_state: &crate::AppState| {
captured.store(true, std::sync::atomic::Ordering::SeqCst);
Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
};
install_mailer_with_factory(&state, &config, Some(factory), false)
.expect("static-build path should skip factory and install cleanly");
assert!(
!factory_called.load(std::sync::atomic::Ordering::SeqCst),
"factory must be skipped when enforce_durable_guard is false"
);
}
#[test]
fn install_mailer_with_factory_propagates_factory_errors() {
let state = crate::AppState::for_test().with_profile("prod");
let config = sample_smtp_config();
let factory = |_state: &crate::AppState| {
Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
"queue offline",
))
};
let error = install_mailer_with_factory(&state, &config, Some(factory), true)
.expect_err("factory error should propagate");
assert!(error.to_string().contains("queue offline"));
}
#[test]
fn install_mailer_with_factory_skips_factory_when_transport_disabled() {
let state = crate::AppState::for_test().with_profile("dev");
let config = MailConfig::default(); let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
let captured = Arc::clone(&factory_called);
let factory = move |_state: &crate::AppState| {
captured.store(true, std::sync::atomic::Ordering::SeqCst);
Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
"queue must not be reached",
))
};
install_mailer_with_factory(&state, &config, Some(factory), true)
.expect("disabled transport should bypass the factory entirely");
assert!(
!factory_called.load(std::sync::atomic::Ordering::SeqCst),
"factory must not run when transport = disabled"
);
}
#[test]
fn install_mailer_with_factory_works_without_factory() {
type FactoryFn = fn(&crate::AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>;
let state = crate::AppState::for_test().with_profile("dev");
let config = sample_smtp_config();
let no_factory: Option<FactoryFn> = None;
install_mailer_with_factory(&state, &config, no_factory, true)
.expect("absent factory should be fine in non-prod");
}
#[test]
fn install_mailer_does_not_run_factory_when_not_enforced_and_no_handle() {
let state = crate::AppState::for_test().with_profile("prod");
let config = sample_smtp_config();
install_mailer(&state, &config, false)
.expect("static-build mode should install cleanly with no queue handle");
let installed = state
.extension::<Mailer>()
.expect("install_mailer should store a Mailer extension");
assert!(
!installed.has_durable_delivery_queue(),
"no queue is expected when run_build_mode skips the factory"
);
}
#[test]
fn install_mailer_skips_production_guard_when_not_enforced() {
let state = crate::AppState::for_test().with_profile("prod");
let config = sample_smtp_config();
install_mailer(&state, &config, false)
.expect("static-build mode should not enforce the deliver_later guard");
}
#[test]
fn spawn_mail_delivery_inherits_parent_span() {
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
struct CapturingQueue(Arc<Mutex<Option<tracing::span::Id>>>);
impl MailDeliveryQueue for CapturingQueue {
fn enqueue<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
let captured = self.0.clone();
Box::pin(async move {
*captured.lock().unwrap() = tracing::Span::current().id();
Ok(())
})
}
}
let captured_span_id: Arc<Mutex<Option<tracing::span::Id>>> = Arc::new(Mutex::new(None));
let mailer = Mailer::builder()
.delivery_queue(CapturingQueue(captured_span_id.clone()))
.build()
.expect("mailer with queue should build");
let mail = sample_mail();
tracing::subscriber::with_default(tracing_subscriber::registry(), || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build runtime");
let outer = tracing::info_span!("deliver_later_outer");
let outer_id = outer.id();
rt.block_on(async {
{
let _guard = outer.enter();
mailer
.try_deliver_later(mail)
.expect("deliver_later must not fail");
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
});
let in_task = captured_span_id.lock().unwrap().clone();
assert_eq!(
in_task, outer_id,
"delivery task must run inside the span that called deliver_later"
);
});
}
#[tokio::test]
async fn spawn_mail_delivery_logs_error_when_queue_fails() {
use std::future::Future;
use std::pin::Pin;
struct AlwaysFailQueue;
impl MailDeliveryQueue for AlwaysFailQueue {
fn enqueue<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async { Err(MailError::RuntimeUnavailable("always fails".to_owned())) })
}
}
let mailer = Mailer::builder()
.delivery_queue(AlwaysFailQueue)
.build()
.expect("build");
mailer
.try_deliver_later(sample_mail())
.expect("should schedule");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
#[tokio::test]
async fn spawn_mail_delivery_logs_error_when_transport_fails() {
use std::future::Future;
use std::pin::Pin;
struct AlwaysFailTransport;
impl MailTransport for AlwaysFailTransport {
fn send<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async {
Err(MailError::RuntimeUnavailable(
"transport offline".to_owned(),
))
})
}
}
let mailer = Mailer::with_transport(AlwaysFailTransport);
mailer
.try_deliver_later(sample_mail())
.expect("should schedule");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
#[test]
fn install_mailer_does_not_attach_queue_when_transport_disabled() {
let state = crate::AppState::for_test().with_profile("dev");
state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
let config = MailConfig::default();
install_mailer(&state, &config, true).expect("disabled transport should install cleanly");
let installed = state
.extension::<Mailer>()
.expect("install_mailer should store a Mailer extension");
assert!(
!installed.has_durable_delivery_queue(),
"disabled transport must suppress queue attachment so deliver_later is a no-op"
);
}
#[tokio::test]
async fn intercepted_mail_transport_short_circuit_prevents_sync_execution() {
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU32, Ordering};
static TRANSPORT_CALLS: AtomicU32 = AtomicU32::new(0);
struct CountingTransport;
impl MailTransport for CountingTransport {
fn send<'a>(
&'a self,
_mail: Mail,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
TRANSPORT_CALLS.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { Ok(()) })
}
fn is_disabled(&self) -> bool {
false
}
}
struct ShortCircuitMailInterceptor;
impl crate::interceptor::MailInterceptor for ShortCircuitMailInterceptor {
fn intercept<'a>(
&'a self,
_mail: &'a Mail,
_next: Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>,
) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
Box::pin(async move {
Err(MailError::RuntimeUnavailable(
"blocked by interceptor".to_owned(),
))
})
}
}
let transport = Arc::new(CountingTransport);
let interceptor = Arc::new(ShortCircuitMailInterceptor);
let intercepted = InterceptedMailTransport {
inner: transport,
interceptor,
};
let mail = Mail::builder()
.to("test@example.com")
.subject("test")
.text("body")
.build()
.unwrap();
TRANSPORT_CALLS.store(0, Ordering::SeqCst);
let res = intercepted.send(mail).await;
assert!(res.is_err());
assert_eq!(TRANSPORT_CALLS.load(Ordering::SeqCst), 0);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_smtp_transport_circuit_breaker() {
let _lock = crate::circuit_breaker::TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
crate::circuit_breaker::global_registry().clear();
let policy = crate::circuit_breaker::CircuitBreakerPolicy {
failure_ratio_threshold: 0.5,
sample_window: std::time::Duration::from_secs(10),
minimum_sample_count: 3,
open_duration: std::time::Duration::from_secs(60),
half_open_trial_count: 2,
};
let breaker =
crate::circuit_breaker::global_registry().get_or_create("smtp_mailer", policy);
assert_eq!(
breaker.state(),
crate::circuit_breaker::CircuitState::Closed
);
let config = SmtpConfig {
host: Some("127.0.0.1".to_string()),
port: Some(9999), tls: TlsMode::Disabled,
username: None,
password_env: None,
};
let transport = SmtpTransport::new(config, None).unwrap();
let mail = Mail::builder()
.from("sender@example.com")
.to("test@example.com")
.subject("test")
.text("body")
.build()
.unwrap();
for _ in 0..3 {
let res = transport.send(mail.clone()).await;
assert!(res.is_err());
}
assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
let res = transport.send(mail.clone()).await;
assert!(res.is_err());
let err_str = res.err().unwrap().to_string();
assert!(
err_str.contains("circuit breaker")
|| err_str.contains("open")
|| err_str.contains("Open")
|| err_str.contains("runtime unavailable")
);
crate::circuit_breaker::global_registry().clear();
}
#[test]
fn validate_log_transport_in_prod_fails() {
let cfg = MailConfig {
transport: Transport::Log,
..MailConfig::default()
};
assert!(cfg.validate(Some("prod")).is_err());
assert!(cfg.validate(Some("production")).is_err());
let allowed = MailConfig {
transport: Transport::Log,
allow_log_in_production: true,
..MailConfig::default()
};
assert!(allowed.validate(Some("prod")).is_ok());
}
#[test]
fn validate_preview_outside_dev_fails() {
let cfg = MailConfig {
preview: true,
..MailConfig::default()
};
assert!(cfg.validate(Some("prod")).is_err());
assert!(cfg.validate(Some("dev")).is_ok());
assert!(cfg.validate(Some("development")).is_ok());
}
#[test]
fn is_valid_https_base_url_edge_cases() {
assert!(is_valid_https_base_url("https://app.example.com"));
assert!(is_valid_https_base_url("https://app.example.com/base"));
assert!(!is_valid_https_base_url("http://app.example.com"));
assert!(!is_valid_https_base_url("https://"));
assert!(!is_valid_https_base_url("https:///path"));
assert!(!is_valid_https_base_url("https://app.example.com?q=1"));
assert!(!is_valid_https_base_url("https://app.example.com#frag"));
assert!(!is_valid_https_base_url("https://host name.com"));
assert!(!is_valid_https_base_url("https://app.example.com:abc"));
assert!(!is_valid_https_base_url("https://@/base"));
assert!(!is_valid_https_base_url("https://user@app.example.com"));
assert!(is_valid_https_base_url("https://app.example.com:8443"));
assert!(!is_valid_https_base_url("https://example.com/<x>"));
assert!(!is_valid_https_base_url("https://example.com/a b"));
assert!(!is_valid_https_base_url("https://example.com/a\r\nb"));
assert!(!is_valid_https_base_url("https:/app.example.com"));
assert!(!is_valid_https_base_url("https:app.example.com"));
}
#[test]
fn is_valid_mailto_address_edge_cases() {
assert!(is_valid_mailto_address("unsub@example.com"));
assert!(is_valid_mailto_address("mailto:unsub@example.com"));
assert!(is_valid_mailto_address(
"mailto:unsub@example.com?subject=hi"
));
assert!(!is_valid_mailto_address("not-an-email"));
assert!(!is_valid_mailto_address("missing@dot"));
assert!(!is_valid_mailto_address("space @example.com"));
assert!(!is_valid_mailto_address(""));
assert!(!is_valid_mailto_address("@example.com")); assert!(!is_valid_mailto_address("local@")); assert!(!is_valid_mailto_address("https://unsub@example.com"));
assert!(!is_valid_mailto_address("mailto:https://unsub@example.com"));
assert!(!is_valid_mailto_address("unsub@https://example.com"));
assert!(!is_valid_mailto_address(
"mailto:unsub@example.com?subject=x\r\nBcc: victim@example.com"
));
assert!(!is_valid_mailto_address("unsub@example.com\nBcc: v@x.com"));
assert!(!is_valid_mailto_address(
"unsub@example.com>,<bogus@example.com"
));
assert!(!is_valid_mailto_address("a@x.com,b@x.com"));
}
#[test]
fn unsubscribe_header_mailto_drops_configured_query_no_injection() {
let runtime = UnsubscribeRuntime {
base_url: None,
mailto: Some("mailto:u@example.com?subject=x\r\nBcc: v@x.com".to_owned()),
signing_keys: Arc::new(test_keys()),
ttl_days: 30,
suppression: None,
};
let header = runtime
.list_unsubscribe_header("a@x.com", "list")
.expect("mailto header");
assert_eq!(header, "<mailto:u@example.com?subject=unsubscribe>");
assert!(!header.contains('\r') && !header.contains('\n'));
assert!(!header.contains("Bcc"));
}
#[test]
fn unsubscribe_runtime_header_both_base_url_and_mailto() {
let runtime = UnsubscribeRuntime {
base_url: Some("https://app.example.com".to_owned()),
mailto: Some("u@example.com".to_owned()),
signing_keys: Arc::new(test_keys()),
ttl_days: 30,
suppression: None,
};
let header = runtime
.list_unsubscribe_header("a@x.com", "list")
.expect("header with both");
assert!(header.contains("https://app.example.com/_autumn/unsubscribe?token="));
assert!(header.contains("mailto:u@example.com?subject=unsubscribe"));
assert!(runtime.supports_one_click());
}
#[test]
fn unsubscribe_runtime_header_neither_configured_returns_none() {
let runtime = UnsubscribeRuntime {
base_url: None,
mailto: None,
signing_keys: Arc::new(test_keys()),
ttl_days: 30,
suppression: None,
};
assert!(runtime.list_unsubscribe_header("a@x.com", "list").is_none());
assert!(!runtime.supports_one_click());
}
}