#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn force_fold_word(output: &mut Vec<u8>, bytes: &[u8], mut line_len: usize) -> usize {
if line_len > 1 {
output.extend_from_slice(b"\r\n ");
line_len = 1;
}
output.extend_from_slice(bytes);
line_len += bytes.len();
line_len
}
fn write_header(output: &mut Vec<u8>, name: &str, value: &str) {
try_write_header(output, name, value)
.expect("test header values must satisfy RFC 5322 line-length limits");
}
fn hdr(s: &str) -> HeaderName {
HeaderName::new_unchecked(s)
}
fn mid(s: &str) -> String {
s.to_owned()
}
fn make_email() -> OutgoingEmail {
OutgoingEmail {
from: vec![Address {
name: Some("Sender".into()),
email: "sender@example.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "to@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Test Subject".into(),
body_text: None,
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
}
}
fn raw_str(built: &BuiltMessage) -> String {
String::from_utf8_lossy(&built.raw).into_owned()
}
#[test]
fn build_text_only() {
let mut email = make_email();
email.body_text = Some("Hello, World!".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Content-Type: text/plain; charset=utf-8"));
assert!(s.contains("Hello, World!"));
assert!(s.contains("From: Sender <sender@example.com>"));
assert!(s.contains("To: to@example.com"));
assert!(s.contains("Subject: Test Subject"));
assert!(s.contains("MIME-Version: 1.0"));
assert!(s.contains("Date: "));
assert!(s.contains("Message-ID: <"));
}
#[test]
fn build_html_only() {
let mut email = make_email();
email.body_html = Some("<h1>Hello</h1>".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Content-Type: text/html; charset=utf-8"));
assert!(s.contains("<h1>Hello</h1>"));
}
#[test]
fn build_text_and_html() {
let mut email = make_email();
email.body_text = Some("Plain text".into());
email.body_html = Some("<p>HTML</p>".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("multipart/alternative"));
assert!(s.contains("text/plain; charset=utf-8"));
assert!(s.contains("text/html; charset=utf-8"));
assert!(s.contains("Plain text"));
assert!(s.contains("<p>HTML</p>"));
let plain_pos = s.find("text/plain").unwrap();
let html_pos = s.find("text/html").unwrap();
assert!(plain_pos < html_pos);
}
#[test]
fn build_with_attachment() {
let mut email = make_email();
email.body_text = Some("See attached".into());
email.attachments = vec![OutgoingAttachment {
filename: "test.pdf".into(),
content_type: "application/pdf".into(),
data: b"fake pdf data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("multipart/mixed"));
assert!(s.contains("Content-Type: application/pdf"));
assert!(s.contains("Content-Disposition: attachment; filename=\"test.pdf\""));
assert!(s.contains("Content-Transfer-Encoding: base64"));
}
#[test]
fn build_inline_image_uses_multipart_related() {
let mut email = make_email();
email.body_html = Some("<img src=\"cid:logo@example.com\">".into());
email.attachments = vec![OutgoingAttachment {
filename: "logo.png".into(),
content_type: "image/png".into(),
data: b"PNG data".to_vec(),
is_inline: true,
content_id: Some("logo@example.com".into()),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("multipart/related"),
"inline images with Content-ID must be in multipart/related (RFC 2387): {s}"
);
}
#[test]
fn build_mixed_inline_and_regular_attachments() {
let mut email = make_email();
email.body_html = Some("<img src=\"cid:logo@example.com\">".into());
email.attachments = vec![
OutgoingAttachment {
filename: "logo.png".into(),
content_type: "image/png".into(),
data: b"PNG data".to_vec(),
is_inline: true,
content_id: Some("logo@example.com".into()),
},
OutgoingAttachment {
filename: "report.pdf".into(),
content_type: "application/pdf".into(),
data: b"PDF data".to_vec(),
is_inline: false,
content_id: None,
},
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("multipart/mixed"),
"mixed inline + regular attachments needs multipart/mixed: {s}"
);
assert!(
s.contains("multipart/related"),
"inline images must be in multipart/related (RFC 2387): {s}"
);
}
#[test]
fn build_inline_attachment_with_content_id() {
let mut email = make_email();
email.body_html = Some("<img src=\"cid:img001@example.com\">".into());
email.attachments = vec![OutgoingAttachment {
filename: "logo.png".into(),
content_type: "image/png".into(),
data: b"fake png data".to_vec(),
is_inline: true,
content_id: Some("img001@example.com".into()),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Disposition: inline; filename=\"logo.png\""),
"inline disposition missing: {s}"
);
assert!(
s.contains("Content-ID: <img001@example.com>"),
"Content-ID header missing or not wrapped in angle brackets: {s}"
);
}
#[test]
fn build_inline_attachment_normalizes_bracketed_content_id() {
let mut email = make_email();
email.body_html = Some("<img src=\"cid:img001@example.com\">".into());
email.attachments = vec![OutgoingAttachment {
filename: "logo.png".into(),
content_type: "image/png".into(),
data: b"fake png data".to_vec(),
is_inline: true,
content_id: Some("<img001@example.com>".into()),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-ID: <img001@example.com>\r\n"),
"Content-ID must be normalized to a single bracketed msg-id: {s}"
);
assert!(
!s.contains("Content-ID: <<img001@example.com>>"),
"Content-ID must not be double-wrapped: {s}"
);
}
#[test]
fn build_text_html_attachments() {
let mut email = make_email();
email.body_text = Some("Text".into());
email.body_html = Some("<b>HTML</b>".into());
email.attachments = vec![OutgoingAttachment {
filename: "file.txt".into(),
content_type: "text/plain".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("multipart/mixed"));
assert!(s.contains("multipart/alternative"));
}
#[test]
fn build_no_body() {
let email = make_email();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Content-Type: text/plain; charset=utf-8"));
}
#[test]
fn build_bcc_not_in_headers() {
let mut email = make_email();
email.bcc = vec![Address {
name: None,
email: "hidden@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(!s.contains("Bcc:"));
assert!(!s.contains("hidden@example.com"));
}
#[test]
fn build_bcc_in_envelope() {
let mut email = make_email();
email.bcc = vec![Address {
name: None,
email: "hidden@example.com".into(),
}];
let built = build_message(&email).unwrap();
assert!(built
.envelope_recipients
.contains(&"hidden@example.com".to_string()));
assert!(built
.envelope_recipients
.contains(&"to@example.com".to_string()));
}
#[test]
fn build_message_id_format() {
let email = make_email();
let built = build_message(&email).unwrap();
assert!(built.message_id.contains('@'));
assert!(built.message_id.ends_with("example.com"));
let s = raw_str(&built);
assert!(s.contains(&format!("Message-ID: <{}>", built.message_id)));
}
#[test]
fn build_threading_headers() {
let mut email = make_email();
email.in_reply_to = vec![mid("parent@host.com")];
email.references = vec![mid("root@host.com"), mid("parent@host.com")];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("In-Reply-To: <parent@host.com>"));
assert!(s.contains("References: <root@host.com> <parent@host.com>"));
}
#[test]
fn build_threading_headers_already_bracketed() {
let mut email = make_email();
email.in_reply_to = vec![mid("<parent@host.com>")];
email.references = vec![mid("<root@host.com>"), mid("<parent@host.com>")];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("<<"),
"Double angle brackets found in headers: {s}"
);
assert!(s.contains("In-Reply-To: <parent@host.com>"));
assert!(s.contains("References: <root@host.com> <parent@host.com>"));
}
#[test]
fn build_invalid_address_error() {
let mut email = make_email();
email.from[0].email = "not-an-email".into();
let result = build_message(&email);
assert!(matches!(result, Err(Error::InvalidAddress(_))));
}
#[test]
fn build_empty_address_error() {
let mut email = make_email();
email.from[0].email = String::new();
let result = build_message(&email);
assert!(matches!(result, Err(Error::InvalidAddress(_))));
}
#[test]
fn build_invalid_mime_fallback() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "file.bin".into(),
content_type: "not_a_valid_mime".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Content-Type: application/octet-stream"));
}
#[test]
fn build_reply_to() {
let mut email = make_email();
email.reply_to = vec![Address {
name: Some("Reply".into()),
email: "reply@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Reply-To: Reply <reply@example.com>"));
}
#[test]
fn build_no_destination_headers_is_valid() {
let mut email = make_email();
email.to.clear();
email.body_text = Some("draft body".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(!s.contains("\r\nTo:"));
assert!(!s.contains("\r\nCc:"));
assert!(!s.contains("\r\nBcc:"));
assert!(built.envelope_recipients.is_empty());
}
#[test]
fn build_cc_in_headers_and_envelope() {
let mut email = make_email();
email.cc = vec![Address {
name: None,
email: "cc@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Cc: cc@example.com"));
assert!(built
.envelope_recipients
.contains(&"cc@example.com".to_string()));
}
#[test]
fn build_attachment_base64_line_wrapping() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "big.bin".into(),
content_type: "application/octet-stream".into(),
data: vec![0xAB; 100],
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let after_b64_header = s.find("Content-Transfer-Encoding: base64").unwrap();
let body_section = &s[after_b64_header..];
for line in body_section.split("\r\n") {
if !line.is_empty() && !line.starts_with("--") && !line.contains(':') {
assert!(
line.len() <= 76,
"Base64 line too long ({} chars): {line}",
line.len()
);
}
}
}
#[test]
fn message_id_domain_fallback() {
let mut email = make_email();
email.from[0].email = "local-only@".into();
assert!(build_message(&email).is_err());
}
#[test]
fn round_trip_build_then_parse() {
let mut email = make_email();
email.body_text = Some("Round-trip test body".into());
email.body_html = Some("<p>Round-trip HTML</p>".into());
email.in_reply_to = vec![mid("parent@host.com")];
email.references = vec![mid("root@host.com"), mid("parent@host.com")];
email.cc = vec![Address {
name: Some("CC User".into()),
email: "cc@example.com".into(),
}];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.from[0].email, "sender@example.com");
assert_eq!(parsed.from[0].name.as_deref(), Some("Sender"));
assert_eq!(parsed.to.len(), 1);
assert_eq!(parsed.to[0].email, "to@example.com");
assert_eq!(parsed.cc.len(), 1);
assert_eq!(parsed.cc[0].email, "cc@example.com");
assert_eq!(parsed.subject.as_deref(), Some("Test Subject"));
assert_eq!(
parsed.message_id.as_deref(),
Some(built.message_id.as_str())
);
assert_eq!(parsed.in_reply_to, vec!["parent@host.com"]);
assert_eq!(parsed.references, vec!["root@host.com", "parent@host.com"]);
assert_eq!(parsed.body_text.as_deref(), Some("Round-trip test body"));
assert_eq!(parsed.body_html.as_deref(), Some("<p>Round-trip HTML</p>"));
assert!(parsed.date.is_some());
}
#[test]
fn message_id_is_crypto_random() {
let email = make_email();
let built1 = build_message(&email).unwrap();
let built2 = build_message(&email).unwrap();
assert_ne!(built1.message_id, built2.message_id);
let at_pos = built1.message_id.find('@').unwrap();
let hex_part = &built1.message_id[..at_pos];
assert_eq!(hex_part.len(), 32);
assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn build_bcc_only_recipients() {
let mut email = make_email();
email.to.clear();
email.bcc = vec![Address {
name: None,
email: "hidden@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(!s.contains("\r\nTo:"));
assert!(!s.contains("Bcc:"));
assert!(!s.contains("hidden@example.com"));
assert_eq!(built.envelope_recipients, vec!["hidden@example.com"]);
}
#[test]
fn build_round_trip_with_attachments() {
let mut email = make_email();
email.body_text = Some("Text body".into());
email.attachments = vec![OutgoingAttachment {
filename: "test.txt".into(),
content_type: "text/plain".into(),
data: b"attachment data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.body_text.as_deref(), Some("Text body"));
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(parsed.attachments[0].filename.as_deref(), Some("test.txt"));
}
#[test]
fn build_html_only_with_attachments() {
let mut email = make_email();
email.body_html = Some("<p>Hello</p>".into());
email.attachments = vec![OutgoingAttachment {
filename: "data.bin".into(),
content_type: "application/octet-stream".into(),
data: vec![1, 2, 3],
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("multipart/mixed"));
assert!(s.contains("text/html; charset=utf-8"));
assert!(s.contains("<p>Hello</p>"));
assert!(s.contains("Content-Disposition: attachment; filename=\"data.bin\""));
}
#[test]
fn build_whitespace_in_address_rejected() {
let mut email = make_email();
email.to = vec![Address {
name: None,
email: "bad user@example.com".into(),
}];
let result = build_message(&email);
assert!(matches!(result, Err(Error::InvalidAddress(_))));
}
#[test]
fn build_control_char_in_address_rejected() {
let mut email = make_email();
email.to = vec![Address {
name: None,
email: "user\x00@example.com".into(),
}];
let result = build_message(&email);
assert!(matches!(result, Err(Error::InvalidAddress(_))));
}
#[test]
fn build_envelope_contains_all_recipients() {
let mut email = make_email();
email.to = vec![Address {
name: None,
email: "to@x.com".into(),
}];
email.cc = vec![Address {
name: None,
email: "cc@x.com".into(),
}];
email.bcc = vec![Address {
name: None,
email: "bcc@x.com".into(),
}];
let built = build_message(&email).unwrap();
assert_eq!(built.envelope_recipients.len(), 3);
assert!(built.envelope_recipients.contains(&"to@x.com".to_string()));
assert!(built.envelope_recipients.contains(&"cc@x.com".to_string()));
assert!(built.envelope_recipients.contains(&"bcc@x.com".to_string()));
}
#[test]
fn build_message_has_crlf_line_endings() {
let mut email = make_email();
email.body_text = Some("Hello".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for line in s.split("\r\n") {
assert!(
!line.contains('\n') || line.is_empty(),
"bare LF found in line: {line:?}"
);
}
}
#[test]
fn build_body_bare_lf_normalized_to_crlf() {
let mut email = make_email();
email.body_text = Some("Line 1\nLine 2\nLine 3".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for line in s.split("\r\n") {
assert!(
!line.contains('\n') || line.is_empty(),
"bare LF found in output: {line:?}"
);
}
assert!(s.contains("Line 1\r\nLine 2\r\nLine 3"));
}
#[test]
fn build_html_body_bare_lf_normalized_to_crlf() {
let mut email = make_email();
email.body_html = Some("<p>Line 1</p>\n<p>Line 2</p>".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for line in s.split("\r\n") {
assert!(
!line.contains('\n') || line.is_empty(),
"bare LF found in HTML output: {line:?}"
);
}
}
#[test]
fn build_body_mixed_line_endings_normalized() {
let mut email = make_email();
email.body_text = Some("CRLF line\r\nLF line\nAnother LF\n".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for line in s.split("\r\n") {
assert!(
!line.contains('\n') || line.is_empty(),
"bare LF in mixed-endings output: {line:?}"
);
}
}
#[test]
fn build_empty_attachment_data() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "empty.bin".into(),
content_type: "application/octet-stream".into(),
data: vec![],
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Content-Disposition: attachment; filename=\"empty.bin\""));
}
#[test]
fn build_multiple_attachments() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![
OutgoingAttachment {
filename: "a.pdf".into(),
content_type: "application/pdf".into(),
data: b"pdf data".to_vec(),
is_inline: false,
content_id: None,
},
OutgoingAttachment {
filename: "b.png".into(),
content_type: "image/png".into(),
data: b"png data".to_vec(),
is_inline: false,
content_id: None,
},
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("filename=\"a.pdf\""));
assert!(s.contains("filename=\"b.png\""));
assert!(s.contains("Content-Type: application/pdf"));
assert!(s.contains("Content-Type: image/png"));
}
#[test]
fn build_date_header_rfc5322_format() {
let email = make_email();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let date_line = s
.lines()
.find(|l| l.starts_with("Date: "))
.expect("Date header missing");
let dow_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
assert!(
dow_names.iter().any(|d| date_line.contains(d)),
"Date header missing day-of-week: {date_line}"
);
assert!(
date_line.contains("+0000"),
"Date header missing timezone: {date_line}"
);
}
#[test]
fn build_custom_date_honored() {
let mut email = make_email();
email.date = Some(crate::types::DateTime {
year: 2020,
month: 6,
day: 15,
hour: 10,
minute: 30,
second: 0,
tz_offset_minutes: -300, });
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let date_line = s
.lines()
.find(|l| l.starts_with("Date: "))
.expect("Date header missing");
assert_eq!(
date_line, "Date: Mon, 15 Jun 2020 10:30:00 -0500",
"builder must use the caller-supplied date"
);
}
#[test]
fn build_unicode_subject() {
let mut email = make_email();
email.subject = "Héllo Wörld 你好 🌍".into();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let header_section = s.split("\r\n\r\n").next().unwrap();
assert!(
header_section.is_ascii(),
"Headers must be pure ASCII per RFC 5322 Section 2.2"
);
assert!(s.contains("=?UTF-8?B?"));
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.subject.as_deref(), Some("Héllo Wörld 你好 🌍"));
}
#[test]
fn build_unicode_display_name() {
let mut email = make_email();
email.from = vec![Address {
name: Some("José García".into()),
email: "jose@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let header_section = s.split("\r\n\r\n").next().unwrap();
assert!(
header_section.is_ascii(),
"Headers must be pure ASCII per RFC 5322 Section 2.2, \
but found non-ASCII in: {header_section}"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.from[0].name.as_deref(), Some("José García"));
assert_eq!(parsed.from[0].email, "jose@example.com");
}
#[test]
fn build_empty_subject() {
let mut email = make_email();
email.subject = String::new();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Subject: \r\n"));
}
#[test]
fn build_special_chars_in_filename() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "my file (1).pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("filename=\"my file (1).pdf\""));
}
#[test]
fn build_non_ascii_filename_rfc2231_encoded() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "résumé.pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("filename*=UTF-8''"),
"Non-ASCII filename must use RFC 2231 encoding, got: {s}"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some("résumé.pdf")
);
}
#[test]
fn build_non_ascii_filename_includes_legacy_fallback() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "résumé.pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("filename*=UTF-8''"),
"Missing RFC 2231 filename*: {s}"
);
assert!(
s.contains("filename=\""),
"Missing legacy filename fallback for non-ASCII attachment: {s}"
);
}
#[test]
fn build_multiple_bcc_all_excluded() {
let mut email = make_email();
email.bcc = vec![
Address {
name: None,
email: "bcc1@example.com".into(),
},
Address {
name: None,
email: "bcc2@example.com".into(),
},
Address {
name: Some("BCC Three".into()),
email: "bcc3@example.com".into(),
},
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(!s.contains("bcc1@example.com"));
assert!(!s.contains("bcc2@example.com"));
assert!(!s.contains("bcc3@example.com"));
assert!(!s.contains("BCC Three"));
assert!(!s.contains("Bcc:"));
assert_eq!(built.envelope_recipients.len(), 4); assert!(built
.envelope_recipients
.contains(&"bcc1@example.com".to_string()));
assert!(built
.envelope_recipients
.contains(&"bcc2@example.com".to_string()));
assert!(built
.envelope_recipients
.contains(&"bcc3@example.com".to_string()));
}
#[test]
fn build_from_without_display_name() {
let mut email = make_email();
email.from = vec![Address {
name: None,
email: "plain@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("From: plain@example.com\r\n"));
}
#[test]
fn build_all_recipient_types_together() {
let mut email = make_email();
email.to = vec![
Address {
name: Some("To One".into()),
email: "to1@x.com".into(),
},
Address {
name: None,
email: "to2@x.com".into(),
},
];
email.cc = vec![Address {
name: Some("CC One".into()),
email: "cc1@x.com".into(),
}];
email.bcc = vec![Address {
name: None,
email: "bcc1@x.com".into(),
}];
email.reply_to = vec![Address {
name: None,
email: "reply@x.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("To: To One <to1@x.com>, to2@x.com"));
assert!(s.contains("Cc: CC One <cc1@x.com>"));
assert!(s.contains("Reply-To: reply@x.com"));
assert!(!s.contains("Bcc:"));
assert_eq!(built.envelope_recipients.len(), 4);
}
#[test]
fn build_large_attachment_base64() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "large.bin".into(),
content_type: "application/octet-stream".into(),
data: vec![0x42; 1000],
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let b64_header = "Content-Transfer-Encoding: base64\r\n\r\n";
let b64_start = s.find(b64_header).unwrap() + b64_header.len();
let b64_end = s[b64_start..].find("\r\n--").unwrap_or(s.len() - b64_start);
let b64_block = &s[b64_start..b64_start + b64_end];
for line in b64_block.split("\r\n") {
if !line.is_empty() {
assert!(
line.len() <= 76,
"Base64 line exceeds 76 chars ({} chars): {}",
line.len(),
line
);
}
}
}
#[test]
fn build_round_trip_text_html_attachments() {
let mut email = make_email();
email.subject = "Complex message".into();
email.body_text = Some("Plain text part".into());
email.body_html = Some("<h1>HTML part</h1>".into());
email.in_reply_to = vec![mid("parent-id@example.com")];
email.references = vec![mid("root@example.com"), mid("parent-id@example.com")];
email.cc = vec![Address {
name: Some("CC User".into()),
email: "cc@example.com".into(),
}];
email.attachments = vec![
OutgoingAttachment {
filename: "doc.pdf".into(),
content_type: "application/pdf".into(),
data: b"pdf content".to_vec(),
is_inline: false,
content_id: None,
},
OutgoingAttachment {
filename: "img.png".into(),
content_type: "image/png".into(),
data: b"png content".to_vec(),
is_inline: false,
content_id: None,
},
];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.subject.as_deref(), Some("Complex message"));
assert_eq!(parsed.from[0].email, "sender@example.com");
assert_eq!(parsed.to.len(), 1);
assert_eq!(parsed.cc.len(), 1);
assert_eq!(
parsed.message_id.as_deref(),
Some(built.message_id.as_str())
);
assert_eq!(parsed.in_reply_to, vec!["parent-id@example.com"]);
assert_eq!(
parsed.references,
vec!["root@example.com", "parent-id@example.com"]
);
assert_eq!(parsed.body_text.as_deref(), Some("Plain text part"));
assert_eq!(parsed.body_html.as_deref(), Some("<h1>HTML part</h1>"));
assert_eq!(parsed.attachments.len(), 2);
assert_eq!(parsed.attachments[0].filename.as_deref(), Some("doc.pdf"));
assert_eq!(parsed.attachments[1].filename.as_deref(), Some("img.png"));
}
#[test]
fn build_message_id_fallback_domain() {
assert_eq!(extract_domain("user@example.com"), Some("example.com"));
assert_eq!(
extract_domain("user@sub.domain.org"),
Some("sub.domain.org")
);
assert_eq!(extract_domain("user@"), None);
assert_eq!(extract_domain("no-at-sign"), None);
}
#[test]
fn build_no_body_with_attachments() {
let mut email = make_email();
email.attachments = vec![OutgoingAttachment {
filename: "data.bin".into(),
content_type: "application/octet-stream".into(),
data: vec![1, 2, 3],
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("multipart/mixed"));
assert!(s.contains("text/plain; charset=utf-8"));
assert!(s.contains("Content-Disposition: attachment; filename=\"data.bin\""));
}
#[test]
fn build_multiple_mime_type_fallbacks() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![
OutgoingAttachment {
filename: "a.bin".into(),
content_type: String::new(), data: b"data".to_vec(),
is_inline: false,
content_id: None,
},
OutgoingAttachment {
filename: "b.bin".into(),
content_type: "no-slash".into(), data: b"data".to_vec(),
is_inline: false,
content_id: None,
},
OutgoingAttachment {
filename: "c.bin".into(),
content_type: "/subtype".into(), data: b"data".to_vec(),
is_inline: false,
content_id: None,
},
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let count = s.matches("Content-Type: application/octet-stream").count();
assert_eq!(count, 3, "Expected 3 fallback MIME types, got {count}");
}
#[test]
fn build_address_at_boundary_rejected() {
let mut email = make_email();
email.to = vec![Address {
name: None,
email: "@domain.com".into(),
}];
let result = build_message(&email);
assert!(matches!(result, Err(Error::InvalidAddress(_))));
let mut email2 = make_email();
email2.to = vec![Address {
name: None,
email: "user@".into(),
}];
let result2 = build_message(&email2);
assert!(matches!(result2, Err(Error::InvalidAddress(_))));
}
#[test]
fn mime_type_validation_subtype_chars() {
assert!(is_valid_mime_type("application/pdf"));
assert!(is_valid_mime_type("image/svg+xml"));
assert!(is_valid_mime_type("application/vnd.ms-excel"));
assert!(
is_valid_mime_type("application/x-my_type"),
"underscore is a valid token char per RFC 2045 Section 5.1"
);
assert!(
is_valid_mime_type("application/x-custom!type"),
"bang is a valid token char per RFC 2045 Section 5.1"
);
assert!(!is_valid_mime_type("text/plain; charset=utf-8"));
assert!(!is_valid_mime_type("text/html@bad"));
assert!(!is_valid_mime_type("text/html(bad)"));
}
#[test]
fn build_parameterized_mime_type_preserved() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "file.bin".into(),
content_type: "application/pdf; extra=bad".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Type: application/pdf; extra=bad; name=\"file.bin\""),
"valid Content-Type parameters must be preserved on attachment parts: {s}"
);
assert!(
!s.contains("Content-Type: application/octet-stream"),
"valid Content-Type parameters must not trigger octet-stream fallback: {s}"
);
}
#[test]
fn build_invalid_parameterized_mime_fallback() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "file.bin".into(),
content_type: "application/pdf; extra".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Type: application/octet-stream"),
"malformed Content-Type parameters must still fall back safely: {s}"
);
}
#[test]
fn build_display_name_with_comma_is_quoted() {
let mut email = make_email();
email.from = vec![Address {
name: Some("Doe, John".into()),
email: "john@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("From: \"Doe, John\" <john@example.com>"),
"From header should quote display name with comma: {s}"
);
}
#[test]
fn build_display_name_with_special_chars_is_quoted() {
let mut email = make_email();
email.from = vec![Address {
name: Some("O'Brien (test)".into()),
email: "ob@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("\"O'Brien (test)\" <ob@example.com>"),
"From header should quote display name with parens: {s}"
);
}
#[test]
fn build_display_name_with_quotes_escaped() {
let mut email = make_email();
email.from = vec![Address {
name: Some("John \"Doc\" Doe".into()),
email: "john@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("\"John \\\"Doc\\\" Doe\" <john@example.com>"),
"From header should escape quotes in display name: {s}"
);
}
#[test]
fn build_display_name_plain_not_quoted() {
let email = make_email();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("From: Sender <sender@example.com>"),
"Simple display name should not be quoted: {s}"
);
}
#[test]
fn build_round_trip_display_name_with_comma() {
let mut email = make_email();
email.from = vec![Address {
name: Some("Doe, John".into()),
email: "john@example.com".into(),
}];
email.body_text = Some("Body".into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.from[0].name.as_deref(), Some("Doe, John"));
assert_eq!(parsed.from[0].email, "john@example.com");
}
#[test]
fn build_round_trip_display_name_with_escaped_quotes() {
let mut email = make_email();
email.from = vec![Address {
name: Some("John \"Doc\" Doe".into()),
email: "john@example.com".into(),
}];
email.body_text = Some("Body".into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.from[0].name.as_deref(), Some("John \"Doc\" Doe"));
assert_eq!(parsed.from[0].email, "john@example.com");
}
#[test]
fn build_attachment_filename_with_quotes_escaped() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "file\"name.pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains(r#"filename="file\"name.pdf""#),
"Filename with quote not properly escaped: {s}"
);
}
#[test]
fn build_attachment_filename_with_backslash_escaped() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "path\\file.pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains(r#"filename="path\\file.pdf""#),
"Filename with backslash not properly escaped: {s}"
);
}
#[test]
fn build_long_subject_is_folded() {
let mut email = make_email();
email.subject = "A".repeat(1000);
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
let parsed_subject = parsed.subject.unwrap();
assert_eq!(
parsed_subject, email.subject,
"Single-word subject must round-trip without inserted spaces"
);
}
#[test]
fn build_long_references_header_is_folded() {
let mut email = make_email();
let ids: Vec<String> = (0..30)
.map(|i| {
mid(&format!(
"id{i:04}@very-long-domain-name-for-testing.example.com"
))
})
.collect();
email.references = ids;
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for line in s.split("\r\n") {
assert!(
line.len() <= 998,
"Line exceeds 998 chars ({} chars)",
line.len()
);
}
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.references.len(), 30);
}
#[test]
fn build_force_fold_preserves_utf8_boundaries() {
let mut email = make_email();
email.subject = "🌍".repeat(300);
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
!line.contains('\u{FFFD}'),
"Line {i} contains UTF-8 replacement character from mid-char split: {line:?}"
);
}
let parsed = crate::parse_email(&built.raw).unwrap();
let parsed_subject = parsed.subject.unwrap();
assert_eq!(
parsed_subject, email.subject,
"UTF-8 subject must round-trip exactly without inserted spaces"
);
}
#[test]
fn split_header_words_handles_escaped_quotes() {
let value = r#""A\" B" <a@b.com>"#;
let words = split_header_words(value);
assert_eq!(
words,
(vec![(r#""A\" B""#, None), ("<a@b.com>", Some(" "))], None),
"split_header_words must skip escaped quotes inside quoted-strings \
per RFC 5322 Section 3.2.4"
);
}
#[test]
fn spec_audit_split_header_words_backslash_multibyte_utf8() {
let value = r#""test\🌍 rest" after"#;
let words = split_header_words(value);
assert_eq!(
words,
(
vec![(r#""test\🌍 rest""#, None), ("after", Some(" "))],
None
),
"split_header_words must skip the full multi-byte UTF-8 character \
after a backslash, not just 2 bytes (RFC 5322 Section 3.2.4)"
);
}
#[test]
fn spec_audit_split_header_words_backslash_ascii_still_works() {
let value = r#""hello\" world" next"#;
let words = split_header_words(value);
assert_eq!(
words,
(
vec![(r#""hello\" world""#, None), ("next", Some(" "))],
None
),
"split_header_words must handle backslash + ASCII in quoted-string \
(RFC 5322 Section 3.2.4)"
);
}
#[test]
fn spec_audit_split_header_words_backslash_2byte_utf8() {
let value = "\"test\\é more\" end";
let words = split_header_words(value);
assert_eq!(
words,
(vec![("\"test\\é more\"", None), ("end", Some(" "))], None),
"split_header_words must handle backslash + 2-byte UTF-8 char \
(RFC 5322 Section 3.2.4)"
);
}
#[test]
fn spec_audit_split_header_words_backslash_3byte_utf8() {
let value = "\"test\\世 more\" end";
let words = split_header_words(value);
assert_eq!(
words,
(vec![("\"test\\世 more\"", None), ("end", Some(" "))], None),
"split_header_words must handle backslash + 3-byte UTF-8 char \
(RFC 5322 Section 3.2.4)"
);
}
#[test]
fn build_crlf_in_display_name_stripped() {
let mut email = make_email();
email.from = vec![Address {
name: Some("evil\r\nBcc: injected@evil.com".into()),
email: "sender@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("\r\nBcc:"),
"CRLF in display name must not inject headers: {s}"
);
assert!(
!built
.envelope_recipients
.contains(&"injected@evil.com".to_string()),
"Injected address must not appear in envelope"
);
}
#[test]
fn build_crlf_in_subject_stripped() {
let mut email = make_email();
email.subject = "Test\r\nBcc: injected@evil.com".into();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("\r\nBcc:"),
"CRLF in subject must not inject headers: {s}"
);
}
#[test]
fn build_bare_lf_in_display_name_stripped() {
let mut email = make_email();
email.from = vec![Address {
name: Some("evil\nBcc: injected@evil.com".into()),
email: "sender@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("\nBcc:"),
"Bare LF in display name must not inject headers: {s}"
);
}
#[test]
fn build_crlf_in_references_stripped() {
let mut email = make_email();
email.references = vec![mid("root@host.com\r\nBcc: injected@evil.com")];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("\r\nBcc:"),
"CRLF in references must not inject headers: {s}"
);
}
#[test]
fn build_round_trip_escaped_quote_name_long_header() {
let mut email = make_email();
email.from = vec![Address {
name: Some("A\" B".into()),
email: "user@very-long-domain-name-that-pushes-header-over-78-chars.example.com".into(),
}];
email.body_text = Some("Body".into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.from[0].name.as_deref(),
Some("A\" B"),
"Display name with escaped quote must survive folding round-trip"
);
}
#[test]
fn build_non_ascii_body_preserved() {
let mut email = make_email();
let body = "Héllo, José! Ñoño. 日本語テスト";
email.body_text = Some(body.into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(body),
"Non-ASCII UTF-8 body text must round-trip through QP encoding"
);
}
#[test]
fn build_non_ascii_html_body_preserved() {
let mut email = make_email();
let html = "<p>Ünïcödé résumé</p>";
email.body_html = Some(html.into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_html.as_deref(),
Some(html),
"Non-ASCII UTF-8 HTML body must round-trip through QP encoding"
);
}
#[test]
fn build_non_ascii_multipart_body_preserved() {
let mut email = make_email();
let text = "Café";
let html = "<b>Café</b>";
email.body_text = Some(text.into());
email.body_html = Some(html.into());
email.attachments = vec![OutgoingAttachment {
filename: "test.txt".into(),
content_type: "text/plain".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(text),
"Non-ASCII in multipart text must round-trip through QP encoding"
);
assert_eq!(
parsed.body_html.as_deref(),
Some(html),
"Non-ASCII in multipart HTML must round-trip through QP encoding"
);
}
#[test]
fn build_non_ascii_subject_rfc2047_round_trip() {
let mut email = make_email();
email.subject = "Héllo Wörld".into();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let header_section = s.split("\r\n\r\n").next().unwrap();
assert!(
header_section.is_ascii(),
"Headers must be pure ASCII per RFC 5322 Section 2.2, \
but found non-ASCII in: {header_section}"
);
assert!(
s.contains("=?UTF-8?B?"),
"Subject must be RFC 2047 B-encoded, got: {s}"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some("Héllo Wörld"),
"Subject must round-trip through RFC 2047 encoding"
);
}
#[test]
fn build_long_body_line_uses_quoted_printable() {
let mut email = make_email();
let long_line = "A".repeat(1200);
email.body_text = Some(long_line.clone());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"Long body lines must trigger quoted-printable encoding \
(RFC 2045 Section 2.8), but got:\n{s}"
);
assert!(
!s.contains("Content-Transfer-Encoding: 8bit"),
"8bit encoding must not be used when body has lines > 998 chars"
);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"Line {i} exceeds 998 chars ({} chars): {}...",
line.len(),
&line[..80.min(line.len())]
);
}
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(long_line.as_str()),
"Body text must round-trip through quoted-printable encoding"
);
}
#[test]
fn build_normal_body_line_uses_7bit() {
let mut email = make_email();
email.body_text = Some("Short line, well under 998 chars.".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: 7bit"),
"Short body lines should use 7bit encoding, got:\n{s}"
);
assert!(
!s.contains("Content-Transfer-Encoding: quoted-printable"),
"Short body lines should not use quoted-printable"
);
}
#[test]
fn build_body_line_exactly_998_chars_uses_7bit() {
let mut email = make_email();
let boundary_line = "B".repeat(998);
email.body_text = Some(boundary_line.clone());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: 7bit"),
"A 998-char pure ASCII line is within the RFC 5322 Section 2.1.1 limit \
and must use 7bit encoding, got:\n{s}"
);
assert!(
!s.contains("Content-Transfer-Encoding: quoted-printable"),
"A 998-char line must not trigger quoted-printable encoding"
);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"Line {i} exceeds 998 chars ({} chars): {}...",
line.len(),
&line[..80.min(line.len())]
);
}
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(boundary_line.as_str()),
"Body text must round-trip through 7bit encoding"
);
}
#[test]
fn build_body_line_exactly_999_chars_uses_quoted_printable() {
let mut email = make_email();
let over_line = "C".repeat(999);
email.body_text = Some(over_line.clone());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"A 999-char line exceeds the RFC 5322 Section 2.1.1 limit \
and must trigger quoted-printable encoding, got:\n{s}"
);
assert!(
!s.contains("Content-Transfer-Encoding: 8bit"),
"8bit encoding must not be used when a body line exceeds 998 chars"
);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"Line {i} exceeds 998 chars ({} chars): {}...",
line.len(),
&line[..80.min(line.len())]
);
}
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(over_line.as_str()),
"Body text must round-trip through quoted-printable encoding"
);
}
#[test]
fn generate_boundary_not_in_avoids_collision() {
let first = generate_boundary();
let content = format!("Some text before\r\n--{first}\r\nSome text after");
let second = generate_boundary_not_in(content.as_bytes());
assert_ne!(
first, second,
"generate_boundary_not_in must produce a boundary that \
differs from one already present in the content"
);
assert!(
!content.contains(&second),
"Returned boundary must not appear in the content, but \
found '{second}' in '{content}'"
);
}
#[test]
fn build_boundary_not_in_body() {
let mut email = make_email();
email.body_text =
Some("Line 1\r\n----=_Part_00000000000000000000000000000000\r\nLine 2".into());
email.body_html = Some("<p>HTML body</p>".into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some("Line 1\r\n----=_Part_00000000000000000000000000000000\r\nLine 2"),
"Body text containing boundary prefix must round-trip correctly"
);
assert_eq!(
parsed.body_html.as_deref(),
Some("<p>HTML body</p>"),
"HTML body must round-trip correctly"
);
}
#[test]
fn build_nested_multipart_boundaries_are_distinct() {
let mut email = make_email();
email.body_text = Some("Plain text".into());
email.body_html = Some("<p>HTML</p>".into());
email.attachments = vec![OutgoingAttachment {
filename: "data.bin".into(),
content_type: "application/octet-stream".into(),
data: vec![1, 2, 3],
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let mut boundaries: Vec<String> = Vec::new();
let lower = s.to_lowercase();
let mut search_from = 0;
while let Some(pos) = lower[search_from..].find("boundary=\"") {
let abs = search_from + pos;
let rest = &s[abs + 10..]; let end = rest.find('"').unwrap_or(rest.len());
boundaries.push(rest[..end].to_string());
search_from = abs + 10 + end;
}
assert_eq!(
boundaries.len(),
2,
"Expected 2 boundary values (mixed + alternative), got {boundaries:?}"
);
assert_ne!(
boundaries[0], boundaries[1],
"Nested multipart boundaries must be distinct per RFC 2046 Section 5.1.1"
);
}
#[test]
fn build_attachment_filename_crlf_sanitized() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "evil\r\nBcc: attacker@evil.com\r\nX-Injected: yes".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("\r\nBcc: attacker@evil.com"),
"CRLF in filename must be stripped to prevent header injection \
(RFC 5322 Section 2.1)"
);
assert!(
!s.contains("\r\nX-Injected:"),
"CRLF in filename must be stripped to prevent header injection"
);
assert!(
s.contains("Content-Disposition: attachment"),
"Content-Disposition header must still be present"
);
}
#[test]
fn build_non_ascii_attachment_filename_crlf_sanitized() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "résumé\r\nBcc: attacker@evil.com".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("\r\nBcc: attacker@evil.com"),
"CRLF in non-ASCII filename must be stripped to prevent header injection"
);
}
#[test]
fn build_attachment_filename_with_backslash_and_quote_escaped() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "path\\file\"name.pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains(r#"filename="path\\file\"name.pdf""#),
"Filename with both backslash and quote not properly escaped: {s}"
);
}
#[test]
fn build_display_name_with_backslash_escaped() {
let mut email = make_email();
email.from = vec![Address {
name: Some("Back\\Slash".into()),
email: "bs@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains(r#""Back\\Slash" <bs@example.com>"#),
"Display name with backslash not properly escaped: {s}"
);
}
#[test]
fn build_then_parse_filename_with_backslash_and_quote_round_trip() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "path\\file\"name.pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some("path\\file\"name.pdf"),
"Round-trip filename with backslash and quote must be preserved"
);
}
#[test]
fn generate_boundary_not_in_with_prefix_in_content() {
let mut content = String::new();
for i in 0..20 {
use std::fmt::Write;
let _ = write!(content, "----=_Part_{i:032x}\r\n");
}
let boundary = generate_boundary_not_in(content.as_bytes());
assert!(
!content.contains(&boundary),
"Returned boundary must not appear in content containing \
many boundary-like strings (RFC 2046 Section 5.1.1)"
);
}
#[test]
fn generate_message_id_format_and_uniqueness() {
let id1 = generate_message_id("example.com");
let id2 = generate_message_id("example.com");
assert_eq!(
id1.matches('@').count(),
1,
"Message-ID must contain exactly one '@' (RFC 5322 Section 3.6.4)"
);
assert!(
id1.ends_with("@example.com"),
"Message-ID must end with @domain: {id1}"
);
assert_ne!(
id1, id2,
"Message-IDs must be unique (RFC 5322 Section 3.6.4)"
);
let local = id1.split('@').next().unwrap();
assert_eq!(local.len(), 32, "Local part must be 32 hex chars: {local}");
assert!(
local.chars().all(|c| c.is_ascii_hexdigit()),
"Local part must be hex digits: {local}"
);
}
#[test]
fn qp_crlf_passthrough() {
let input = b"Line one\r\nLine two\r\n";
let encoded = encode_quoted_printable(input);
assert_eq!(
encoded, b"Line one\r\nLine two\r\n",
"CRLF must pass through unchanged in QP encoding \
(RFC 2045 Section 6.7 Rule #4)"
);
}
#[test]
fn qp_trailing_whitespace_encoded() {
let input = b"trailing space \r\nnext line";
let encoded = encode_quoted_printable(input);
let encoded_str = String::from_utf8_lossy(&encoded);
assert!(
encoded_str.contains("trailing space=20\r\n"),
"Trailing space before CRLF must be encoded as =20 \
(RFC 2045 Section 6.7 Rule #3), got: {encoded_str}"
);
let input_tab = b"trailing tab\t\r\nnext line";
let encoded_tab = encode_quoted_printable(input_tab);
let encoded_tab_str = String::from_utf8_lossy(&encoded_tab);
assert!(
encoded_tab_str.contains("trailing tab=09\r\n"),
"Trailing TAB before CRLF must be encoded as =09 \
(RFC 2045 Section 6.7 Rule #3), got: {encoded_tab_str}"
);
}
#[test]
fn qp_trailing_whitespace_at_eof_encoded() {
let input = b"end with space ";
let encoded = encode_quoted_printable(input);
let encoded_str = String::from_utf8_lossy(&encoded);
assert!(
encoded_str.ends_with("=20"),
"Trailing space at EOF must be encoded as =20 \
(RFC 2045 Section 6.7 Rule #3), got: {encoded_str}"
);
}
#[test]
fn qp_non_trailing_whitespace_passthrough() {
let input = b"hello world";
let encoded = encode_quoted_printable(input);
assert_eq!(
encoded, b"hello world",
"Non-trailing space must pass through unchanged \
(RFC 2045 Section 6.7 Rule #2)"
);
}
#[test]
fn qp_equals_sign_encoded() {
let input = b"a=b";
let encoded = encode_quoted_printable(input);
assert_eq!(
encoded, b"a=3Db",
"`=` must be encoded as =3D (RFC 2045 Section 6.7 Rule #1)"
);
}
#[test]
fn qp_non_printable_bytes_encoded() {
let input = &[0x00u8];
let encoded = encode_quoted_printable(input);
assert_eq!(
encoded, b"=00",
"NUL byte must be encoded as =00 (RFC 2045 Section 6.7 Rule #1)"
);
let input_hi = &[0xFFu8];
let encoded_hi = encode_quoted_printable(input_hi);
assert_eq!(
encoded_hi, b"=FF",
"0xFF must be encoded as =FF (RFC 2045 Section 6.7 Rule #1)"
);
let input_del = &[0x7Fu8];
let encoded_del = encode_quoted_printable(input_del);
assert_eq!(
encoded_del, b"=7F",
"DEL must be encoded as =7F (RFC 2045 Section 6.7 Rule #1)"
);
}
#[test]
fn qp_soft_line_break_on_long_encoded_data() {
let input: Vec<u8> = vec![0xFF; 100];
let encoded = encode_quoted_printable(&input);
let encoded_str = String::from_utf8_lossy(&encoded);
for line in encoded_str.split("\r\n") {
assert!(
line.len() <= 76,
"QP line exceeds 76-char limit ({} chars): {line} \
(RFC 2045 Section 6.7 Rule #5)",
line.len()
);
}
let ff_count = encoded_str.matches("=FF").count();
assert_eq!(
ff_count, 100,
"All 100 bytes must be encoded as =FF, got {ff_count}"
);
}
#[test]
fn qp_soft_line_break_on_long_literal_data() {
let input: Vec<u8> = vec![b'A'; 200];
let encoded = encode_quoted_printable(&input);
let encoded_str = String::from_utf8_lossy(&encoded);
for line in encoded_str.split("\r\n") {
assert!(
line.len() <= 76,
"QP line exceeds 76-char limit ({} chars): {line}",
line.len()
);
}
let reassembled: String = encoded_str.replace("=\r\n", "");
let a_count = reassembled.chars().filter(|&c| c == 'A').count();
assert_eq!(a_count, 200, "All 200 'A' chars must be preserved");
}
#[test]
fn qp_mixed_content_round_trip() {
let mut email = make_email();
let mut long_line = String::new();
long_line.push_str("Start ");
for _ in 0..200 {
long_line.push_str("abcde");
}
long_line.push_str(" \t end"); long_line.push_str("\r\n");
long_line.push_str("Line with = sign and \x01 control char\r\n");
email.body_text = Some(long_line.clone());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"Long body line must trigger QP encoding (RFC 2045 Section 2.8)"
);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"Line {i} exceeds 998 chars ({} chars)",
line.len()
);
}
}
#[test]
fn is_trailing_whitespace_before_crlf() {
let data = b"abc \r\n";
assert!(
is_trailing_whitespace(data, 3),
"Space directly before CRLF is trailing"
);
let data2 = b"abc \r\n";
assert!(
is_trailing_whitespace(data2, 3),
"Space followed by spaces before CRLF is trailing"
);
let data3 = b"abc\t\r\n";
assert!(
is_trailing_whitespace(data3, 3),
"TAB directly before CRLF is trailing"
);
}
#[test]
fn is_trailing_whitespace_at_eof() {
let data = b"abc ";
assert!(
is_trailing_whitespace(data, 3),
"Space at end of data is trailing"
);
let data2 = b"abc\t";
assert!(
is_trailing_whitespace(data2, 3),
"TAB at end of data is trailing"
);
}
#[test]
fn is_trailing_whitespace_not_trailing() {
let data = b"abc def";
assert!(
!is_trailing_whitespace(data, 3),
"Space followed by non-whitespace is NOT trailing"
);
let data2 = b"abc\tdef";
assert!(
!is_trailing_whitespace(data2, 3),
"TAB followed by non-whitespace is NOT trailing"
);
}
#[test]
fn utf8_char_len_all_classes() {
assert_eq!(utf8_char_len(b'A'), 1, "ASCII 'A' is 1 byte");
assert_eq!(utf8_char_len(0x00), 1, "NUL is 1 byte");
assert_eq!(utf8_char_len(0x7F), 1, "DEL is 1 byte");
assert_eq!(utf8_char_len(0xC3), 2, "0xC3 (é lead) is 2-byte char");
assert_eq!(utf8_char_len(0xC0), 2, "0xC0 is 2-byte lead");
assert_eq!(utf8_char_len(0xDF), 2, "0xDF is 2-byte lead");
assert_eq!(utf8_char_len(0xE4), 3, "0xE4 (CJK lead) is 3-byte char");
assert_eq!(utf8_char_len(0xE0), 3, "0xE0 is 3-byte lead");
assert_eq!(utf8_char_len(0xEF), 3, "0xEF is 3-byte lead");
assert_eq!(utf8_char_len(0xF0), 4, "0xF0 (emoji lead) is 4-byte char");
assert_eq!(utf8_char_len(0xF4), 4, "0xF4 is 4-byte lead");
assert_eq!(utf8_char_len(0xFF), 4, "0xFF is 4-byte lead");
}
#[test]
fn snap_utf8_chunk_end_undersized_budget() {
let emoji = "🌍".as_bytes();
assert_eq!(emoji.len(), 4);
let end = snap_utf8_chunk_end(emoji, 0, 1);
assert_eq!(end, 4, "Budget too small for one char must advance past it");
let end = snap_utf8_chunk_end(emoji, 0, 2);
assert_eq!(end, 4);
let end = snap_utf8_chunk_end(emoji, 0, 3);
assert_eq!(end, 4);
let end = snap_utf8_chunk_end(emoji, 0, 4);
assert_eq!(end, 4);
let cjk = "你".as_bytes();
assert_eq!(cjk.len(), 3);
let end = snap_utf8_chunk_end(cjk, 0, 2);
assert_eq!(
end, 3,
"Budget of 2 can't fit 3-byte char, must advance past it"
);
let accent = "é".as_bytes();
assert_eq!(accent.len(), 2);
let end = snap_utf8_chunk_end(accent, 0, 1);
assert_eq!(
end, 2,
"Budget of 1 can't fit 2-byte char, must advance past it"
);
}
#[test]
fn snap_utf8_chunk_end_snaps_to_boundary() {
let data = "Aé".as_bytes();
assert_eq!(data.len(), 3);
let end = snap_utf8_chunk_end(data, 0, 2);
assert_eq!(
end, 1,
"Must snap back to char boundary, excluding incomplete 'é'"
);
let end = snap_utf8_chunk_end(data, 0, 3);
assert_eq!(end, 3, "Budget of 3 fits both 'A' and 'é'");
}
#[test]
fn build_very_long_single_word_subject_preserved() {
let mut email = make_email();
email.subject = "X".repeat(1100);
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
let parsed_subject = parsed.subject.unwrap();
assert_eq!(
parsed_subject, email.subject,
"Single-word subject must round-trip without inserted spaces"
);
}
#[test]
fn build_very_long_address_list_folded() {
let mut email = make_email();
email.to = (0..20)
.map(|i| Address {
name: Some(format!("User Number {i:03}")),
email: format!("user{i:03}@very-long-domain-name.example.com"),
})
.collect();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"Line {i} exceeds 998-char hard limit ({} chars) in long address list",
line.len()
);
}
assert_eq!(built.envelope_recipients.len(), 20);
}
#[test]
fn build_long_rfc2047_subject_folded() {
let mut email = make_email();
email.subject = "日本語テスト ".repeat(50);
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"Line {i} exceeds 998-char limit for RFC 2047 subject ({} chars)",
line.len()
);
}
let parsed = crate::parse_email(&built.raw).unwrap();
let original = email.subject.trim();
let parsed_subject = parsed.subject.unwrap();
assert_eq!(
parsed_subject.trim(),
original,
"Long RFC 2047 subject must round-trip"
);
}
#[test]
fn rfc2047_encoded_word_lines_must_not_exceed_76_chars() {
let mut email = make_email();
email.subject = "日本語テスト ".repeat(30);
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for (i, line) in s.split("\r\n").enumerate() {
if line.contains("=?") && line.contains("?=") {
assert!(
line.len() <= 76,
"RFC 2047 Section 2: line {i} with encoded-word exceeds \
76 chars ({} chars): {line}",
line.len()
);
}
}
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.subject.unwrap().trim(),
email.subject.trim(),
"Subject must survive RFC 2047 encode → fold → decode round-trip"
);
}
#[test]
fn rfc2047_encoded_word_address_lines_must_not_exceed_76_chars() {
let mut email = make_email();
email.reply_to = vec![Address {
name: Some("日本太郎さんの長い名前テスト用の表示名".into()),
email: "taro@example.com".into(),
}];
email.body_text = Some("test".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for (i, line) in s.split("\r\n").enumerate() {
if line.contains("=?") && line.contains("?=") {
assert!(
line.len() <= 76,
"RFC 2047 Section 2: line {i} with encoded-word exceeds \
76 chars ({} chars): {line}",
line.len()
);
}
}
}
#[test]
fn encode_rfc2047_multibyte_chars() {
let two_byte = "é".repeat(100);
let encoded = encode_rfc2047_if_needed(&two_byte);
assert!(
encoded.contains("=?UTF-8?B?"),
"Non-ASCII text must be RFC 2047 encoded"
);
for word in encoded.split(' ') {
if word.starts_with("=?") {
assert!(
word.len() <= 75,
"Encoded word exceeds 75 chars ({} chars): {word}",
word.len()
);
}
}
let three_byte = "日".repeat(100);
let encoded = encode_rfc2047_if_needed(&three_byte);
for word in encoded.split(' ') {
if word.starts_with("=?") {
assert!(
word.len() <= 75,
"3-byte char encoded word exceeds 75 chars ({} chars): {word}",
word.len()
);
}
}
let four_byte = "🌍".repeat(100);
let encoded = encode_rfc2047_if_needed(&four_byte);
for word in encoded.split(' ') {
if word.starts_with("=?") {
assert!(
word.len() <= 75,
"4-byte char encoded word exceeds 75 chars ({} chars): {word}",
word.len()
);
}
}
let ascii = "Hello World";
assert_eq!(
encode_rfc2047_if_needed(ascii),
"Hello World",
"Pure ASCII must not be encoded (RFC 2047 Section 5)"
);
}
#[test]
fn build_qp_body_with_crlf_and_trailing_whitespace() {
let mut email = make_email();
let mut body = String::new();
body.push_str(&"B".repeat(1100));
body.push_str("\r\n");
body.push_str("trailing space \r\n");
body.push_str("trailing tab\t\r\n");
body.push_str("equals = sign\r\n");
body.push_str("café résumé\r\n");
email.body_text = Some(body);
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"Must use QP encoding for body with >998-char lines"
);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"Line {i} exceeds 998-char limit ({} chars)",
line.len()
);
}
}
#[test]
fn build_long_html_body_uses_quoted_printable() {
let mut email = make_email();
let long_html = format!("<p>{}</p>", "x".repeat(1100));
email.body_html = Some(long_html);
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"Long HTML body lines must trigger QP encoding"
);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"HTML QP line {i} exceeds 998-char limit ({} chars)",
line.len()
);
}
}
#[test]
fn build_multipart_with_long_lines_uses_qp() {
let mut email = make_email();
email.body_text = Some("T".repeat(1100));
email.body_html = Some("H".repeat(1100));
email.attachments = vec![OutgoingAttachment {
filename: "file.bin".into(),
content_type: "application/octet-stream".into(),
data: vec![1, 2, 3],
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let qp_count = s
.matches("Content-Transfer-Encoding: quoted-printable")
.count();
assert_eq!(
qp_count, 2,
"Both text and HTML parts with long lines must use QP, got {qp_count}"
);
for (i, line) in s.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"Multipart QP line {i} exceeds 998-char limit ({} chars)",
line.len()
);
}
}
#[test]
fn build_seven_bit_ascii_uses_7bit_cte() {
let mut email = make_email();
email.body_text = Some("Short ASCII line".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: 7bit"),
"SevenBit with pure ASCII must use 7bit CTE, got:\n{s}"
);
assert!(
!s.contains("Content-Transfer-Encoding: 8bit"),
"SevenBit must not produce 8bit encoding"
);
assert!(
!s.contains("Content-Transfer-Encoding: quoted-printable"),
"SevenBit with pure ASCII should not use quoted-printable"
);
}
#[test]
fn build_seven_bit_encodes_non_ascii_body_round_trips() {
let mut email = make_email();
let body = "Héllo, José! 日本語テスト";
email.body_text = Some(body.into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"SevenBit must use quoted-printable for non-ASCII body"
);
let raw = &built.raw;
let cte_needle = b"Content-Transfer-Encoding: quoted-printable\r\n\r\n";
let body_start = raw
.windows(cte_needle.len())
.position(|w| w == cte_needle.as_slice())
.expect("must contain CTE: quoted-printable header")
+ cte_needle.len();
for (i, &byte) in raw[body_start..].iter().enumerate() {
assert!(
byte <= 127,
"SevenBit body byte at offset {i} is non-ASCII: 0x{byte:02x}"
);
}
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(body),
"Non-ASCII body must round-trip through SevenBit QP encoding"
);
}
#[test]
fn build_seven_bit_multipart_ascii_both_parts_use_7bit() {
let mut email = make_email();
email.body_text = Some("Plain text".into());
email.body_html = Some("<p>HTML</p>".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let seven_bit_count = s.matches("Content-Transfer-Encoding: 7bit").count();
assert_eq!(
seven_bit_count, 2,
"SevenBit multipart with ASCII must use 7bit for both parts, got {seven_bit_count}"
);
assert!(
!s.contains("Content-Transfer-Encoding: 8bit"),
"SevenBit must not produce any 8bit encoding"
);
}
#[test]
fn build_seven_bit_multipart_non_ascii_both_parts_use_qp() {
let mut email = make_email();
email.body_text = Some("Héllo café".into());
email.body_html = Some("<p>résumé</p>".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let qp_count = s
.matches("Content-Transfer-Encoding: quoted-printable")
.count();
assert_eq!(
qp_count, 2,
"SevenBit multipart with non-ASCII must QP-encode both parts, got {qp_count}"
);
assert!(
!s.contains("Content-Transfer-Encoding: 8bit"),
"SevenBit must not produce any 8bit encoding"
);
assert!(
!s.contains("Content-Transfer-Encoding: 7bit"),
"Non-ASCII content must not use 7bit CTE"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.body_text.as_deref(), Some("Héllo café"));
assert_eq!(parsed.body_html.as_deref(), Some("<p>résumé</p>"));
}
#[test]
fn build_seven_bit_ascii_long_lines_uses_qp() {
let mut email = make_email();
let long_line = "A".repeat(1200);
email.body_text = Some(long_line);
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"SevenBit with long ASCII lines must use QP, got:\n{s}"
);
assert!(
!s.contains("Content-Transfer-Encoding: 7bit"),
"Long lines cannot use 7bit CTE"
);
}
#[test]
fn build_ascii_uses_7bit_cte() {
let mut email = make_email();
email.body_text = Some("Pure ASCII".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: 7bit"),
"Pure ASCII must use 7bit CTE, got:\n{s}"
);
}
#[test]
fn build_text_with_nul_uses_quoted_printable() {
let mut email = make_email();
let body = "nul\0byte";
email.body_text = Some(body.into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"Text containing NUL must not be emitted as raw 8bit: {s:?}"
);
assert!(
!built.raw.contains(&0x00),
"quoted-printable output must not contain raw NUL octets"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(body),
"NUL-containing body must round-trip through quoted-printable"
);
}
#[test]
fn build_seven_bit_text_with_nul_uses_quoted_printable() {
let mut email = make_email();
let body = "nul\0byte";
email.body_text = Some(body.into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"SevenBit text containing NUL must use quoted-printable: {s:?}"
);
assert!(
!built.raw.contains(&0x00),
"quoted-printable output must not contain raw NUL octets"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some(body),
"NUL-containing body must round-trip through quoted-printable"
);
}
#[test]
fn build_seven_bit_empty_body_uses_7bit_cte() {
let email = make_email();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Transfer-Encoding: 7bit"),
"SevenBit with empty body must use 7bit CTE, got:\n{s}"
);
assert!(
!s.contains("Content-Transfer-Encoding: 8bit"),
"SevenBit must not produce 8bit encoding"
);
assert!(
!s.contains("Content-Transfer-Encoding: quoted-printable"),
"Empty body should not need quoted-printable"
);
}
#[test]
fn build_message_multiple_reply_to() {
let email = OutgoingEmail {
from: vec![Address {
name: Some("Sender".into()),
email: "sender@example.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "to@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![
Address {
name: Some("Reply One".into()),
email: "reply1@example.com".into(),
},
Address {
name: Some("Reply Two".into()),
email: "reply2@example.com".into(),
},
],
date: None,
subject: "Test multiple Reply-To".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let raw = String::from_utf8_lossy(&built.raw);
assert!(
raw.contains("reply1@example.com"),
"First Reply-To address must appear in message: {raw}"
);
assert!(
raw.contains("reply2@example.com"),
"Second Reply-To address must appear in message: {raw}"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.reply_to.len(),
2,
"Parsed message must have 2 Reply-To addresses, got {:?}",
parsed.reply_to
);
}
#[test]
fn build_message_with_extra_headers() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "sender@example.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "to@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Test extra headers".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![
(hdr("X-Mailer"), "TestMailer/1.0".into()),
(hdr("List-Unsubscribe"), "<mailto:unsub@example.com>".into()),
],
};
let built = build_message(&email).unwrap();
let raw = String::from_utf8_lossy(&built.raw);
assert!(
raw.contains("X-Mailer: TestMailer/1.0"),
"X-Mailer header must appear in message: {raw}"
);
assert!(
raw.contains("List-Unsubscribe:"),
"List-Unsubscribe header must appear in message: {raw}"
);
assert!(
raw.contains("mailto:unsub@example.com"),
"List-Unsubscribe value must appear in message: {raw}"
);
}
#[test]
fn header_name_rejects_invalid_ftext() {
assert!(
HeaderName::new("Invalid: Name").is_err(),
"Header name with colon must be rejected (RFC 5322 Section 2.2)"
);
assert!(
HeaderName::new("X Bad").is_err(),
"Header name with space must be rejected (RFC 5322 Section 2.2)"
);
assert!(
HeaderName::new("").is_err(),
"Empty header name must be rejected (RFC 5322 Section 2.2)"
);
}
#[test]
fn validate_address_rejects_multiple_at_signs() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "user@@domain.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "recipient@example.com".into(),
}],
cc: Vec::new(),
bcc: Vec::new(),
reply_to: Vec::new(),
date: None,
subject: "test".into(),
body_text: Some("test".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: Vec::new(),
extra_headers: Vec::new(),
};
assert!(
build_message(&email).is_err(),
"address with multiple '@' must be rejected (RFC 5322 Section 3.4.1)"
);
}
#[test]
fn validate_address_rejects_leading_trailing_dot_in_domain() {
let make_email = |addr: &str| OutgoingEmail {
from: vec![Address {
name: None,
email: addr.into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "recipient@example.com".into(),
}],
cc: Vec::new(),
bcc: Vec::new(),
reply_to: Vec::new(),
date: None,
subject: "test".into(),
body_text: Some("test".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: Vec::new(),
extra_headers: Vec::new(),
};
assert!(
build_message(&make_email("user@.domain.com")).is_err(),
"leading dot in domain must be rejected (RFC 5321 Section 4.1.2)"
);
assert!(
build_message(&make_email("user@domain.com.")).is_err(),
"trailing dot in domain must be rejected (RFC 5321 Section 4.1.2)"
);
}
#[test]
fn spec_audit_extra_header_non_ascii_encoded() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "a@b.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "c@d.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Test".into(),
body_text: Some("Body".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![(hdr("X-Custom"), "Héllo Wörld".into())],
};
let built = build_message(&email).unwrap();
let raw_str = String::from_utf8_lossy(&built.raw);
let header_section = raw_str.split("\r\n\r\n").next().unwrap();
assert!(
header_section.is_ascii(),
"All header field bodies must be US-ASCII per RFC 5322 Section 2.2; \
non-ASCII extra header value was not RFC 2047 encoded"
);
}
#[test]
fn format_address_whitespace_only_name_treated_as_absent() {
let addr = Address {
name: Some(" ".to_string()),
email: "test@example.com".into(),
};
assert_eq!(format_address(&addr), "test@example.com");
}
#[test]
fn sanitize_header_value_replaces_crlf_with_space() {
assert_eq!(sanitize_header_value("Line1\nLine2"), "Line1 Line2");
assert_eq!(sanitize_header_value("Line1\rLine2"), "Line1 Line2");
assert_eq!(sanitize_header_value("Line1\r\nLine2"), "Line1 Line2");
assert_eq!(sanitize_header_value("A\n\n\nB"), "A B");
assert_eq!(sanitize_header_value("A\r\n \r\nB"), "A B");
assert_eq!(sanitize_header_value("Hello World"), "Hello World");
}
#[test]
fn sanitize_header_value_preserves_tabs_strips_crlf() {
assert_eq!(
sanitize_header_value("Hello\t\t\tWorld"),
"Hello\t\t\tWorld"
);
assert_eq!(sanitize_header_value("A\r\n\t\tB"), "A \t\tB");
assert_eq!(sanitize_header_value("A\t \t B"), "A\t \t B");
}
#[test]
fn sanitize_header_value_strips_control_characters() {
assert_eq!(sanitize_header_value("Hello\x00World"), "Hello World");
assert_eq!(sanitize_header_value("A\x01B\x7FC"), "A B C");
assert_eq!(sanitize_header_value("A\x0B\x0CB"), "A B");
assert_eq!(sanitize_header_value("Café"), "Café");
assert_eq!(sanitize_header_value("X\x02\x03\x04Y"), "X Y");
assert_eq!(sanitize_header_value("A\x7FB"), "A B");
}
#[test]
fn sanitize_header_preserves_htab() {
assert_eq!(sanitize_header_value("Hello\tWorld"), "Hello\tWorld");
assert_eq!(sanitize_header_value("\tIndented"), "\tIndented");
assert_eq!(sanitize_header_value("A \tB"), "A \tB");
}
#[test]
fn attachment_content_type_includes_name_parameter() {
let mut email = make_email();
email.body_text = Some("text".into());
email.attachments.push(OutgoingAttachment {
filename: "report.pdf".into(),
content_type: "application/pdf".into(),
data: vec![0x25, 0x50, 0x44, 0x46], is_inline: false,
content_id: None,
});
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Content-Type: application/pdf; name=\"report.pdf\""),
"Content-Type header must include name parameter for attachments \
(RFC 2045 Section 5). Got:\n{s}"
);
}
#[test]
fn validate_address_accepts_quoted_local_part() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "\"user name\"@example.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "recipient@example.com".into(),
}],
cc: Vec::new(),
bcc: Vec::new(),
reply_to: Vec::new(),
date: None,
subject: "test".into(),
body_text: Some("test".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: Vec::new(),
extra_headers: Vec::new(),
};
assert!(
build_message(&email).is_ok(),
"address with quoted local part must be accepted (RFC 5322 Section 3.4.1)"
);
}
#[test]
fn validate_address_accepts_quoted_local_part_with_specials() {
let addr = Address {
name: None,
email: "\"john..doe\"@example.com".into(),
};
assert!(
validate_address(&addr).is_ok(),
"quoted local part with consecutive dots must be accepted (RFC 5322 Section 3.4.1)"
);
}
#[test]
fn validate_address_accepts_rfc5322_domain_literal() {
let addr: Address = "user@[10,0,0,1]"
.parse()
.expect("RFC 5322 domain-literal must parse");
let email = OutgoingEmail {
from: vec![addr],
sender: None,
to: vec![Address {
name: None,
email: "recipient@example.com".into(),
}],
cc: Vec::new(),
bcc: Vec::new(),
reply_to: Vec::new(),
date: None,
subject: "test".into(),
body_text: Some("test".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: Vec::new(),
extra_headers: Vec::new(),
};
assert!(
build_message(&email).is_ok(),
"message builder must accept RFC 5322 domain-literals in header addresses"
);
}
#[test]
fn validate_address_accepts_utf8_domain_literal() {
let addr: Address = "user@[例え]"
.parse()
.expect("RFC 6532 UTF-8 domain-literal must parse");
let email = OutgoingEmail {
from: vec![addr],
sender: None,
to: vec![Address {
name: None,
email: "recipient@example.com".into(),
}],
cc: Vec::new(),
bcc: Vec::new(),
reply_to: Vec::new(),
date: None,
subject: "test".into(),
body_text: Some("test".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: Vec::new(),
extra_headers: Vec::new(),
};
assert!(
build_message(&email).is_ok(),
"message builder must accept UTF-8 domain-literals per RFC 6532 Section 3.2"
);
}
#[test]
fn validate_address_rejects_unquoted_local_with_whitespace() {
let addr = Address {
name: None,
email: "user name@example.com".into(),
};
assert!(
validate_address(&addr).is_err(),
"unquoted local part with whitespace must be rejected"
);
}
#[test]
fn build_non_ascii_filename_content_type_name_is_ascii() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "résumé.pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for line in s.split("\r\n") {
if line.starts_with("Content-Type:") && line.contains("name=") {
assert!(
line.is_ascii(),
"Content-Type name parameter must be ASCII per RFC 5322 Section 2.2, \
but found non-ASCII: {line}"
);
}
}
}
#[test]
fn build_non_ascii_filename_content_type_has_name_star() {
let mut email = make_email();
email.body_text = Some("Body".into());
email.attachments = vec![OutgoingAttachment {
filename: "résumé.pdf".into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let unfolded = s.replace("\r\n ", " ").replace("\r\n\t", "\t");
let ct_line = unfolded
.lines()
.find(|l| l.starts_with("Content-Type: application/pdf"))
.expect("Should have Content-Type for PDF attachment");
assert!(
ct_line.contains("name*=UTF-8''"),
"RFC 2231 Section 4: Content-Type must include name* for non-ASCII \
filenames, got: {ct_line}"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some("résumé.pdf"),
"Non-ASCII filename must roundtrip via Content-Type name*"
);
}
#[test]
fn build_long_non_ascii_filename_content_type_has_name_star_continuation() {
let mut email = make_email();
email.body_text = Some("Body".into());
let filename = "これはとても長いファイル名です_日本語のテスト_2024年度報告書_最終版.pdf";
email.attachments = vec![OutgoingAttachment {
filename: filename.into(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let unfolded = s.replace("\r\n ", " ").replace("\r\n\t", "\t");
let ct_line = unfolded
.lines()
.find(|l| l.starts_with("Content-Type: application/pdf"))
.expect("Should have Content-Type for PDF attachment");
assert!(
ct_line.contains("name*=UTF-8''") || ct_line.contains("name*0*=UTF-8''"),
"RFC 2231 Section 3-4: Content-Type must include name* or name*0* \
for non-ASCII filenames, got: {ct_line}"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some(filename),
"Long non-ASCII filename must roundtrip"
);
}
#[test]
fn encode_rfc2047_four_byte_boundary_length() {
let max_expected_word_len = 68;
for ascii_prefix_len in 0..48 {
let mut input = "x".repeat(ascii_prefix_len);
for _ in 0..30 {
input.push('\u{1F30D}');
}
let encoded = encode_rfc2047_if_needed(&input);
for word in encoded.split(' ') {
if word.starts_with("=?") {
assert!(
word.len() <= max_expected_word_len,
"RFC 2047 Section 2: encoded word is {} chars (max {max_expected_word_len}) \
with ascii_prefix_len={ascii_prefix_len}: {word}",
word.len()
);
}
}
}
}
#[test]
fn extra_headers_rejects_standard_header_names() {
let standard_names = [
"From",
"To",
"Cc",
"Bcc",
"Reply-To",
"Subject",
"Date",
"Message-ID",
"MIME-Version",
"Content-Type",
"Content-Transfer-Encoding",
"In-Reply-To",
"References",
];
for name in &standard_names {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr(name), "duplicate".into())];
let result = build_message(&email);
assert!(
result.is_err(),
"extra_headers should reject standard header '{name}' (RFC 5322 Section 3.6)"
);
}
}
#[test]
fn extra_headers_rejects_standard_names_case_insensitive() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("from"), "dup".into())];
let result = build_message(&email);
assert!(
result.is_err(),
"extra_headers should reject 'from' case-insensitively (RFC 5322 Section 3.6)"
);
}
#[test]
fn extra_headers_accepts_custom_headers() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("X-Custom"), "value".into())];
let result = build_message(&email);
assert!(result.is_ok(), "custom headers should be accepted");
let s = raw_str(&result.unwrap());
assert!(s.contains("X-Custom: value"));
}
#[test]
fn content_id_crlf_injection_rejected() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "a@b.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "c@d.com".into(),
}],
subject: "test".into(),
body_html: Some("<img src=\"cid:img@ex.com\">".into()),
body_text: None,
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
in_reply_to: vec![],
references: vec![],
extra_headers: vec![],
attachments: vec![OutgoingAttachment {
filename: "logo.png".into(),
content_type: "image/png".into(),
data: b"PNG".to_vec(),
is_inline: true,
content_id: Some("img@ex.com>\r\nBcc: attacker@evil.com".into()),
}],
};
let err = build_message(&email).unwrap_err();
assert!(
err.to_string().contains("Content-ID"),
"invalid Content-ID must be rejected with a Content-ID-specific error: {err}"
);
}
#[test]
fn multipart_related_includes_type_parameter() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "a@b.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "c@d.com".into(),
}],
subject: "test".into(),
body_html: Some("<img src=\"cid:logo@ex.com\">".into()),
body_text: None,
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
in_reply_to: vec![],
references: vec![],
extra_headers: vec![],
attachments: vec![OutgoingAttachment {
filename: "logo.png".into(),
content_type: "image/png".into(),
data: b"PNG".to_vec(),
is_inline: true,
content_id: Some("logo@ex.com".into()),
}],
};
let built = build_message(&email).unwrap();
let s = String::from_utf8_lossy(&built.raw);
assert!(
s.contains("type=\"text/html\"") || s.contains("type=\"multipart/alternative\""),
"multipart/related must include type parameter per RFC 2387 Section 3.1: {s}"
);
}
#[test]
fn in_reply_to_multiple_message_ids() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "a@b.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "c@d.com".into(),
}],
subject: "test".into(),
body_text: Some("body".into()),
body_html: None,
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
in_reply_to: vec![mid("id1@host.com"), mid("id2@host.com")],
references: vec![],
extra_headers: vec![],
attachments: vec![],
};
let built = build_message(&email).unwrap();
let s = String::from_utf8_lossy(&built.raw);
assert!(
s.contains("<id1@host.com> <id2@host.com>"),
"In-Reply-To must support multiple msg-ids per RFC 5322 Section 3.6.4: {s}"
);
}
#[test]
fn in_reply_to_quoted_id_left_skipped() {
let mut email = make_email();
email.body_text = Some("text".into());
email.in_reply_to = vec![mid("\"user@inner\"@example.com")];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("In-Reply-To:"),
"quoted obsolete msg-id must be dropped from In-Reply-To per RFC 5322 Section 3.6.4: {s}"
);
}
#[test]
fn spec_audit_in_reply_to_valid_msgid() {
let mut email = make_email();
email.body_text = Some("text".into());
email.in_reply_to = vec![mid("abc@example.com")];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("In-Reply-To: <abc@example.com>"),
"Valid message-id must be emitted in angle brackets: {s}"
);
}
#[test]
fn spec_audit_in_reply_to_invalid_msgid_skipped() {
let mut email = make_email();
email.body_text = Some("text".into());
email.in_reply_to = vec![mid("invalid-no-at")];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("In-Reply-To:"),
"In-Reply-To with no valid msg-ids must not be emitted: {s}"
);
}
#[test]
fn spec_audit_references_mixed_valid_invalid() {
let mut email = make_email();
email.body_text = Some("text".into());
email.references = vec![
mid("good@example.com"),
mid("invalid-no-at"),
mid("also-bad"),
mid("better@host.org"),
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("<good@example.com>"),
"Valid msg-id must be emitted: {s}"
);
assert!(
s.contains("<better@host.org>"),
"Valid msg-id must be emitted: {s}"
);
assert!(
!s.contains("invalid-no-at"),
"Invalid token without @ must be skipped: {s}"
);
assert!(
!s.contains("also-bad"),
"Invalid token without @ must be skipped: {s}"
);
}
#[test]
fn spec_audit_in_reply_to_all_invalid_omits_header() {
let mut email = make_email();
email.body_text = Some("text".into());
email.in_reply_to = vec![mid("no-at-one"), mid("no-at-two")];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("In-Reply-To:"),
"In-Reply-To must be omitted when all tokens are invalid: {s}"
);
}
#[test]
fn spec_audit_msgid_empty_id_left_or_right_skipped() {
let mut email = make_email();
email.body_text = Some("text".into());
email.in_reply_to = vec![mid("foo@")];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("In-Reply-To:"),
"msg-id with empty id-right must be skipped \
(RFC 5322 Section 3.6.4): {s}"
);
let mut email2 = make_email();
email2.body_text = Some("text".into());
email2.references = vec![mid("@bar.com")];
let built2 = build_message(&email2).unwrap();
let s2 = raw_str(&built2);
assert!(
!s2.contains("References:"),
"msg-id with empty id-left must be skipped \
(RFC 5322 Section 3.6.4): {s2}"
);
}
#[test]
fn spec_audit_message_rfc822_attachment_uses_7bit_encoding() {
let rfc822_body = b"From: nested@example.com\r\nTo: other@example.com\r\nSubject: inner\r\n\r\nInner body\r\n";
let mut email = make_email();
email.body_text = Some("outer body".into());
email.attachments = vec![OutgoingAttachment {
filename: "forwarded.eml".into(),
content_type: "message/rfc822".into(),
data: rfc822_body.to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("Content-Transfer-Encoding: base64"),
"message/rfc822 MUST NOT use base64 per RFC 2046 Section 5.2.1: {s}"
);
assert!(
s.contains("Content-Transfer-Encoding: 7bit"),
"message/rfc822 with ASCII data must use 7bit encoding: {s}"
);
assert!(
s.contains("From: nested@example.com"),
"message/rfc822 data must be written verbatim: {s}"
);
assert!(
s.contains("Inner body"),
"message/rfc822 body must be written verbatim: {s}"
);
}
#[test]
fn spec_audit_message_rfc822_non_ascii_uses_8bit_encoding() {
let rfc822_body = "From: nested@example.com\r\nTo: other@example.com\r\nSubject: =?UTF-8?B?w7xiZXI=?=\r\n\r\nK\u{f6}rper\r\n";
let mut email = make_email();
email.body_text = Some("outer body".into());
email.attachments = vec![OutgoingAttachment {
filename: "forwarded.eml".into(),
content_type: "message/rfc822".into(),
data: rfc822_body.as_bytes().to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("Content-Transfer-Encoding: base64"),
"message/rfc822 MUST NOT use base64 per RFC 2046 Section 5.2.1: {s}"
);
assert!(
s.contains("Content-Transfer-Encoding: 8bit"),
"message/rfc822 with non-ASCII data must use 8bit encoding: {s}"
);
assert!(
s.contains("K\u{f6}rper"),
"message/rfc822 data must be written verbatim: {s}"
);
}
#[test]
fn spec_audit_message_rfc822_rejects_utf8_headers() {
let rfc822_body = "From: nested@example.com\r\nSubject: caf\u{00E9}\r\n\r\nASCII body\r\n";
let mut email = make_email();
email.body_text = Some("outer body".into());
email.attachments = vec![OutgoingAttachment {
filename: "forwarded.eml".into(),
content_type: "message/rfc822".into(),
data: rfc822_body.as_bytes().to_vec(),
is_inline: false,
content_id: None,
}];
let err =
build_message(&email).expect_err("message/rfc822 with raw UTF-8 headers must be rejected");
match err {
Error::InvalidAttachment(msg) => {
assert!(
msg.contains("message/global"),
"error should direct callers to message/global, got: {msg}"
);
}
other => panic!("expected InvalidAttachment error, got {other:?}"),
}
}
#[test]
fn spec_audit_message_rfc822_requires_from_subject_or_date() {
let rfc822_body = b"To: other@example.com\r\n\r\nInner body\r\n";
let mut email = make_email();
email.body_text = Some("outer body".into());
email.attachments = vec![OutgoingAttachment {
filename: "forwarded.eml".into(),
content_type: "message/rfc822".into(),
data: rfc822_body.to_vec(),
is_inline: false,
content_id: None,
}];
let err = build_message(&email).expect_err(
"message/rfc822 without From, Subject, or Date must be rejected per RFC 2046 Section 5.2.1",
);
match err {
Error::InvalidAttachment(msg) => {
assert!(
msg.contains("From") && msg.contains("Subject") && msg.contains("Date"),
"error should mention the required header set, got: {msg}"
);
}
other => panic!("expected InvalidAttachment error, got {other:?}"),
}
}
#[test]
fn spec_audit_message_rfc822_rejects_bare_lf_body() {
let mut email = make_email();
email.body_text = Some("outer body".into());
email.attachments = vec![OutgoingAttachment {
filename: "forwarded.eml".into(),
content_type: "message/rfc822".into(),
data: b"From: nested@example.com\nSubject: inner\n\nInner body\n".to_vec(),
is_inline: false,
content_id: None,
}];
let err = build_message(&email)
.expect_err("message/rfc822 with bare LF must be rejected before emitting 7bit/8bit");
match err {
Error::InvalidAttachment(msg) => {
assert!(
msg.contains("bare LF"),
"error should explain the CRLF violation, got: {msg}"
);
}
other => panic!("expected InvalidAttachment error, got {other:?}"),
}
}
#[test]
fn spec_audit_message_rfc822_rejects_nul_bytes() {
let mut email = make_email();
email.body_text = Some("outer body".into());
email.attachments = vec![OutgoingAttachment {
filename: "forwarded.eml".into(),
content_type: "message/rfc822".into(),
data: b"From: nested@example.com\r\nSubject: inner\r\n\r\nbody\x00\r\n".to_vec(),
is_inline: false,
content_id: None,
}];
let err = build_message(&email)
.expect_err("message/rfc822 with NUL bytes must be rejected before emitting 8bit");
match err {
Error::InvalidAttachment(msg) => {
assert!(
msg.contains("NUL"),
"error should explain the NUL violation, got: {msg}"
);
}
other => panic!("expected InvalidAttachment error, got {other:?}"),
}
}
#[test]
fn spec_audit_message_partial_rejects_non_ascii_bytes() {
let mut email = make_email();
email.body_text = Some("outer body".into());
email.attachments = vec![OutgoingAttachment {
filename: "fragment.eml".into(),
content_type: "message/partial; id=\"frag-1\"; number=1; total=2".into(),
data: b"From: nested@example.com\r\nSubject: inner\r\n\r\nK\xc3\xb6rper\r\n".to_vec(),
is_inline: false,
content_id: None,
}];
let err = build_message(&email).expect_err(
"message/partial must reject non-ASCII bytes because RFC 2046 Section 5.2.2 requires 7bit only",
);
match err {
Error::InvalidAttachment(msg) => {
assert!(
msg.contains("7bit"),
"error should explain the 7bit-only rule, got: {msg}"
);
}
other => panic!("expected InvalidAttachment error, got {other:?}"),
}
}
#[test]
fn spec_audit_message_external_body_rejects_non_ascii_bytes() {
let mut email = make_email();
email.body_text = Some("outer body".into());
email.attachments = vec![OutgoingAttachment {
filename: "external-body.eml".into(),
content_type: "message/external-body; access-type=URL; URL=\"https://example.test/ref\""
.into(),
data: b"Content-Type: text/plain; charset=UTF-8\r\n\r\nK\xc3\xb6rper\r\n".to_vec(),
is_inline: false,
content_id: None,
}];
let err = build_message(&email).expect_err(
"message/external-body must reject non-ASCII bytes because RFC 2046 Section 5.2.3 requires 7bit only",
);
match err {
Error::InvalidAttachment(msg) => {
assert!(
msg.contains("7bit"),
"error should explain the 7bit-only rule, got: {msg}"
);
}
other => panic!("expected InvalidAttachment error, got {other:?}"),
}
}
#[test]
fn spec_audit_message_type_case_insensitive() {
let rfc822_body = b"From: a@b.com\r\nSubject: test\r\n\r\nbody\r\n";
let mut email = make_email();
email.body_text = Some("outer".into());
email.attachments = vec![OutgoingAttachment {
filename: "msg.eml".into(),
content_type: "Message/RFC822".into(),
data: rfc822_body.to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("Content-Transfer-Encoding: base64"),
"Message/RFC822 (mixed case) MUST NOT use base64: {s}"
);
}
#[test]
fn test_validate_address_rejects_consecutive_dots_in_domain() {
let cases = [
"user@exam..ple.com",
"user@..example.com",
"user@example..com",
"user@a..b..c.com",
];
for email in &cases {
let addr = Address {
name: None,
email: (*email).to_string(),
};
let result = validate_address(&addr);
assert!(
result.is_err(),
"expected rejection for consecutive dots in domain: {email}"
);
}
}
#[test]
fn test_validate_address_rejects_overlong_domain_label() {
let overlong = format!("user@{}.example", "a".repeat(64));
let addr = Address {
name: None,
email: overlong,
};
let err = validate_address(&addr).expect_err(
"domain labels longer than 63 octets must be rejected during builder validation",
);
match err {
Error::InvalidAddress(msg) => assert!(
msg.contains("63-octet") || msg.contains("63 octet"),
"error should explain the 63-octet domain label limit, got: {msg}"
),
other => panic!("expected InvalidAddress for overlong label, got {other:?}"),
}
}
#[test]
fn test_validate_address_rejects_consecutive_dots_in_local_part() {
let cases = vec![
"us..er@example.com",
"user..@example.com",
"..user@example.com",
];
for email in &cases {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_err(),
"expected rejection for unquoted local-part with consecutive dots: {email}"
);
}
let quoted_addr = Address {
name: None,
email: "\"us..er\"@example.com".to_string(),
};
assert!(
validate_address("ed_addr).is_ok(),
"quoted local-part with consecutive dots should be accepted"
);
}
#[test]
fn validate_address_rejects_malformed_quoted_local() {
let addr = Address {
name: None,
email: "\"a\"b\"@example.com".into(),
};
assert!(
validate_address(&addr).is_err(),
"quoted local-part with unescaped inner double-quote must be rejected \
(RFC 5322 Section 3.2.4)"
);
}
#[test]
fn validate_address_accepts_properly_escaped_quoted_local() {
let addr = Address {
name: None,
email: r#""a\"b"@example.com"#.into(),
};
assert!(
validate_address(&addr).is_ok(),
"quoted local-part with properly escaped inner quote must be accepted \
(RFC 5322 Section 3.2.4)"
);
}
#[test]
fn validate_address_rejects_trailing_backslash_in_quoted_local() {
let addr = Address {
name: None,
email: "\"abc\\\"@example.com".into(),
};
assert!(
validate_address(&addr).is_err(),
"quoted local-part with backslash escaping the closing quote must be rejected \
(RFC 5322 Section 3.2.4)"
);
}
#[test]
fn edge_address_all_whitespace_display_name() {
let addr = Address {
name: Some(" ".to_string()),
email: "a@b.com".to_string(),
};
let formatted = format!("{addr}");
assert_eq!(
formatted, "a@b.com",
"All-whitespace display name must be treated as absent \
per RFC 5322 Section 3.4"
);
}
#[test]
fn edge_subject_crlf_injection_sanitized() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "a@b.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "c@d.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Hello\r\nBcc: evil@hacker.com".into(),
body_text: Some("test".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let raw = String::from_utf8_lossy(&built.raw);
assert!(
!raw.contains("\r\nBcc:"),
"CR/LF in subject must be sanitized to prevent header injection \
(RFC 5322 Section 2.1). Got:\n{raw}"
);
}
#[test]
fn edge_extra_header_crlf_injection() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "a@b.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "c@d.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Normal".into(),
body_text: Some("test".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![(hdr("X-Custom"), "value\r\nBcc: evil@hacker.com".into())],
};
let built = build_message(&email).unwrap();
let raw = String::from_utf8_lossy(&built.raw);
assert!(
!raw.contains("\r\nBcc:"),
"CR/LF in extra_headers must be sanitized (RFC 5322 Section 2.1). \
Got:\n{raw}"
);
}
#[test]
fn edge_address_tab_only_display_name() {
let addr = Address {
name: Some("\t".to_string()),
email: "a@b.com".to_string(),
};
let formatted = format!("{addr}");
assert_eq!(
formatted, "a@b.com",
"Tab-only display name must be treated as absent"
);
}
#[test]
fn edge_display_name_crlf_injection() {
let email = OutgoingEmail {
from: vec![Address {
name: Some("Alice\r\nBcc: evil@hacker.com".into()),
email: "a@b.com".into(),
}],
sender: None,
to: vec![Address {
name: None,
email: "c@d.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Test".into(),
body_text: Some("test".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let raw = String::from_utf8_lossy(&built.raw);
assert!(
!raw.contains("\r\nBcc:"),
"CR/LF in display name must be sanitized (RFC 5322 Section 2.1). \
Got:\n{raw}"
);
}
#[test]
fn validate_address_rejects_control_chars_in_quoted_local() {
let addr = Address {
name: None,
email: "\"\x00user\"@example.com".to_string(),
};
assert!(
validate_address(&addr).is_err(),
"quoted local-part with NUL (0x00) must be rejected \
(RFC 5322 Section 3.2.4: qtext does not include control characters)"
);
let addr = Address {
name: None,
email: "\"\x07bell\"@example.com".to_string(),
};
assert!(
validate_address(&addr).is_err(),
"quoted local-part with BEL (0x07) must be rejected \
(RFC 5322 Section 3.2.4)"
);
let addr = Address {
name: None,
email: "\"line\nfeed\"@example.com".into(),
};
assert!(
validate_address(&addr).is_err(),
"quoted local-part with LF (0x0A) must be rejected \
(RFC 5322 Section 3.2.4)"
);
let addr = Address {
name: None,
email: "\"del\x7F\"@example.com".to_string(),
};
assert!(
validate_address(&addr).is_err(),
"quoted local-part with DEL (0x7F) must be rejected \
(RFC 5322 Section 3.2.4)"
);
}
#[test]
fn validate_address_rejects_non_atext_in_unquoted_local() {
let specials = vec![
("user(comment)@example.com", '('),
("user)bad@example.com", ')'),
("user<admin>@example.com", '<'),
("user>test@example.com", '>'),
("user,name@example.com", ','),
("user;name@example.com", ';'),
("user:name@example.com", ':'),
("user\\name@example.com", '\\'),
("user\"name@example.com", '"'),
("user[name@example.com", '['),
("user]name@example.com", ']'),
("user name@example.com", ' '),
];
for (email, ch) in &specials {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_err(),
"unquoted local-part with '{ch}' must be rejected \
per RFC 5322 Section 3.2.3 (atext): {email}"
);
}
}
#[test]
fn validate_address_accepts_valid_atext_local() {
let cases = vec![
"user@example.com",
"user.name@example.com",
"user+tag@example.com",
"user-name@example.com",
"user_name@example.com",
"user!name@example.com",
"user#name@example.com",
"user$name@example.com",
"user%name@example.com",
"user&name@example.com",
"user'name@example.com",
"user*name@example.com",
"user/name@example.com",
"user=name@example.com",
"user?name@example.com",
"user^name@example.com",
"user`name@example.com",
"user{name@example.com",
"user|name@example.com",
"user}name@example.com",
"user~name@example.com",
];
for email in &cases {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_ok(),
"valid atext local-part must be accepted: {email}"
);
}
}
#[test]
fn validate_address_accepts_address_literals() {
let cases = vec![
"user@[127.0.0.1]",
"user@[192.168.1.1]",
"user@[IPv6:::1]",
"user@[IPv6:2001:db8::1]",
];
for email in &cases {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_ok(),
"RFC 5321-style address-literal must be accepted as a valid \
RFC 5322 domain-literal: {email}"
);
}
}
#[test]
fn validate_address_rejects_invalid_address_literals() {
let cases = vec![
"user@[127.0.0.1",
"user@[127.0.0.1]]",
"user@[bad literal]",
"user@[bad\\literal]",
"user@[tag:bad value]",
];
for email in &cases {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_err(),
"invalid RFC 5322 domain-literal syntax must be rejected: {email}"
);
}
}
#[test]
fn force_fold_preserves_word_integrity_with_multibyte_utf8() {
let word = "\u{1F389}".to_string() + &"a".repeat(100);
let bytes = word.as_bytes();
let mut output = Vec::new();
output.extend_from_slice(&vec![b'X'; 995]);
let final_line_len = force_fold_word(&mut output, bytes, 995);
let result = String::from_utf8(output).expect("output should be valid UTF-8");
let lines: Vec<&str> = result.split("\r\n").collect();
assert_eq!(lines.len(), 2, "must fold once: before the word");
assert_eq!(lines[0].len(), 995, "first line retains original content");
assert_eq!(
&lines[1][1..],
word,
"word must appear intact without internal folds"
);
assert_eq!(
final_line_len,
1 + bytes.len(),
"line_len must track the new line correctly"
);
}
#[test]
fn validate_address_rejects_invalid_domain_characters() {
let invalid_domains = vec![
"user@exam(ple.com",
"user@exam)ple.com",
"user@exam<ple.com",
"user@exam>ple.com",
"user@exam,ple.com",
"user@exam;ple.com",
"user@exam@ple.com", "user@exam\"ple.com",
"user@exam\\ple.com",
"user@exam ple.com", "user@exam\tple.com", ];
for email in &invalid_domains {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_err(),
"domain with invalid character must be rejected per RFC 5321 Section 4.1.2: {email}"
);
}
}
#[test]
fn validate_address_rejects_invalid_domain_characters_quoted_local() {
let invalid_domains = vec![
r#""user"@exam(ple.com"#,
r#""user"@exam)ple.com"#,
r#""user"@exam<ple.com"#,
r#""user"@exam>ple.com"#,
r#""user"@exam,ple.com"#,
r#""user"@exam;ple.com"#,
];
for email in &invalid_domains {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_err(),
"domain with invalid character must be rejected per RFC 5321 Section 4.1.2 \
(quoted local-part branch): {email}"
);
}
}
#[test]
fn validate_address_accepts_valid_domains() {
let valid_emails = vec![
"user@example.com",
"user@sub.example.com",
"user@example-host.com",
"user@a.b.c.d",
"user@host123.example.org",
"user@123.456",
"usuario@domínio.com",
"user@münchen.de",
"user@例え.jp",
];
for email in &valid_emails {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_ok(),
"valid domain must be accepted per RFC 5321 Section 4.1.2: {email}"
);
}
}
#[test]
fn validate_address_accepts_domain_literals_after_fix() {
let valid_emails = vec![
"user@[127.0.0.1]",
"user@[192.168.1.1]",
"user@[IPv6:::1]",
"user@[IPv6:2001:db8::1]",
"user@[IPv6:not-an-ip]",
"user@[IPv6:]",
];
for email in &valid_emails {
let addr = Address {
name: None,
email: (*email).to_string(),
};
assert!(
validate_address(&addr).is_ok(),
"domain-literal must be accepted per RFC 5322 Section 3.4.1: {email}"
);
}
}
#[test]
fn build_multi_from_without_sender_returns_error() {
let email = OutgoingEmail {
from: vec![
Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
},
Address {
name: Some("Bob".into()),
email: "bob@example.com".into(),
},
],
sender: None,
to: vec![Address {
name: None,
email: "rcpt@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Multi from".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let result = build_message(&email);
assert!(
matches!(result, Err(Error::MissingSender)),
"multi-From without Sender must return MissingSender \
(RFC 5322 Section 3.6.2), got: {result:?}"
);
}
#[test]
fn build_multi_from_with_sender_emits_both_headers() {
let email = OutgoingEmail {
from: vec![
Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
},
Address {
name: Some("Bob".into()),
email: "bob@example.com".into(),
},
],
sender: Some(Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
}),
to: vec![Address {
name: None,
email: "rcpt@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Multi from with sender".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let raw = raw_str(&built);
assert!(
raw.contains("alice@example.com") && raw.contains("bob@example.com"),
"From header must contain both addresses: {raw}"
);
assert!(
raw.contains("Sender: Alice <alice@example.com>"),
"Sender header must be emitted for multi-From \
(RFC 5322 Section 3.6.2): {raw}"
);
}
#[test]
fn build_single_from_with_same_sender_omits_sender() {
let email = OutgoingEmail {
from: vec![Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
}],
sender: Some(Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
}),
to: vec![Address {
name: None,
email: "rcpt@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Single from same sender".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let raw = raw_str(&built);
assert!(
!raw.contains("\r\nSender:"),
"Sender header should be omitted when identical to single From \
(RFC 5322 Section 3.6.2): {raw}"
);
}
#[test]
fn build_single_from_with_same_addr_spec_but_different_sender_name_emits_sender() {
let email = OutgoingEmail {
from: vec![Address {
name: Some("Alice Example".into()),
email: "alice@example.com".into(),
}],
sender: Some(Address {
name: Some("Alice via Assistant".into()),
email: "alice@example.com".into(),
}),
to: vec![Address {
name: None,
email: "rcpt@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Single from sender display-name differs".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let raw = raw_str(&built);
assert!(
raw.contains("Sender: Alice via Assistant <alice@example.com>"),
"Sender header must be emitted when the mailbox specification differs \
even if the addr-spec matches (RFC 5322 Section 3.6.2): {raw}"
);
}
#[test]
fn build_single_from_with_different_sender_emits_sender() {
let email = OutgoingEmail {
from: vec![Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
}],
sender: Some(Address {
name: Some("Secretary".into()),
email: "secretary@example.com".into(),
}),
to: vec![Address {
name: None,
email: "rcpt@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Single from different sender".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let raw = raw_str(&built);
assert!(
raw.contains("Sender: Secretary <secretary@example.com>"),
"Sender header must be emitted when different from single From \
(RFC 5322 Section 3.6.2): {raw}"
);
}
#[test]
fn build_empty_from_returns_error() {
let email = OutgoingEmail {
from: vec![],
sender: None,
to: vec![Address {
name: None,
email: "rcpt@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "No from".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let result = build_message(&email);
assert!(
matches!(result, Err(Error::MissingFrom)),
"empty From must return MissingFrom (RFC 5322 Section 3.6.2), \
got: {result:?}"
);
}
#[test]
fn build_multi_from_round_trip() {
let email = OutgoingEmail {
from: vec![
Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
},
Address {
name: Some("Bob".into()),
email: "bob@example.com".into(),
},
],
sender: Some(Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
}),
to: vec![Address {
name: None,
email: "rcpt@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "Multi from round-trip".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.from.len(), 2, "parsed From must contain 2 addresses");
assert_eq!(parsed.from[0].email, "alice@example.com");
assert_eq!(parsed.from[1].email, "bob@example.com");
assert!(
parsed.sender.is_some(),
"parsed Sender must be present for multi-From"
);
assert_eq!(parsed.sender.as_ref().unwrap().email, "alice@example.com");
}
#[test]
fn build_invalid_sender_address_returns_error() {
let email = OutgoingEmail {
from: vec![
Address {
name: None,
email: "alice@example.com".into(),
},
Address {
name: None,
email: "bob@example.com".into(),
},
],
sender: Some(Address {
name: None,
email: "not-valid".into(),
}),
to: vec![Address {
name: None,
email: "rcpt@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "test".into(),
body_text: Some("Hello".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let result = build_message(&email);
assert!(
matches!(result, Err(Error::InvalidAddress(_))),
"invalid Sender address must return InvalidAddress, got: {result:?}"
);
}
#[test]
fn generate_boundary_not_in_never_returns_boundary_present_in_content() {
use std::fmt::Write;
let current = MSG_ID_COUNTER.load(Ordering::Relaxed);
let mut content = String::new();
for i in current..current + 200 {
let _ = writeln!(content, "----=_Part_fallback_{i:016x}");
}
let content_bytes = content.as_bytes();
let boundary = generate_boundary_not_in(content_bytes);
assert!(
!contains_boundary(content_bytes, &boundary),
"generate_boundary_not_in returned a boundary that appears in the content: {boundary}"
);
}
#[test]
fn extra_headers_rejects_sender_header() {
let mut email = make_email();
email.body_text = Some("body".into());
email.sender = Some(Address {
name: Some("Secretary".into()),
email: "secretary@example.com".into(),
});
email.extra_headers = vec![(hdr("Sender"), "evil@example.com".into())];
let result = build_message(&email);
assert!(
result.is_err(),
"extra_headers must reject 'Sender' because it is a standard header \
managed by the builder (RFC 5322 Section 3.6)"
);
}
#[test]
fn inline_attachment_without_html_not_dropped() {
let mut email = make_email();
email.body_text = Some("Plain text body".into());
email.body_html = None;
email.attachments = vec![OutgoingAttachment {
filename: "logo.png".into(),
content_type: "image/png".into(),
data: vec![0x89, 0x50, 0x4E, 0x47], is_inline: true,
content_id: Some("logo001@example.com".into()),
}];
let built = build_message(&email).unwrap();
let raw = raw_str(&built);
assert!(
raw.contains("logo.png"),
"inline attachment filename must appear in the message even without HTML body"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.attachments.len(),
1,
"parsed message must contain the inline attachment"
);
assert_eq!(parsed.attachments[0].filename.as_deref(), Some("logo.png"));
}
#[test]
fn rfc2047_subject_no_spurious_leading_space_after_fold() {
let mut email = make_email();
let subject = "\u{4F60}\u{597D}\u{4E16}\u{754C}\u{4F60}\u{597D}\
\u{4E16}\u{754C}\u{4F60}\u{597D}\u{4E16}\u{754C}\
\u{4F60}\u{597D}\u{4E16}\u{754C}\u{4F60}\u{597D}\
\u{4E16}\u{754C}";
email.subject = subject.to_string();
email.body_text = Some("body".into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
let parsed_subject = parsed.subject.unwrap();
assert!(
!parsed_subject.starts_with(' '),
"parsed subject must not have a leading space; got: {parsed_subject:?}"
);
assert_eq!(
parsed_subject, subject,
"round-tripped subject must match original"
);
}
#[test]
fn build_long_non_ascii_filename_lines_within_limit() {
let mut email = make_email();
email.body_text = Some("Body".into());
let long_name: String = "日".repeat(400);
let filename = format!("{long_name}.txt");
email.attachments = vec![OutgoingAttachment {
filename: filename.clone(),
content_type: "application/octet-stream".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).expect("build must succeed");
let raw = raw_str(&built);
for (i, line) in raw.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"line {} is {} chars (max 998): {:?}",
i + 1,
line.len(),
&line[..line.len().min(120)],
);
}
let parsed = crate::parse_email(&built.raw).expect("parse must succeed");
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some(filename.as_str()),
"Long non-ASCII filename must round-trip through RFC 2231 \
continuation parameters"
);
}
#[test]
fn build_long_non_ascii_filename_uses_continuation() {
let mut email = make_email();
email.body_text = Some("Body".into());
let long_name: String = "é".repeat(200);
let filename = format!("{long_name}.pdf");
email.attachments = vec![OutgoingAttachment {
filename: filename.clone(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).expect("build must succeed");
let raw = raw_str(&built);
assert!(
raw.contains("filename*0*=UTF-8''"),
"Long filename must use RFC 2231 continuation (filename*0*=), got: \
{raw}",
);
let parsed = crate::parse_email(&built.raw).expect("parse must succeed");
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some(filename.as_str()),
);
}
#[test]
fn build_medium_long_ascii_filename_uses_rfc2231_in_content_disposition() {
let mut email = make_email();
email.body_text = Some("Body".into());
let filename = format!("{}.txt", "a".repeat(90));
email.attachments = vec![OutgoingAttachment {
filename: filename.clone(),
content_type: "application/octet-stream".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).expect("build must succeed");
let raw = raw_str(&built);
assert!(
raw.contains("Content-Disposition: attachment;\r\n filename*0*=UTF-8''")
|| raw.contains("Content-Disposition: attachment; filename*0*=UTF-8''"),
"long ASCII Content-Disposition filename must use RFC 2231 continuation: {raw}"
);
assert!(
!raw.contains(&format!(
"Content-Disposition: attachment; filename=\"{filename}\""
)),
"long ASCII Content-Disposition filename must not use plain quoted-string: {raw}"
);
let parsed = crate::parse_email(&built.raw).expect("parse must succeed");
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some(filename.as_str())
);
}
#[test]
fn build_medium_long_ascii_filename_uses_rfc2231_in_content_type_name() {
let mut email = make_email();
email.body_text = Some("Body".into());
let filename = format!("{}.pdf", "b".repeat(90));
email.attachments = vec![OutgoingAttachment {
filename: filename.clone(),
content_type: "application/pdf".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).expect("build must succeed");
let raw = raw_str(&built);
assert!(
raw.contains("Content-Type: application/pdf;\r\n name*0*=UTF-8''")
|| raw.contains("Content-Type: application/pdf; name*0*=UTF-8''"),
"long ASCII Content-Type name parameter must use RFC 2231 continuation: {raw}"
);
assert!(
!raw.contains(&format!(
"Content-Type: application/pdf; name=\"{filename}\""
)),
"long ASCII Content-Type name parameter must not use plain quoted-string: {raw}"
);
}
#[test]
fn build_long_ascii_filename_lines_within_limit() {
let mut email = make_email();
email.body_text = Some("Body".into());
let long_name = "a".repeat(1200);
let filename = format!("{long_name}.txt");
email.attachments = vec![OutgoingAttachment {
filename: filename.clone(),
content_type: "application/octet-stream".into(),
data: b"data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).expect("build must succeed");
let raw = raw_str(&built);
for (i, line) in raw.split("\r\n").enumerate() {
assert!(
line.len() <= 998,
"line {} is {} chars (max 998): {:?}",
i + 1,
line.len(),
&line[..line.len().min(120)],
);
}
let parsed = crate::parse_email(&built.raw).expect("parse must succeed");
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some(filename.as_str()),
"Long ASCII filename must round-trip through RFC 2231 \
continuation parameters"
);
}
#[test]
fn test_msg_id_validation_rejects_bad_chars() {
let mut email = make_email();
email.body_text = Some("test".into());
email.in_reply_to = vec![
mid("hello world@example.com"), mid("test@exam ple.com"), mid("test\x01@domain.com"), mid("a<b@domain.com"), mid("unique123@domain.com"), mid("abc.def@example.org"), ];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("<unique123@domain.com>"),
"valid msg-id must be present"
);
assert!(
s.contains("<abc.def@example.org>"),
"valid msg-id must be present"
);
assert!(
!s.contains("hello world@example.com"),
"msg-id with space in id-left must be rejected"
);
assert!(
!s.contains("exam ple.com"),
"msg-id with space in id-right must be rejected"
);
assert!(
!s.contains("test\x01@domain.com"),
"msg-id with control char must be rejected"
);
assert!(
!s.contains("a<b@domain.com"),
"msg-id with angle bracket must be rejected"
);
}
#[test]
fn write_header_no_false_positive_encoded_word_detection() {
let plain_value = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk =? lll ?= bbb pppp";
let mut output = Vec::new();
write_header(&mut output, "Subject", plain_value);
let header = String::from_utf8(output).unwrap();
let first_line = header.split("\r\n").next().unwrap();
assert!(
first_line.len() == 77,
"RFC 2047 Section 2: plain text with literal '=?' and '?=' must \
fold at 78, not 76. First line should be 77 chars but is {} chars: {:?}",
first_line.len(),
first_line,
);
let encoded_value =
"aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii =?UTF-8?B?dGVzdA==?= jj kkkk llll";
let mut output2 = Vec::new();
write_header(&mut output2, "Subject", encoded_value);
let header2 = String::from_utf8(output2).unwrap();
let first_line_enc = header2.split("\r\n").next().unwrap();
assert!(
first_line_enc.len() <= 76,
"RFC 2047 Section 2: lines with real encoded-words must fold at ≤76. \
First line is {} chars: {:?}",
first_line_enc.len(),
first_line_enc,
);
}
#[test]
fn generate_boundary_not_in_never_collides() {
let content = b"Here is some text with ----=_Part_fallback_0000000000000001 \
and ----=_Part_ mixed in for good measure";
let boundary = generate_boundary_not_in(content);
assert!(
!contains_boundary(content, &boundary),
"RFC 2046 Section 5.1.1: generated boundary must not appear in \
the content. Got: {boundary:?}"
);
}
#[test]
fn generate_boundary_not_in_avoids_existing_boundary() {
let first = generate_boundary_not_in(b"");
let content = format!("Body text contains {first} in the middle");
let second = generate_boundary_not_in(content.as_bytes());
assert_ne!(
first, second,
"must generate a different boundary when the first collides"
);
assert!(
!contains_boundary(content.as_bytes(), &second),
"RFC 2046 Section 5.1.1: regenerated boundary must not appear in content"
);
}
#[test]
fn msg_id_rejects_specials_rfc5322_section_3_6_4() {
assert!(
!is_valid_msg_id("user(comment)@example.com"),
"parentheses are specials"
);
assert!(
!is_valid_msg_id("user:name@example.com"),
"colon is a special"
);
assert!(
!is_valid_msg_id("user;name@example.com"),
"semicolon is a special"
);
assert!(
!is_valid_msg_id("user,name@example.com"),
"comma is a special"
);
assert!(
!is_valid_msg_id("user name@example.com"),
"space not allowed"
);
assert!(
!is_valid_msg_id("user\"name@example.com"),
"double-quote is a special"
);
assert!(
!is_valid_msg_id("user\\name@example.com"),
"backslash is a special"
);
assert!(!is_valid_msg_id(".user@example.com"), "leading dot");
assert!(
!is_valid_msg_id("user.@example.com"),
"trailing dot in id-left"
);
assert!(
!is_valid_msg_id("user@.example.com"),
"leading dot in id-right"
);
assert!(
!is_valid_msg_id("user@example.com."),
"trailing dot in id-right"
);
assert!(
!is_valid_msg_id("user..name@example.com"),
"consecutive dots"
);
assert!(
!is_valid_msg_id("user@example..com"),
"consecutive dots in id-right"
);
assert!(is_valid_msg_id("user@example.com"));
assert!(is_valid_msg_id("user.name@example.com"));
assert!(is_valid_msg_id("abc123+def@host.example.org"));
assert!(is_valid_msg_id("a!b#c$d%e&f@g.h"));
assert!(is_valid_msg_id("user@[127.0.0.1]"));
assert!(is_valid_msg_id("user@[IPv6:2001:db8::1]"));
}
#[test]
fn test_validate_address_allows_non_ascii_local_part_rfc6531() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "josé@example.com".into(),
}],
to: vec![Address {
name: None,
email: "user@example.com".into(),
}],
subject: "test".into(),
body_text: Some("hello".into()),
body_html: None,
attachments: vec![],
cc: vec![],
bcc: vec![],
reply_to: vec![],
sender: None,
date: None,
in_reply_to: vec![],
references: vec![],
extra_headers: vec![],
};
assert!(
build_message(&email).is_ok(),
"RFC 6531 Section 3.3: non-ASCII local-part should be accepted"
);
}
#[test]
fn test_format_address_quotes_encoded_word_lookalike() {
let addr = Address {
name: Some("=?UTF-8?B?SGVsbG8=?=".into()),
email: "user@example.com".into(),
};
let email = OutgoingEmail {
from: vec![addr],
to: vec![Address {
name: None,
email: "dest@example.com".into(),
}],
subject: "test".into(),
body_text: Some("hello".into()),
body_html: None,
attachments: vec![],
cc: vec![],
bcc: vec![],
reply_to: vec![],
sender: None,
date: None,
in_reply_to: vec![],
references: vec![],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.from[0].name.as_deref(),
Some("=?UTF-8?B?SGVsbG8=?="),
"display name containing encoded-word syntax must round-trip unchanged"
);
}
#[test]
fn test_non_ascii_message_id_in_reply_to_rfc6532() {
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: "user@example.com".into(),
}],
to: vec![Address {
name: None,
email: "dest@example.com".into(),
}],
subject: "test".into(),
body_text: Some("hello".into()),
body_html: None,
attachments: vec![],
cc: vec![],
bcc: vec![],
reply_to: vec![],
sender: None,
date: None,
in_reply_to: vec![mid("réponse@example.com")],
references: vec![mid("référence@example.com")],
extra_headers: vec![],
};
let built = build_message(&email).unwrap();
let raw = String::from_utf8_lossy(&built.raw);
assert!(
raw.contains("<réponse@example.com>"),
"In-Reply-To must include non-ASCII message-ID per RFC 6532 Section 3.5"
);
assert!(
raw.contains("<référence@example.com>"),
"References must include non-ASCII message-ID per RFC 6532 Section 3.5"
);
}
#[test]
fn subject_with_encoded_word_lookalike_round_trips() {
let mut email = make_email();
email.subject = "=?UTF-8?Q?test?= literal".into();
email.body_text = Some("body".into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some("=?UTF-8?Q?test?= literal"),
"Subject containing encoded-word syntax must round-trip unchanged \
(RFC 2047 Section 5)"
);
}
#[test]
fn extra_header_with_encoded_word_lookalike_round_trips() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("X-Custom"), "=?UTF-8?B?SGVsbG8=?= world".into())];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
let custom = parsed
.extra_headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("X-Custom"))
.map(|(_, v)| v.as_str());
assert_eq!(
custom,
Some("=?UTF-8?B?SGVsbG8=?= world"),
"Extra header containing encoded-word syntax must round-trip unchanged \
(RFC 2047 Section 5)"
);
}
#[test]
fn audit_rfc2231_round_trip_long_non_ascii_filename() {
let long_name = "файл_с_очень_длинным_именем_который_будет_закодирован_через_rfc2231.pdf";
let mut email = make_email();
email.body_text = Some("body".into());
email.attachments = vec![OutgoingAttachment {
filename: long_name.into(),
content_type: "application/pdf".into(),
data: b"fake pdf".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some(long_name),
"RFC 2231 filename round-trip failed"
);
}
#[test]
fn audit_rfc2047_round_trip_non_ascii_subject() {
let subject = "日本語のテスト件名: Ärger mit Ümlauten";
let mut email = make_email();
email.body_text = Some("body".into());
email.subject = subject.into();
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some(subject),
"RFC 2047 subject round-trip failed"
);
}
#[test]
fn audit_bcc_only_message_valid() {
let mut email = make_email();
email.to.clear();
email.bcc = vec![Address {
name: None,
email: "hidden@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = String::from_utf8_lossy(&built.raw);
assert!(!s.contains("Bcc:"), "BCC header must not appear in message");
assert!(
!s.contains("hidden@example.com"),
"BCC address must not appear in headers"
);
assert!(
built
.envelope_recipients
.contains(&"hidden@example.com".to_string()),
"BCC must be in envelope recipients"
);
}
#[test]
fn audit_cc_only_message_valid() {
let mut email = make_email();
email.to.clear();
email.cc = vec![Address {
name: None,
email: "cc@example.com".into(),
}];
let built = build_message(&email).unwrap();
let s = String::from_utf8_lossy(&built.raw);
assert!(s.contains("Cc: cc@example.com"), "Cc header missing");
assert!(
!s.contains("\r\nTo:"),
"To header should not appear when no To recipients"
);
}
#[test]
fn audit_seven_bit_encoding_ascii_uses_7bit() {
let mut email = make_email();
email.body_text = Some("Pure ASCII text here".into());
let built = build_message(&email).unwrap();
let s = String::from_utf8_lossy(&built.raw);
assert!(
s.contains("Content-Transfer-Encoding: 7bit"),
"Pure ASCII with SevenBit should use 7bit CTE, got: {s}"
);
}
#[test]
fn audit_seven_bit_encoding_non_ascii_uses_qp() {
let mut email = make_email();
email.body_text = Some("Héllo Wörld with non-ASCII".into());
let built = build_message(&email).unwrap();
let s = String::from_utf8_lossy(&built.raw);
assert!(
s.contains("Content-Transfer-Encoding: quoted-printable"),
"Non-ASCII with SevenBit should use quoted-printable CTE, got: {s}"
);
}
#[test]
fn audit_full_complex_round_trip() {
let mut email = make_email();
email.from = vec![Address {
name: Some("José García".into()),
email: "jose@example.com".into(),
}];
email.to = vec![Address {
name: Some("Müller".into()),
email: "muller@example.com".into(),
}];
email.subject = "Ärger mit Ümlauten — a test".into();
email.body_text = Some("Plain text body with UTF-8: café".into());
email.body_html = Some("<p>HTML body with UTF-8: café</p>".into());
email.in_reply_to = vec![mid("parent@example.com")];
email.references = vec![mid("root@example.com"), mid("parent@example.com")];
email.attachments = vec![OutgoingAttachment {
filename: "données.txt".into(),
content_type: "text/plain".into(),
data: b"file data".to_vec(),
is_inline: false,
content_id: None,
}];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some("Ärger mit Ümlauten — a test"),
"Subject round-trip failed"
);
assert_eq!(
parsed.body_text.as_deref(),
Some("Plain text body with UTF-8: café"),
"body_text round-trip failed"
);
assert_eq!(
parsed.body_html.as_deref(),
Some("<p>HTML body with UTF-8: café</p>"),
"body_html round-trip failed"
);
assert_eq!(parsed.from[0].email, "jose@example.com");
assert_eq!(
parsed.from[0].name.as_deref(),
Some("José García"),
"From display name round-trip failed"
);
assert_eq!(parsed.to[0].email, "muller@example.com");
assert_eq!(parsed.in_reply_to, vec!["parent@example.com"]);
assert_eq!(
parsed.references,
vec!["root@example.com", "parent@example.com"]
);
assert_eq!(parsed.attachments.len(), 1);
assert_eq!(
parsed.attachments[0].filename.as_deref(),
Some("données.txt"),
"Attachment filename round-trip failed"
);
}
#[test]
fn audit_boundary_with_special_chars() {
let raw = b"From: a@b.com\r\n\
Content-Type: multipart/mixed; boundary=\"=_Part+/test.boundary\"\r\n\
\r\n\
--=_Part+/test.boundary\r\n\
Content-Type: text/plain\r\n\
\r\n\
Hello\r\n\
--=_Part+/test.boundary--";
let parsed = crate::parse_email(raw).unwrap();
assert_eq!(parsed.body_text.as_deref(), Some("Hello"));
}
#[test]
fn audit_body_with_encoded_word_syntax_preserved() {
let mut email = make_email();
email.body_text = Some("The value is =?UTF-8?B?dGVzdA==?= literally".into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.body_text.as_deref(),
Some("The value is =?UTF-8?B?dGVzdA==?= literally"),
"Body with =? syntax should be preserved literally"
);
}
#[test]
fn audit_multiple_from_with_sender_round_trip() {
let mut email = make_email();
email.from = vec![
Address {
name: Some("Alice".into()),
email: "alice@example.com".into(),
},
Address {
name: Some("Bob".into()),
email: "bob@example.com".into(),
},
];
email.sender = Some(Address {
name: None,
email: "alice@example.com".into(),
});
email.body_text = Some("body".into());
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.from.len(),
2,
"Both From addresses must be preserved"
);
assert_eq!(parsed.from[0].email, "alice@example.com");
assert_eq!(parsed.from[1].email, "bob@example.com");
assert!(parsed.sender.is_some(), "Sender must be present");
assert_eq!(parsed.sender.as_ref().unwrap().email, "alice@example.com");
}
#[test]
fn audit_header_folding_with_htab_continuation() {
let raw = b"From: sender@example.com\r\n\
Subject: Hello\r\n\t World\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
\r\n\
body";
let parsed = crate::parse_email(raw).unwrap();
assert!(
parsed.subject.as_deref().unwrap().contains("World"),
"Subject must include continuation value after HTAB fold"
);
}
#[test]
fn build_extra_header_non_ascii_is_rfc2047_encoded() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("X-Custom"), "Caf\u{e9} r\u{e9}sum\u{e9}".into())];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("X-Custom: =?UTF-8?B?"),
"Non-ASCII extra header value must be RFC 2047 Base64 encoded \
(RFC 2047 Section 5, RFC 5322 Section 2.2): {s}"
);
assert!(
s.contains("?="),
"RFC 2047 encoded word must have closing delimiter: {s}"
);
let header_line = s
.lines()
.find(|l| l.starts_with("X-Custom:"))
.expect("X-Custom header must be present");
assert!(
header_line.is_ascii(),
"Extra header line must be pure ASCII after RFC 2047 encoding \
(RFC 5322 Section 2.2): {header_line}"
);
}
#[test]
fn build_extra_header_non_ascii_round_trip() {
let mut email = make_email();
email.body_text = Some("body".into());
let original_value = "Caf\u{e9} r\u{e9}sum\u{e9}";
email.extra_headers = vec![(hdr("X-Custom"), original_value.into())];
let built = build_message(&email).unwrap();
let parsed = crate::parse_email(&built.raw).unwrap();
let custom = parsed
.extra_headers
.iter()
.find(|(k, _)| k == "x-custom")
.expect("x-custom header must be present in parsed extra_headers");
assert_eq!(
custom.1, original_value,
"Non-ASCII extra header must round-trip through RFC 2047 encode/decode: \
expected {original_value:?}, got {:?}",
custom.1
);
}
#[test]
fn build_structured_extra_header_preserves_utf8_without_rfc2047() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Return-Path"), "<us\u{00E9}r@example.com>".into()),
(
hdr("Received"),
"from mx.example by inbox.example; Fri, 21 Nov 1997 09:55:06 -0600".into(),
),
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let header_line = s
.lines()
.find(|l| l.starts_with("Return-Path:"))
.expect("Return-Path header must be present");
assert!(
header_line.contains("<us\u{00E9}r@example.com>"),
"Structured Return-Path must preserve raw UTF-8, not rewrite the field body: {header_line}"
);
assert!(
!header_line.contains("=?UTF-8?"),
"Structured Return-Path must not use RFC 2047 encoded-words (RFC 2047 Section 5): {header_line}"
);
}
#[test]
fn build_structured_resent_from_extra_header_preserves_utf8_without_rfc2047() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Jos\u{00E9} <jose@example.com>".into()),
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let header_line = s
.lines()
.find(|l| l.starts_with("Resent-From:"))
.expect("Resent-From header must be present");
assert!(
header_line.contains("Jos\u{00E9} <jose@example.com>"),
"Structured Resent-From must preserve raw UTF-8 address syntax, not rewrite the field body: {header_line}"
);
assert!(
!header_line.contains("=?UTF-8?"),
"Structured Resent-From must not use RFC 2047 encoded-words (RFC 2047 Section 5): {header_line}"
);
}
#[test]
fn return_path_extra_header_is_prepended_before_generated_headers() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Return-Path"), "<bounce@example.com>".into()),
(
hdr("Received"),
"from mx.example by inbox.example; Fri, 21 Nov 1997 09:55:06 -0600".into(),
),
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let header_lines: Vec<&str> = s
.split("\r\n")
.take_while(|line| !line.is_empty())
.collect();
assert_eq!(
header_lines.first().copied(),
Some("Return-Path: <bounce@example.com>"),
"Return-Path must be emitted before From/Date/Message-ID per RFC 5321 Section 4.4: {header_lines:?}"
);
assert!(
header_lines
.get(1)
.is_some_and(|line| line.starts_with("Received: from mx.example by inbox.example;")),
"Received must immediately follow Return-Path in the prepended trace block: {header_lines:?}"
);
}
#[test]
fn return_path_extra_header_requires_received_header() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("Return-Path"), "<bounce@example.com>".into())];
let err = build_message(&email)
.expect_err("Return-Path without Received must be rejected per RFC 5322 Section 3.6.7");
assert!(
matches!(err, Error::InvalidTraceHeader(ref message) if message.contains("Return-Path") && message.contains("Received")),
"expected InvalidTraceHeader error mentioning Return-Path and Received, got {err:?}"
);
}
#[test]
fn return_path_extra_header_rejects_duplicates() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Return-Path"), "<bounce-1@example.com>".into()),
(
hdr("Received"),
"from mx.example by inbox.example; Fri, 21 Nov 1997 09:55:06 -0600".into(),
),
(hdr("Return-Path"), "<bounce-2@example.com>".into()),
];
let err = build_message(&email)
.expect_err("duplicate Return-Path headers must be rejected per RFC 5322 Section 3.6.7");
assert!(
matches!(err, Error::InvalidTraceHeader(ref message) if message.contains("at most one Return-Path")),
"expected InvalidTraceHeader error mentioning duplicate Return-Path, got {err:?}"
);
}
#[test]
fn return_path_extra_header_rejects_invalid_path() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Return-Path"), "not-an-angle-addr".into()),
(
hdr("Received"),
"from mx.example by inbox.example; Fri, 21 Nov 1997 09:55:06 -0600".into(),
),
];
let err = build_message(&email)
.expect_err("invalid Return-Path path must be rejected per RFC 5322 Section 3.6.7");
assert!(
matches!(err, Error::InvalidTraceHeader(ref message) if message.contains("Return-Path")),
"expected InvalidTraceHeader error mentioning Return-Path, got {err:?}"
);
}
#[test]
fn received_extra_header_requires_semicolon_date_time() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("Received"), "from mx.example by inbox.example".into())];
let err = build_message(&email)
.expect_err("Received without '; date-time' must be rejected per RFC 5322 Section 3.6.7");
assert!(
matches!(err, Error::InvalidTraceHeader(ref message) if message.contains("Received")),
"expected InvalidTraceHeader error mentioning Received, got {err:?}"
);
}
#[test]
fn received_extra_header_rejects_invalid_date_time() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(
hdr("Received"),
"from mx.example by inbox.example; not-a-date".into(),
)];
let err = build_message(&email)
.expect_err("Received with invalid date-time must be rejected per RFC 5322 Section 3.6.7");
assert!(
matches!(err, Error::InvalidTraceHeader(ref message) if message.contains("Received")),
"expected InvalidTraceHeader error mentioning Received, got {err:?}"
);
}
#[test]
fn resent_extra_headers_are_prepended_before_original_headers() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Jos\u{00E9} <jose@example.com>".into()),
];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
let header_lines: Vec<&str> = s
.split("\r\n")
.take_while(|line| !line.is_empty())
.collect();
let resent_date_pos = header_lines
.iter()
.position(|line| line.starts_with("Resent-Date:"))
.expect("Resent-Date header must be present");
let resent_from_pos = header_lines
.iter()
.position(|line| line.starts_with("Resent-From:"))
.expect("Resent-From header must be present");
let from_pos = header_lines
.iter()
.position(|line| line.starts_with("From:"))
.expect("From header must be present");
let date_pos = header_lines
.iter()
.position(|line| line.starts_with("Date:"))
.expect("Date header must be present");
assert!(
resent_date_pos < from_pos && resent_from_pos < date_pos,
"Resent block must be prepended before the original headers per RFC 5322 Section 3.6.6: {header_lines:?}"
);
}
#[test]
fn resent_reply_to_extra_header_is_grouped_and_preserves_raw_utf8() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Alice <alice@example.com>".into()),
(
hdr("Resent-Reply-To"),
"Rel\u{00E9} Desk <relay@example.com>".into(),
),
];
let built = build_message(&email)
.expect("obsolete Resent-Reply-To should be accepted as part of the resent block");
let s = raw_str(&built);
let header_lines: Vec<&str> = s
.split("\r\n")
.take_while(|line| !line.is_empty())
.collect();
let resent_reply_to_pos = header_lines
.iter()
.position(|line| line.starts_with("Resent-Reply-To:"))
.expect("Resent-Reply-To header must be present");
let from_pos = header_lines
.iter()
.position(|line| line.starts_with("From:"))
.expect("From header must be present");
let resent_reply_to_line = header_lines[resent_reply_to_pos];
assert!(
resent_reply_to_pos < from_pos,
"Resent-Reply-To must be prepended with the resent block per RFC 5322 Section 4.5.6: {header_lines:?}"
);
assert!(
resent_reply_to_line.contains("Rel\u{00E9} Desk <relay@example.com>"),
"Resent-Reply-To must preserve raw UTF-8 address syntax instead of rewriting the field body: {resent_reply_to_line}"
);
assert!(
!resent_reply_to_line.contains("=?UTF-8?"),
"Resent-Reply-To must not use RFC 2047 encoded-words in a structured address field: {resent_reply_to_line}"
);
}
#[test]
fn resent_extra_headers_require_resent_date_and_from() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("Resent-From"), "Jos\u{00E9} <jose@example.com>".into())];
let err = build_message(&email)
.expect_err("partial resent blocks must be rejected per RFC 5322 Section 3.6.6");
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Date") && message.contains("Resent-From")),
"expected InvalidResentHeader error mentioning required resent fields, got {err:?}"
);
}
#[test]
fn resent_extra_headers_with_multiple_from_mailboxes_require_resent_sender() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(
hdr("Resent-From"),
"Alice <alice@example.com>, Bob <bob@example.com>".into(),
),
];
let err = build_message(&email).expect_err(
"multi-mailbox Resent-From must require Resent-Sender per RFC 5322 Section 3.6.6",
);
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Sender")),
"expected InvalidResentHeader error mentioning Resent-Sender, got {err:?}"
);
}
#[test]
fn resent_extra_headers_validate_each_block_for_required_fields() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Alice <alice@example.com>".into()),
(hdr("Resent-Date"), "Sat, 22 Nov 1997 10:00:00 -0600".into()),
(hdr("Resent-To"), "Bob <bob@example.com>".into()),
];
let err = build_message(&email).expect_err(
"each resent block must include both Resent-Date and Resent-From per RFC 5322 Section 3.6.6",
);
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Date") && message.contains("Resent-From")),
"expected InvalidResentHeader error mentioning required per-block resent fields, got {err:?}"
);
}
#[test]
fn resent_extra_headers_validate_each_block_for_resent_sender() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(
hdr("Resent-From"),
"Alice <alice@example.com>, Bob <bob@example.com>".into(),
),
(hdr("Resent-Date"), "Sat, 22 Nov 1997 10:00:00 -0600".into()),
(hdr("Resent-From"), "Carol <carol@example.com>".into()),
];
let err = build_message(&email).expect_err(
"multi-mailbox Resent-From in any resent block must require Resent-Sender per RFC 5322 Section 3.6.6",
);
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Sender")),
"expected InvalidResentHeader error mentioning per-block Resent-Sender, got {err:?}"
);
}
#[test]
fn resent_extra_headers_reject_invalid_resent_date() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "not-a-date".into()),
(hdr("Resent-From"), "Alice <alice@example.com>".into()),
];
let err = build_message(&email)
.expect_err("invalid Resent-Date must be rejected per RFC 5322 Section 3.6.6");
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Date")),
"expected InvalidResentHeader error mentioning Resent-Date, got {err:?}"
);
}
#[test]
fn resent_extra_headers_reject_invalid_resent_from() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "not-an-address".into()),
];
let err = build_message(&email)
.expect_err("invalid Resent-From must be rejected per RFC 5322 Section 3.6.6");
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-From")),
"expected InvalidResentHeader error mentioning Resent-From, got {err:?}"
);
}
#[test]
fn resent_extra_headers_reject_invalid_resent_reply_to() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Alice <alice@example.com>".into()),
(hdr("Resent-Reply-To"), "not-an-address".into()),
];
let err = build_message(&email)
.expect_err("invalid Resent-Reply-To must be rejected per RFC 5322 Section 4.5.6");
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Reply-To")),
"expected InvalidResentHeader error mentioning Resent-Reply-To, got {err:?}"
);
}
#[test]
fn resent_extra_headers_reject_invalid_resent_sender() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(
hdr("Resent-From"),
"Alice <alice@example.com>, Bob <bob@example.com>".into(),
),
(
hdr("Resent-Sender"),
"Carol <carol@example.com>, Dave <dave@example.com>".into(),
),
];
let err = build_message(&email)
.expect_err("invalid Resent-Sender must be rejected per RFC 5322 Section 3.6.6");
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Sender")),
"expected InvalidResentHeader error mentioning Resent-Sender, got {err:?}"
);
}
#[test]
fn resent_extra_headers_reject_invalid_resent_bcc() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Alice <alice@example.com>".into()),
(hdr("Resent-Bcc"), "not-an-address".into()),
];
let err = build_message(&email)
.expect_err("invalid Resent-Bcc must be rejected per RFC 5322 Section 3.6.6");
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Bcc")),
"expected InvalidResentHeader error mentioning Resent-Bcc, got {err:?}"
);
}
#[test]
fn resent_extra_headers_reject_invalid_resent_message_id() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Alice <alice@example.com>".into()),
(hdr("Resent-Message-ID"), "<missing-at-sign>".into()),
];
let err = build_message(&email)
.expect_err("invalid Resent-Message-ID must be rejected per RFC 5322 Section 3.6.6");
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Message-ID")),
"expected InvalidResentHeader error mentioning Resent-Message-ID, got {err:?}"
);
}
#[test]
fn resent_extra_headers_do_not_let_earlier_sender_satisfy_later_block() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Alice <alice@example.com>".into()),
(hdr("Resent-Sender"), "Relay <relay@example.com>".into()),
(hdr("Resent-Date"), "Sat, 22 Nov 1997 10:00:00 -0600".into()),
(
hdr("Resent-From"),
"Bob <bob@example.com>, Carol <carol@example.com>".into(),
),
];
let err = build_message(&email)
.expect_err("a later multi-mailbox Resent-From must not borrow an earlier Resent-Sender");
assert!(
matches!(err, Error::InvalidResentHeader(ref message) if message.contains("Resent-Sender")),
"expected InvalidResentHeader error mentioning later-block Resent-Sender, got {err:?}"
);
}
#[test]
fn resent_extra_headers_allow_valid_noncanonical_field_order_within_block() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![
(hdr("Resent-To"), "Bob <bob@example.com>".into()),
(hdr("Resent-Date"), "Fri, 21 Nov 1997 09:55:06 -0600".into()),
(hdr("Resent-From"), "Alice <alice@example.com>".into()),
];
let built = build_message(&email)
.expect("valid resent block ordering should not require Resent-Date first");
let text = String::from_utf8_lossy(&built.raw);
assert!(
text.contains("Resent-To: Bob <bob@example.com>\r\n"),
"resent block should preserve the caller-provided field order: {text}"
);
assert!(
text.contains("Resent-Date: Fri, 21 Nov 1997 09:55:06 -0600\r\n"),
"resent block should emit Resent-Date even when it is not the first field: {text}"
);
assert!(
text.contains("Resent-From: Alice <alice@example.com>\r\n"),
"resent block should emit Resent-From for a valid noncanonical ordering: {text}"
);
}
#[test]
fn build_extra_header_crlf_injection_sanitized() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("X-Safe"), "value\r\nX-Injected: evil".into())];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
!s.contains("\r\nX-Injected:"),
"CRLF injection must be sanitized — injected header must not appear \
as a separate line (RFC 5322 Section 2.2): {s}"
);
assert!(
s.contains("X-Safe:"),
"Original header name must survive sanitization: {s}"
);
}
#[test]
fn whitespace_only_subject_round_trip() {
let mut email = make_email();
email.subject = " \t ".into();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Subject:"),
"Builder must emit a Subject header even for whitespace-only input \
(RFC 5322 Section 3.6.5)"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some(" \t "),
"Whitespace-only subject must preserve legal WSP exactly \
(RFC 5322 Section 3.6.5)"
);
}
#[test]
fn leading_whitespace_subject_preserves_content() {
let mut email = make_email();
email.subject = " Hello".into();
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Subject:"),
"Builder must emit a Subject header (RFC 5322 Section 3.6.5)"
);
assert!(
s.contains("Hello"),
"Subject content must appear in the raw output"
);
let parsed = crate::parse_email(&built.raw).unwrap();
assert_eq!(
parsed.subject.as_deref(),
Some(" Hello"),
"Leading whitespace is part of the Subject field body and must be \
preserved through the round-trip (RFC 5322 Section 3.6.5)"
);
}
#[test]
fn rfc2047_very_long_non_ascii_from_display_name_lines_within_76_chars() {
let long_name = "日".repeat(120);
let mut email = make_email();
email.from = vec![Address {
name: Some(long_name.clone()),
email: "sender@example.com".into(),
}];
email.body_text = Some("test body".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for (i, line) in s.split("\r\n").enumerate() {
if line.contains("=?") && line.contains("?=") {
assert!(
line.len() <= 76,
"RFC 2047 Section 2: From header line {i} with encoded-word \
exceeds 76 chars ({} chars): {line}",
line.len()
);
}
}
let parsed = crate::parse_email(&built.raw).unwrap();
assert!(
!parsed.from.is_empty(),
"Parsed message must have at least one From address"
);
assert_eq!(
parsed.from[0].name.as_deref().unwrap(),
long_name,
"From display name must survive RFC 2047 encode → fold → decode round-trip"
);
assert_eq!(parsed.from[0].email, "sender@example.com");
}
#[test]
fn rfc2047_very_long_non_ascii_reply_to_display_name_lines_within_76_chars() {
let long_name = "日".repeat(120);
let mut email = make_email();
email.reply_to = vec![Address {
name: Some(long_name.clone()),
email: "reply@example.com".into(),
}];
email.body_text = Some("test body".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
for (i, line) in s.split("\r\n").enumerate() {
if line.contains("=?") && line.contains("?=") {
assert!(
line.len() <= 76,
"RFC 2047 Section 2: Reply-To header line {i} with \
encoded-word exceeds 76 chars ({} chars): {line}",
line.len()
);
}
}
let parsed = crate::parse_email(&built.raw).unwrap();
assert!(
!parsed.reply_to.is_empty(),
"Parsed message must have at least one Reply-To address"
);
assert_eq!(
parsed.reply_to[0].name.as_deref().unwrap(),
long_name,
"Reply-To display name must survive RFC 2047 encode → fold → decode round-trip"
);
assert_eq!(parsed.reply_to[0].email, "reply@example.com");
}
#[test]
fn message_rfc822_attachment_valid_lines_accepted() {
let mut email = make_email();
email.body_text = Some("test body".into());
let inner_msg = "From: inner@example.com\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Subject: Inner\r\n\
\r\n\
Normal body line\r\n";
email.attachments.push(OutgoingAttachment {
filename: "forwarded.eml".into(),
content_type: "message/rfc822".into(),
data: inner_msg.as_bytes().to_vec(),
is_inline: false,
content_id: None,
});
let result = build_message(&email);
assert!(
result.is_ok(),
"message/rfc822 attachment with conforming line lengths must be \
accepted (RFC 2046 Section 5.2.1): {result:?}"
);
let raw = raw_str(&result.unwrap());
assert!(
raw.contains("Content-Transfer-Encoding: 7bit"),
"Pure-ASCII message/rfc822 should use 7bit CTE"
);
}
#[test]
fn message_rfc822_attachment_overlong_line_rejected() {
let mut email = make_email();
email.body_text = Some("test body".into());
let overlong_line = "X".repeat(1000);
let inner_msg = format!(
"From: inner@example.com\r\n\
Date: Thu, 13 Feb 2025 15:47:33 +0000\r\n\
Subject: Inner\r\n\
\r\n\
{overlong_line}\r\n"
);
email.attachments.push(OutgoingAttachment {
filename: "forwarded.eml".into(),
content_type: "message/rfc822".into(),
data: inner_msg.into_bytes(),
is_inline: false,
content_id: None,
});
let result = build_message(&email);
assert!(
result.is_err(),
"RFC 2046 Section 5.2.1 / RFC 2045 Section 2.8: message/* \
attachment with lines >998 octets must be rejected because \
base64/quoted-printable encoding is forbidden for message/* types"
);
}
#[test]
fn message_global_attachment_overlong_line_is_base64_encoded() {
let mut email = make_email();
email.body_text = Some("test body".into());
let overlong_line = "é".repeat(600);
let inner_msg = format!(
"From: inner@example.com\r\n\
Subject: café\r\n\
\r\n\
{overlong_line}\r\n"
);
email.attachments.push(OutgoingAttachment {
filename: "forwarded.global".into(),
content_type: "message/global".into(),
data: inner_msg.into_bytes(),
is_inline: false,
content_id: None,
});
let built = build_message(&email).expect(
"message/global attachments may use base64 when the embedded message \
is not 7bit/8bit-safe (RFC 6532 Section 3.7)",
);
let raw = raw_str(&built);
assert!(
raw.contains("Content-Type: message/global"),
"message/global content type must be preserved: {raw}"
);
assert!(
raw.contains("Content-Transfer-Encoding: base64"),
"message/global with overlong lines should use base64 per RFC 6532 Section 3.7: {raw}"
);
assert!(
!raw.contains(&overlong_line),
"base64-encoded message/global attachment must not write the overlong line verbatim"
);
}
#[test]
fn validate_address_allows_utf8_in_quoted_local_part() {
let addr = Address {
name: None,
email: "\"用户\"@example.com".into(),
};
let result = validate_address(&addr);
assert!(
result.is_ok(),
"RFC 6531 Section 3.3: UTF-8 bytes must be allowed in quoted \
local-parts, but got: {result:?}"
);
}
#[test]
fn validate_address_allows_at_in_quoted_local_part() {
let addr = Address {
name: None,
email: "\"user@company\"@example.com".into(),
};
let result = validate_address(&addr);
assert!(
result.is_ok(),
"RFC 5322 Section 3.4.1: '@' is valid qtext inside a quoted \
local-part, but got: {result:?}"
);
}
#[test]
fn attachment_content_type_preserves_rfc2231_extended_param_names() {
let mut email = make_email();
email.body_text = Some("body".into());
email.attachments.push(OutgoingAttachment {
filename: "report.bin".into(),
content_type: "application/x-example; title*=UTF-8''caf%C3%A9".into(),
data: b"payload".to_vec(),
is_inline: false,
content_id: None,
});
let built = build_message(&email)
.expect("RFC 2231 extended parameter names on attachment Content-Type must be accepted");
let raw = raw_str(&built);
assert!(
raw.contains("Content-Type: application/x-example; title*=UTF-8''caf%C3%A9;"),
"attachment Content-Type with RFC 2231 extended parameter names must keep the \
extended attribute instead of falling back: {raw}"
);
assert!(
raw.contains("name=\"report.bin\""),
"attachment Content-Type should still add the builder's name= parameter: {raw}"
);
assert!(
!raw.contains("Content-Type: application/octet-stream"),
"attachment Content-Type must not fall back to application/octet-stream: {raw}"
);
}
#[test]
fn attachment_content_type_allows_rfc2045_comments() {
let mut email = make_email();
email.body_text = Some("body".into());
email.attachments.push(OutgoingAttachment {
filename: "report.bin".into(),
content_type: "application/x-example (audit note); title*=UTF-8''caf%C3%A9 (Plain text)"
.into(),
data: b"payload".to_vec(),
is_inline: false,
content_id: None,
});
let built = build_message(&email)
.expect("RFC 2045 Section 5.1 commented Content-Type must be accepted for attachments");
let raw = raw_str(&built);
let unfolded = raw.replace("\r\n ", " ").replace("\r\n\t", " ");
assert!(
unfolded.contains(
"Content-Type: application/x-example (audit note); title*=UTF-8''caf%C3%A9 (Plain text);"
),
"attachment Content-Type comments must be preserved instead of falling back: {raw}"
);
assert!(
unfolded.contains("name=\"report.bin\""),
"builder should still append the name= parameter after the commented Content-Type: {raw}"
);
assert!(
!raw.contains("Content-Type: application/octet-stream"),
"commented attachment Content-Type must not fall back to application/octet-stream: {raw}"
);
}
#[test]
fn extra_headers_allow_top_level_content_disposition() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(
hdr("Content-Disposition"),
"attachment; filename=\"message.eml\"".into(),
)];
let built = build_message(&email).expect("top-level Content-Disposition should be accepted");
let raw = String::from_utf8(built.raw).expect("message bytes must be valid UTF-8");
assert!(
raw.contains("Content-Disposition: attachment; filename=\"message.eml\"\r\n"),
"top-level Content-Disposition must be preserved on the main message per RFC 2183 Section 2.10: {raw}"
);
}
#[test]
fn extra_headers_allow_top_level_content_id() {
let mut email = make_email();
email.body_text = Some("body".into());
email.extra_headers = vec![(hdr("Content-ID"), "<test@example.com>".into())];
let built = build_message(&email).expect("top-level Content-ID should be accepted");
let raw = String::from_utf8(built.raw).expect("message bytes must be valid UTF-8");
assert!(
raw.contains("Content-ID: <test@example.com>\r\n"),
"top-level Content-ID must be preserved on the main MIME entity per RFC 2045 Section 7: {raw}"
);
}
#[test]
fn build_rejects_overlong_in_reply_to_msg_id_token() {
let overlong_msg_id = format!("{}@{}", "a".repeat(400), "b".repeat(600));
let mut email = make_email();
email.in_reply_to = vec![mid(&overlong_msg_id)];
let err = build_message(&email)
.expect_err("overlong single msg-id token must be rejected before writing");
assert!(
matches!(err, Error::HeaderLineTooLong(ref message) if message.contains("In-Reply-To")
&& message.contains("998")
&& message.contains("RFC 5322 Sections 2.1.1 and 2.2.3")),
"expected HeaderLineTooLong error about impossible-to-fold In-Reply-To token, got {err:?}"
);
}
#[test]
fn write_header_preserves_htab_in_subject() {
let mut email = make_email();
email.subject = "Hello\tWorld".into();
let built = build_message(&email).unwrap();
let raw = std::str::from_utf8(&built.raw).unwrap();
let subject_line = raw
.lines()
.find(|l| l.starts_with("Subject:"))
.expect("Subject header must be present");
assert!(
subject_line.contains('\t'),
"HTAB must be preserved in Subject header, got: {subject_line:?}"
);
}
#[test]
fn build_rejects_overlong_from_address_token() {
let long_local = "a".repeat(500);
let long_domain = vec!["b".repeat(63); 8].join(".") + ".com";
let email_addr = format!("{long_local}@{long_domain}");
let email = OutgoingEmail {
from: vec![Address {
name: None,
email: email_addr,
}],
sender: None,
to: vec![Address {
name: None,
email: "to@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: vec![],
date: None,
subject: "test".into(),
body_text: Some("body".into()),
body_html: None,
in_reply_to: vec![],
references: vec![],
attachments: vec![],
extra_headers: vec![],
};
let err = build_message(&email)
.expect_err("overlong single mailbox token must be rejected before writing");
assert!(
matches!(err, Error::HeaderLineTooLong(ref message) if message.contains("From")
&& message.contains("998")
&& message.contains("RFC 5322 Sections 2.1.1 and 2.2.3")),
"expected HeaderLineTooLong error about impossible-to-fold From token, got {err:?}"
);
}
#[test]
fn extract_domain_quoted_local_part_with_at() {
assert_eq!(
extract_domain("\"user@company\"@example.com"),
Some("example.com")
);
assert_eq!(extract_domain("user@example.com"), Some("example.com"));
assert_eq!(extract_domain("noatsign"), None);
assert_eq!(extract_domain("user@"), None);
}
#[test]
fn generate_message_id_preserves_domain_literals() {
let id = generate_message_id("[IPv6:2001:db8::1]");
assert!(
id.ends_with("@[IPv6:2001:db8::1]"),
"Message-ID must preserve a no-fold-literal id-right: {id}"
);
let id2 = generate_message_id("example.com");
assert!(id2.ends_with("@example.com"));
}
#[test]
fn build_in_reply_to_accepts_domain_literal_msg_id() {
let mut email = make_email();
email.in_reply_to = vec![mid("reply@[IPv6:2001:db8::1]")];
let built = build_message(&email).expect("build should accept a valid domain-literal msg-id");
let raw = String::from_utf8_lossy(&built.raw);
assert!(
raw.contains("In-Reply-To: <reply@[IPv6:2001:db8::1]>\r\n"),
"In-Reply-To must preserve a valid no-fold-literal msg-id: {raw}"
);
}
#[test]
fn build_content_id_accepts_domain_literal_msg_id() {
let mut email = make_email();
email.body_html = Some("<img src=\"cid:part@[IPv6:2001:db8::1]\">".into());
email.attachments = vec![OutgoingAttachment {
filename: "image.png".into(),
content_type: "image/png".into(),
data: vec![0x89, b'P', b'N', b'G'],
is_inline: true,
content_id: Some("part@[IPv6:2001:db8::1]".into()),
}];
let built =
build_message(&email).expect("build should accept a valid domain-literal Content-ID");
let raw = String::from_utf8_lossy(&built.raw);
assert!(
raw.contains("Content-ID: <part@[IPv6:2001:db8::1]>\r\n"),
"Content-ID must preserve a valid no-fold-literal msg-id: {raw}"
);
}
#[test]
fn format_address_encodes_control_chars_in_display_name() {
let addr = crate::types::Address {
name: Some("Bell\x07Char".to_string()),
email: "user@example.com".to_string(),
};
let formatted = format_address(&addr);
assert!(
formatted.contains("=?UTF-8?B?"),
"display name with control chars must be RFC 2047 encoded, got: {formatted}"
);
assert!(
formatted.contains("<user@example.com>"),
"email must be angle-bracketed: {formatted}"
);
}
#[test]
fn resent_field_kind_unknown_field_returns_error() {
let result = resent_field_kind("X-Not-A-Resent-Field", 0);
assert!(
result.is_err(),
"unknown resent field must return Err, not panic"
);
}
#[test]
fn resent_field_kind_valid_fields_resolve() {
assert!(resent_field_kind("Resent-Date", 0).is_ok());
assert!(resent_field_kind("resent-from", 2).is_ok());
assert!(resent_field_kind("RESENT-TO", 0).is_ok());
assert!(resent_field_kind("resent-message-id", 0).is_ok());
}