#![allow(clippy::unwrap_used, clippy::expect_used)]
mod round_trip {
use daaki_message::{build_message, parse_email, Address, OutgoingAttachment, OutgoingEmail};
#[test]
fn round_trip_text_and_html() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::with_name("Sender", "sender@example.com").unwrap()];
email.to = vec![Address::new("to@example.com").unwrap()];
email.subject = "Round-trip test".into();
email.body_text = Some("Hello, plain text!".into());
email.body_html = Some("<p>Hello, HTML!</p>".into());
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some("Hello, plain text!"),
"body_text mismatch after round-trip"
);
assert_eq!(
parsed.body_html.as_deref(),
Some("<p>Hello, HTML!</p>"),
"body_html mismatch after round-trip"
);
assert_eq!(parsed.subject.as_deref(), Some("Round-trip test"));
}
#[test]
fn round_trip_text_html_and_attachment() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("sender@example.com").unwrap()];
email.to = vec![Address::new("to@example.com").unwrap()];
email.subject = "With attachment".into();
email.body_text = Some("Plain body".into());
email.body_html = Some("<b>HTML body</b>".into());
email.attachments = vec![OutgoingAttachment::new(
"test.txt",
"text/plain",
b"attachment content here".to_vec(),
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some("Plain body"),
"body_text mismatch with attachment"
);
assert_eq!(
parsed.body_html.as_deref(),
Some("<b>HTML body</b>"),
"body_html mismatch with attachment"
);
assert!(
!parsed.attachments.is_empty(),
"expected at least one attachment"
);
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some("test.txt"),
"attachment filename mismatch"
);
}
#[test]
fn round_trip_non_ascii_subject() {
let subject = "Héllo Wörld! 日本語テスト";
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = subject.into();
email.body_text = Some("test".into());
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some(subject),
"Non-ASCII subject did not round-trip correctly"
);
}
#[test]
fn round_trip_non_ascii_display_name() {
let name = "Ünïcödé Üser";
let mut email = OutgoingEmail::default();
email.from = vec![Address::with_name(name, "unicode@example.com").unwrap()];
email.to = vec![Address::new("to@example.com").unwrap()];
email.subject = "test".into();
email.body_text = Some("test".into());
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.from[0].name.as_deref(),
Some(name),
"Non-ASCII display name did not round-trip"
);
}
#[test]
fn round_trip_body_text_with_long_lines() {
let long_line = "x".repeat(1200);
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Long line test".into();
email.body_text = Some(long_line.clone());
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(long_line.as_str()),
"Long-line body_text did not round-trip"
);
}
#[test]
fn round_trip_body_with_crlf_normalization() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "CRLF test".into();
email.body_text = Some("line1\nline2\nline3".into());
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
let body = parsed.body_text.unwrap();
assert!(
body.contains("line1") && body.contains("line2") && body.contains("line3"),
"Line breaks lost in round-trip: {body:?}"
);
}
}
mod rfc2047_edge_cases {
use daaki_message::parse_email;
#[test]
fn very_long_non_ascii_subject() {
let long_subject = "日本語".repeat(200); let encoded = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
long_subject.as_bytes(),
);
let mut subject_parts = Vec::new();
let mut remaining = encoded.as_str();
while !remaining.is_empty() {
let (chunk, rest) = if remaining.len() > 60 {
remaining.split_at(60)
} else {
(remaining, "")
};
subject_parts.push(format!("=?UTF-8?B?{chunk}?="));
remaining = rest;
}
let subject_line = subject_parts.join("\r\n ");
let raw = format!(
"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: {subject_line}\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n"
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some(long_subject.as_str()),
"Very long non-ASCII subject was not decoded correctly"
);
}
#[test]
fn literal_encoded_word_syntax_in_subject_not_decoded() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?=\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some("HelloWorld"),
"Adjacent encoded-words should be decoded and concatenated"
);
}
#[test]
fn encoded_word_with_q_encoding() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: =?UTF-8?Q?Hello_World?=\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some("Hello World"),
"Q-encoded subject should decode underscore as space"
);
}
#[test]
fn encoded_word_iso_8859_1() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: =?ISO-8859-1?Q?caf=E9?=\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some("café"),
"ISO-8859-1 Q-encoded subject should decode correctly"
);
}
}
mod quoted_printable_edge_cases {
use daaki_message::parse_email;
#[test]
fn bare_cr_in_qp_body() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: QP test\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: quoted-printable\r\n\
\r\n\
Hello=0DWorld\r\n";
let parsed = parse_email(raw).unwrap();
let body = parsed.body_text.unwrap();
assert!(
body.contains("Hello\rWorld")
|| body.contains("Hello\r\nWorld")
|| body.contains("HelloWorld"),
"Bare CR =0D should be decoded: got {body:?}"
);
assert!(
body.contains('\r'),
"=0D should decode to CR character: got {body:?}"
);
}
#[test]
fn qp_soft_line_break() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: QP soft break\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: quoted-printable\r\n\
\r\n\
Hello =\r\n\
World\r\n";
let parsed = parse_email(raw).unwrap();
let body = parsed.body_text.unwrap();
assert!(
body.contains("Hello World"),
"Soft line break should join lines: got {body:?}"
);
}
#[test]
fn qp_trailing_equals() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: QP trailing =\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: quoted-printable\r\n\
\r\n\
Hello World=";
let parsed = parse_email(raw).unwrap();
let body = parsed.body_text.unwrap();
assert_eq!(body.trim(), "Hello World", "Trailing = is soft break");
}
}
mod multipart_boundary {
use daaki_message::parse_email;
#[test]
fn boundary_prefix_of_body_string() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Boundary prefix test\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/alternative; boundary=\"abc\"\r\n\
\r\n\
--abc\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
The string abcdef should not be affected by boundary matching\r\n\
--abc\r\n\
Content-Type: text/html; charset=utf-8\r\n\
\r\n\
<p>HTML part</p>\r\n\
--abc--\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("The string abcdef should not be affected by boundary matching"),
"Boundary prefix should not corrupt body text"
);
assert_eq!(
parsed.body_html.as_deref().map(str::trim),
Some("<p>HTML part</p>"),
"HTML part should be present"
);
}
#[test]
fn empty_mime_parts_between_boundaries() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Empty parts\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/mixed; boundary=\"sep\"\r\n\
\r\n\
--sep\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
\r\n\
--sep\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
non-empty part\r\n\
--sep--\r\n";
let parsed = parse_email(raw).unwrap();
let body = parsed.body_text.unwrap_or_default();
assert!(
body.is_empty() || body.contains("non-empty"),
"Should handle empty parts gracefully: got {body:?}"
);
}
#[test]
fn nested_multipart() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Nested\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/mixed; boundary=\"outer\"\r\n\
\r\n\
--outer\r\n\
Content-Type: multipart/alternative; boundary=\"inner\"\r\n\
\r\n\
--inner\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Plain text\r\n\
--inner\r\n\
Content-Type: text/html; charset=utf-8\r\n\
\r\n\
<p>HTML</p>\r\n\
--inner--\r\n\
--outer\r\n\
Content-Type: application/octet-stream\r\n\
Content-Disposition: attachment; filename=\"file.bin\"\r\n\
\r\n\
binarydata\r\n\
--outer--\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Plain text"),
"Nested multipart text extraction failed"
);
assert_eq!(
parsed.body_html.as_deref().map(str::trim),
Some("<p>HTML</p>"),
"Nested multipart HTML extraction failed"
);
assert!(!parsed.attachments.is_empty(), "Should have attachment");
}
}
mod rfc2231_continuation {
use daaki_message::parse_email;
#[test]
fn filename_sections_out_of_order() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: RFC 2231 out of order\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/mixed; boundary=\"sep\"\r\n\
\r\n\
--sep\r\n\
Content-Type: text/plain\r\n\
\r\n\
body\r\n\
--sep\r\n\
Content-Type: application/octet-stream\r\n\
Content-Disposition: attachment; filename*2=\"part3.txt\"; filename*0=\"part1\"; filename*1=\"part2\"\r\n\
\r\n\
data\r\n\
--sep--\r\n";
let parsed = parse_email(raw).unwrap();
assert!(!parsed.attachments.is_empty(), "Should find attachment");
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "part1part2part3.txt",
"RFC 2231 out-of-order sections should reassemble correctly, got: {filename}"
);
}
#[test]
fn filename_rfc2231_charset_encoded() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: RFC 2231 charset\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/mixed; boundary=\"sep\"\r\n\
\r\n\
--sep\r\n\
Content-Type: text/plain\r\n\
\r\n\
body\r\n\
--sep\r\n\
Content-Type: application/octet-stream\r\n\
Content-Disposition: attachment; filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt\r\n\
\r\n\
data\r\n\
--sep--\r\n";
let parsed = parse_email(raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "日本語.txt",
"RFC 2231 charset-encoded filename should decode: got {filename}"
);
}
#[test]
fn filename_rfc2231_continuation_with_charset() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: RFC 2231 continuation+charset\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/mixed; boundary=\"sep\"\r\n\
\r\n\
--sep\r\n\
Content-Type: text/plain\r\n\
\r\n\
body\r\n\
--sep\r\n\
Content-Type: application/octet-stream\r\n\
Content-Disposition: attachment; filename*0*=UTF-8''%E6%97%A5%E6%9C%AC; filename*1*=%E8%AA%9E.txt\r\n\
\r\n\
data\r\n\
--sep--\r\n";
let parsed = parse_email(raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "日本語.txt",
"RFC 2231 continuation+charset should decode: got {filename}"
);
}
}
mod address_parsing {
use daaki_message::{parse_email, Address};
#[test]
fn group_address_undisclosed_recipients() {
let raw = b"From: sender@example.com\r\n\
To: undisclosed-recipients:;\r\n\
Subject: Group test\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert!(
parsed.to.is_empty(),
"undisclosed-recipients:; should produce no addresses, got {:?}",
parsed.to
);
}
#[test]
fn multiple_groups() {
let raw = b"From: sender@example.com\r\n\
To: Friends: a@b.com, c@d.com; , Enemies: x@y.com;\r\n\
Subject: Multi-group test\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.to.len(),
3,
"Should extract 3 addresses from two groups, got {:?}",
parsed.to
);
let emails: Vec<&str> = parsed.to.iter().map(|a| a.email.as_str()).collect();
assert!(emails.contains(&"a@b.com"), "Missing a@b.com: {emails:?}");
assert!(emails.contains(&"c@d.com"), "Missing c@d.com: {emails:?}");
assert!(emails.contains(&"x@y.com"), "Missing x@y.com: {emails:?}");
}
#[test]
fn domain_literal_address() {
let raw = b"From: user@[127.0.0.1]\r\n\
To: to@example.com\r\n\
Subject: Domain literal\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.from[0].email, "user@[127.0.0.1]",
"Domain literal address should be preserved"
);
}
#[test]
fn quoted_local_part() {
let raw = b"From: \"user name\"@example.com\r\n\
To: to@example.com\r\n\
Subject: Quoted local-part\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert!(
parsed.from[0].email.contains("user name")
|| parsed.from[0].email.contains("\"user name\""),
"Quoted local-part should be parsed: got {:?}",
parsed.from[0]
);
}
#[test]
fn address_with_comment_as_display_name() {
let raw = b"From: user@example.com (John Doe)\r\n\
To: to@example.com\r\n\
Subject: Comment display name\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(parsed.from[0].email, "user@example.com");
assert_eq!(
parsed.from[0].name.as_deref(),
Some("John Doe"),
"Comment in parentheses should become display name"
);
}
#[test]
fn address_from_str_domain_literal() {
let addr: Address = "user@[127.0.0.1]".parse().unwrap();
assert_eq!(addr.email, "user@[127.0.0.1]");
assert!(addr.name.is_none());
}
#[test]
fn address_from_str_quoted_local_part() {
let addr: Address = "\"user name\"@example.com".parse().unwrap();
assert!(
addr.email.contains("user name") || addr.email.contains("\"user name\""),
"Should parse quoted local-part: got {addr:?}"
);
}
#[test]
fn address_display_name_with_comma() {
let addr = Address::with_name("Last, First", "user@example.com").unwrap();
let formatted = addr.to_string();
let parsed: Address = formatted.parse().unwrap();
assert_eq!(parsed.name.as_deref(), Some("Last, First"));
assert_eq!(parsed.email, "user@example.com");
}
#[test]
fn multiple_from_addresses() {
let raw = b"From: a@b.com, c@d.com\r\n\
To: to@example.com\r\n\
Subject: Multiple from\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(parsed.from.len(), 2, "Should parse two From addresses");
}
}
mod date_parsing {
use daaki_message::{parse_email, DateTime};
#[test]
fn two_digit_year_post_2000() {
let dt = DateTime::parse_rfc5322("Thu, 13 Feb 25 15:47:33 +0000");
assert!(dt.is_some(), "Should parse 2-digit year");
let dt = dt.unwrap();
assert_eq!(dt.year(), 2025, "2-digit year 25 should become 2025");
}
#[test]
fn two_digit_year_pre_2000() {
let dt = DateTime::parse_rfc5322("Fri, 01 Jan 99 00:00:00 +0000");
assert!(dt.is_some(), "Should parse 2-digit year 99");
let dt = dt.unwrap();
assert_eq!(dt.year(), 1999, "2-digit year 99 should become 1999");
}
#[test]
fn date_with_named_timezone_est() {
let dt = DateTime::parse_rfc5322("Thu, 13 Feb 2025 15:47:33 EST");
assert!(dt.is_some(), "Should parse named timezone EST");
let dt = dt.unwrap();
assert_eq!(
dt.tz_offset_minutes(),
-300,
"EST should be -0500 (-300 minutes)"
);
}
#[test]
fn date_with_named_timezone_pst() {
let dt = DateTime::parse_rfc5322("Thu, 13 Feb 2025 15:47:33 PST");
assert!(dt.is_some(), "Should parse named timezone PST");
let dt = dt.unwrap();
assert_eq!(
dt.tz_offset_minutes(),
-480,
"PST should be -0800 (-480 minutes)"
);
}
#[test]
fn date_with_leap_second() {
let dt = DateTime::parse_rfc5322("Sat, 30 Jun 2012 23:59:60 +0000");
assert!(dt.is_some(), "Should parse leap second (60)");
let dt = dt.unwrap();
assert_eq!(dt.second(), 60, "Leap second should be preserved as 60");
}
#[test]
fn date_without_seconds() {
let dt = DateTime::parse_rfc5322("Thu, 13 Feb 2025 15:47 +0000");
assert!(dt.is_some(), "Should parse date without seconds");
let dt = dt.unwrap();
assert_eq!(dt.second(), 0, "Missing seconds should default to 0");
}
#[test]
fn date_without_timezone() {
let dt = DateTime::parse_rfc5322("Thu, 13 Feb 2025 15:47:33");
assert!(dt.is_some(), "Should parse date without timezone");
let dt = dt.unwrap();
assert_eq!(
dt.tz_offset_minutes(),
0,
"Missing timezone should default to +0000"
);
}
#[test]
fn date_in_email_parsed_correctly() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Date test\r\n\
Date: Thu, 13 Feb 2025 10:30:00 -0500\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
let dt = parsed.date.unwrap();
assert_eq!(dt.year(), 2025);
assert_eq!(dt.month(), 2);
assert_eq!(dt.day(), 13);
assert_eq!(dt.hour(), 10);
assert_eq!(dt.minute(), 30);
assert_eq!(dt.second(), 0);
assert_eq!(dt.tz_offset_minutes(), -300);
}
#[test]
fn date_three_digit_year() {
let dt = DateTime::parse_rfc5322("Sat, 01 Jan 100 00:00:00 +0000");
assert!(dt.is_some(), "Should parse 3-digit year");
let dt = dt.unwrap();
assert_eq!(dt.year(), 2000, "3-digit year 100 should become 2000");
}
#[test]
fn date_from_unix_timestamp_roundtrip() {
let ts: i64 = 1_700_000_000; let dt = DateTime::from_unix_timestamp(ts, 0);
let back = dt.to_unix_timestamp();
assert_eq!(ts, back, "Unix timestamp should round-trip: {ts} vs {back}");
}
#[test]
fn date_from_unix_timestamp_with_offset_roundtrip() {
let ts: i64 = 1_700_000_000;
let dt = DateTime::from_unix_timestamp(ts, 330); let back = dt.to_unix_timestamp();
assert_eq!(
ts, back,
"Unix timestamp with +0530 offset should round-trip"
);
}
#[test]
fn datetime_rfc5322_string_roundtrip() {
let dt = DateTime::new(2025, 3, 15, 14, 30, 45, -300);
let s = dt.to_rfc5322_string();
let parsed = DateTime::parse_rfc5322(&s).unwrap();
assert_eq!(parsed.year(), 2025);
assert_eq!(parsed.month(), 3);
assert_eq!(parsed.day(), 15);
assert_eq!(parsed.hour(), 14);
assert_eq!(parsed.minute(), 30);
assert_eq!(parsed.second(), 45);
assert_eq!(parsed.tz_offset_minutes(), -300);
}
}
mod content_type_parsing {
use daaki_message::parse_email;
#[test]
fn content_type_with_comment() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: CT comment\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain (comment); charset=utf-8\r\n\
\r\n\
Hello with comment in CT\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Hello with comment in CT"),
"Content-Type with comment should parse as text/plain"
);
}
#[test]
fn content_type_with_extra_whitespace_around_slash() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: CT whitespace\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text / html ; charset=utf-8\r\n\
\r\n\
<p>HTML with spaced CT</p>\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_html.as_deref().map(str::trim),
Some("<p>HTML with spaced CT</p>"),
"Content-Type with whitespace around / should be tolerated"
);
}
#[test]
fn content_type_missing() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: No CT\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
Plain text body without Content-Type header\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Plain text body without Content-Type header"),
"Missing Content-Type should default to text/plain"
);
}
#[test]
fn content_type_case_insensitive() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: CT case\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: TEXT/HTML; charset=utf-8\r\n\
\r\n\
<p>Case test</p>\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_html.as_deref().map(str::trim),
Some("<p>Case test</p>"),
"TEXT/HTML should be treated as text/html"
);
}
#[test]
fn content_type_with_no_subtype() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: No subtype\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text\r\n\
\r\n\
body with invalid content-type\r\n";
let parsed = parse_email(raw).unwrap();
assert!(
parsed.body_text.is_some(),
"Invalid Content-Type without subtype should fallback to text/plain"
);
}
}
mod builder_edge_cases {
use daaki_message::{build_message, parse_email, Address, HeaderName, OutgoingEmail};
fn hdr(s: &str) -> HeaderName {
HeaderName::new(s).expect("test header name must be valid")
}
fn mid(s: &str) -> String {
s.to_owned()
}
#[test]
fn build_bcc_not_in_headers() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("sender@example.com").unwrap()];
email.to = vec![Address::new("to@example.com").unwrap()];
email.bcc = vec![Address::new("bcc@example.com").unwrap()];
email.subject = "BCC test".into();
email.body_text = Some("body".into());
let built = build_message(&email).unwrap();
let raw_str = String::from_utf8_lossy(&built.raw);
assert!(
!raw_str.contains("Bcc:") && !raw_str.contains("bcc:") && !raw_str.contains("BCC:"),
"BCC header should NOT appear in message headers"
);
let header_end = raw_str.find("\r\n\r\n").unwrap_or(raw_str.len());
let headers = &raw_str[..header_end];
assert!(
!headers.contains("bcc@example.com"),
"BCC email address should NOT appear in headers section"
);
assert!(
built
.envelope_recipients
.contains(&"bcc@example.com".to_string()),
"BCC should be in envelope recipients"
);
}
#[test]
fn build_empty_subject() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.body_text = Some("body".into());
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(
parsed.subject.is_none() || parsed.subject.as_deref() == Some(""),
"Empty subject should be preserved"
);
}
#[test]
fn build_no_from_should_error() {
let mut email = OutgoingEmail::default();
email.to = vec![Address::new("to@example.com").unwrap()];
email.subject = "test".into();
email.body_text = Some("body".into());
assert!(build_message(&email).is_err(), "Empty From should error");
}
#[test]
fn build_no_recipients_is_valid_rfc5322_message() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.subject = "test".into();
email.body_text = Some("body".into());
let built_result = build_message(&email);
assert!(
built_result.is_ok(),
"messages without destination fields are valid"
);
let Ok(built) = built_result else {
unreachable!("asserted build_message succeeded")
};
let raw = String::from_utf8_lossy(&built.raw);
assert!(
!raw.contains("\r\nTo:") && !raw.contains("\r\nCc:") && !raw.contains("\r\nBcc:"),
"builder must not synthesize destination headers for a message that omits them: {raw}"
);
assert!(
built.envelope_recipients.is_empty(),
"builder must return an empty envelope recipient list when To/Cc/Bcc are all absent"
);
}
#[test]
fn build_multiple_from_without_sender_should_error() {
let mut email = OutgoingEmail::default();
email.from = vec![
Address::new("a@b.com").unwrap(),
Address::new("c@d.com").unwrap(),
];
email.to = vec![Address::new("to@example.com").unwrap()];
email.subject = "test".into();
email.body_text = Some("body".into());
assert!(
build_message(&email).is_err(),
"Multiple From without Sender should error"
);
}
#[test]
fn build_in_reply_to_and_references() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Reply test".into();
email.body_text = Some("body".into());
email.in_reply_to = vec![mid("original@example.com")];
email.references = vec![mid("root@example.com"), mid("original@example.com")];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.in_reply_to,
vec!["original@example.com"],
"In-Reply-To should round-trip"
);
assert_eq!(
parsed.references,
vec!["root@example.com", "original@example.com"],
"References should round-trip"
);
}
#[test]
fn build_extra_headers() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Extra headers".into();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("X-Custom-Header"), "custom value".into()),
(hdr("X-Priority"), "1".into()),
];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
let custom = parsed
.extra_headers
.iter()
.find(|(k, _)| k == "x-custom-header");
assert!(custom.is_some(), "X-Custom-Header should be present");
assert_eq!(custom.unwrap().1, "custom value");
}
}
mod parser_robustness {
use daaki_message::parse_email;
#[test]
fn empty_input() {
let result = parse_email(b"");
assert!(result.is_err(), "Empty input should error");
}
#[test]
fn headers_only_no_body() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: No body\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(parsed.subject.as_deref(), Some("No body"));
}
#[test]
fn bare_lf_line_endings() {
let raw = b"From: sender@example.com\n\
To: to@example.com\n\
Subject: Bare LF\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\n\
\n\
body with bare LF\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(parsed.subject.as_deref(), Some("Bare LF"));
assert!(
parsed.body_text.is_some(),
"Should parse body with bare LF line endings"
);
}
#[test]
fn duplicate_headers() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: First\r\n\
Subject: Second\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert!(parsed.subject.is_some());
}
#[test]
fn very_long_header_line_folded() {
let long_value = "x".repeat(500);
let raw = format!(
"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: {long_value}\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n"
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert_eq!(
parsed.subject.as_deref().unwrap().len(),
500,
"Long subject should be preserved"
);
}
#[test]
fn base64_body() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Base64\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
SGVsbG8gV29ybGQ=\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Hello World"),
"Base64-encoded body should be decoded"
);
}
#[test]
fn message_id_angle_brackets_stripped() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: MsgID\r\n\
Message-ID: <unique123@example.com>\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.message_id.as_deref(),
Some("unique123@example.com"),
"Message-ID angle brackets should be stripped"
);
}
#[test]
fn non_utf8_body_with_charset() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: ISO body\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=iso-8859-1\r\n\
Content-Transfer-Encoding: quoted-printable\r\n\
\r\n\
caf=E9\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("café"),
"ISO-8859-1 body should be decoded to UTF-8"
);
}
#[test]
fn garbage_after_headers() {
let mut raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Garbage\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n"
.to_vec();
raw.extend_from_slice(&[0x00, 0xFF, 0xFE, 0xFD, 0x80, 0x81]);
let parsed = parse_email(&raw);
assert!(parsed.is_ok(), "Should handle binary garbage in body");
}
#[test]
fn header_injection_in_subject() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Normal\r\nX-Injected: evil\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let result = parse_email(raw);
assert!(
result.is_ok(),
"Should not crash on embedded CRLF in header area"
);
}
}
mod rfc2231_folded_headers {
use daaki_message::parse_email;
#[test]
fn filename_rfc2231_charset_on_folded_line() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: RFC 2231 folded\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/mixed; boundary=\"sep\"\r\n",
"\r\n",
"--sep\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"body\r\n",
"--sep\r\n",
"Content-Type: application/octet-stream\r\n",
"Content-Disposition: attachment;\r\n",
" filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt\r\n",
"\r\n",
"data\r\n",
"--sep--\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert!(!parsed.attachments.is_empty(), "Should find attachment");
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "日本語.txt",
"RFC 2231 charset filename on folded line should decode: got {filename}"
);
}
#[test]
fn filename_rfc2231_continuation_on_folded_lines() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: RFC 2231 continuation folded\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/mixed; boundary=\"sep\"\r\n",
"\r\n",
"--sep\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"body\r\n",
"--sep\r\n",
"Content-Type: application/octet-stream\r\n",
"Content-Disposition: attachment;\r\n",
" filename*0*=UTF-8''%E6%97%A5%E6%9C%AC;\r\n",
" filename*1*=%E8%AA%9E.txt\r\n",
"\r\n",
"data\r\n",
"--sep--\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert!(!parsed.attachments.is_empty(), "Should find attachment");
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "日本語.txt",
"RFC 2231 continuation on folded lines should decode: got {filename}"
);
}
#[test]
fn filename_out_of_order_on_folded_lines() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: RFC 2231 out of order folded\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/mixed; boundary=\"sep\"\r\n",
"\r\n",
"--sep\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"body\r\n",
"--sep\r\n",
"Content-Type: application/octet-stream\r\n",
"Content-Disposition: attachment;\r\n",
" filename*2=\"part3.txt\";\r\n",
" filename*0=\"part1\";\r\n",
" filename*1=\"part2\"\r\n",
"\r\n",
"data\r\n",
"--sep--\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert!(!parsed.attachments.is_empty(), "Should find attachment");
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "part1part2part3.txt",
"Out-of-order RFC 2231 on folded lines should reassemble: got {filename}"
);
}
}
mod parser_additional {
use daaki_message::parse_email;
#[test]
fn windows_1252_charset_body() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Win1252\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=windows-1252\r\n\
\r\n\
\x93Hello\x94\r\n";
let parsed = parse_email(raw).unwrap();
let body = parsed.body_text.unwrap();
assert!(
body.contains('\u{201C}') && body.contains('\u{201D}'),
"Windows-1252 smart quotes should decode: got {body:?}"
);
}
#[test]
fn multipart_digest_default_content_type() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Digest\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/digest; boundary=\"dig\"\r\n\
\r\n\
--dig\r\n\
\r\n\
From: inner@example.com\r\n\
To: inner-to@example.com\r\n\
Subject: Inner message\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
Inner body\r\n\
--dig--\r\n";
let parsed = parse_email(raw);
assert!(
parsed.is_ok(),
"Should handle multipart/digest without panic"
);
}
#[test]
fn inline_attachment_with_content_id() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Inline\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/related; boundary=\"rel\"\r\n\
\r\n\
--rel\r\n\
Content-Type: text/html; charset=utf-8\r\n\
\r\n\
<img src=\"cid:img1@example.com\">\r\n\
--rel\r\n\
Content-Type: image/png\r\n\
Content-ID: <img1@example.com>\r\n\
Content-Disposition: inline; filename=\"image.png\"\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
iVBORw0KGgo=\r\n\
--rel--\r\n";
let parsed = parse_email(raw).unwrap();
assert!(
!parsed.attachments.is_empty(),
"Should find inline attachment"
);
let att = &parsed.attachments[0];
assert!(att.is_inline, "Should be marked as inline");
assert_eq!(
att.content_id.as_deref(),
Some("img1@example.com"),
"Content-ID should be stripped of angle brackets"
);
}
#[test]
fn header_folding_with_tab() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Line1\r\n\
\tLine2\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
let subject = parsed.subject.unwrap();
assert!(
subject.contains("Line1") && subject.contains("Line2"),
"Folded subject with tab should unfold: got {subject:?}"
);
}
#[test]
fn parse_headers_only_function() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Headers only\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
This body should be ignored\r\n";
let parsed = daaki_message::parse_headers_only(raw).unwrap();
assert_eq!(parsed.subject.as_deref(), Some("Headers only"));
assert!(
parsed.body_text.is_none(),
"parse_headers_only should not extract body"
);
}
#[test]
fn multiple_in_reply_to_ids() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Multi IRT\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
In-Reply-To: <id1@example.com> <id2@example.com>\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.in_reply_to.len(),
2,
"Should parse multiple In-Reply-To IDs"
);
assert_eq!(parsed.in_reply_to[0], "id1@example.com");
assert_eq!(parsed.in_reply_to[1], "id2@example.com");
}
#[test]
fn mixed_bracketed_and_bare_references_recovers_all_ids() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Mixed refs\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
References: root@example.com <parent@example.com> child@example.com\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.references,
vec![
"root@example.com".to_string(),
"parent@example.com".to_string(),
"child@example.com".to_string(),
],
"mixed bracketed and bare References IDs should preserve all recoverable IDs"
);
}
}
mod datetime_edge_cases {
use daaki_message::DateTime;
#[test]
fn datetime_leap_second_timestamp() {
let dt = DateTime::new(2012, 6, 30, 23, 59, 60, 0);
let ts = dt.to_unix_timestamp();
let dt59 = DateTime::new(2012, 6, 30, 23, 59, 59, 0);
assert_eq!(
ts,
dt59.to_unix_timestamp(),
"Leap second should clamp to 59 for timestamp"
);
}
#[test]
fn datetime_negative_offset() {
let dt = DateTime::new(2025, 1, 1, 0, 0, 0, -480);
let s = dt.to_rfc5322_string();
assert!(s.contains("-0800"), "Should format -0800: got {s}");
}
#[test]
fn datetime_positive_offset_0530() {
let dt = DateTime::new(2025, 1, 1, 0, 0, 0, 330);
let s = dt.to_rfc5322_string();
assert!(s.contains("+0530"), "Should format +0530: got {s}");
}
#[test]
fn datetime_feb_29_leap_year() {
let dt = DateTime::new(2024, 2, 29, 12, 0, 0, 0);
let s = dt.to_rfc5322_string();
assert!(
s.contains("29 Feb 2024"),
"Should handle Feb 29 in leap year: {s}"
);
}
#[test]
fn datetime_feb_29_non_leap_year_clamped() {
let dt = DateTime::new(2025, 2, 29, 12, 0, 0, 0);
let s = dt.to_rfc5322_string();
assert!(
s.contains("28 Feb 2025"),
"Non-leap-year Feb 29 should clamp to 28: {s}"
);
}
#[test]
fn datetime_month_0_clamped() {
let dt = DateTime::new(2025, 0, 15, 12, 0, 0, 0);
let s = dt.to_rfc5322_string();
assert!(s.contains("Jan"), "Month 0 should clamp to Jan: {s}");
}
#[test]
fn datetime_iso8601_format() {
let dt = DateTime::new(2025, 3, 15, 14, 30, 45, -300);
let s = dt.to_iso8601_string();
assert_eq!(s, "2025-03-15T14:30:45-05:00");
}
}
mod seven_bit_encoding {
use daaki_message::{build_message, parse_email, Address, OutgoingEmail};
#[test]
fn seven_bit_non_ascii_body_uses_qp() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "7bit test".into();
email.body_text = Some("Hello café world".into());
let built = build_message(&email).unwrap();
let raw_str = String::from_utf8_lossy(&built.raw);
assert!(
raw_str.contains("quoted-printable") || raw_str.contains("7bit"),
"Non-ASCII body with SevenBit should use QP or 7bit encoding"
);
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some("Hello café world"),
"SevenBit round-trip should preserve non-ASCII text"
);
}
}
mod qp_round_trip {
use daaki_message::{build_message, parse_email, Address, OutgoingEmail};
fn make_email(body: &str) -> OutgoingEmail {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "QP round-trip".into();
email.body_text = Some(body.into());
email
}
#[test]
fn body_with_equals_sign() {
let body = "3 + 2 = 5 and 10 = 10";
let built = build_message(&make_email(body)).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some(body),
"Equals sign should survive round-trip"
);
}
#[test]
fn body_with_trailing_whitespace() {
let body = "line with trailing spaces \nsecond line";
let built = build_message(&make_email(body)).unwrap();
let parsed = parse_email(&built.raw).unwrap();
let parsed_body = parsed.body_text.unwrap();
assert!(
parsed_body.contains("line with trailing spaces")
&& parsed_body.contains("second line"),
"Body with trailing whitespace: got {parsed_body:?}"
);
}
#[test]
fn body_with_special_qp_chars() {
let body = "Tab:\there, equals:=here, high-byte:\u{00FF}";
let built = build_message(&make_email(body)).unwrap();
let parsed = parse_email(&built.raw).unwrap();
let parsed_body = parsed.body_text.unwrap();
assert!(
parsed_body.contains("Tab:") && parsed_body.contains("equals:"),
"Special chars should round-trip: got {parsed_body:?}"
);
}
#[test]
fn body_empty_string() {
let built = build_message(&make_email("")).unwrap();
let parsed = parse_email(&built.raw).unwrap();
let body = parsed.body_text.unwrap_or_default();
assert!(
body.trim().is_empty(),
"Empty body should round-trip as empty: got {body:?}"
);
}
#[test]
fn body_with_only_newlines() {
let body = "\n\n\n";
let built = build_message(&make_email(body)).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(parsed.body_text.is_some());
}
#[test]
fn body_with_null_bytes() {
let body = "before\0after";
let built = build_message(&make_email(body)).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(
parsed.body_text.is_some(),
"Should handle null bytes in body"
);
}
}
mod tricky_multipart {
use daaki_message::parse_email;
#[test]
fn boundary_appears_in_body_text() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: Boundary in body\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/alternative; boundary=\"BOUNDARY\"\r\n",
"\r\n",
"--BOUNDARY\r\n",
"Content-Type: text/plain; charset=utf-8\r\n",
"\r\n",
"This text mentions BOUNDARY but not at start of line\r\n",
"--BOUNDARY\r\n",
"Content-Type: text/html; charset=utf-8\r\n",
"\r\n",
"<p>HTML</p>\r\n",
"--BOUNDARY--\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
let body = parsed.body_text.unwrap();
assert!(
body.contains("BOUNDARY"),
"Boundary string inside body should be preserved"
);
}
#[test]
fn missing_closing_boundary() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: Truncated\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/alternative; boundary=\"bound\"\r\n",
"\r\n",
"--bound\r\n",
"Content-Type: text/plain; charset=utf-8\r\n",
"\r\n",
"Truncated message without closing boundary\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert!(
parsed.body_text.is_some(),
"Should extract text from truncated multipart"
);
}
#[test]
fn preamble_text_before_first_boundary() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: Preamble\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/alternative; boundary=\"bound\"\r\n",
"\r\n",
"This is the preamble and should be ignored.\r\n",
"--bound\r\n",
"Content-Type: text/plain; charset=utf-8\r\n",
"\r\n",
"Actual body\r\n",
"--bound--\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
let body = parsed.body_text.unwrap();
assert!(
!body.contains("preamble"),
"Preamble text should be ignored: got {body:?}"
);
assert!(
body.contains("Actual body"),
"Should extract actual body part"
);
}
#[test]
fn epilogue_text_after_closing_boundary() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: Epilogue\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/alternative; boundary=\"bound\"\r\n",
"\r\n",
"--bound\r\n",
"Content-Type: text/plain; charset=utf-8\r\n",
"\r\n",
"Actual body\r\n",
"--bound--\r\n",
"This is the epilogue and should be ignored.\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
let body = parsed.body_text.unwrap();
assert!(
!body.contains("epilogue"),
"Epilogue text should not appear in body"
);
}
#[test]
fn multipart_with_no_parts() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: No parts\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/mixed; boundary=\"nonexistent\"\r\n",
"\r\n",
"No actual boundary delimiters in this body\r\n",
);
let parsed = parse_email(raw.as_bytes());
assert!(
parsed.is_ok(),
"Should handle multipart with no actual parts"
);
}
#[test]
fn deeply_nested_multipart() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: Deep nesting\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/mixed; boundary=\"L1\"\r\n",
"\r\n",
"--L1\r\n",
"Content-Type: multipart/mixed; boundary=\"L2\"\r\n",
"\r\n",
"--L2\r\n",
"Content-Type: multipart/mixed; boundary=\"L3\"\r\n",
"\r\n",
"--L3\r\n",
"Content-Type: multipart/mixed; boundary=\"L4\"\r\n",
"\r\n",
"--L4\r\n",
"Content-Type: multipart/mixed; boundary=\"L5\"\r\n",
"\r\n",
"--L5\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"Deep body\r\n",
"--L5--\r\n",
"--L4--\r\n",
"--L3--\r\n",
"--L2--\r\n",
"--L1--\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Deep body"),
"Should extract text from deeply nested multipart"
);
}
}
mod address_edge_cases {
use daaki_message::{build_message, parse_email, Address, OutgoingEmail};
#[test]
fn address_with_plus_in_local_part() {
let raw = b"From: user+tag@example.com\r\n\
To: to@example.com\r\n\
Subject: Plus addressing\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.from[0].email, "user+tag@example.com",
"Plus addressing should be preserved"
);
}
#[test]
fn address_with_very_long_display_name() {
let long_name = "A".repeat(500);
let addr = Address::with_name(long_name.clone(), "user@example.com").unwrap();
let formatted = addr.to_string();
let parsed: Address = formatted.parse().unwrap();
assert_eq!(parsed.name.as_deref(), Some(long_name.as_str()));
assert_eq!(parsed.email, "user@example.com");
}
#[test]
fn address_with_empty_display_name() {
let addr = Address::with_name("", "user@example.com").unwrap();
let formatted = addr.to_string();
assert_eq!(formatted, "user@example.com");
}
#[test]
fn address_with_whitespace_only_display_name() {
let addr = Address::with_name(" ", "user@example.com").unwrap();
let formatted = addr.to_string();
assert_eq!(
formatted, "user@example.com",
"Whitespace-only name should be treated as absent"
);
}
#[test]
fn build_with_domain_literal_address() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("user@[127.0.0.1]").unwrap()];
email.to = vec![Address::new("to@example.com").unwrap()];
email.subject = "Domain literal".into();
email.body_text = Some("body".into());
let result = build_message(&email);
match result {
Ok(built) => {
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(parsed.from[0].email, "user@[127.0.0.1]");
}
Err(e) => {
assert!(
format!("{e}").contains("domain") || format!("{e}").contains("address"),
"Error should mention domain or address: {e}"
);
}
}
}
#[test]
fn comma_separated_to_with_angle_brackets() {
let raw = b"From: sender@example.com\r\n\
To: \"Doe, Jane\" <jane@example.com>, Bob <bob@example.com>\r\n\
Subject: Comma in name\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(parsed.to.len(), 2, "Should parse 2 To addresses");
assert_eq!(parsed.to[0].email, "jane@example.com");
assert_eq!(parsed.to[0].name.as_deref(), Some("Doe, Jane"));
assert_eq!(parsed.to[1].email, "bob@example.com");
}
#[test]
fn multiple_spaces_in_display_name() {
let raw = b"From: John Doe <john@example.com>\r\n\
To: to@example.com\r\n\
Subject: test\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
let name = parsed.from[0].name.as_deref().unwrap_or("");
assert!(
name.contains("John") && name.contains("Doe"),
"Display name should contain both parts: got {name:?}"
);
}
}
mod cte_edge_cases {
use daaki_message::parse_email;
#[test]
fn unknown_cte_treated_as_8bit() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Unknown CTE\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: x-custom\r\n\
\r\n\
Body with unknown CTE\r\n";
let parsed = parse_email(raw).unwrap();
assert!(
parsed.body_text.is_some(),
"Unknown CTE should be tolerated"
);
}
#[test]
fn cte_case_insensitive() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: CTE case\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: BASE64\r\n\
\r\n\
SGVsbG8gV29ybGQ=\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Hello World"),
"Uppercase BASE64 should be recognized"
);
}
#[test]
fn base64_with_extra_whitespace() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Base64 whitespace\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
SGVs\r\n\
bG8g\r\n\
V29y\r\n\
bGQ=\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Hello World"),
"Base64 with line breaks should decode correctly"
);
}
#[test]
fn base64_unpadded() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Unpadded base64\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
SGVsbG8\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Hello"),
"Unpadded base64 should be tolerated"
);
}
}
mod header_edge_cases {
use std::fmt::Write;
use daaki_message::parse_email;
#[test]
fn very_long_to_header_many_recipients() {
let mut to = String::new();
for i in 0..50 {
if i > 0 {
to.push_str(", ");
}
let _ = write!(to, "user{i}@example.com");
}
let raw = format!(
"From: sender@example.com\r\n\
To: {to}\r\n\
Subject: Many recipients\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n"
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert_eq!(parsed.to.len(), 50, "Should parse all 50 recipients");
}
#[test]
fn subject_with_tab_folding() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Part1\r\n\
\t Part2\r\n\
\t Part3\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
let subject = parsed.subject.unwrap();
assert!(
subject.contains("Part1") && subject.contains("Part2") && subject.contains("Part3"),
"Multi-folded subject should unfold: got {subject:?}"
);
}
#[test]
fn header_with_utf8_value() {
let raw = "From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Héllo Wörld\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw.as_bytes()).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some("Héllo Wörld"),
"Raw UTF-8 in subject should be preserved (RFC 6532)"
);
}
#[test]
fn reply_to_with_display_name() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Reply-To: \"Support Team\" <support@example.com>\r\n\
Subject: Reply-To test\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(parsed.reply_to.len(), 1);
assert_eq!(parsed.reply_to[0].email, "support@example.com");
assert_eq!(parsed.reply_to[0].name.as_deref(), Some("Support Team"));
}
#[test]
fn sender_header_parsed() {
let raw = b"From: a@b.com, c@d.com\r\n\
Sender: a@b.com\r\n\
To: to@example.com\r\n\
Subject: Sender test\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert!(parsed.sender.is_some(), "Sender should be parsed");
assert_eq!(parsed.sender.unwrap().email, "a@b.com");
}
}
mod attachment_edge_cases {
use daaki_message::{build_message, parse_email, Address, OutgoingAttachment, OutgoingEmail};
#[test]
fn multiple_attachments_round_trip() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Multi attach".into();
email.body_text = Some("body".into());
email.attachments = vec![
OutgoingAttachment::new("file1.txt", "text/plain", b"content1".to_vec()),
OutgoingAttachment::new("file2.pdf", "application/pdf", b"content2".to_vec()),
];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert_eq!(
parsed.attachments.len(),
2,
"Should detect 2 attachments, got {}",
parsed.attachments.len()
);
}
#[test]
fn inline_attachment_with_html() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Inline attach".into();
email.body_html = Some("<img src=\"cid:img@ex.com\">".into());
email.attachments = vec![OutgoingAttachment::inline(
"image.png",
"image/png",
vec![0x89, 0x50, 0x4E, 0x47], "img@ex.com",
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(parsed.body_html.is_some(), "HTML body should be present");
assert!(
!parsed.attachments.is_empty(),
"Inline attachment should be detected"
);
}
#[test]
fn attachment_with_special_chars_in_filename() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Special filename".into();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment::new(
"file (1).txt",
"text/plain",
b"data".to_vec(),
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "file (1).txt",
"Filename with special chars should round-trip: got {filename}"
);
}
#[test]
fn attachment_with_unicode_filename() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Unicode filename".into();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment::new(
"日本語ファイル.txt",
"text/plain",
b"data".to_vec(),
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "日本語ファイル.txt",
"Unicode filename should round-trip: got {filename}"
);
}
#[test]
fn attachment_with_quotes_in_filename() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Quoted filename".into();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment::new(
"file\"with\"quotes.txt",
"text/plain",
b"data".to_vec(),
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "file\"with\"quotes.txt",
"Filename with quotes should round-trip: got {filename}"
);
}
#[test]
fn attachment_with_semicolon_in_filename() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Semicolon filename".into();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment::new(
"file;name.txt",
"text/plain",
b"data".to_vec(),
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "file;name.txt",
"Filename with semicolon should round-trip: got {filename}"
);
}
#[test]
fn attachment_with_backslash_in_filename() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Backslash filename".into();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment::new(
"path\\to\\file.txt",
"text/plain",
b"data".to_vec(),
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, "path\\to\\file.txt",
"Filename with backslashes should round-trip: got {filename}"
);
}
#[test]
fn attachment_large_data() {
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Large attachment".into();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment::new(
"large.bin",
"application/octet-stream",
vec![0xAB; 100_000], )];
let built = build_message(&email).unwrap();
assert!(
built.raw.len() > 100_000,
"Message should be larger than attachment data"
);
let parsed = parse_email(&built.raw).unwrap();
assert!(!parsed.attachments.is_empty());
}
#[test]
fn attachment_with_very_long_filename() {
let long_name = format!("{}.txt", "a".repeat(200));
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Long filename".into();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment::new(
long_name.clone(),
"text/plain",
b"data".to_vec(),
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename,
long_name,
"Very long ASCII filename should round-trip: got len={} expected len={}",
filename.len(),
long_name.len()
);
}
#[test]
fn attachment_with_very_long_unicode_filename() {
let long_name = format!("{}.txt", "日本".repeat(50));
let mut email = OutgoingEmail::default();
email.from = vec![Address::new("a@b.com").unwrap()];
email.to = vec![Address::new("c@d.com").unwrap()];
email.subject = "Long unicode filename".into();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment::new(
long_name.clone(),
"text/plain",
b"data".to_vec(),
)];
let built = build_message(&email).unwrap();
let parsed = parse_email(&built.raw).unwrap();
assert!(!parsed.attachments.is_empty());
let filename = parsed.attachments[0].filename.as_deref().unwrap_or("");
assert_eq!(
filename, long_name,
"Very long Unicode filename should round-trip: got {filename:?}"
);
}
}
mod adversarial_inputs {
use std::fmt::Write;
use daaki_message::parse_email;
#[test]
fn only_whitespace_input() {
let result = parse_email(b" \r\n \r\n ");
if let Ok(parsed) = result {
let _ = parsed.subject;
}
}
#[test]
fn colon_at_start_of_header() {
let raw = b": no-name-header\r\n\
From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: Colon at start\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(parsed.subject.as_deref(), Some("Colon at start"));
}
#[test]
fn very_many_headers() {
let mut raw = String::from("From: sender@example.com\r\n");
raw.push_str("To: to@example.com\r\n");
raw.push_str("Subject: Many headers\r\n");
raw.push_str("Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n");
for i in 0..500 {
let _ = write!(raw, "X-Header-{i}: value-{i}\r\n");
}
raw.push_str("\r\nbody\r\n");
let parsed = parse_email(raw.as_bytes()).unwrap();
assert_eq!(parsed.subject.as_deref(), Some("Many headers"));
assert!(
parsed.extra_headers.len() >= 500,
"Should parse all 500 extra headers"
);
}
#[test]
fn mixed_crlf_and_bare_lf() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\n\
Subject: Mixed endings\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(parsed.subject.as_deref(), Some("Mixed endings"));
}
#[test]
fn header_value_with_only_whitespace() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: \r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
let subject = parsed.subject.unwrap_or_default();
assert!(
subject.trim().is_empty(),
"Whitespace-only subject should be empty or None"
);
}
#[test]
fn header_with_no_value() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject:\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
let subject = parsed.subject.unwrap_or_default();
assert!(
subject.is_empty(),
"Empty subject value should parse as empty"
);
}
#[test]
fn boundary_with_special_chars() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: Special boundary\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/alternative; boundary=\"=_NextPart_000_001\"\r\n",
"\r\n",
"--=_NextPart_000_001\r\n",
"Content-Type: text/plain; charset=utf-8\r\n",
"\r\n",
"Plain text\r\n",
"--=_NextPart_000_001\r\n",
"Content-Type: text/html; charset=utf-8\r\n",
"\r\n",
"<p>HTML</p>\r\n",
"--=_NextPart_000_001--\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
assert_eq!(
parsed.body_text.as_deref().map(str::trim),
Some("Plain text")
);
assert_eq!(
parsed.body_html.as_deref().map(str::trim),
Some("<p>HTML</p>")
);
}
#[test]
fn multipart_alternative_prefers_last() {
let raw = concat!(
"From: sender@example.com\r\n",
"To: to@example.com\r\n",
"Subject: Alt preference\r\n",
"Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n",
"Content-Type: multipart/alternative; boundary=\"alt\"\r\n",
"\r\n",
"--alt\r\n",
"Content-Type: text/plain; charset=utf-8\r\n",
"\r\n",
"Plain text\r\n",
"--alt\r\n",
"Content-Type: text/html; charset=utf-8\r\n",
"\r\n",
"<p>First HTML</p>\r\n",
"--alt\r\n",
"Content-Type: text/html; charset=utf-8\r\n",
"\r\n",
"<p>Second HTML</p>\r\n",
"--alt--\r\n",
);
let parsed = parse_email(raw.as_bytes()).unwrap();
let html = parsed.body_html.unwrap();
assert!(
html.contains("Second HTML"),
"In multipart/alternative, last text/html should be preferred: got {html:?}"
);
}
#[test]
fn encoded_word_split_across_lines() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: =?UTF-8?B?SGVsbG8=?=\r\n \
=?UTF-8?B?V29ybGQ=?=\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
let subject = parsed.subject.unwrap();
assert!(
subject.contains("Hello") && subject.contains("World"),
"Encoded words on folded lines should decode: got {subject:?}"
);
}
#[test]
fn from_with_obsolete_source_route() {
let raw = b"From: <@hop1,@hop2:user@domain.com>\r\n\
To: to@example.com\r\n\
Subject: Source route\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body\r\n";
let parsed = parse_email(raw).unwrap();
assert_eq!(
parsed.from[0].email, "user@domain.com",
"Obsolete source route should be stripped"
);
}
#[test]
fn content_type_multipart_without_boundary() {
let raw = b"From: sender@example.com\r\n\
To: to@example.com\r\n\
Subject: No boundary\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Content-Type: multipart/mixed\r\n\
\r\n\
body text\r\n";
let parsed = parse_email(raw);
assert!(
parsed.is_ok(),
"multipart without boundary should not panic"
);
}
}
mod tz_offset_clamping {
use daaki_message::DateTime;
#[test]
fn to_unix_timestamp_clamps_tz_offset() {
let dt = DateTime::new(2025, 1, 1, 12, 0, 0, 5000);
let formatted = dt.to_rfc5322_string();
assert!(
formatted.contains("+2359"),
"to_rfc5322_string should clamp to +2359, got: {formatted}"
);
let reparsed = DateTime::parse_rfc5322(&formatted).unwrap();
assert_eq!(
dt.to_unix_timestamp(),
reparsed.to_unix_timestamp(),
"to_unix_timestamp must clamp tz_offset_minutes to ±1439 \
(RFC 5322 Section 3.3) so the timestamp matches the formatted string"
);
}
#[test]
fn to_unix_timestamp_clamps_negative_tz_offset() {
let dt = DateTime::new(2025, 6, 15, 6, 0, 0, -5000);
let formatted = dt.to_rfc5322_string();
assert!(
formatted.contains("-2359"),
"to_rfc5322_string should clamp to -2359, got: {formatted}"
);
let reparsed = DateTime::parse_rfc5322(&formatted).unwrap();
assert_eq!(
dt.to_unix_timestamp(),
reparsed.to_unix_timestamp(),
"negative tz_offset_minutes must also be clamped to ±1439"
);
}
#[test]
fn from_unix_timestamp_clamps_tz_offset() {
let ts: i64 = 1_700_000_000;
let dt = DateTime::from_unix_timestamp(ts, 5000);
let formatted = dt.to_rfc5322_string();
let reparsed = DateTime::parse_rfc5322(&formatted).unwrap();
assert_eq!(
dt.to_unix_timestamp(),
reparsed.to_unix_timestamp(),
"from_unix_timestamp must clamp tz_offset_minutes for round-trip consistency"
);
}
}