Files
smartmta/rust/crates/mailer-core/src/mime.rs

378 lines
13 KiB
Rust
Raw Normal View History

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<String> {
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::<Vec<_>>()
.join(", ")
));
}
if !email.cc.is_empty() {
output.push_str(&format!(
"Cc: {}\r\n",
email
.cc
.iter()
.map(|a| Email::sanitize_string(a))
.collect::<Vec<_>>()
.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(&quoted_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(&quoted_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(&quoted_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(&quoted_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(&quoted_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(&quoted_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("<test@example.com>");
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("<p>HTML content</p>");
email.set_message_id("<test@example.com>");
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("<test@example.com>");
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);
}
}