use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use crate::error::Error;
use crate::types::{Address, BuiltMessage, DateTime, OutgoingAttachment, OutgoingEmail};
static MSG_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
#[allow(clippy::too_many_lines)]
pub fn build_message(email: &OutgoingEmail) -> Result<BuiltMessage, Error> {
validate_address(&email.from)?;
for addr in &email.to {
validate_address(addr)?;
}
for addr in &email.cc {
validate_address(addr)?;
}
for addr in &email.bcc {
validate_address(addr)?;
}
if let Some(ref reply_to) = email.reply_to {
validate_address(reply_to)?;
}
if email.to.is_empty() && email.cc.is_empty() && email.bcc.is_empty() {
return Err(Error::Build("at least one recipient required".into()));
}
let domain = extract_domain(&email.from.email).unwrap_or("daaki.local");
let message_id = generate_message_id(domain);
let mut raw = Vec::new();
write_header(
&mut raw,
"From",
&sanitize_header_value(&format_address(&email.from)),
);
if !email.to.is_empty() {
write_header(
&mut raw,
"To",
&sanitize_header_value(&format_address_list(&email.to)),
);
}
if !email.cc.is_empty() {
write_header(
&mut raw,
"Cc",
&sanitize_header_value(&format_address_list(&email.cc)),
);
}
if let Some(ref reply_to) = email.reply_to {
write_header(
&mut raw,
"Reply-To",
&sanitize_header_value(&format_address(reply_to)),
);
}
write_header(
&mut raw,
"Subject",
&encode_rfc2047_if_needed(&sanitize_header_value(&email.subject)),
);
write_header(&mut raw, "Date", &DateTime::now().to_rfc5322_string());
write_header(&mut raw, "Message-ID", &format!("<{message_id}>"));
write_header(&mut raw, "MIME-Version", "1.0");
if let Some(ref in_reply_to) = email.in_reply_to {
let sanitized = sanitize_header_value(in_reply_to);
let bare = strip_angle_brackets(&sanitized);
write_header(&mut raw, "In-Reply-To", &format!("<{bare}>"));
}
if let Some(ref references) = email.references {
let sanitized = sanitize_header_value(references);
let refs: String = sanitized
.split_whitespace()
.map(|id| {
let bare = strip_angle_brackets(id);
format!("<{bare}>")
})
.collect::<Vec<_>>()
.join(" ");
write_header(&mut raw, "References", &refs);
}
let has_text = email.body_text.is_some();
let has_html = email.body_html.is_some();
let has_attachments = !email.attachments.is_empty();
let encapsulated_content = {
let mut buf = Vec::new();
if let Some(ref text) = email.body_text {
buf.extend_from_slice(text.as_bytes());
}
if let Some(ref html) = email.body_html {
buf.extend_from_slice(html.as_bytes());
}
for att in &email.attachments {
buf.extend_from_slice(&att.data);
buf.extend_from_slice(att.filename.as_bytes());
}
buf
};
if has_attachments {
let mixed_boundary = generate_boundary_not_in(&encapsulated_content);
write_header(
&mut raw,
"Content-Type",
&format!("multipart/mixed; boundary=\"{mixed_boundary}\""),
);
raw.extend_from_slice(b"\r\n");
write_boundary(&mut raw, &mixed_boundary, false);
if has_text && has_html {
let mut alt_boundary = generate_boundary_not_in(&encapsulated_content);
while alt_boundary == mixed_boundary {
alt_boundary = generate_boundary_not_in(&encapsulated_content);
}
write_header(
&mut raw,
"Content-Type",
&format!("multipart/alternative; boundary=\"{alt_boundary}\""),
);
raw.extend_from_slice(b"\r\n");
write_boundary(&mut raw, &alt_boundary, false);
write_text_part(
&mut raw,
email.body_text.as_deref().unwrap_or(""),
"text/plain",
);
write_boundary(&mut raw, &alt_boundary, false);
write_text_part(
&mut raw,
email.body_html.as_deref().unwrap_or(""),
"text/html",
);
write_boundary(&mut raw, &alt_boundary, true);
} else if has_text {
write_text_part(
&mut raw,
email.body_text.as_deref().unwrap_or(""),
"text/plain",
);
} else if has_html {
write_text_part(
&mut raw,
email.body_html.as_deref().unwrap_or(""),
"text/html",
);
} else {
write_text_part(&mut raw, "", "text/plain");
}
for attachment in &email.attachments {
write_boundary(&mut raw, &mixed_boundary, false);
write_attachment_part(&mut raw, attachment);
}
write_boundary(&mut raw, &mixed_boundary, true);
} else if has_text && has_html {
let alt_boundary = generate_boundary_not_in(&encapsulated_content);
write_header(
&mut raw,
"Content-Type",
&format!("multipart/alternative; boundary=\"{alt_boundary}\""),
);
raw.extend_from_slice(b"\r\n");
write_boundary(&mut raw, &alt_boundary, false);
write_text_part(
&mut raw,
email.body_text.as_deref().unwrap_or(""),
"text/plain",
);
write_boundary(&mut raw, &alt_boundary, false);
write_text_part(
&mut raw,
email.body_html.as_deref().unwrap_or(""),
"text/html",
);
write_boundary(&mut raw, &alt_boundary, true);
} else if has_html {
write_text_part(
&mut raw,
email.body_html.as_deref().unwrap_or(""),
"text/html",
);
} else {
write_text_part(
&mut raw,
email.body_text.as_deref().unwrap_or(""),
"text/plain",
);
}
let mut envelope_recipients: Vec<String> = email.to.iter().map(|a| a.email.clone()).collect();
envelope_recipients.extend(email.cc.iter().map(|a| a.email.clone()));
envelope_recipients.extend(email.bcc.iter().map(|a| a.email.clone()));
Ok(BuiltMessage {
raw,
envelope_recipients,
message_id,
})
}
fn normalize_line_endings(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\r' => {
result.push_str("\r\n");
if chars.peek() == Some(&'\n') {
chars.next();
}
}
'\n' => {
result.push_str("\r\n");
}
_ => {
result.push(c);
}
}
}
result
}
fn sanitize_header_value(value: &str) -> String {
value.chars().filter(|&c| c != '\r' && c != '\n').collect()
}
pub(crate) fn encode_rfc2047_if_needed(text: &str) -> String {
if text.bytes().all(|b| b.is_ascii()) {
return text.to_string();
}
let max_raw_bytes: usize = 45;
let bytes = text.as_bytes();
let mut words: Vec<String> = Vec::new();
let mut pos = 0;
while pos < bytes.len() {
let chunk_end = snap_utf8_chunk_end(bytes, pos, max_raw_bytes);
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes[pos..chunk_end]);
words.push(format!("=?UTF-8?B?{b64}?="));
pos = chunk_end;
}
words.join(" ")
}
fn validate_address(addr: &Address) -> Result<(), Error> {
let email = &addr.email;
if email.is_empty() {
return Err(Error::InvalidAddress("empty email address".into()));
}
let at_pos = email
.find('@')
.ok_or_else(|| Error::InvalidAddress(format!("missing '@' in email: {email}")))?;
let local = &email[..at_pos];
let domain = &email[at_pos + 1..];
if local.is_empty() {
return Err(Error::InvalidAddress(format!("empty local part: {email}")));
}
if domain.is_empty() {
return Err(Error::InvalidAddress(format!("empty domain part: {email}")));
}
if email
.chars()
.any(|c| c.is_ascii_whitespace() || c.is_ascii_control())
{
return Err(Error::InvalidAddress(format!(
"email contains invalid characters: {email}"
)));
}
Ok(())
}
fn escape_quoted_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn format_address(addr: &Address) -> String {
match &addr.name {
Some(name) if !name.is_empty() => {
if name.bytes().any(|b| !b.is_ascii()) {
let encoded = encode_rfc2047_if_needed(name);
format!("{encoded} <{}>", addr.email)
} else if needs_quoting(name) {
let escaped = escape_quoted_string(name);
format!("\"{escaped}\" <{}>", addr.email)
} else {
format!("{name} <{}>", addr.email)
}
}
_ => addr.email.clone(),
}
}
fn needs_quoting(name: &str) -> bool {
name.chars().any(|c| {
matches!(
c,
'(' | ')' | '<' | '>' | '[' | ']' | ':' | ';' | '@' | '\\' | ',' | '.' | '"'
)
})
}
fn format_address_list(addrs: &[Address]) -> String {
addrs
.iter()
.map(format_address)
.collect::<Vec<_>>()
.join(", ")
}
fn strip_angle_brackets(s: &str) -> &str {
s.strip_prefix('<')
.and_then(|s| s.strip_suffix('>'))
.unwrap_or(s)
}
fn extract_domain(email: &str) -> Option<&str> {
let at = email.find('@')?;
let domain = &email[at + 1..];
if domain.is_empty() {
None
} else {
Some(domain)
}
}
fn generate_message_id(domain: &str) -> String {
let hex = generate_unique_hex();
format!("{hex}@{domain}")
}
fn generate_boundary() -> String {
let hex = generate_unique_hex();
format!("----=_Part_{hex}")
}
fn generate_boundary_not_in(content: &[u8]) -> String {
for _ in 0..10 {
let boundary = generate_boundary();
if !contains_boundary(content, &boundary) {
return boundary;
}
}
let count = MSG_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let boundary = format!("----=_Part_fallback_{count:016x}");
boundary
}
fn contains_boundary(content: &[u8], boundary: &str) -> bool {
let boundary_bytes = boundary.as_bytes();
content
.windows(boundary_bytes.len())
.any(|w| w == boundary_bytes)
}
fn generate_unique_hex() -> String {
let mut buf = [0u8; 16];
if read_urandom(&mut buf).is_ok() {
return buf.iter().fold(String::with_capacity(32), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
}
#[allow(clippy::cast_possible_truncation)]
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let count = MSG_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = u64::from(std::process::id());
format!("{nanos:016x}{pid:08x}{count:08x}")
}
fn read_urandom(buf: &mut [u8]) -> std::io::Result<()> {
use std::io::Read;
let mut f = std::fs::File::open("/dev/urandom")?;
f.read_exact(buf)
}
const MAX_LINE_LEN: usize = 78;
const HARD_LINE_LIMIT: usize = 998;
fn write_header(output: &mut Vec<u8>, name: &str, value: &str) {
let prefix = format!("{name}: ");
let mut line_len = prefix.len();
output.extend_from_slice(prefix.as_bytes());
let mut first_word = true;
for word in split_header_words(value) {
let word_with_sep_len = if first_word {
word.len()
} else {
1 + word.len()
};
if !first_word && line_len + word_with_sep_len > MAX_LINE_LEN && line_len > 0 {
output.extend_from_slice(b"\r\n ");
line_len = 1; } else if !first_word {
output.push(b' ');
line_len += 1;
}
if line_len + word.len() > HARD_LINE_LIMIT {
let bytes = word.as_bytes();
let mut pos = 0;
while pos < bytes.len() {
let remaining = HARD_LINE_LIMIT - line_len;
let chunk_end = snap_utf8_chunk_end(bytes, pos, remaining);
output.extend_from_slice(&bytes[pos..chunk_end]);
line_len += chunk_end - pos;
pos = chunk_end;
if pos < bytes.len() {
output.extend_from_slice(b"\r\n ");
line_len = 1;
}
}
} else {
output.extend_from_slice(word.as_bytes());
line_len += word.len();
}
first_word = false;
}
output.extend_from_slice(b"\r\n");
}
fn split_header_words(value: &str) -> Vec<&str> {
let mut words = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let mut in_angles = false;
let bytes = value.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' if in_quotes => {
i += 2;
continue;
}
b'"' => in_quotes = !in_quotes,
b'<' if !in_quotes => in_angles = true,
b'>' if !in_quotes => in_angles = false,
b' ' | b'\t' if !in_quotes && !in_angles => {
if i > start {
words.push(&value[start..i]);
}
start = i + 1;
}
_ => {}
}
i += 1;
}
if start < bytes.len() {
words.push(&value[start..]);
}
words
}
fn write_boundary(output: &mut Vec<u8>, boundary: &str, closing: bool) {
output.extend_from_slice(b"--");
output.extend_from_slice(boundary.as_bytes());
if closing {
output.extend_from_slice(b"--");
}
output.extend_from_slice(b"\r\n");
}
fn needs_quoted_printable(text: &str) -> bool {
text.split("\r\n").any(|line| line.len() > HARD_LINE_LIMIT)
}
fn encode_quoted_printable(data: &[u8]) -> Vec<u8> {
const QP_LINE_LIMIT: usize = 76;
let mut result = Vec::with_capacity(data.len() * 2);
let mut line_len: usize = 0;
let mut i = 0;
while i < data.len() {
if data[i] == b'\r' && i + 1 < data.len() && data[i + 1] == b'\n' {
result.extend_from_slice(b"\r\n");
line_len = 0;
i += 2;
continue;
}
let byte = data[i];
let needs_encoding = if byte == b'\t' || byte == b' ' {
is_trailing_whitespace(data, i)
} else if byte == b'=' {
true
} else if (33..=126).contains(&byte) {
false
} else {
true
};
if needs_encoding {
if line_len + 3 > QP_LINE_LIMIT - 1 {
result.extend_from_slice(b"=\r\n");
line_len = 0;
}
result.push(b'=');
let hi = HEX_UPPER[(byte >> 4) as usize];
let lo = HEX_UPPER[(byte & 0x0F) as usize];
result.push(hi);
result.push(lo);
line_len += 3;
} else {
if line_len + 1 > QP_LINE_LIMIT - 1 {
result.extend_from_slice(b"=\r\n");
line_len = 0;
}
result.push(byte);
line_len += 1;
}
i += 1;
}
result
}
const HEX_UPPER: [u8; 16] = *b"0123456789ABCDEF";
fn is_trailing_whitespace(data: &[u8], pos: usize) -> bool {
let mut j = pos + 1;
while j < data.len() {
match data[j] {
b'\r' | b'\n' => return true,
b' ' | b'\t' => j += 1,
_ => return false,
}
}
true
}
fn write_text_part(output: &mut Vec<u8>, text: &str, mime_type: &str) {
write_header(
output,
"Content-Type",
&format!("{mime_type}; charset=utf-8"),
);
let normalized = normalize_line_endings(text);
if needs_quoted_printable(&normalized) {
write_header(output, "Content-Transfer-Encoding", "quoted-printable");
output.extend_from_slice(b"\r\n");
let encoded = encode_quoted_printable(normalized.as_bytes());
output.extend_from_slice(&encoded);
if !encoded.ends_with(b"\r\n") {
output.extend_from_slice(b"\r\n");
}
} else {
write_header(output, "Content-Transfer-Encoding", "8bit");
output.extend_from_slice(b"\r\n");
output.extend_from_slice(normalized.as_bytes());
output.extend_from_slice(b"\r\n");
}
}
fn write_attachment_part(output: &mut Vec<u8>, attachment: &OutgoingAttachment) {
let content_type = if is_valid_mime_type(&attachment.content_type) {
&attachment.content_type
} else {
"application/octet-stream"
};
write_header(output, "Content-Type", content_type);
let filename = sanitize_header_value(&attachment.filename);
if filename.bytes().any(|b| !b.is_ascii()) {
let encoded = percent_encode_filename(&filename);
let legacy: String = filename
.chars()
.map(|c| if c.is_ascii() { c } else { '_' })
.collect();
let escaped_legacy = escape_quoted_string(&legacy);
write_header(
output,
"Content-Disposition",
&format!("attachment; filename=\"{escaped_legacy}\"; filename*=UTF-8''{encoded}"),
);
} else {
let escaped_filename = escape_quoted_string(&filename);
write_header(
output,
"Content-Disposition",
&format!("attachment; filename=\"{escaped_filename}\""),
);
}
write_header(output, "Content-Transfer-Encoding", "base64");
output.extend_from_slice(b"\r\n");
let encoded = base64::engine::general_purpose::STANDARD.encode(&attachment.data);
for chunk in encoded.as_bytes().chunks(76) {
output.extend_from_slice(chunk);
output.extend_from_slice(b"\r\n");
}
}
fn percent_encode_filename(filename: &str) -> String {
let mut encoded = String::with_capacity(filename.len() * 3);
for &b in filename.as_bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
encoded.push(b as char);
} else {
use std::fmt::Write;
let _ = write!(encoded, "%{b:02X}");
}
}
encoded
}
fn is_valid_mime_type(ct: &str) -> bool {
let ct = ct.trim();
if let Some(slash) = ct.find('/') {
let type_part = &ct[..slash];
let subtype_part = &ct[slash + 1..];
!type_part.is_empty()
&& !subtype_part.is_empty()
&& type_part.chars().all(is_mime_token_char)
&& subtype_part.chars().all(is_mime_token_char)
} else {
false
}
}
fn is_mime_token_char(c: char) -> bool {
c.is_ascii()
&& !c.is_ascii_whitespace()
&& !c.is_ascii_control()
&& !matches!(
c,
'(' | ')'
| '<'
| '>'
| '@'
| ','
| ';'
| ':'
| '\\'
| '"'
| '/'
| '['
| ']'
| '?'
| '='
)
}
fn snap_utf8_chunk_end(bytes: &[u8], pos: usize, max_bytes: usize) -> usize {
let mut end = (pos + max_bytes).min(bytes.len());
while end > pos && end < bytes.len() && is_utf8_continuation(bytes[end]) {
end -= 1;
}
if end == pos && pos < bytes.len() {
end = (pos + utf8_char_len(bytes[pos])).min(bytes.len());
}
end
}
pub(crate) fn is_utf8_continuation(b: u8) -> bool {
(b & 0xC0) == 0x80
}
pub(crate) fn utf8_char_len(lead: u8) -> usize {
if lead < 0x80 {
1
} else if lead < 0xE0 {
2
} else if lead < 0xF0 {
3
} else {
4
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn make_email() -> OutgoingEmail {
OutgoingEmail {
from: Address {
name: Some("Sender".into()),
email: "sender@example.com".into(),
},
to: vec![Address {
name: None,
email: "to@example.com".into(),
}],
cc: vec![],
bcc: vec![],
reply_to: None,
subject: "Test Subject".into(),
body_text: None,
body_html: None,
in_reply_to: None,
references: None,
attachments: 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(),
}];
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_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(),
}];
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 = Some("parent@host.com".into());
email.references = Some("root@host.com parent@host.com".into());
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 = Some("<parent@host.com>".into());
email.references = Some("<root@host.com> <parent@host.com>".into());
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.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.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(),
}];
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 = Some(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_recipients_error() {
let mut email = make_email();
email.to.clear();
let result = build_message(&email);
assert!(matches!(result, Err(Error::Build(_))));
}
#[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],
}];
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.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 = Some("parent@host.com".into());
email.references = Some("root@host.com parent@host.com".into());
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.email, "sender@example.com");
assert_eq!(parsed.from.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.as_deref(), Some("parent@host.com"));
assert_eq!(
parsed.references.as_deref(),
Some("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(),
}];
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],
}];
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![],
}];
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(),
},
OutgoingAttachment {
filename: "b.png".into(),
content_type: "image/png".into(),
data: b"png data".to_vec(),
},
];
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_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 = 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.name.as_deref(), Some("José García"));
assert_eq!(parsed.from.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(),
}];
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(),
}];
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(),
}];
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 = 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 = Some(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],
}];
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 = Some("parent-id@example.com".into());
email.references = Some("root@example.com parent-id@example.com".into());
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(),
},
OutgoingAttachment {
filename: "img.png".into(),
content_type: "image/png".into(),
data: b"png content".to_vec(),
},
];
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.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.as_deref(), Some("parent-id@example.com"));
assert_eq!(
parsed.references.as_deref(),
Some("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],
}];
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(),
},
OutgoingAttachment {
filename: "b.bin".into(),
content_type: "no-slash".into(), data: b"data".to_vec(),
},
OutgoingAttachment {
filename: "c.bin".into(),
content_type: "/subtype".into(), data: b"data".to_vec(),
},
];
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_invalid_subtype_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=bad".into(),
data: b"data".to_vec(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(s.contains("Content-Type: application/octet-stream"));
}
#[test]
fn build_display_name_with_comma_is_quoted() {
let mut email = make_email();
email.from = 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 = 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 = 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 = 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.name.as_deref(), Some("Doe, John"));
assert_eq!(parsed.from.email, "john@example.com");
}
#[test]
fn build_round_trip_display_name_with_escaped_quotes() {
let mut email = make_email();
email.from = 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.name.as_deref(), Some("John \"Doc\" Doe"));
assert_eq!(parsed.from.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(),
}];
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(),
}];
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 s = raw_str(&built);
for line in s.split("\r\n") {
assert!(
line.len() <= 998,
"Line exceeds 998 chars ({} chars): {}...",
line.len(),
&line[..80.min(line.len())]
);
}
}
#[test]
fn build_long_references_header_is_folded() {
let mut email = make_email();
let ids: Vec<String> = (0..30)
.map(|i| format!("id{i:04}@very-long-domain-name-for-testing.example.com"))
.collect();
email.references = Some(ids.join(" "));
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!(parsed.references.is_some());
let parsed_refs = parsed.references.unwrap();
assert_eq!(parsed_refs.split_whitespace().count(), 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();
let original_no_ws: String = email
.subject
.chars()
.filter(|c| !c.is_whitespace())
.collect();
let parsed_no_ws: String = parsed_subject
.chars()
.filter(|c| !c.is_whitespace())
.collect();
assert_eq!(
parsed_no_ws, original_no_ws,
"Force-folded UTF-8 subject must round-trip without data corruption"
);
}
#[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""#, "<a@b.com>"],
"split_header_words must skip escaped quotes inside quoted-strings \
per RFC 5322 Section 3.2.4"
);
}
#[test]
fn build_crlf_in_display_name_stripped() {
let mut email = make_email();
email.from = 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 = 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 = Some("root@host.com\r\nBcc: injected@evil.com".into());
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 = 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.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();
email.body_text = Some("Héllo, José! Ñoño. 日本語テスト".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Héllo, José! Ñoño. 日本語テスト"),
"Non-ASCII UTF-8 body text must be preserved, got: {s}"
);
}
#[test]
fn build_non_ascii_html_body_preserved() {
let mut email = make_email();
email.body_html = Some("<p>Ünïcödé résumé</p>".into());
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("<p>Ünïcödé résumé</p>"),
"Non-ASCII UTF-8 HTML body must be preserved, got: {s}"
);
}
#[test]
fn build_non_ascii_multipart_body_preserved() {
let mut email = make_email();
email.body_text = Some("Café".into());
email.body_html = Some("<b>Café</b>".into());
email.attachments = vec![OutgoingAttachment {
filename: "test.txt".into(),
content_type: "text/plain".into(),
data: b"data".to_vec(),
}];
let built = build_message(&email).unwrap();
let s = raw_str(&built);
assert!(
s.contains("Café"),
"Non-ASCII in multipart text must be preserved, got: {s}"
);
assert!(
s.contains("<b>Café</b>"),
"Non-ASCII in multipart HTML must be preserved, got: {s}"
);
}
#[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_8bit() {
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: 8bit"),
"Short body lines should use 8bit encoding, got:\n{s}"
);
assert!(
!s.contains("Content-Transfer-Encoding: quoted-printable"),
"Short body lines should not use quoted-printable"
);
}
#[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],
}];
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(),
}];
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(),
}];
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(),
}];
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 = 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(),
}];
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_force_folded() {
let mut email = make_email();
email.subject = "X".repeat(1100);
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 chars ({} chars) after force-fold: {}...",
line.len(),
&line[..80.min(line.len())]
);
}
}
#[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 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],
}];
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()
);
}
}
}