- 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.
323 lines
11 KiB
Rust
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));
|
|
}
|
|
}
|