feat(rust): implement mailer-core and mailer-security crates with CLI
Rust migration Phase 1 — implements real functionality in the previously stubbed mailer-core and mailer-security crates (38 passing tests). mailer-core: Email/EmailAddress/Attachment types, RFC 5322 MIME builder, email format validation with scoring, bounce detection (14 types, 40+ regex patterns), DSN status parsing, retry delay calculation. mailer-security: DKIM signing (RSA-SHA256) and verification, SPF checking, DMARC verification with public suffix list, DNSBL IP reputation checking (10 default servers, parallel queries), all powered by mail-auth 0.7. mailer-bin: Full CLI with validate/bounce/check-ip/verify-email/dkim-sign subcommands plus --management mode for smartrust JSON-over-stdin/stdout IPC.
This commit is contained in:
377
rust/crates/mailer-core/src/mime.rs
Normal file
377
rust/crates/mailer-core/src/mime.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
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("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("<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user