From a0e14b180b7347b84bcdeca0db5dce287552fc68 Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Sat, 10 Oct 2026 20:09:03 +0200 Subject: [PATCH] document CowMail methods, serialization and CowMailReader --- src/lib.rs | 298 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 2382770..65ae800 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -512,6 +512,16 @@ impl<'a> Default for CowMail<'a, CowHeader<'a>> { } impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { + /// An empty part: no headers, no body, no subparts. + /// + /// Borrows nothing, so the returned value is valid for any lifetime. [`end_bytes`] starts out + /// empty — there is nothing to terminate yet — and [`delimiter_end`] CRLF, for the case the + /// part is added to a container. + /// + /// Use [`CowMail::default`] to get one with the default header type, [`CowHeader`]. + /// + /// [`end_bytes`]: Self::end_bytes + /// [`delimiter_end`]: Self::delimiter_end pub fn new() -> Self { Self { bytes_orig: &[], @@ -535,6 +545,29 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { PartInfo { ctype, media } } + /// Replace the content of this part with text. + /// + /// Drops the existing content first — all `Content-*` headers, the body and the subparts, see + /// [`Mail::clear_content`] — then writes `content`, declaring `ctype` (`text/plain` when + /// `None`) and whatever `Content-Transfer-Encoding` [`body::encode`] picked. A `charset=UTF-8` + /// parameter is added for non-ASCII content unless `ctype` already carries a charset. + /// + /// # Examples + /// + /// ``` + /// use email_message::{CowMail, HeaderMap, Mail, MediaType, names}; + /// + /// let mut part = CowMail::default(); + /// part.set_content("

hi

", Some(MediaType::new(names::TEXT, names::HTML))); + /// + /// assert_eq!(part.content_type().mimetype, "text/html"); + /// assert_eq!(part.get_content().unwrap(), "

hi

"); + /// + /// // Non-ASCII text gets a charset, and an encoding that survives transport. + /// part.set_content("Grüße", None); + /// assert_eq!(part.find_value("Content-Type").unwrap(), "text/plain; charset=UTF-8"); + /// assert_eq!(part.get_content().unwrap(), "Grüße"); + /// ``` pub fn set_content(&mut self, content: T, ctype: Option) { let content = content.to_string(); @@ -546,6 +579,12 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { self.set_content_raw(content, Some(ctype)); } + /// Replace the content of this part with bytes. + /// + /// Like [`set_content`](Self::set_content), but without the charset handling, and `ctype` is + /// left off entirely when `None`. Binary content always ends up base64-encoded; pass a + /// [`BodyContent::Text`] (or a `&str`/`String`, which convert into one) to allow the more + /// compact encodings. pub fn set_content_raw>(&mut self, content: B, ctype: Option) { self.clear_content(); @@ -562,18 +601,35 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { self.end_bytes = b"\r\n"; } + /// The filename this part declares, if any. + /// + /// The `filename` parameter of `Content-Disposition`, falling back to the deprecated `name` + /// parameter of `Content-Type`. pub fn get_filename(&self) -> Option { self.content_disposition() .and_then(|disp| disp.params.get("filename").cloned()) .or_else(|| self.content_type().params.get("name").cloned()) } + /// The `boundary` parameter of this part's `Content-Type` header, if it has one. + /// + /// Reads the header directly, so it is `None` for a part with no `Content-Type` — unlike + /// [`Mail::message_type`], which falls back to the default content type. pub fn get_boundary(&self) -> Option { self.headers .find_header("Content-Type") .and_then(|h| h.get_param("boundary")) } + /// Set the `boundary` parameter of this part's `Content-Type` header. + /// + /// The subparts keep serializing correctly afterwards, because each one stores only the *tail* + /// of its delimiter line, not the boundary — see [`delimiter_end`](Self::delimiter_end). + /// + /// # Errors + /// + /// [`CowMailError::MissingContentTypeHeader`] when the part has no `Content-Type` header to + /// put the parameter on. Call [`make_mixed`](Self::make_mixed) or one of its siblings first. pub fn set_boundary(&mut self, boundary: T) -> Result<(), CowMailError> { let header = self .headers @@ -656,11 +712,45 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { } } + /// The part holding the `text/plain` or `text/html` body, searching the whole subpart tree. + /// + /// A part qualifies when it is a leaf of content type `text/plain` or `text/html` matching + /// `bodytype` and is not an attachment. Containers are searched depth-first in order, except + /// that a `multipart/related` naming a root part with its `start` parameter — matched against + /// the subparts' `Content-ID` headers — searches only that one. Returns `self` when this part + /// is itself the body. + /// + /// # Examples + /// + /// ``` + /// use email_message::{BodyType, CowMail, Mail}; + /// + /// let raw = b"Content-Type: multipart/alternative; boundary=\"b\"\r\n\ + /// \r\n\ + /// --b\r\n\ + /// Content-Type: text/plain\r\n\ + /// \r\n\ + /// plain\r\n\ + /// --b\r\n\ + /// Content-Type: text/html\r\n\ + /// \r\n\ + ///

