diff --git a/README.md b/README.md new file mode 100644 index 0000000..5003452 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# email-message + +A Rust library for parsing, modifying and serializing email messages (RFC 5322 / MIME). + +Copy-on-write throughout: a parsed message borrows from the buffer it was parsed from, and only the +fields you actually change become owned. Serializing hands back a `Vec>` of borrowed and +owned chunks rather than one concatenated buffer, so untouched parts are never copied. + +`parse → serialize` is **byte-identical**. The separators a message was written with — line endings, +delimiter-line padding, preamble and epilogue — are kept verbatim, so a message can be read, +modified in one place, and written back without disturbing anything else. This matters for signed +messages and for anything that has to pass a message through unchanged. + +## Quickstart + +```rust +use email_message::{CowMail, HeaderMap, Mail}; + +let raw = b"Subject: Hello\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + Hi there\r\n"; + +let mut mail = CowMail::try_from(&raw[..]).unwrap(); + +// Read headers and the decoded body. +assert_eq!(mail.find_value("Subject").unwrap(), "Hello"); +assert_eq!(mail.get_content().unwrap(), "Hi there"); + +// An untouched message serializes back to exactly the bytes it was parsed from. +assert_eq!(mail.serialize().concat(), raw.to_vec()); + +// Modify it. +mail.add_header("X-Mailer", "email-message"); +mail.set_text_body("Hi again").unwrap(); + +let out: Vec = mail.serialize().concat(); +assert!(out.starts_with(b"Subject: Hello")); +``` + +Building a multipart message from scratch: + +```rust +use email_message::{CowMail, HeaderMap, Mail, MediaType, names}; + +let mut mail = CowMail::default(); +mail.add_header("Subject", "Report"); +mail.set_body("plain text version", "

html version

