use base64::engine::general_purpose::STANDARD; use base64::Engine; use crate::email::Email; use crate::error::Result; /// Generate a MIME boundary string. fn generate_boundary() -> String { let id = uuid::Uuid::new_v4(); format!("----=_Part_{}", id.as_simple()) } /// Build an RFC 5322 compliant email message from an Email struct. pub fn build_rfc822(email: &Email) -> Result { let mut output = String::with_capacity(4096); let message_id = email.message_id(); // Required headers output.push_str(&format!( "From: {}\r\n", Email::sanitize_string(&email.from) )); if !email.to.is_empty() { output.push_str(&format!( "To: {}\r\n", email .to .iter() .map(|a| Email::sanitize_string(a)) .collect::>() .join(", ") )); } if !email.cc.is_empty() { output.push_str(&format!( "Cc: {}\r\n", email .cc .iter() .map(|a| Email::sanitize_string(a)) .collect::>() .join(", ") )); } output.push_str(&format!( "Subject: {}\r\n", Email::sanitize_string(&email.subject) )); output.push_str(&format!("Message-ID: {}\r\n", message_id)); output.push_str(&format!("Date: {}\r\n", rfc2822_now())); output.push_str("MIME-Version: 1.0\r\n"); // Priority headers match email.priority { crate::email::Priority::High => { output.push_str("X-Priority: 1\r\n"); output.push_str("Importance: high\r\n"); } crate::email::Priority::Low => { output.push_str("X-Priority: 5\r\n"); output.push_str("Importance: low\r\n"); } crate::email::Priority::Normal => {} } // Custom headers for (name, value) in &email.headers { output.push_str(&format!( "{}: {}\r\n", Email::sanitize_string(name), Email::sanitize_string(value) )); } let has_html = email.html.is_some(); let has_attachments = !email.attachments.is_empty(); match (has_html, has_attachments) { (false, false) => { // Plain text only output.push_str("Content-Type: text/plain; charset=UTF-8\r\n"); output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); output.push_str("\r\n"); output.push_str("ed_printable_encode(&email.text)); } (true, false) => { // multipart/alternative (text + html) let boundary = generate_boundary(); output.push_str(&format!( "Content-Type: multipart/alternative; boundary=\"{}\"\r\n", boundary )); output.push_str("\r\n"); // Text part output.push_str(&format!("--{}\r\n", boundary)); output.push_str("Content-Type: text/plain; charset=UTF-8\r\n"); output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); output.push_str("\r\n"); output.push_str("ed_printable_encode(&email.text)); output.push_str("\r\n"); // HTML part output.push_str(&format!("--{}\r\n", boundary)); output.push_str("Content-Type: text/html; charset=UTF-8\r\n"); output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); output.push_str("\r\n"); output.push_str("ed_printable_encode(email.html.as_deref().unwrap())); output.push_str("\r\n"); output.push_str(&format!("--{}--\r\n", boundary)); } (_, true) => { // multipart/mixed with optional multipart/alternative inside let mixed_boundary = generate_boundary(); output.push_str(&format!( "Content-Type: multipart/mixed; boundary=\"{}\"\r\n", mixed_boundary )); output.push_str("\r\n"); if has_html { // multipart/alternative for text+html let alt_boundary = generate_boundary(); output.push_str(&format!("--{}\r\n", mixed_boundary)); output.push_str(&format!( "Content-Type: multipart/alternative; boundary=\"{}\"\r\n", alt_boundary )); output.push_str("\r\n"); // Text part output.push_str(&format!("--{}\r\n", alt_boundary)); output.push_str("Content-Type: text/plain; charset=UTF-8\r\n"); output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); output.push_str("\r\n"); output.push_str("ed_printable_encode(&email.text)); output.push_str("\r\n"); // HTML part output.push_str(&format!("--{}\r\n", alt_boundary)); output.push_str("Content-Type: text/html; charset=UTF-8\r\n"); output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); output.push_str("\r\n"); output.push_str("ed_printable_encode(email.html.as_deref().unwrap())); output.push_str("\r\n"); output.push_str(&format!("--{}--\r\n", alt_boundary)); } else { // Plain text only output.push_str(&format!("--{}\r\n", mixed_boundary)); output.push_str("Content-Type: text/plain; charset=UTF-8\r\n"); output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); output.push_str("\r\n"); output.push_str("ed_printable_encode(&email.text)); output.push_str("\r\n"); } // Attachments for attachment in &email.attachments { output.push_str(&format!("--{}\r\n", mixed_boundary)); output.push_str(&format!( "Content-Type: {}; name=\"{}\"\r\n", attachment.content_type, attachment.filename )); output.push_str("Content-Transfer-Encoding: base64\r\n"); if let Some(ref cid) = attachment.content_id { output.push_str(&format!("Content-ID: <{}>\r\n", cid)); output.push_str("Content-Disposition: inline\r\n"); } else { output.push_str(&format!( "Content-Disposition: attachment; filename=\"{}\"\r\n", attachment.filename )); } output.push_str("\r\n"); output.push_str(&base64_encode_wrapped(&attachment.content)); output.push_str("\r\n"); } output.push_str(&format!("--{}--\r\n", mixed_boundary)); } } Ok(output) } /// Encode a string as quoted-printable (RFC 2045). fn quoted_printable_encode(input: &str) -> String { let mut output = String::with_capacity(input.len() * 2); let mut line_len = 0; for byte in input.bytes() { let encoded = match byte { // Printable ASCII that doesn't need encoding (except =) b' '..=b'<' | b'>'..=b'~' => { line_len += 1; (byte as char).to_string() } b'\t' => { line_len += 1; "\t".to_string() } b'\r' => continue, // handled with \n b'\n' => { line_len = 0; "\r\n".to_string() } _ => { line_len += 3; format!("={:02X}", byte) } }; // Soft line break at 76 characters if line_len > 75 && byte != b'\n' { output.push_str("=\r\n"); line_len = encoded.len(); } output.push_str(&encoded); } output } /// Base64-encode binary data with 76-character line wrapping. fn base64_encode_wrapped(data: &[u8]) -> String { let encoded = STANDARD.encode(data); let mut output = String::with_capacity(encoded.len() + encoded.len() / 76 * 2); for (i, ch) in encoded.chars().enumerate() { if i > 0 && i % 76 == 0 { output.push_str("\r\n"); } output.push(ch); } output } /// Generate current date in RFC 2822 format (e.g., "Tue, 10 Feb 2026 12:00:00 +0000"). fn rfc2822_now() -> String { use std::time::SystemTime; let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); // Simple UTC formatting without chrono dependency let days = now / 86400; let time_of_day = now % 86400; let hours = time_of_day / 3600; let minutes = (time_of_day % 3600) / 60; let seconds = time_of_day % 60; // Calculate year/month/day from days since epoch let (year, month, day) = days_to_ymd(days); let day_of_week = ((days + 4) % 7) as usize; // Jan 1 1970 = Thursday (4) let dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; let mon = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; format!( "{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000", dow[day_of_week], day, mon[(month - 1) as usize], year, hours, minutes, seconds ) } /// Convert days since Unix epoch to (year, month, day). fn days_to_ymd(days: u64) -> (u64, u64, u64) { // Algorithm from https://howardhinnant.github.io/date_algorithms.html let z = days + 719468; let era = z / 146097; let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; (y, m, d) } #[cfg(test)] mod tests { use super::*; use crate::email::Email; #[test] fn test_plain_text_email() { let mut email = Email::new("sender@example.com", "Test Subject", "Hello World"); email.add_to("recipient@example.com"); email.set_message_id(""); let rfc822 = build_rfc822(&email).unwrap(); assert!(rfc822.contains("From: sender@example.com")); assert!(rfc822.contains("To: recipient@example.com")); assert!(rfc822.contains("Subject: Test Subject")); assert!(rfc822.contains("Content-Type: text/plain; charset=UTF-8")); assert!(rfc822.contains("Hello World")); } #[test] fn test_html_email() { let mut email = Email::new("sender@example.com", "HTML Test", "Plain text"); email.add_to("recipient@example.com"); email.set_html("