html

\r\n\ + /// --b--\r\n"; + /// + /// let mail = CowMail::try_from(&raw[..]).unwrap(); + /// let html = mail.get_body_part(BodyType::HTML).unwrap(); + /// assert_eq!(html.get_content().unwrap(), "

html

"); + /// ``` pub fn get_body_part(&self, bodytype: BodyType) -> Option<&CowMail<'a, H>> { let path = self.find_body_path(bodytype)?; Some(self.get_part_at_path(&path)) } + /// The part holding the `text/plain` or `text/html` body, mutably. + /// + /// Same search as [`get_body_part`](Self::get_body_part). Modifying the part this returns is + /// how [`set_text_body`](Self::set_text_body) and [`set_html_body`](Self::set_html_body) + /// update an existing body. pub fn get_body_part_mut(&mut self, bodytype: BodyType) -> Option<&mut CowMail<'a, H>> { let path = self.find_body_path(bodytype)?; Some(self.get_part_at_path_mut(&path)) @@ -708,14 +798,50 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { self.subparts.push(subpart); } + /// Turn this part into a `multipart/mixed` container holding its former content. + /// + /// All `Content-*` headers, the body bytes, any existing subparts and + /// [`end_bytes`](Self::end_bytes) move into a new first subpart; this part keeps its remaining + /// headers and gains `MIME-Version: 1.0` (if absent) plus a `Content-Type` of + /// `multipart/mixed` with `boundary`, or a fresh [`generate_boundary`] when that is `None`. + /// + /// Does nothing when this part already is a `multipart/mixed`, which is what makes + /// [`add_attachment`](Self::add_attachment) work on a container as well as on a leaf. pub fn make_mixed(&mut self, boundary: Option) { self.make_multipart(MIXED, boundary) } + /// Turn this part into a `multipart/alternative` container holding its former content. + /// + /// Same move semantics and the same no-op rule as [`make_mixed`](Self::make_mixed). + /// + /// # Examples + /// + /// ``` + /// use email_message::{CowMail, HeaderMap, Mail}; + /// + /// let mut mail = CowMail::default(); + /// mail.add_header("Subject", "Hi"); + /// mail.set_content("plain", None); + /// + /// mail.make_alternative(Some("boundary".to_string())); + /// + /// // The content moved down one level; Subject stayed up top. + /// assert_eq!(mail.content_type().mimetype, "multipart/alternative"); + /// assert_eq!(mail.find_value("Subject").unwrap(), "Hi"); + /// assert_eq!(mail.subparts[0].get_content().unwrap(), "plain"); + /// + /// // Calling it again is a no-op: no extra nesting. + /// mail.make_alternative(None); + /// assert_eq!(mail.subparts.len(), 1); + /// ``` pub fn make_alternative(&mut self, boundary: Option) { self.make_multipart(ALTERNATIVE, boundary) } + /// Turn this part into a `multipart/related` container holding its former content. + /// + /// Same move semantics and the same no-op rule as [`make_mixed`](Self::make_mixed). pub fn make_related(&mut self, boundary: Option) { self.make_multipart(RELATED, boundary) } @@ -727,6 +853,11 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { self.subparts.push(subpart); } + /// Add `subpart` as a related part — an inline image, say — wrapping this part into a + /// `multipart/related` first if it is not one already. + /// + /// `subpart` gains `Content-Disposition: inline` unless it already declares a disposition. + /// Give it a `Content-ID` header if the body is going to reference it with a `cid:` URL. pub fn add_related(&mut self, mut subpart: CowMail<'a, H>) { if subpart.headers.find_header("Content-Disposition").is_none() { subpart.headers.add_header("Content-Disposition", "inline"); @@ -734,10 +865,40 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { self.add_multipart(subpart, RELATED) } + /// Add `subpart` as an alternative rendering of this part's content, wrapping this part into a + /// `multipart/alternative` first if it is not one already. + /// + /// Alternatives are ordered least to most preferred, per RFC 2046 section 5.1.4, so the part + /// added last is the one a client should prefer. pub fn add_alternative(&mut self, subpart: CowMail<'a, H>) { self.add_multipart(subpart, ALTERNATIVE) } + /// Add `subpart` as an attachment, wrapping this part into a `multipart/mixed` first if it is + /// not one already. + /// + /// `subpart` gains `Content-Disposition: attachment` unless it already declares a disposition. + /// + /// # Examples + /// + /// ``` + /// use email_message::{CowMail, HeaderMap, Mail, MediaType, names}; + /// + /// let mut mail = CowMail::default(); + /// mail.set_text_body("see attached").unwrap(); + /// + /// let mut attachment = CowMail::new(); + /// attachment.set_content_raw( + /// &b"\x89PNG\r\n\x1a\n"[..], + /// Some(MediaType::new(names::IMAGE, names::PNG)), + /// ); + /// mail.add_attachment(attachment); + /// + /// assert_eq!(mail.content_type().mimetype, "multipart/mixed"); + /// let last = mail.subparts.last().unwrap(); + /// assert!(last.is_attachment()); + /// assert_eq!(last.find_value("Content-Transfer-Encoding").unwrap(), "base64"); + /// ``` pub fn add_attachment(&mut self, mut subpart: CowMail<'a, H>) { if subpart.headers.find_header("Content-Disposition").is_none() { subpart.headers.add_header("Content-Disposition", "attachment"); @@ -849,19 +1010,71 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { Ok(()) } + /// Set the `text/plain` body of the message, wherever it belongs in the structure. + /// + /// Three cases, in order: + /// + /// 1. A plain body already exists — it is updated in place. + /// 2. The message has no text body at all — the part is wrapped into a `multipart/mixed` if it + /// is not already a multipart, and the new body is inserted as the first subpart. + /// 3. Only an HTML body exists — the two are paired in a `multipart/alternative`, created + /// where the structure requires it. + /// + /// The plain part is inserted before the HTML one, per RFC 2046 section 5.1.4. + /// + /// # Errors + /// + /// Currently infallible; the [`Result`] is part of the signature so that future structural + /// checks do not break callers. pub fn set_text_body(&mut self, content: T) -> Result<(), CowMailError> { self.set_body_part(content, BodyType::PLAIN) } + /// Set the `text/html` body of the message, wherever it belongs in the structure. + /// + /// The same three cases as [`set_text_body`](Self::set_text_body), except that the HTML part is + /// appended rather than inserted first. + /// + /// # Errors + /// + /// Currently infallible; see [`set_text_body`](Self::set_text_body). pub fn set_html_body(&mut self, content: T) -> Result<(), CowMailError> { self.set_body_part(content, BodyType::HTML) } + /// Set both bodies at once: [`set_text_body`](Self::set_text_body) followed by + /// [`set_html_body`](Self::set_html_body). + /// + /// # Errors + /// + /// Currently infallible; see [`set_text_body`](Self::set_text_body). + /// + /// # Examples + /// + /// ``` + /// use email_message::{BodyType, CowMail, HeaderMap, Mail}; + /// + /// let mut mail = CowMail::default(); + /// mail.add_header("Subject", "Hi"); + /// mail.set_body("plain", "

