From 2041f2232c18a87339bd9bc735e1894fa1c03e8e Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Sun, 13 Sep 2026 03:37:27 +0200 Subject: [PATCH] CowMailError in headers mod and use mailparse::parse_mail implementation --- CLAUDE.md | 56 -------------- src/header/headers.rs | 7 +- src/header/mod.rs | 13 ++-- src/lib.rs | 165 ++++++++++++++++++++++-------------------- src/tests.rs | 109 ++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 141 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ca4c792..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,56 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build Commands - -```bash -cargo build # Build the library -cargo build --release # Build with optimizations (LTO enabled) -cargo test # Run all tests -cargo test test_name # Run a specific test -cargo doc --open # Generate and view documentation -``` - -## Architecture - -This is a Rust library for parsing, modifying, and serializing email messages (RFC 5322/MIME). It uses copy-on-write semantics to minimize allocations when working with email data. - -### Core Types - -- **`CowMail`** (`lib.rs`): Main email message struct. Holds headers, body bytes, and nested subparts for multipart messages. Uses `Cow<[u8]>` for zero-copy parsing with owned modifications. - -- **`CowMailReader`** (`lib.rs`): Streaming reader implementing `std::io::Read`. Allows efficient serialization without full memory allocation. - -- **`CowHeader`** (`header.rs`): Single email header with copy-on-write storage. Tracks byte indices for key/value positions within raw data. - -- **`Header` trait** (`header.rs`): Interface for header implementations including parsing, serialization, and RFC 2045 parameter handling. - -- **`HeaderMap` trait** (`headers.rs`): Collection operations for headers - find, iterate, add, replace, remove. - -- **`Mail` trait** (`lib.rs`): High-level operations for email bodies - content decoding, content-type detection, multipart handling. - -- **`CowMailError`** (`lib.rs`): Error type for mail operations (generic errors, parse errors, missing headers). - -- **`BodyType`** (`lib.rs`): Enum for body type selection (HTML, PLAIN). - -- **`MessageType`** (`lib.rs`): Enum distinguishing SinglePart from MultiPart messages (with boundary). - -- **`ContentType`** (`lib.rs`): Wrapper around `ParsedContentType` with conditional defaults for multipart/digest. - -### Key Design Patterns - -**Copy-on-Write**: Parsed messages borrow from the original byte slice. Only modified fields become owned. The `serialize()` method returns `Vec>` to preserve this optimization. - -**Multipart Handling**: `CowMail` recursively contains `subparts: Vec`. Methods like `make_mixed()`, `make_alternative()`, and `make_related()` transform single-part messages into multipart containers. The original content becomes the first subpart. - -**Body Part Navigation**: `get_body_part(BodyType)` traverses the MIME tree to find text/plain or text/html parts, correctly handling `multipart/alternative`, `multipart/related` with `start` parameter, and attachments. The internal `BodyStatus` enum tracks whether a part is a body, not a body, or needs traversal. - -### Content Encoding - -Body content is automatically encoded/decoded using the `lettre` and `mailparse` crates. `set_content()` automatically selects transfer encoding (quoted-printable for UTF-8 text) and sets appropriate headers. - -### High-Level Body Manipulation - -- `set_text_body()` / `set_html_body()` / `set_body()`: Intelligently insert or update body parts, creating `multipart/alternative` structures as needed -- These methods use `get_body_parent_part_mut()` to find the correct insertion point in nested MIME structures diff --git a/src/header/headers.rs b/src/header/headers.rs index dc9c5d8..72916a0 100644 --- a/src/header/headers.rs +++ b/src/header/headers.rs @@ -1,4 +1,5 @@ use super::{Header, parse_header}; +use crate::CowMailError; pub trait HeaderMap<'a, H: Header<'a> + 'a> { fn iter_headers(&'a self, key: &str) -> impl Iterator; @@ -93,7 +94,7 @@ impl<'a, H: Header<'a> + 'a> HeaderMap<'a, H> for Vec { } -pub fn parse_headers<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(Vec, usize), &'static str> { +pub fn parse_headers<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(Vec, usize), CowMailError> { let mut headers: Vec = Vec::new(); let mut ix = 0; loop { @@ -107,7 +108,9 @@ pub fn parse_headers<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(Vec, u ix += 2; break; } else { - return Err("Headers were followed by an unexpected lone CR character!"); + return Err(CowMailError::Generic( + "Headers were followed by an unexpected lone CR character!".to_string(), + )); } } let (header, ix_next) = parse_header(&raw_data[ix..])?; diff --git a/src/header/mod.rs b/src/header/mod.rs index bc9ac2e..07e0e0c 100644 --- a/src/header/mod.rs +++ b/src/header/mod.rs @@ -11,6 +11,8 @@ use std::fmt::{self, Display}; use charset::decode_latin1; use mailparse::parse_content_type; +use crate::CowMailError; + pub trait Header<'a>: Clone + Display { fn new(key: K, value: V) -> Self; fn from(raw_bytes: &'a [u8], key_end: usize, value_start: usize, value_end: usize) -> Self; @@ -228,11 +230,11 @@ enum HeaderParseState { ValueNewline, } -pub fn parse_header<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(H, usize), &'static str> { +pub fn parse_header<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(H, usize), CowMailError> { let mut it = raw_data.iter(); let mut ix = 0; let mut c = match it.next() { - None => return Err("Empty string provided"), + None => return Err(CowMailError::Generic("Empty string provided".to_string())), Some(v) => *v, }; @@ -245,9 +247,10 @@ pub fn parse_header<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(H, usize), match state { HeaderParseState::Initial => { if c == b' ' { - return Err( - "Header cannot start with a space; it is likely an overhanging line from a previous header", - ); + return Err(CowMailError::Generic( + "Header cannot start with a space; it is likely an overhanging line from a previous header" + .to_string(), + )); }; state = HeaderParseState::Key; continue; diff --git a/src/lib.rs b/src/lib.rs index b357f94..0d07f10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ use header::CowHeader; pub use header::{Header, HeaderMap, parse_headers}; use mailparse::{ - DispositionType, MailParseError, ParsedContentDisposition, ParsedContentType, body::Body, + DispositionType, MailParseError, ParsedContentDisposition, ParsedContentType, ParsedMail, body::Body, parse_content_disposition, parse_content_type, }; pub use mediatype::{MediaType, names, values}; @@ -836,7 +836,7 @@ impl<'a, H: Header<'a> + 'a> Mail<'a, H> for CowMail<'a, H> { } impl<'a> TryFrom<&'a [u8]> for CowMail<'a, CowHeader<'a>> { - type Error = &'static str; + type Error = CowMailError; fn try_from(raw_data: &'a [u8]) -> Result { parse_mail(raw_data) @@ -897,14 +897,44 @@ impl std::io::Read for CowMailReader<'_> { } } -pub fn parse_mail<'a, H: Header<'a> + 'a>(raw_data: &'a [u8]) -> Result, &'static str> { - parse_mail_recursive(raw_data, false) +pub fn parse_mail<'a, H: Header<'a> + 'a>(raw_data: &'a [u8]) -> Result, CowMailError> { + let parsed = mailparse::parse_mail(raw_data)?; + build_mail(raw_data, &parsed, false) } -fn parse_mail_recursive<'a, H: Header<'a> + 'a>( +/// Byte offset of `inner` within `outer`. Both have to point into the same +/// buffer, which holds for every `ParsedMail::raw_bytes` relative to its +/// parent's. Returns `None` instead of panicking, so that a mailparse that +/// stops handing back subslices surfaces as an error, not as a bad index. +fn offset_in(outer: &[u8], inner: &[u8]) -> Option { + let ix = (inner.as_ptr() as usize).checked_sub(outer.as_ptr() as usize)?; + (ix + inner.len() <= outer.len()).then_some(ix) +} + +/// Undo mailparse's `strip_trailing_crlf`: widen `end` over the newline that +/// separates a part from the delimiter line following it, so that the slice +/// covers everything the part occupied in the message. +fn extend_over_newline(raw_data: &[u8], end: usize) -> usize { + if raw_data[end..].starts_with(b"\r\n") { + end + 2 + } else if raw_data[end..].starts_with(b"\n") { + end + 1 + } else { + end + } +} + +/// Build a `CowMail` from a part mailparse has already split out. +/// +/// `raw_data` is the part as it appears in the message, including the newline +/// mailparse strips off before a delimiter line; `parsed` has to start at the +/// same offset. mailparse decides where the parts are, we keep the separators +/// it drops. +fn build_mail<'a, H: Header<'a> + 'a>( raw_data: &'a [u8], + parsed: &ParsedMail<'a>, in_multipart_digest: bool, -) -> Result, &'static str> { +) -> Result, CowMailError> { let (headers, body_idx) = parse_headers::(raw_data)?; let mut body_end_idx = raw_data.len(); @@ -915,13 +945,6 @@ fn parse_mail_recursive<'a, H: Header<'a> + 'a>( end_bytes = b"\r\n"; } - let default_ctype = ContentType::default_conditional(in_multipart_digest); - - let ctype = headers - .find_value("Content-Type") - .map(|s| ContentType::from(s.as_str())) - .unwrap_or(default_ctype.clone()); - let mut mail = CowMail { bytes_orig: raw_data, header_bytes_orig: &raw_data[0..body_idx], @@ -930,73 +953,59 @@ fn parse_mail_recursive<'a, H: Header<'a> + 'a>( body_bytes_orig: &raw_data[body_idx..body_end_idx], subparts: Vec::new(), end_bytes, - default_ctype, + default_ctype: ContentType::default_conditional(in_multipart_digest), }; - if ctype.mimetype.starts_with("multipart/") && ctype.params.contains_key("boundary") && raw_data.len() > body_idx { - let in_multipart_digest = ctype.mimetype == "multipart/digest"; - let boundary = String::from("--") + &ctype.params["boundary"]; - if let Some(body_end_idx) = find_from_u8_line_prefix(raw_data, body_idx, boundary.as_bytes()) { - mail.body_bytes = Cow::Borrowed(&raw_data[body_idx..body_end_idx]); - let mut boundary_end_idx = body_end_idx + boundary.len(); - while let Some(part_start_idx) = find_from_u8(raw_data, boundary_end_idx, b"\n").map(|v| v + 1) { - let part_end_idx = - find_from_u8_line_prefix(raw_data, part_start_idx, boundary.as_bytes()).unwrap_or(raw_data.len()); - mail.subparts.push(parse_mail_recursive( - &raw_data[part_start_idx..part_end_idx], - in_multipart_digest, - )?); - boundary_end_idx = part_end_idx + boundary.len(); - if boundary_end_idx + 2 > raw_data.len() - || (raw_data[boundary_end_idx] == b'-' && raw_data[boundary_end_idx + 1] == b'-') - { - boundary_end_idx += 2; - if boundary_end_idx < raw_data.len() { - mail.end_bytes = &raw_data[boundary_end_idx..]; - } - break; - } - } - } + // An empty subpart list covers both "not a multipart" and "boundary declared + // but never seen"; either way the body stays what it is above. Note that + // mailparse drops the body in the latter case, so its body_bytes is of no use + // to us here. + let Some(first_part) = parsed.subparts.first() else { + return Ok(mail); + }; + + let boundary = parsed + .ctype + .params + .get("boundary") + .ok_or_else(|| CowMailError::Generic("multipart without a boundary parameter".to_string()))?; + + let offset_of = |part: &ParsedMail<'a>| { + offset_in(raw_data, part.raw_bytes) + .ok_or_else(|| CowMailError::Generic("subpart is not a slice of its parent".to_string())) + }; + + // The preamble ends where the delimiter line before the first part begins. + let first_part_idx = offset_of(first_part)?; + let scan_end_idx = first_part_idx.saturating_sub(1).max(body_idx); + let mut delimiter_idx = raw_data[body_idx..scan_end_idx] + .iter() + .rposition(|&byte| byte == b'\n') + .map(|ix| body_idx + ix + 1) + .unwrap_or(body_idx); + + mail.body_bytes = Cow::Borrowed(&raw_data[body_idx..delimiter_idx]); + mail.body_bytes_orig = &raw_data[body_idx..delimiter_idx]; + + let in_multipart_digest = parsed.ctype.mimetype == "multipart/digest"; + + for part in &parsed.subparts { + let part_idx = offset_of(part)?; + // Widening over the stripped newline is what keeps an empty part, or one + // whose body ends in a blank line, byte-exact: the last newline is the + // part's terminator, not part of the delimiter. + let part_end_idx = extend_over_newline(raw_data, part_idx + part.raw_bytes.len()); + mail.subparts + .push(build_mail(&raw_data[part_idx..part_end_idx], part, in_multipart_digest)?); + // The part ends right where the next delimiter line begins. + delimiter_idx = part_end_idx; } + + // Everything after the closing delimiter is the epilogue. + let closing_idx = delimiter_idx + 2 + boundary.len(); + if closing_idx + 2 <= raw_data.len() && &raw_data[closing_idx..closing_idx + 2] == b"--" { + mail.end_bytes = &raw_data[closing_idx + 2..]; + } + Ok(mail) } - -fn find_from_u8(line: &[u8], ix_start: usize, key: &[u8]) -> Option { - assert!(!key.is_empty()); - assert!(ix_start < line.len()); - if line.len() < key.len() { - return None; - } - let ix_end = line.len() - key.len(); - if ix_start <= ix_end { - for i in ix_start..ix_end { - if line[i] == key[0] { - let mut success = true; - for j in 1..key.len() { - if line[i + j] != key[j] { - success = false; - break; - } - } - if success { - return Some(i); - } - } - } - } - None -} - -/// Like find_from_u8, but additionally filters such that `key` is at the start -/// of a line (preceded by `\n`) or at the start of the search space. -fn find_from_u8_line_prefix(line: &[u8], ix_start: usize, key: &[u8]) -> Option { - let mut start = ix_start; - while let Some(ix) = find_from_u8(line, start, key) { - if ix == ix_start || line[ix - 1] == b'\n' { - return Some(ix); - } - start = ix + 1; - } - None -} diff --git a/src/tests.rs b/src/tests.rs index 3267475..456af23 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -654,3 +654,112 @@ assert_eq!(mail.subparts[1].content_type().mimetype, "multipart/alternative"); assert!(mail.subparts[2].is_attachment()); } + + // Test: the parser splits preamble, parts, terminators and epilogue the way + // serialization expects them back. + #[test] + fn test_parse_part_boundaries() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "preamble\r\n", + "--bound\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "Plain text\r\n", + "--bound\r\n", + "Content-Type: text/html\r\n", + "\r\n", + "

