From f7e698b09fdcbcd3a265b89118d0eae70c015836 Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 21 May 2026 08:23:18 -0700 Subject: [PATCH] smtp: extract validate_send_input + 9 unit tests for size caps Refactor: pull the pre-flight validation block out of send() into a standalone validate_send_input() function. send() now starts with a single validate_send_input(&input)? call. Behavior identical; the extraction is purely so unit tests can exercise the validation paths without standing up a fake SMTP server. New tests (9): - validate_accepts_minimal_input (the happy path) - validate_rejects_empty_to - validate_rejects_too_many_recipients (150 > 100 cap) - validate_recipient_cap_boundary_passes (exactly 100 OK) - validate_rejects_oversized_body - validate_rejects_oversized_body_html - validate_rejects_too_many_attachments - validate_rejects_oversized_attachment_encoded (pre-decode bound) - validate_accepts_at_attachment_boundary Test count: 18 -> 27. All passing. --- crates/mail-mcp/src/smtp.rs | 119 +++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) 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));