html

").unwrap(); + /// + /// assert_eq!(mail.content_type().mimetype, "multipart/alternative"); + /// assert_eq!(mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(), "plain"); + /// assert_eq!(mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), "

html

"); + /// + /// // Plain comes first. + /// assert_eq!(mail.subparts[0].content_type().mimetype, "text/plain"); + /// ``` pub fn set_body(&mut self, text: T, html: T) -> Result<(), CowMailError> { self.set_text_body(text)?; self.set_html_body(html) } + /// The header block of this part, terminated by the blank line that separates it from the body. + /// + /// One chunk per header, borrowed from the original message where the header was not modified. + /// The blank line follows the line ending of the header right before it, so an untouched + /// LF-terminated message keeps LF while rewritten headers use CRLF. pub fn serialize_headers(&self) -> Vec> { let mut res: Vec> = self.headers.iter().flat_map(|h| h.serialize()).collect(); // The blank line between headers and body follows the line ending of the @@ -876,6 +1089,12 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { res } + /// The body of this part: the raw bytes for a leaf, or preamble, delimiter lines, serialized + /// subparts and closing delimiter for a container. + /// + /// A part that declares a boundary but has no subparts — because the boundary never appeared + /// in the message — is written out as the body it was parsed with; no closing delimiter is + /// invented for it. pub fn serialize_body(&self) -> Vec> { match self.message_type() { MessageType::MultiPart(boundary) if !self.subparts.is_empty() => { @@ -901,6 +1120,27 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { } } + /// The whole part as a list of chunks: [`serialize_headers`] followed by [`serialize_body`]. + /// + /// Chunks rather than one buffer, so that everything untouched stays borrowed from the message + /// this part was parsed from and is never copied. Use `.concat()` for a single `Vec`, + /// [`CowMailReader`] to stream it, or write the chunks out one by one. + /// + /// For a message that was parsed and not modified, the result is byte-identical to the input. + /// + /// [`serialize_headers`]: Self::serialize_headers + /// [`serialize_body`]: Self::serialize_body + /// + /// # Examples + /// + /// ``` + /// use email_message::CowMail; + /// + /// let raw = b"Subject: Hi\r\n\r\nbody\r\n"; + /// let mail = CowMail::try_from(&raw[..]).unwrap(); + /// + /// assert_eq!(mail.serialize().concat(), raw.to_vec()); + /// ``` pub fn serialize(&self) -> Vec> { let mut res = self.serialize_headers(); res.append(&mut self.serialize_body()); @@ -954,6 +1194,34 @@ impl<'a> TryFrom<&'a [u8]> for CowMail<'a, CowHeader<'a>> { } } +/// A [`std::io::Read`] over a serialized message, without building it in memory first. +/// +/// Walks the chunks of [`CowMail::serialize`] in order, so the message is copied only into the +/// caller's buffer. Create one with [`CowMailReader::new`] or `CowMailReader::from(&mail)`, and +/// restrict it to one half with [`skip_headers`] or [`skip_body`]. +/// +/// [`skip_headers`]: Self::skip_headers +/// [`skip_body`]: Self::skip_body +/// +/// # Examples +/// +/// ``` +/// use std::io::Read; +/// use email_message::{CowMail, CowMailReader}; +/// +/// let raw = b"Subject: Hi\r\n\r\nbody\r\n"; +/// let mail = CowMail::try_from(&raw[..]).unwrap(); +/// +/// let mut out = Vec::new(); +/// CowMailReader::new(&mail).read_to_end(&mut out).unwrap(); +/// assert_eq!(out, raw.to_vec()); +/// +/// let mut reader = CowMailReader::new(&mail); +/// reader.skip_headers(); +/// let mut body = String::new(); +/// reader.read_to_string(&mut body).unwrap(); +/// assert_eq!(body, "body\r\n"); +/// ``` pub struct CowMailReader<'a> { headers: Box> + 'a>, body: Box> + 'a>, @@ -961,14 +1229,21 @@ pub struct CowMailReader<'a> { } impl<'a> CowMailReader<'a> { + /// A reader over `msg`, headers first, then the body. + /// + /// `msg` is borrowed for as long as the reader lives. pub fn new>(msg: &'a CowMail<'a, H>) -> CowMailReader<'a> { CowMailReader::from(msg) } + /// Drop the headers from what is left to read, so only the body comes out. + /// + /// Has no effect once reading has moved past the headers. pub fn skip_headers(&mut self) { self.headers = Box::new(std::iter::empty()); } + /// Drop the body from what is left to read, so only the headers come out. pub fn skip_body(&mut self) { self.body = Box::new(std::iter::empty()); } @@ -1016,6 +1291,29 @@ impl std::io::Read for CowMailReader<'_> { } } +/// Parse a message, borrowing from `raw_data`. +/// +/// Generic over the header representation; [`CowMail::try_from`] is the same thing fixed to +/// [`CowHeader`] and is the usual way in. +/// +/// The MIME structure is decided by [`mailparse::parse_mail`] — boundary matching, +/// `multipart/digest` propagation, and its limit on nesting depth. What this adds is the +/// separators mailparse discards, which is what makes [`CowMail::serialize`] byte-exact. +/// +/// # Errors +/// +/// [`CowMailError::MailParseError`] when mailparse rejects the message; the other variants signal +/// a broken invariant of the bridge to mailparse and should not occur. +/// +/// # Examples +/// +/// ``` +/// use email_message::{CowHeader, CowMail, HeaderMap, parse_mail}; +/// +/// let raw = b"Subject: Hi\r\n\r\nbody\r\n"; +/// let mail: CowMail = parse_mail(&raw[..]).unwrap(); +/// assert_eq!(mail.find_value("Subject").unwrap(), "Hi"); +/// ``` 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)