HTML content

"); email.set_message_id(""); let rfc822 = build_rfc822(&email).unwrap(); assert!(rfc822.contains("multipart/alternative")); assert!(rfc822.contains("text/plain")); assert!(rfc822.contains("text/html")); assert!(rfc822.contains("Plain text")); assert!(rfc822.contains("HTML content")); } #[test] fn test_email_with_attachment() { let mut email = Email::new("sender@example.com", "Attachment Test", "See attached"); email.add_to("recipient@example.com"); email.set_message_id(""); email.add_attachment(crate::email::Attachment { filename: "test.txt".to_string(), content: b"Hello attachment".to_vec(), content_type: "text/plain".to_string(), content_id: None, }); let rfc822 = build_rfc822(&email).unwrap(); assert!(rfc822.contains("multipart/mixed")); assert!(rfc822.contains("Content-Disposition: attachment")); assert!(rfc822.contains("test.txt")); } #[test] fn test_quoted_printable() { let input = "Hello = World"; let encoded = quoted_printable_encode(input); assert!(encoded.contains("=3D")); // = is encoded let input = "Plain ASCII text"; let encoded = quoted_printable_encode(input); assert_eq!(encoded, "Plain ASCII text"); } #[test] fn test_base64_wrapped() { let data = vec![0u8; 100]; let encoded = base64_encode_wrapped(&data); for line in encoded.split("\r\n") { assert!(line.len() <= 76); } } #[test] fn test_rfc2822_date() { let date = rfc2822_now(); // Should match pattern like "Tue, 10 Feb 2026 12:00:00 +0000" assert!(date.contains("+0000")); assert!(date.len() > 20); } }