#![cfg(feature = "mail")]
#![allow(clippy::literal_string_with_formatting_args)]
use std::sync::{Arc, Mutex};
use autumn_web::mail::{Mail, MailConfig, MailError, MailTransport, Mailer, Transport};
const STYLED_HTML: &str = r#"<style>.btn{color:#fff;background:#06c}</style><a class="btn">Go</a>"#;
#[derive(Clone, Default)]
struct CapturingTransport {
last: Arc<Mutex<Option<Mail>>>,
}
impl CapturingTransport {
fn last_html(&self) -> String {
self.last
.lock()
.expect("lock")
.as_ref()
.expect("a mail was delivered")
.html
.clone()
.expect("delivered mail has an html body")
}
fn last_text(&self) -> Option<String> {
self.last
.lock()
.expect("lock")
.as_ref()
.expect("a mail was delivered")
.text
.clone()
}
}
impl MailTransport for CapturingTransport {
fn send<'a>(
&'a self,
mail: Mail,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), MailError>> + Send + 'a>>
{
Box::pin(async move {
*self.last.lock().expect("lock") = Some(mail);
Ok(())
})
}
}
fn styled_mail() -> Mail {
Mail::builder()
.from("app@example.com")
.to("user@example.com")
.subject("Hi")
.html(STYLED_HTML)
.build()
.expect("valid mail")
}
fn assert_inlined(html: &str) {
assert!(
html.contains("style=") && html.contains("#fff"),
"expected the anchor to carry an inline style with the class rule; got: {html}"
);
assert!(
!html.contains(".btn{color") && !html.contains(".btn {"),
"expected the inlined `.btn` selector to be stripped from the <style> block; got: {html}"
);
}
fn assert_not_inlined(html: &str) {
assert!(
html.contains(".btn{color:#fff;background:#06c}"),
"expected the raw <style> rule to be delivered unchanged; got: {html}"
);
assert!(
html.contains(r#"<a class="btn">Go</a>"#),
"expected the anchor to be delivered without an inline style; got: {html}"
);
}
#[tokio::test]
async fn builder_opt_in_inlines_html_at_send() {
let transport = CapturingTransport::default();
let mailer = Mailer::with_transport(transport.clone());
let mail = Mail::builder()
.from("app@example.com")
.to("user@example.com")
.subject("Hi")
.html(STYLED_HTML)
.inline_css(true)
.build()
.expect("valid mail");
mailer.send(mail).await.expect("send succeeds");
assert_inlined(&transport.last_html());
}
#[tokio::test]
async fn default_off_leaves_html_untouched() {
let transport = CapturingTransport::default();
let mailer = Mailer::with_transport(transport.clone());
mailer.send(styled_mail()).await.expect("send succeeds");
assert_not_inlined(&transport.last_html());
}
#[tokio::test]
async fn text_body_is_never_inlined() {
let transport = CapturingTransport::default();
let mailer = Mailer::with_transport(transport.clone());
let mail = Mail::builder()
.from("app@example.com")
.to("user@example.com")
.subject("Hi")
.text("<style>.btn{color:#fff}</style> literal text")
.inline_css(true)
.build()
.expect("valid mail");
mailer.send(mail).await.expect("send succeeds");
assert_eq!(
transport.last_text().as_deref(),
Some("<style>.btn{color:#fff}</style> literal text"),
"text bodies must never be inlined"
);
}
#[tokio::test]
async fn config_default_on_inlines_delivered_mime() {
let dir = tempfile::tempdir().expect("tempdir");
let config = MailConfig {
transport: Transport::File,
file_dir: dir.path().to_path_buf(),
inline_css: true,
..MailConfig::default()
};
let mailer = Mailer::from_config(&config).expect("mailer from config");
mailer.send(styled_mail()).await.expect("send succeeds");
let eml = read_only_eml(dir.path());
assert!(
eml.contains("text/html"),
"the delivered MIME must contain the html part; got: {eml}"
);
assert_inlined(&eml);
}
#[tokio::test]
async fn per_message_opt_out_overrides_config_default_on() {
let dir = tempfile::tempdir().expect("tempdir");
let config = MailConfig {
transport: Transport::File,
file_dir: dir.path().to_path_buf(),
inline_css: true, ..MailConfig::default()
};
let mailer = Mailer::from_config(&config).expect("mailer from config");
let mail = Mail::builder()
.from("app@example.com")
.to("user@example.com")
.subject("Hi")
.html(STYLED_HTML)
.inline_css(false)
.build()
.expect("valid mail");
mailer.send(mail).await.expect("send succeeds");
assert_not_inlined(&read_only_eml(dir.path()));
}
#[tokio::test]
async fn per_message_opt_in_overrides_config_default_off() {
let dir = tempfile::tempdir().expect("tempdir");
let config = MailConfig {
transport: Transport::File,
file_dir: dir.path().to_path_buf(),
..MailConfig::default()
};
assert!(
!config.inline_css,
"config default must be off for this test"
);
let mailer = Mailer::from_config(&config).expect("mailer from config");
let mail = Mail::builder()
.from("app@example.com")
.to("user@example.com")
.subject("Hi")
.html(STYLED_HTML)
.inline_css(true)
.build()
.expect("valid mail");
mailer.send(mail).await.expect("send succeeds");
assert_inlined(&read_only_eml(dir.path()));
}
#[tokio::test]
async fn malformed_body_with_inlining_on_is_delivered_gracefully() {
let garbage = "<style>@@@!!!{{{color:::}}}</style>plain fragment";
let unclosed = r#"<style>.x{color:#123 <a class="x">dangling"#;
for body in [garbage, unclosed] {
let transport = CapturingTransport::default();
let mailer = Mailer::with_transport(transport.clone());
let mail = Mail::builder()
.from("app@example.com")
.to("user@example.com")
.subject("Hi")
.html(body)
.inline_css(true)
.build()
.expect("valid mail");
mailer
.send(mail)
.await
.expect("send succeeds on a malformed body");
let delivered = transport.last_html();
assert!(
!delivered.is_empty(),
"a non-empty body must still be delivered for input: {body:?}"
);
}
}
fn read_only_eml(dir: &std::path::Path) -> String {
let entry = std::fs::read_dir(dir)
.expect("read mail dir")
.filter_map(Result::ok)
.find(|e| e.path().extension().is_some_and(|ext| ext == "eml"))
.expect("exactly one .eml was written");
std::fs::read_to_string(entry.path()).expect("read .eml")
}