diff --git a/crates/mail-mcp/src/smtp.rs b/crates/mail-mcp/src/smtp.rs index 5854428..e12b0a7 100644 --- a/crates/mail-mcp/src/smtp.rs +++ b/crates/mail-mcp/src/smtp.rs @@ -61,7 +61,10 @@ const MAX_ATTACHMENTS: usize = 25; const MAX_BODY_BYTES: usize = 5 * 1024 * 1024; const MAX_TOTAL_RECIPIENTS: usize = 100; -pub async fn send(account: &Account, input: SendInput) -> Result { +/// Pre-flight validation on a `SendInput`. Extracted from `send()` so it's +/// callable from unit tests without a live SMTP server. All caps are enforced +/// here; the SMTP transport handles only the wire-format work after this passes. +pub fn validate_send_input(input: &SendInput) -> Result<()> { if input.to.is_empty() { return Err(anyhow!("at least one `to` address required")); } @@ -104,6 +107,11 @@ pub async fn send(account: &Account, input: SendInput) -> Result { )); } } + Ok(()) +} + +pub async fn send(account: &Account, input: SendInput) -> Result { + validate_send_input(&input)?; // Build From, To, Cc, Bcc. let from_str = format!("{} <{}>", account.from_name, account.from_addr); @@ -287,6 +295,115 @@ fn civil_from_unix(t: i64) -> (i64, u32, u32, u32, u32, u32) { mod tests { use super::*; + // ----- validate_send_input ----- + + fn ok_input() -> SendInput { + SendInput { + to: vec!["a@b.com".into()], + body: "hello".into(), + subject: "test".into(), + ..Default::default() + } + } + + #[test] + fn validate_accepts_minimal_input() { + assert!(validate_send_input(&ok_input()).is_ok()); + } + + #[test] + fn validate_rejects_empty_to() { + let mut i = ok_input(); + i.to.clear(); + assert!(validate_send_input(&i) + .unwrap_err() + .to_string() + .contains("at least one")); + } + + #[test] + fn validate_rejects_too_many_recipients() { + let mut i = ok_input(); + i.to = (0..50).map(|n| format!("to-{n}@x.com")).collect(); + i.cc = (0..50).map(|n| format!("cc-{n}@x.com")).collect(); + i.bcc = (0..50).map(|n| format!("bcc-{n}@x.com")).collect(); + // 150 total — over 100 cap + let err = validate_send_input(&i).unwrap_err().to_string(); + assert!(err.contains("too many recipients"), "got: {err}"); + } + + #[test] + fn validate_recipient_cap_boundary_passes() { + let mut i = ok_input(); + i.to = (0..100).map(|n| format!("to-{n}@x.com")).collect(); + assert!(validate_send_input(&i).is_ok()); + } + + #[test] + fn validate_rejects_oversized_body() { + let mut i = ok_input(); + i.body = "x".repeat(MAX_BODY_BYTES + 1); + assert!(validate_send_input(&i) + .unwrap_err() + .to_string() + .contains("body too large")); + } + + #[test] + fn validate_rejects_oversized_body_html() { + let mut i = ok_input(); + i.body_html = Some("y".repeat(MAX_BODY_BYTES + 1)); + assert!(validate_send_input(&i) + .unwrap_err() + .to_string() + .contains("body_html too large")); + } + + #[test] + fn validate_rejects_too_many_attachments() { + let mut i = ok_input(); + i.attachments = (0..MAX_ATTACHMENTS + 1) + .map(|n| AttachmentSpec { + filename: format!("a-{n}.txt"), + content_base64: "aGVsbG8=".into(), // "hello" + mime_type: "text/plain".into(), + }) + .collect(); + assert!(validate_send_input(&i) + .unwrap_err() + .to_string() + .contains("too many attachments")); + } + + #[test] + fn validate_rejects_oversized_attachment_encoded() { + let mut i = ok_input(); + // Encoded size ~33% > decoded cap → refused before decode. + let huge_encoded = "A".repeat((MAX_ATTACHMENT_BYTES * 4 + 2) / 3 + 100); + i.attachments.push(AttachmentSpec { + filename: "big.bin".into(), + content_base64: huge_encoded, + mime_type: "application/octet-stream".into(), + }); + let err = validate_send_input(&i).unwrap_err().to_string(); + assert!(err.contains("exceeds limit"), "got: {err}"); + } + + #[test] + fn validate_accepts_at_attachment_boundary() { + let mut i = ok_input(); + i.attachments = (0..MAX_ATTACHMENTS) + .map(|n| AttachmentSpec { + filename: format!("a-{n}.txt"), + content_base64: "aGVsbG8=".into(), + mime_type: "text/plain".into(), + }) + .collect(); + assert!(validate_send_input(&i).is_ok()); + } + + // ----- civil_from_unix ----- + #[test] fn civil_from_unix_epoch() { assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));