carrier/crates/mail-mcp/src/smtp.rs
Sulkta 730fd017fd audit-fix round 3: LOW-1 mime cleanup, INFO-2 drop empty snippet, INFO-3 unit tests + format_imap_since tightening
- LOW-1: mime_type construction simplified. Single `content_type().map()`
  with proper fallback instead of two unwrap_or chains where the second
  default could never fire.
- INFO-2: ListEntry.snippet field dropped. Was always an empty string
  because list mode doesn't fetch the body. Field stays out until /
  unless we add a partial-body fetch in Phase C.
- INFO-3: 18 unit tests for the pure validation helpers — validate_mailbox
  (accept + reject CR/LF/NUL/quote/backslash), has_imap_literal (with /
  without digits), format_imap_since (canonical + bad-shape rejection),
  strip_msgid_braces, clamp_limit, render_flag (every variant +
  Custom), strip_quotes (matched / unmatched / inner / empties),
  civil_from_unix (epoch / Y2K / 2026-05-21 / pre-epoch / leap day).
- Bonus catch from the test suite: format_imap_since accepted
  malformed shapes like "21-05-2026" (parsed as y=21 m=5 d=2026)
  and "2026-5-21" (no field-width check). Added 4-2-2 digit-width
  check + year range (1900..=9999) + day range (1..=31). Month range
  was already enforced.

All 18 tests pass.
2026-05-21 08:00:50 -07:00

323 lines
11 KiB
Rust

