use base64::Engine;
use crate::error::{HaiError, Result};
use crate::types::SendEmailOptions;
pub(crate) fn sanitize_header(value: &str) -> String {
value
.chars()
.filter(|c| *c != '\r' && *c != '\n' && *c != '"')
.collect()
}
pub fn build_rfc5322_email(opts: &SendEmailOptions, from_email: &str) -> Result<Vec<u8>> {
let date = time::OffsetDateTime::now_utc();
let date_str = date
.format(&time::format_description::well_known::Rfc2822)
.map_err(|e| HaiError::Message(format!("failed to format date: {e}")))?;
let message_id = format!("<{}@hai.ai>", uuid::Uuid::new_v4());
let safe_to = sanitize_header(&opts.to);
let safe_from = sanitize_header(from_email);
let safe_subject = sanitize_header(&opts.subject);
let cc_header = if !opts.cc.is_empty() {
let safe_cc: Vec<String> = opts.cc.iter().map(|a| sanitize_header(a)).collect();
format!("Cc: {}\r\n", safe_cc.join(", "))
} else {
String::new()
};
let bcc_header = if !opts.bcc.is_empty() {
let safe_bcc: Vec<String> = opts.bcc.iter().map(|a| sanitize_header(a)).collect();
format!("Bcc: {}\r\n", safe_bcc.join(", "))
} else {
String::new()
};
if opts.attachments.is_empty() {
let mut email = String::new();
email.push_str(&format!("From: <{}>\r\n", safe_from));
email.push_str(&format!("To: {}\r\n", safe_to));
email.push_str(&cc_header);
email.push_str(&bcc_header);
email.push_str(&format!("Subject: {}\r\n", safe_subject));
email.push_str(&format!("Date: {}\r\n", date_str));
email.push_str(&format!("Message-ID: {}\r\n", message_id));
if let Some(ref reply_to) = opts.in_reply_to {
let safe_reply = sanitize_header(reply_to);
email.push_str(&format!("In-Reply-To: {}\r\n", safe_reply));
email.push_str(&format!("References: {}\r\n", safe_reply));
}
email.push_str("MIME-Version: 1.0\r\n");
email.push_str("Content-Type: text/plain; charset=utf-8\r\n");
email.push_str("Content-Transfer-Encoding: 8bit\r\n");
email.push_str("\r\n"); email.push_str(&opts.body);
email.push_str("\r\n");
Ok(email.into_bytes())
} else {
let boundary = format!("hai-boundary-{}", uuid::Uuid::new_v4().simple());
let mut email = String::new();
email.push_str(&format!("From: <{}>\r\n", safe_from));
email.push_str(&format!("To: {}\r\n", safe_to));
email.push_str(&cc_header);
email.push_str(&bcc_header);
email.push_str(&format!("Subject: {}\r\n", safe_subject));
email.push_str(&format!("Date: {}\r\n", date_str));
email.push_str(&format!("Message-ID: {}\r\n", message_id));
if let Some(ref reply_to) = opts.in_reply_to {
let safe_reply = sanitize_header(reply_to);
email.push_str(&format!("In-Reply-To: {}\r\n", safe_reply));
email.push_str(&format!("References: {}\r\n", safe_reply));
}
email.push_str("MIME-Version: 1.0\r\n");
email.push_str(&format!(
"Content-Type: multipart/mixed; boundary=\"{}\"\r\n",
boundary
));
email.push_str("\r\n");
email.push_str(&format!("--{}\r\n", boundary));
email.push_str("Content-Type: text/plain; charset=utf-8\r\n");
email.push_str("Content-Transfer-Encoding: 8bit\r\n");
email.push_str("\r\n");
email.push_str(&opts.body);
email.push_str("\r\n");
for att in &opts.attachments {
let raw_data = att.effective_data();
let b64 = base64::engine::general_purpose::STANDARD.encode(&raw_data);
let safe_filename = sanitize_header(&att.filename);
let safe_content_type = sanitize_header(&att.content_type);
email.push_str(&format!("--{}\r\n", boundary));
email.push_str(&format!(
"Content-Type: {}; name=\"{}\"\r\n",
safe_content_type, safe_filename
));
email.push_str(&format!(
"Content-Disposition: attachment; filename=\"{}\"\r\n",
safe_filename
));
email.push_str("Content-Transfer-Encoding: base64\r\n");
email.push_str("\r\n");
for chunk in b64.as_bytes().chunks(76) {
email.push_str(std::str::from_utf8(chunk).unwrap_or(""));
email.push_str("\r\n");
}
}
email.push_str(&format!("--{}--\r\n", boundary));
Ok(email.into_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{EmailAttachment, SendEmailOptions};
fn simple_opts() -> SendEmailOptions {
SendEmailOptions {
to: "recipient@hai.ai".to_string(),
subject: "Test Subject".to_string(),
body: "Hello, world!".to_string(),
cc: vec![],
bcc: vec![],
in_reply_to: None,
attachments: vec![],
labels: vec![],
append_footer: None,
}
}
#[test]
fn build_simple_text_email() {
let raw = build_rfc5322_email(&simple_opts(), "sender@hai.ai").unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(text.contains("From: <sender@hai.ai>\r\n"));
assert!(text.contains("To: recipient@hai.ai\r\n"));
assert!(text.contains("Subject: Test Subject\r\n"));
assert!(text.contains("Date: "));
assert!(text.contains("Message-ID: <"));
assert!(text.contains("Content-Type: text/plain; charset=utf-8\r\n"));
assert!(text.contains("Hello, world!"));
}
#[test]
fn build_email_with_attachments() {
let opts = SendEmailOptions {
to: "recipient@hai.ai".to_string(),
subject: "With Attachments".to_string(),
body: "See attached.".to_string(),
cc: vec![],
bcc: vec![],
in_reply_to: None,
attachments: vec![
EmailAttachment::new(
"file1.txt".to_string(),
"text/plain".to_string(),
b"content of file 1".to_vec(),
),
EmailAttachment::new(
"file2.pdf".to_string(),
"application/pdf".to_string(),
b"fake pdf content".to_vec(),
),
],
labels: vec![],
append_footer: None,
};
let raw = build_rfc5322_email(&opts, "sender@hai.ai").unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(text.contains("Content-Type: multipart/mixed; boundary="));
assert!(text.contains("Content-Disposition: attachment; filename=\"file1.txt\""));
assert!(text.contains("Content-Disposition: attachment; filename=\"file2.pdf\""));
assert!(text.contains("Content-Transfer-Encoding: base64"));
assert!(text.contains("See attached."));
}
#[test]
fn build_reply_email() {
let opts = SendEmailOptions {
to: "recipient@hai.ai".to_string(),
subject: "Re: Original".to_string(),
body: "Reply body".to_string(),
cc: vec![],
bcc: vec![],
in_reply_to: Some("<original-id@hai.ai>".to_string()),
attachments: vec![],
labels: vec![],
append_footer: None,
};
let raw = build_rfc5322_email(&opts, "sender@hai.ai").unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(text.contains("In-Reply-To: <original-id@hai.ai>\r\n"));
assert!(text.contains("References: <original-id@hai.ai>\r\n"));
}
#[test]
fn crlf_injection_sanitized() {
let opts = SendEmailOptions {
to: "recipient@hai.ai".to_string(),
subject: "Bad\r\nBcc: attacker@evil.com".to_string(),
body: "Body".to_string(),
cc: vec![],
bcc: vec![],
in_reply_to: None,
attachments: vec![],
labels: vec![],
append_footer: None,
};
let raw = build_rfc5322_email(&opts, "sender@hai.ai").unwrap();
let text = String::from_utf8_lossy(&raw);
for line in text.split("\r\n") {
assert!(
!line.starts_with("Bcc:"),
"CRLF injection succeeded: found header line starting with Bcc:"
);
}
assert!(text.contains("Subject: BadBcc: attacker@evil.com\r\n"));
}
#[test]
fn output_is_valid_rfc5322() {
let raw = build_rfc5322_email(&simple_opts(), "sender@hai.ai").unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(text.contains("\r\n\r\n"), "must have header/body separator");
assert!(text.contains("From:"));
assert!(text.contains("To:"));
assert!(text.contains("Subject:"));
assert!(text.contains("Date:"));
assert!(text.contains("Message-ID:"));
assert!(text.contains("MIME-Version: 1.0"));
}
#[test]
fn filename_quote_injection_sanitized() {
let opts = SendEmailOptions {
to: "recipient@hai.ai".to_string(),
subject: "Test".to_string(),
body: "Body".to_string(),
cc: vec![],
bcc: vec![],
in_reply_to: None,
attachments: vec![EmailAttachment::new(
"file\"; name=\"evil".to_string(),
"text/plain".to_string(),
b"content".to_vec(),
)],
labels: vec![],
append_footer: None,
};
let raw = build_rfc5322_email(&opts, "sender@hai.ai").unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(
!text.contains("filename=\"file\""),
"Quote injection: filename quote broke out of parameter"
);
for line in text.split("\r\n") {
assert!(
!line.contains("name=\"evil\""),
"Parameter injection succeeded: found injected name parameter"
);
}
}
#[test]
fn output_has_crlf_line_endings() {
let raw = build_rfc5322_email(&simple_opts(), "sender@hai.ai").unwrap();
let text = String::from_utf8(raw).unwrap();
for line in text.split("\r\n") {
assert!(!line.contains('\n'), "found bare \\n in line: {:?}", line);
}
}
}