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.
This commit is contained in:
Kayos 2026-05-21 08:23:18 -07:00
parent 6fb63b0ca0
commit f7e698b09f

View file

@ -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<SendOutput> {
/// 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<SendOutput> {
));
}
}
Ok(())
}
pub async fn send(account: &Account, input: SendInput) -> Result<SendOutput> {
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));