").unwrap(); + +let mut attachment = CowMail::new(); +attachment.add_header("Content-Disposition", "attachment; filename=\"report.csv\""); +attachment.set_content("a,b\n1,2\n", Some(MediaType::new(names::TEXT, names::CSV))); +mail.add_attachment(attachment); + +assert!(mail.is_multipart()); +``` + +## Design + +- **`CowMail`** is the message, and every part of a multipart message is a `CowMail` as well. Its + fields are public: `headers`, `body_bytes` and `subparts` are meant to be edited directly when the + provided methods do not fit. +- The `*_orig` fields (`bytes_orig`, `header_bytes_orig`, `body_bytes_orig`) keep the originally + parsed slices even after `body_bytes` has become owned, so the original and the modified message + can be compared. +- **`HeaderMap`** finds, iterates and edits headers by key; keys match case-insensitively and an + `index` argument counts occurrences of that key, not positions in the list. It is implemented for + `CowMail` and for `Vec`. +- **`Mail`** adds the MIME questions on top: content type, disposition, multipart-ness, and decoding + the body. +- **`Header`** abstracts a single header line; `CowHeader` is the default implementation and stores + the raw line plus the offsets of its key and value. +- MIME structure is decided by [`mailparse`](https://crates.io/crates/mailparse) — boundary + matching, `multipart/digest` propagation, recursion limits. This crate keeps the separators + mailparse discards, which is what makes the round-trip exact. + +## Limitations + +- Anything written by this crate uses CRLF. A message whose headers were rewritten therefore mixes + line endings with its untouched parts — that is deliberate: fidelity for what was not touched. +- A message truncated before its closing delimiter gains a `--boundary--` on the way out, because + its declared structure says there should be one. +- A message that ends with its header block and no blank line gains that blank line, because the + header/body separator is always written. + +## License + +GPL-3.0. See [LICENSE](LICENSE). diff --git a/src/body.rs b/src/body.rs index c77a8b1..02c39c3 100644 --- a/src/body.rs +++ b/src/body.rs @@ -1,4 +1,9 @@ //! Body transfer-encoding. +//! +//! [`encode`] takes the content of a part and returns the encoded bytes together with the +//! [`ContentTransferEncoding`] to declare for them. It picks the most compact of `7bit`, +//! `quoted-printable` and `base64`, so a plain ASCII body stays readable in the serialized message +//! while binary content ends up base64-encoded. use std::fmt::{self, Display}; use std::mem; diff --git a/src/header/encoding.rs b/src/header/encoding.rs index dbbb26a..894af70 100644 --- a/src/header/encoding.rs +++ b/src/header/encoding.rs @@ -1,3 +1,5 @@ +//! RFC 2047 encoding and folding of header values. + use std::fmt::Write; use email_encoding::headers::writer::EmailWriter; diff --git a/src/header/headers.rs b/src/header/headers.rs index 52bb187..85da09b 100644 --- a/src/header/headers.rs +++ b/src/header/headers.rs @@ -1,3 +1,5 @@ +//! The [`HeaderMap`] collection trait and header-block parsing. + use mailparse::MailHeader; use super::{Header, build_header}; diff --git a/src/header/mod.rs b/src/header/mod.rs index 064f0d8..87c3568 100644 --- a/src/header/mod.rs +++ b/src/header/mod.rs @@ -1,3 +1,13 @@ +//! Header lines. +//! +//! A header is abstracted by the [`Header`] trait, which is generic over the storage so that a +//! message can be parsed into whatever representation the caller needs. [`CowHeader`] is the +//! default implementation: it keeps the raw line — key, separator, value and trailing newline — +//! as a [`Cow`], and becomes owned only when the value is changed. +//! +//! [`HeaderMap`] is the collection side: finding, iterating and editing headers by key. It is +//! implemented for `Vec` and for [`CowMail`](crate::CowMail). + mod encoding; mod headers; @@ -255,8 +265,7 @@ fn build_header<'a, H: Header<'a>>( next: Option<&MailHeader<'a>>, ) -> Result<(H, usize), CowMailError> { let offset_of = |bytes: &[u8]| { - offset_in(raw_data, bytes) - .ok_or_else(|| CowMailError::Generic("header is not a slice of its part".to_string())) + offset_in(raw_data, bytes).ok_or(CowMailError::HeaderNotASubslice) }; let key = parsed.get_key_raw(); diff --git a/src/lib.rs b/src/lib.rs index 73e22fb..1d8f4ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,40 +1,83 @@ +#![doc = include_str!("../README.md")] #![forbid(unsafe_code)] +#![warn(missing_docs)] +#![warn(missing_debug_implementations)] +#![warn(rustdoc::broken_intra_doc_links)] +/// Body transfer-encoding: choosing one, and the types describing it. pub mod body; +/// Header lines: the [`Header`] trait, [`CowHeader`](header::CowHeader), and header-value encoding. pub mod header; #[cfg(test)] mod tests; pub use body::{BodyContent, ContentTransferEncoding}; -use header::{CowHeader, build_headers}; -pub use header::{Header, HeaderMap, parse_headers}; +pub use header::{CowHeader, Header, HeaderMap, parse_header, parse_headers}; +use header::build_headers; -use mailparse::{ - DispositionType, MailParseError, ParsedContentDisposition, ParsedContentType, ParsedMail, body::Body, - parse_content_disposition, parse_content_type, -}; +/// Re-exported from [`mailparse`], which parses the MIME structure for this crate: these types +/// appear in this crate's public API, so depending on `mailparse` separately is neither necessary +/// nor advisable — the version re-exported here is the one these signatures refer to. +pub use mailparse::{DispositionType, MailParseError, ParsedContentDisposition, ParsedContentType}; +use mailparse::{ParsedMail, body::Body, parse_content_disposition, parse_content_type}; + +/// Re-exported from [`mediatype`]. [`MediaType`] is the content type accepted by +/// [`CowMail::set_content`] and [`CowMail::set_content_raw`], and [`names`] / [`values`] hold the +/// typed constants to build one with, so depending on `mediatype` separately is neither necessary +/// nor advisable. pub use mediatype::{MediaType, names, values}; use mediatype::{MediaTypeBuf, Name, ReadParams, WriteParams, names::*, values::UTF_8}; -use rand::{distr::Alphanumeric, rng, RngExt}; +use rand::{RngExt, distr::Alphanumeric, rng}; use std::borrow::Cow; use std::ops::Deref; +/// Everything that can go wrong while parsing or modifying a message. #[derive(Debug)] pub enum CowMailError { - Generic(String), + /// mailparse failed to parse the message. Carries its error unchanged. MailParseError(MailParseError), + /// A part declares a `multipart/*` content type without a `boundary` parameter. + /// + /// Only reachable from [`parse_mail`], and only for a part mailparse did split into subparts — + /// which it cannot do without a boundary. In other words: a broken invariant of the bridge to + /// mailparse, not malformed input. + MissingBoundary, + /// A header line mailparse scanned does not point into the part it was scanned from. + /// + /// Header positions are recovered by comparing pointers (see the crate's `offset_in`), which + /// relies on every key and value being an exact subslice of its part. A mailparse that stops + /// doing that surfaces here instead of as a wrong index. + HeaderNotASubslice, + /// A subpart mailparse split out does not point into its parent's bytes. + /// + /// The same invariant as [`HeaderNotASubslice`](Self::HeaderNotASubslice), one level up: part + /// positions are recovered from `ParsedMail::raw_bytes` being an exact subslice of its + /// parent's. + SubpartNotASubslice, + /// The `Content-Type` header a modification needs is absent. + /// + /// Returned by [`CowMail::set_boundary`], which has no header to put the parameter on. MissingContentTypeHeader, } -impl std::error::Error for CowMailError {} +impl std::error::Error for CowMailError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::MailParseError(error) => Some(error), + _ => None, + } + } +} impl std::fmt::Display for CowMailError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Generic(error) => write!(f, "{}", error), Self::MailParseError(error) => write!(f, "{}", error), - Self::MissingContentTypeHeader => write!(f, "missing ContentType header"), + Self::MissingBoundary => write!(f, "multipart without a boundary parameter"), + Self::HeaderNotASubslice => write!(f, "header is not a slice of its part"), + Self::SubpartNotASubslice => write!(f, "subpart is not a slice of its parent"), + Self::MissingContentTypeHeader => write!(f, "missing Content-Type header"), } } } @@ -731,6 +774,14 @@ impl<'a> CowMailReader<'a> { } } +impl std::fmt::Debug for CowMailReader<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CowMailReader") + .field("current", &self.current) + .finish_non_exhaustive() + } +} + impl<'a, H: Header<'a>> From<&'a CowMail<'a, H>> for CowMailReader<'a> { fn from(msg: &'a CowMail<'a, H>) -> Self { CowMailReader { @@ -849,11 +900,10 @@ fn build_mail<'a, H: Header<'a> + 'a>( .ctype .params .get("boundary") - .ok_or_else(|| CowMailError::Generic("multipart without a boundary parameter".to_string()))?; + .ok_or(CowMailError::MissingBoundary)?; 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())) + offset_in(raw_data, part.raw_bytes).ok_or(CowMailError::SubpartNotASubslice) }; // The preamble ends where the delimiter line before the first part begins.