Skip to main content

mail_builder/
mime.rs

1use std::borrow::Cow;
2
3use crate::{
4    encoders::{base64::MIME, encode::BodyEncoding},
5    headers::{
6        Header, HeaderType,
7        content_type::ContentType,
8        fold::{FoldWriter, write_control_free},
9        message_id::MessageId,
10        raw::Raw,
11        text::Text,
12    },
13    writer::Writer,
14};
15
16/// MIME part of an e-mail.
17#[derive(Clone, Debug)]
18pub struct MimePart<'x> {
19    pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
20    pub contents: BodyPart<'x>,
21}
22
23#[derive(Clone, Debug)]
24pub enum BodyPart<'x> {
25    Text(Cow<'x, str>),
26    Binary(Cow<'x, [u8]>),
27    Multipart(Vec<MimePart<'x>>),
28}
29
30impl<'x> From<&'x str> for BodyPart<'x> {
31    fn from(value: &'x str) -> Self {
32        BodyPart::Text(value.into())
33    }
34}
35
36impl<'x> From<&'x [u8]> for BodyPart<'x> {
37    fn from(value: &'x [u8]) -> Self {
38        BodyPart::Binary(value.into())
39    }
40}
41
42impl From<String> for BodyPart<'_> {
43    fn from(value: String) -> Self {
44        BodyPart::Text(value.into())
45    }
46}
47
48impl<'x> From<&'x String> for BodyPart<'x> {
49    fn from(value: &'x String) -> Self {
50        BodyPart::Text(value.as_str().into())
51    }
52}
53
54impl<'x> From<Cow<'x, str>> for BodyPart<'x> {
55    fn from(value: Cow<'x, str>) -> Self {
56        BodyPart::Text(value)
57    }
58}
59
60impl From<Vec<u8>> for BodyPart<'_> {
61    fn from(value: Vec<u8>) -> Self {
62        BodyPart::Binary(value.into())
63    }
64}
65
66impl<'x> From<Vec<MimePart<'x>>> for BodyPart<'x> {
67    fn from(value: Vec<MimePart<'x>>) -> Self {
68        BodyPart::Multipart(value)
69    }
70}
71
72impl<'x> From<&'x str> for ContentType<'x> {
73    fn from(value: &'x str) -> Self {
74        ContentType::new(value)
75    }
76}
77
78impl From<String> for ContentType<'_> {
79    fn from(value: String) -> Self {
80        ContentType::new(value)
81    }
82}
83
84impl<'x> From<&'x String> for ContentType<'x> {
85    fn from(value: &'x String) -> Self {
86        ContentType::new(value.as_str())
87    }
88}
89
90const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
91
92/// Writes `value` as lowercase hexadecimal digits without allocating.
93#[inline]
94fn write_hex(output: &mut impl Writer, mut value: u64) {
95    let mut buffer = [0u8; 16];
96    let mut at = buffer.len();
97    loop {
98        at -= 1;
99        if let Some(slot) = buffer.get_mut(at) {
100            *slot = HEX_DIGITS[(value & 0x0F) as usize];
101        }
102        value >>= 4;
103        if value == 0 || at == 0 {
104            break;
105        }
106    }
107    output.write(buffer.get(at..).unwrap_or_default());
108}
109
110/// Writes a random MIME boundary without allocating.
111///
112/// The boundary is 128 random bits split into two hexadecimal fields joined by
113/// `separator`, which should be a non-empty string. Boundaries never contain
114/// characters that are forbidden in a MIME boundary, nor any whitespace.
115pub fn write_boundary(output: &mut impl Writer, separator: &str) {
116    write_hex(output, rand::random::<u64>());
117    output.write(separator.as_bytes());
118    write_hex(output, rand::random::<u64>());
119}
120
121/// Returns a random MIME boundary. See [`write_boundary`].
122pub fn make_boundary(separator: &str) -> String {
123    let mut boundary = Vec::with_capacity(2 * 16 + separator.len());
124    write_boundary(&mut boundary, separator);
125    // The output is ASCII, so it is always valid UTF-8.
126    String::from_utf8(boundary).unwrap_or_default()
127}
128
129/// Returns a random boundary that does not occur at the start of a line in any
130/// of `parts`.
131///
132/// RFC 2046 section 5.1 requires that a boundary delimiter "MUST NOT appear
133/// inside any of the encapsulated parts", so a generated boundary is verified
134/// against the part contents before it is used. The chance of a false match is
135/// negligible, but checking keeps messages unambiguous even when a part body
136/// happens to contain an attacker-chosen value.
137fn fresh_boundary(parts: &[MimePart<'_>]) -> String {
138    loop {
139        let boundary = make_boundary("_");
140        if !parts
141            .iter()
142            .any(|part| part_contains_boundary(part, boundary.as_bytes()))
143        {
144            return boundary;
145        }
146    }
147}
148
149fn part_contains_boundary(part: &MimePart<'_>, boundary: &[u8]) -> bool {
150    match &part.contents {
151        BodyPart::Text(text) => contains_boundary_line(text.as_bytes(), boundary),
152        BodyPart::Binary(binary) => contains_boundary_line(binary, boundary),
153        BodyPart::Multipart(children) => children.iter().any(|child| part_contains_boundary(child, boundary)),
154    }
155}
156
157/// Returns true when `data` contains a line beginning with `--boundary`.
158///
159/// Only the beginning of each line is compared, matching the way RFC 2046
160/// section 5.1.1 says boundary delimiters must be recognised.
161fn contains_boundary_line(data: &[u8], boundary: &[u8]) -> bool {
162    let mut start = 0;
163    while start <= data.len() {
164        let rest = data.get(start..).unwrap_or_default();
165        if rest.starts_with(b"--") && rest.get(2..).is_some_and(|tail| tail.starts_with(boundary)) {
166            return true;
167        }
168        match rest.iter().position(|&byte| byte == b'\n') {
169            Some(position) => start += position + 1,
170            None => break,
171        }
172    }
173    false
174}
175
176impl<'x> MimePart<'x> {
177    /// Create a new MIME part.
178    pub fn new(content_type: impl Into<ContentType<'x>>, contents: impl Into<BodyPart<'x>>) -> Self {
179        let mut content_type = content_type.into();
180        let contents = contents.into();
181
182        if matches!(contents, BodyPart::Text(_)) && content_type.attributes.is_empty() {
183            content_type.attributes.reserve_exact(1);
184            content_type.attributes.push((Cow::from("charset"), Cow::from("utf-8")));
185        }
186
187        let mut headers = Vec::with_capacity(2);
188        headers.push((Cow::from("Content-Type"), content_type.into()));
189
190        Self {
191            contents,
192            headers,
193        }
194    }
195
196    /// Create a new raw MIME part that includes both headers and body.
197    pub fn raw(contents: impl Into<BodyPart<'x>>) -> Self {
198        Self {
199            contents: contents.into(),
200            headers: vec![],
201        }
202    }
203
204    /// Set the attachment filename of a MIME part.
205    pub fn attachment(mut self, filename: impl Into<Cow<'x, str>>) -> Self {
206        self.headers.push((
207            "Content-Disposition".into(),
208            ContentType::new("attachment").attribute("filename", filename).into(),
209        ));
210        self
211    }
212
213    /// Set the MIME part as inline.
214    pub fn inline(mut self) -> Self {
215        self.headers
216            .push(("Content-Disposition".into(), ContentType::new("inline").into()));
217        self
218    }
219
220    /// Set the Content-Language header of a MIME part.
221    pub fn language(mut self, value: impl Into<Cow<'x, str>>) -> Self {
222        self.headers.push(("Content-Language".into(), Text::new(value).into()));
223        self
224    }
225
226    /// Set the Content-ID header of a MIME part.
227    pub fn cid(mut self, value: impl Into<Cow<'x, str>>) -> Self {
228        self.headers.push(("Content-ID".into(), MessageId::new(value).into()));
229        self
230    }
231
232    /// Set the Content-Location header of a MIME part.
233    pub fn location(mut self, value: impl Into<Cow<'x, str>>) -> Self {
234        self.headers.push(("Content-Location".into(), Raw::new(value).into()));
235        self
236    }
237
238    /// Disable automatic Content-Transfer-Encoding detection and treat this as a raw MIME part
239    pub fn transfer_encoding(mut self, value: impl Into<Cow<'x, str>>) -> Self {
240        self.headers
241            .push(("Content-Transfer-Encoding".into(), Raw::new(value).into()));
242        self
243    }
244
245    /// Set custom headers of a MIME part.
246    pub fn header(mut self, header: impl Into<Cow<'x, str>>, value: impl Into<HeaderType<'x>>) -> Self {
247        self.headers.push((header.into(), value.into()));
248        self
249    }
250
251    /// Returns the part's size
252    pub fn size(&self) -> usize {
253        match &self.contents {
254            BodyPart::Text(b) => b.len(),
255            BodyPart::Binary(b) => b.len(),
256            BodyPart::Multipart(bl) => bl.iter().map(|b| b.size()).sum(),
257        }
258    }
259
260    pub(crate) fn estimated_len(&self) -> usize {
261        self.estimated_len_at_depth(0)
262    }
263
264    fn estimated_len_at_depth(&self, depth: usize) -> usize {
265        let headers = estimated_headers_len(&self.headers);
266        match &self.contents {
267            BodyPart::Text(text) => headers
268                .saturating_add(MIME.encoded_len(text.len()))
269                .saturating_add(LEAF_OVERHEAD),
270            BodyPart::Binary(binary) => headers
271                .saturating_add(MIME.encoded_len(binary.len()))
272                .saturating_add(LEAF_OVERHEAD),
273            BodyPart::Multipart(parts) if depth < MAX_ESTIMATE_DEPTH => {
274                parts
275                    .iter()
276                    .fold(headers.saturating_add(MULTIPART_OVERHEAD), |total, part| {
277                        total
278                            .saturating_add(part.estimated_len_at_depth(depth + 1))
279                            .saturating_add(BOUNDARY_OVERHEAD)
280                    })
281            }
282            BodyPart::Multipart(parts) => headers
283                .saturating_add(MULTIPART_OVERHEAD)
284                .saturating_add(parts.len().saturating_mul(BOUNDARY_OVERHEAD)),
285        }
286    }
287
288    /// Add a body part to a multipart/* MIME part.
289    pub fn add_part(&mut self, part: MimePart<'x>) {
290        if let BodyPart::Multipart(ref mut parts) = self.contents {
291            parts.push(part);
292        }
293    }
294
295    /// Write the MIME part to a writer.
296    pub fn write_part(self, output: &mut impl Writer) {
297        let children = match self.contents {
298            BodyPart::Text(text) => {
299                return write_leaf_part(&self.headers, text.as_bytes(), true, output);
300            }
301            BodyPart::Binary(binary) => {
302                return write_leaf_part(&self.headers, binary.as_ref(), false, output);
303            }
304            BodyPart::Multipart(children) => children,
305        };
306
307        let mut boundary = write_multipart_headers(self.headers, &children, output);
308        let mut parts = children.into_iter();
309        let mut stack: Vec<(std::vec::IntoIter<MimePart<'x>>, Cow<'x, str>)> = Vec::new();
310
311        loop {
312            while let Some(part) = parts.next() {
313                output.write(b"\r\n--");
314                output.write(boundary.as_bytes());
315                output.write(b"\r\n");
316
317                match part.contents {
318                    BodyPart::Text(text) => {
319                        write_leaf_part(&part.headers, text.as_bytes(), true, output);
320                    }
321                    BodyPart::Binary(binary) => {
322                        write_leaf_part(&part.headers, binary.as_ref(), false, output);
323                    }
324                    BodyPart::Multipart(children) => {
325                        stack.push((parts, boundary));
326                        boundary = write_multipart_headers(part.headers, &children, output);
327                        parts = children.into_iter();
328                    }
329                }
330            }
331
332            output.write(b"\r\n--");
333            output.write(boundary.as_bytes());
334            output.write(b"--\r\n");
335
336            match stack.pop() {
337                Some((previous_parts, previous_boundary)) => {
338                    parts = previous_parts;
339                    boundary = previous_boundary;
340                }
341                None => return,
342            }
343        }
344    }
345}
346
347const MAX_ESTIMATE_DEPTH: usize = 32;
348const LEAF_OVERHEAD: usize = 96;
349const MULTIPART_OVERHEAD: usize = 128;
350const BOUNDARY_OVERHEAD: usize = 64;
351
352pub(crate) fn estimated_headers_len(headers: &[(Cow<'_, str>, HeaderType<'_>)]) -> usize {
353    headers.iter().map(|(name, _)| name.len() + HEADER_VALUE_ESTIMATE).sum()
354}
355
356const HEADER_VALUE_ESTIMATE: usize = 64;
357
358fn write_leaf_part(
359    headers: &[(Cow<'_, str>, HeaderType<'_>)],
360    body: &[u8],
361    is_text_body: bool,
362    output: &mut impl Writer,
363) {
364    let mut is_text = is_text_body;
365    let mut is_attachment = false;
366    let mut is_raw = headers.is_empty();
367
368    for (header_name, header_value) in headers {
369        write_header_name(header_name, output);
370
371        if !is_text && header_name.eq_ignore_ascii_case("Content-Type") {
372            is_text = header_value.as_content_type().is_some_and(|value| value.is_text());
373        } else if !is_attachment && header_name.eq_ignore_ascii_case("Content-Disposition") {
374            is_attachment = header_value
375                .as_content_type()
376                .is_some_and(|value| value.is_attachment());
377        } else if !is_raw && header_name.eq_ignore_ascii_case("Content-Transfer-Encoding") {
378            is_raw = true;
379        }
380
381        header_value.write_header(output, header_name.len() + 2);
382    }
383
384    if !is_raw {
385        if is_text {
386            BodyEncoding::for_text(body, !is_attachment).write(body, output);
387        } else {
388            BodyEncoding::Base64.write(body, output);
389        }
390    } else {
391        if !headers.is_empty() {
392            output.write(b"\r\n");
393        }
394        output.write(body);
395    }
396}
397
398fn write_multipart_headers<'x>(
399    headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
400    children: &[MimePart<'x>],
401    output: &mut impl Writer,
402) -> Cow<'x, str> {
403    let mut boundary: Option<Cow<'x, str>> = None;
404
405    for (header_name, header_value) in headers {
406        write_header_name(&header_name, output);
407
408        if boundary.is_none() && header_name.eq_ignore_ascii_case("Content-Type") {
409            boundary = Some(match header_value {
410                HeaderType::ContentType(mut content_type) => {
411                    let position = match content_type
412                        .attributes
413                        .iter()
414                        .position(|(attribute, _)| attribute.eq_ignore_ascii_case("boundary"))
415                    {
416                        Some(position) => position,
417                        None => {
418                            let position = content_type.attributes.len();
419                            content_type
420                                .attributes
421                                .push(("boundary".into(), fresh_boundary(children).into()));
422                            position
423                        }
424                    };
425                    content_type.write_header(output, 14);
426                    content_type.attributes.swap_remove(position).1
427                }
428                HeaderType::Raw(raw) => raw_boundary(raw, children, output),
429                HeaderType::Text(text) => raw_boundary(Raw::new(text.text), children, output),
430                other => {
431                    other.write_header(output, header_name.len() + 2);
432                    continue;
433                }
434            });
435        } else {
436            header_value.write_header(output, header_name.len() + 2);
437        }
438    }
439
440    let boundary = boundary.unwrap_or_else(|| {
441        output.write(b"Content-Type: ");
442        let boundary = fresh_boundary(children);
443        ContentType::new("multipart/mixed")
444            .attribute("boundary", &boundary)
445            .write_header(output, 14);
446        boundary.into()
447    });
448
449    output.write(b"\r\n");
450    boundary
451}
452
453fn raw_boundary<'x>(raw: Raw<'x>, children: &[MimePart<'x>], output: &mut impl Writer) -> Cow<'x, str> {
454    match raw.raw.find("boundary=\"") {
455        Some(position) => {
456            raw.write_header(output, 14);
457            match raw.raw {
458                Cow::Borrowed(value) => value
459                    .get(position..)
460                    .and_then(|tail| tail.split('"').nth(1))
461                    .map_or_else(|| fresh_boundary(children).into(), Cow::Borrowed),
462                Cow::Owned(value) => value
463                    .get(position..)
464                    .and_then(|tail| tail.split('"').nth(1))
465                    .map_or_else(|| fresh_boundary(children), str::to_string)
466                    .into(),
467            }
468        }
469        None => {
470            let boundary = fresh_boundary(children);
471            let mut folder = FoldWriter::new(output, 14);
472            write_control_free(&mut folder, raw.raw.as_bytes());
473            folder.write(b"; boundary=\"");
474            folder.write(boundary.as_bytes());
475            folder.write(b"\"");
476            folder.finish();
477            boundary.into()
478        }
479    }
480}
481
482/// Writes a header name, silently dropping the characters that could otherwise
483/// end the field and inject another header (CR, LF and other controls). Header
484/// names are ASCII by definition, so this only removes invalid bytes.
485pub(crate) fn write_header_name(name: &str, output: &mut impl Writer) {
486    let mut start = 0;
487    for (at, byte) in name.bytes().enumerate() {
488        if byte <= 0x20 || byte == 0x7F {
489            if at > start {
490                output.write(name.get(start..at).unwrap_or_default().as_bytes());
491            }
492            start = at + 1;
493        }
494    }
495    if start < name.len() {
496        output.write(name.get(start..).unwrap_or_default().as_bytes());
497    }
498    output.write(b": ");
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn unexpected_multipart_content_type_values_do_not_panic() {
507        let mut output = Vec::new();
508        MimePart::raw(vec![MimePart::new("text/plain", "hello")])
509            .header("Content-Type", Text::new("multipart/mixed"))
510            .write_part(&mut output);
511        let output = String::from_utf8(output).unwrap();
512        assert!(output.starts_with("Content-Type: multipart/mixed; boundary=\""), "{output:?}");
513        assert!(output.ends_with("--\r\n"), "{output:?}");
514
515        let mut output = Vec::new();
516        MimePart::raw(vec![MimePart::new("text/plain", "hello")])
517            .header("Content-Type", MessageId::new("id@example.org"))
518            .write_part(&mut output);
519        let output = String::from_utf8(output).unwrap();
520        assert!(
521            output.contains("Content-Type: multipart/mixed;") && output.contains("boundary=\""),
522            "{output:?}"
523        );
524    }
525
526    #[test]
527    fn raw_content_type_with_boundary_is_written_and_reused() {
528        let mut output = Vec::new();
529        MimePart::raw(vec![MimePart::new("text/plain", "hello")])
530            .header("Content-Type", Raw::new("multipart/mixed; boundary=\"abc\""))
531            .write_part(&mut output);
532        let output = String::from_utf8(output).unwrap();
533        assert!(
534            output.starts_with("Content-Type: multipart/mixed; boundary=\"abc\"\r\n\r\n\r\n--abc\r\n"),
535            "{output:?}"
536        );
537        assert!(output.ends_with("\r\n--abc--\r\n"), "{output:?}");
538    }
539
540    fn is_boundary_safe(value: &str) -> bool {
541        !value.is_empty()
542            && value.bytes().all(|byte| {
543                byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b'+' | b'=' | b':' | b'/')
544            })
545    }
546
547    #[test]
548    fn boundary_has_a_safe_shape() {
549        let boundary = make_boundary("_");
550        assert_eq!(boundary.split('_').count(), 2, "{boundary}");
551        assert!(
552            boundary
553                .split('_')
554                .all(|field| !field.is_empty() && field.bytes().all(|b| b.is_ascii_hexdigit())),
555            "{boundary}"
556        );
557        assert!(boundary.len() <= 70, "{boundary}");
558        assert!(is_boundary_safe(&boundary), "{boundary}");
559        assert_eq!(make_boundary(".").split('.').count(), 2);
560    }
561
562    #[cfg(not(target_arch = "wasm32"))]
563    #[test]
564    fn boundaries_are_unique_across_threads() {
565        let per_thread = 5_000;
566        let threads = (0..8)
567            .map(|_| std::thread::spawn(move || (0..per_thread).map(|_| make_boundary("_")).collect::<Vec<_>>()))
568            .collect::<Vec<_>>();
569        let mut seen = std::collections::HashSet::with_capacity(per_thread * 8);
570        for thread in threads {
571            for boundary in thread.join().expect("thread panicked") {
572                assert!(seen.insert(boundary), "duplicate boundary");
573            }
574        }
575        assert_eq!(seen.len(), per_thread * 8);
576    }
577
578    #[test]
579    fn detects_boundary_lines_only_at_start_of_line() {
580        assert!(contains_boundary_line(b"--abc\r\n", b"abc"));
581        assert!(contains_boundary_line(b"x\r\n--abc\r\n", b"abc"));
582        assert!(contains_boundary_line(b"x\n--abc--\r\n", b"abc"));
583        assert!(!contains_boundary_line(b"x--abc\r\n", b"abc"));
584        assert!(!contains_boundary_line(b"	--abc\r\n", b"abc"));
585        assert!(!contains_boundary_line(b"", b"abc"));
586    }
587
588    #[test]
589    fn header_names_are_sanitized() {
590        let mut output = Vec::new();
591        write_header_name("X-Evil\r\nBcc", &mut output);
592        let output = String::from_utf8(output).unwrap();
593        assert_eq!(output, "X-EvilBcc: ");
594        assert!(!output.contains('\n'));
595    }
596
597    #[test]
598    fn estimates_cover_the_output() {
599        let ascii = "lorem ipsum dolor sit amet ".repeat(2_000);
600        let latin = "réunion d'équipe: résumé ".repeat(2_000);
601        let cjk = "안녕하세요 세계 ".repeat(2_000);
602        let parts = vec![
603            MimePart::new("text/plain", ascii.as_str()),
604            MimePart::new("text/plain", latin.as_str()),
605            MimePart::new("text/plain", cjk.as_str()),
606            MimePart::new("image/png", (0..200_000u32).map(|value| value as u8).collect::<Vec<_>>()),
607            MimePart::new(
608                "multipart/alternative",
609                vec![
610                    MimePart::new("text/plain", "short"),
611                    MimePart::new("text/html", "<p>short</p>"),
612                ],
613            ),
614        ];
615        for part in parts {
616            let estimate = part.estimated_len();
617            let mut output = Vec::new();
618            part.write_part(&mut output);
619            assert!(estimate >= output.len(), "estimate {estimate} below output {}", output.len());
620        }
621    }
622}