HTML

\r\n", + "\r\n", + "--bound--\r\n", + "epilogue\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + assert_eq!(mail.raw_body(), b"preamble\r\n"); + assert_eq!(mail.end_bytes, b"\r\nepilogue\r\n"); + assert_eq!(mail.subparts.len(), 2); + + assert_eq!(mail.subparts[0].raw_body(), b"Plain text"); + assert_eq!(mail.subparts[0].end_bytes, b"\r\n"); + // A body ending in a blank line keeps both CRLFs: one for the body, one + // as the part terminator. + assert_eq!(mail.subparts[1].raw_body(), b"

HTML

\r\n"); + assert_eq!(mail.subparts[1].end_bytes, b"\r\n"); + } + + // Test: an empty part keeps its terminator instead of swallowing the header + // separator. + #[test] + fn test_parse_empty_part() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "--bound\r\n", + "Subject: empty\r\n", + "\r\n", + "--bound--\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + assert_eq!(mail.subparts.len(), 1); + assert_eq!(mail.subparts[0].bytes_orig, b"Subject: empty\r\n\r\n"); + assert_eq!(mail.subparts[0].raw_body(), b""); + assert_eq!(mail.subparts[0].end_bytes, b""); + } + + // Test: a declared boundary that never appears must not lose the body + // (mailparse drops it in that case). + #[test] + fn test_parse_missing_boundary_keeps_body() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "no boundary here\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + assert!(mail.subparts.is_empty()); + assert_eq!(mail.raw_body(), b"no boundary here"); + assert_eq!(mail.end_bytes, b"\r\n"); + } + + // Test: a part of a multipart/digest defaults to message/rfc822 + #[test] + fn test_parse_digest_default_ctype() { + let raw = concat!( + "Content-Type: multipart/digest; boundary=\"bound\"\r\n", + "\r\n", + "--bound\r\n", + "\r\n", + "Subject: forwarded\r\n", + "\r\n", + "--bound--\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + assert_eq!(mail.subparts.len(), 1); + assert_eq!(mail.subparts[0].content_type().mimetype, "message/rfc822"); + } + + // Test: a closing delimiter at the very end of the buffer, with no trailing + // CRLF, used to be missed by the hand-rolled boundary search. + #[test] + fn test_parse_closing_boundary_at_eof() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "--bound\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "Plain text\r\n", + "--bound--" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + assert_eq!(mail.subparts.len(), 1); + assert_eq!(mail.subparts[0].raw_body(), b"Plain text"); + assert_eq!(mail.subparts[0].end_bytes, b"\r\n"); + assert_eq!(mail.end_bytes, b""); + }