//! Outbound SMTP via `lettre` with explicit header discipline.
//!
//! Headers we guarantee:
//! - `Date` — lettre auto, UTC, RFC 5322
//! - `Message-ID` — `<uuid-v4@{message_id_domain}>` — own-domain, not local hostname
//! - `From` — `name <addr>`
//! - `MIME-Version` — lettre auto
//! - `User-Agent` — `mail-mcp/<crate version>`
//! - `In-Reply-To` — if provided
//! - `References` — if provided (space-joined)
//!
//! Body shape:
//! - body only → `text/plain; charset=utf-8`
//! - body + body_html → `multipart/alternative` (text first per RFC 2046)
//! - attachments → `multipart/mixed` wrap around the alternative
//! (or around the singlepart text body if no html)
use anyhow::{anyhow, Context, Result};
use base64::Engine;
use lettre::message::header::ContentType;
use lettre::message::{Attachment, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::config::Account;
#[derive(Debug, Clone)]
pub struct AttachmentSpec {
pub filename: String,
pub content_base64: String,
pub mime_type: String,
}
#[derive(Debug, Clone, Default)]
pub struct SendInput {
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub subject: String,
pub body: String,
pub body_html: Option<String>,
pub attachments: Vec<AttachmentSpec>,
pub in_reply_to: Option<String>,
pub references: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SendOutput {
pub message_id: String,
pub sent_at: String, // RFC-3339
}
const USER_AGENT: &str = concat!("mail-mcp/", env!("CARGO_PKG_VERSION"));
/// Hard caps to keep an MCP-driver-gone-wrong from OOM-ing the box.
/// Match Gmail's effective 25 MB per-message ceiling — any single attachment
/// past this is almost certainly unintended. Body caps are generous; HTML
/// bodies past 5 MB are a smell.
const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
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> {
if input.to.is_empty() {
return Err(anyhow!("at least one `to` address required"));
}
let total_recipients = input.to.len() + input.cc.len() + input.bcc.len();
if total_recipients > MAX_TOTAL_RECIPIENTS {
return Err(anyhow!(
"too many recipients: {total_recipients} (cap {MAX_TOTAL_RECIPIENTS})"
));
}
if input.body.len() > MAX_BODY_BYTES {
return Err(anyhow!(
"body too large: {} bytes (cap {MAX_BODY_BYTES})",
input.body.len()
));
}
if let Some(html) = &input.body_html {
if html.len() > MAX_BODY_BYTES {
return Err(anyhow!(
"body_html too large: {} bytes (cap {MAX_BODY_BYTES})",
html.len()
));
}
}
if input.attachments.len() > MAX_ATTACHMENTS {
return Err(anyhow!(
"too many attachments: {} (cap {MAX_ATTACHMENTS})",
input.attachments.len()
));
}
// Coarse pre-decode size estimate — base64 expands the payload ~33%,
// so the encoded string is ~4*N/3 of the decoded bytes. Refuse early
// if the encoded form alone exceeds 4/3 of MAX, saving a giant decode.
let max_encoded = (MAX_ATTACHMENT_BYTES * 4 + 2) / 3;
for att in &input.attachments {
if att.content_base64.len() > max_encoded {
return Err(anyhow!(
"attachment `{}` encoded length {} exceeds limit (decoded cap {MAX_ATTACHMENT_BYTES})",
att.filename,
att.content_base64.len()
));
}
}
// Build From, To, Cc, Bcc.
let from_str = format!("{} <{}>", account.from_name, account.from_addr);
let mut builder = Message::builder()
.from(from_str.parse().context("parse from address")?)
.subject(&input.subject);
for addr in &input.to {
builder = builder.to(addr.parse().with_context(|| format!("parse to `{addr}`"))?);
}
for addr in &input.cc {
builder = builder.cc(addr.parse().with_context(|| format!("parse cc `{addr}`"))?);
}
for addr in &input.bcc {
builder = builder
.bcc(addr.parse().with_context(|| format!("parse bcc `{addr}`"))?);
}
// Own-domain Message-ID. Lettre defaults to the local hostname; we
// want the sender domain so receivers don't see `<...@container-id>`.
let message_id = format!("<{}@{}>", uuid::Uuid::new_v4(), account.msgid_domain());
builder = builder.message_id(Some(message_id.clone()));
// Threading.
if let Some(parent) = &input.in_reply_to {
builder = builder.in_reply_to(parent.clone());
}
if !input.references.is_empty() {
builder = builder.references(input.references.join(" "));
}
// User-Agent — uses the lettre `user_agent()` shorthand which writes
// the standard header.
builder = builder.user_agent(USER_AGENT.to_string());
// Body.
let body_part: MultiPart = build_body(&input)?;
let email = builder
.multipart(body_part)
.context("compose message")?;
// SMTP transport. STARTTLS on submission port (587) is the canonical
// path; SMTPS-on-465 supported too if someone configures `smtp_starttls = false`.
let creds = Credentials::new(
account.username.clone(),
account.resolve_password()?,
);
let transport: AsyncSmtpTransport<Tokio1Executor> = if account.smtp_starttls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&account.smtp_host)
.with_context(|| format!("smtp starttls relay {}", account.smtp_host))?
.port(account.smtp_port)
.credentials(creds)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::relay(&account.smtp_host)
.with_context(|| format!("smtp relay {}", account.smtp_host))?
.port(account.smtp_port)
.credentials(creds)
.build()
};
let sent_at = chrono_rfc3339_now();
transport
.send(email)
.await
.with_context(|| format!("send to {}:{}", account.smtp_host, account.smtp_port))?;
Ok(SendOutput {
message_id,
sent_at,
})
}
fn build_body(input: &SendInput) -> Result<MultiPart> {
// Inner content: plain or alternative(plain, html).
let inner_alternative: Option<MultiPart> = input.body_html.as_ref().map(|html| {
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(input.body.clone()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html.clone()),
)
});
let plain_only: SinglePart = SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(input.body.clone());
if input.attachments.is_empty() {
// No attachments — return alternative if html else a one-part
// "mixed" wrapping just the plain body (keeps return type uniform).
if let Some(alt) = inner_alternative {
return Ok(alt);
}
// Wrap the singlepart in a "mixed" container so we always return
// MultiPart. This adds one MIME boundary but is RFC-valid.
return Ok(MultiPart::mixed().singlepart(plain_only));
}
// Have attachments — multipart/mixed wraps either the alternative
// (if html provided) or just the plain body.
let mut mixed = if let Some(alt) = inner_alternative {
MultiPart::mixed().multipart(alt)
} else {
MultiPart::mixed().singlepart(plain_only)
};
for att in &input.attachments {
let bytes = base64::engine::general_purpose::STANDARD
.decode(&att.content_base64)
.with_context(|| format!("attachment `{}`: invalid base64", att.filename))?;
if bytes.len() > MAX_ATTACHMENT_BYTES {
return Err(anyhow!(
"attachment `{}` decoded to {} bytes — exceeds cap {MAX_ATTACHMENT_BYTES}",
att.filename,
bytes.len()
));
}
let content_type: ContentType = att.mime_type.parse().with_context(|| {
format!(
"attachment `{}`: invalid mime_type `{}`",
att.filename, att.mime_type
)
})?;
mixed = mixed.singlepart(
Attachment::new(att.filename.clone()).body(bytes, content_type),
);
}
Ok(mixed)
}
/// Render `now` as RFC-3339 UTC (`2026-05-21T06:42:18Z`). We avoid pulling
/// the full `chrono` crate just for this; build the string by hand from
/// std time.
fn chrono_rfc3339_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// RFC-3339 via the same algorithm `httpdate` uses, simplified for UTC.
let (year, month, day, hour, minute, second) = civil_from_unix(secs as i64);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hour, minute, second
)
}
/// Convert a unix timestamp (UTC) to civil (Y,M,D,h,m,s). Copy of the
/// classic Howard Hinnant algorithm — works for the full proleptic Gregorian range.
fn civil_from_unix(t: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = t.div_euclid(86_400);
let secs_of_day = t.rem_euclid(86_400);
let hour = (secs_of_day / 3600) as u32;
let minute = ((secs_of_day % 3600) / 60) as u32;
let second = (secs_of_day % 60) as u32;
// 0000-03-01 is the start of the cycle ("era").
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe =
(doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + 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) as u32;
let m = if mp < 10 { (mp + 3) as u32 } else { (mp - 9) as u32 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d, hour, minute, second)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_from_unix_epoch() {
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn civil_from_unix_y2k() {
// 2000-01-01 00:00:00 UTC = 946684800
assert_eq!(civil_from_unix(946_684_800), (2000, 1, 1, 0, 0, 0));
}
#[test]
fn civil_from_unix_2026_05_21() {
// 2026-05-21 13:44:03 UTC = 1779371043
assert_eq!(
civil_from_unix(1_779_371_043),
(2026, 5, 21, 13, 44, 3)
);
}
#[test]
fn civil_from_unix_pre_epoch() {
// 1969-12-31 23:59:59 UTC = -1
assert_eq!(civil_from_unix(-1), (1969, 12, 31, 23, 59, 59));
// 1969-01-01 00:00:00 UTC = -31536000
assert_eq!(civil_from_unix(-31_536_000), (1969, 1, 1, 0, 0, 0));
}
#[test]
fn civil_from_unix_leap_year() {
// 2024-02-29 00:00:00 UTC = 1709164800 (leap day)
assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0, 0, 0));
}
}