mail_builder/
mime.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use std::{
8    borrow::Cow,
9    cell::Cell,
10    collections::hash_map::DefaultHasher,
11    hash::{Hash, Hasher},
12    io::{self, Write},
13    thread,
14    time::{Duration, SystemTime, UNIX_EPOCH},
15};
16
17use crate::{
18    encoders::{
19        base64::base64_encode_mime,
20        encode::{get_encoding_type, EncodingType},
21        quoted_printable::quoted_printable_encode,
22    },
23    headers::{
24        content_type::ContentType, message_id::MessageId, raw::Raw, text::Text, Header, HeaderType,
25    },
26};
27
28/// MIME part of an e-mail.
29#[derive(Clone, Debug)]
30pub struct MimePart<'x> {
31    pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
32    pub contents: BodyPart<'x>,
33}
34
35#[derive(Clone, Debug)]
36pub enum BodyPart<'x> {
37    Text(Cow<'x, str>),
38    Binary(Cow<'x, [u8]>),
39    Multipart(Vec<MimePart<'x>>),
40}
41
42impl<'x> From<&'x str> for BodyPart<'x> {
43    fn from(value: &'x str) -> Self {
44        BodyPart::Text(value.into())
45    }
46}
47
48impl<'x> From<&'x [u8]> for BodyPart<'x> {
49    fn from(value: &'x [u8]) -> Self {
50        BodyPart::Binary(value.into())
51    }
52}
53
54impl From<String> for BodyPart<'_> {
55    fn from(value: String) -> Self {
56        BodyPart::Text(value.into())
57    }
58}
59
60impl<'x> From<&'x String> for BodyPart<'x> {
61    fn from(value: &'x String) -> Self {
62        BodyPart::Text(value.as_str().into())
63    }
64}
65
66impl<'x> From<Cow<'x, str>> for BodyPart<'x> {
67    fn from(value: Cow<'x, str>) -> Self {
68        BodyPart::Text(value)
69    }
70}
71
72impl From<Vec<u8>> for BodyPart<'_> {
73    fn from(value: Vec<u8>) -> Self {
74        BodyPart::Binary(value.into())
75    }
76}
77
78impl<'x> From<Vec<MimePart<'x>>> for BodyPart<'x> {
79    fn from(value: Vec<MimePart<'x>>) -> Self {
80        BodyPart::Multipart(value)
81    }
82}
83
84impl<'x> From<&'x str> for ContentType<'x> {
85    fn from(value: &'x str) -> Self {
86        ContentType::new(value)
87    }
88}
89
90impl From<String> for ContentType<'_> {
91    fn from(value: String) -> Self {
92        ContentType::new(value)
93    }
94}
95
96impl<'x> From<&'x String> for ContentType<'x> {
97    fn from(value: &'x String) -> Self {
98        ContentType::new(value.as_str())
99    }
100}
101
102thread_local!(static COUNTER: Cell<u64> = const { Cell::new(0) });
103
104pub fn make_boundary(separator: &str) -> String {
105    // Create a pseudo-unique boundary
106    let mut s = DefaultHasher::new();
107    ((&s as *const DefaultHasher) as usize).hash(&mut s);
108    thread::current().id().hash(&mut s);
109    let hash = s.finish();
110
111    format!(
112        "{:x}{}{:x}{}{:x}",
113        SystemTime::now()
114            .duration_since(UNIX_EPOCH)
115            .unwrap_or_else(|_| Duration::new(0, 0))
116            .as_nanos(),
117        separator,
118        COUNTER.with(|c| {
119            hash.wrapping_add(c.replace(c.get() + 1))
120                .wrapping_mul(11400714819323198485u64)
121        }),
122        separator,
123        hash,
124    )
125}
126
127impl<'x> MimePart<'x> {
128    /// Create a new MIME part.
129    pub fn new(
130        content_type: impl Into<ContentType<'x>>,
131        contents: impl Into<BodyPart<'x>>,
132    ) -> Self {
133        let mut content_type = content_type.into();
134        let contents = contents.into();
135
136        if matches!(contents, BodyPart::Text(_)) && content_type.attributes.is_empty() {
137            content_type
138                .attributes
139                .push((Cow::from("charset"), Cow::from("utf-8")));
140        }
141
142        Self {
143            contents,
144            headers: vec![("Content-Type".into(), content_type.into())],
145        }
146    }
147
148    /// Create a new raw MIME part that includes both headers and body.
149    pub fn raw(contents: impl Into<BodyPart<'x>>) -> Self {
150        Self {
151            contents: contents.into(),
152            headers: vec![],
153        }
154    }
155
156    /// Set the attachment filename of a MIME part.
157    pub fn attachment(mut self, filename: impl Into<Cow<'x, str>>) -> Self {
158        self.headers.push((
159            "Content-Disposition".into(),
160            ContentType::new("attachment")
161                .attribute("filename", filename)
162                .into(),
163        ));
164        self
165    }
166
167    /// Set the MIME part as inline.
168    pub fn inline(mut self) -> Self {
169        self.headers.push((
170            "Content-Disposition".into(),
171            ContentType::new("inline").into(),
172        ));
173        self
174    }
175
176    /// Set the Content-Language header of a MIME part.
177    pub fn language(mut self, value: impl Into<Cow<'x, str>>) -> Self {
178        self.headers
179            .push(("Content-Language".into(), Text::new(value).into()));
180        self
181    }
182
183    /// Set the Content-ID header of a MIME part.
184    pub fn cid(mut self, value: impl Into<Cow<'x, str>>) -> Self {
185        self.headers
186            .push(("Content-ID".into(), MessageId::new(value).into()));
187        self
188    }
189
190    /// Set the Content-Location header of a MIME part.
191    pub fn location(mut self, value: impl Into<Cow<'x, str>>) -> Self {
192        self.headers
193            .push(("Content-Location".into(), Raw::new(value).into()));
194        self
195    }
196
197    /// Disable automatic Content-Transfer-Encoding detection and treat this as a raw MIME part
198    pub fn transfer_encoding(mut self, value: impl Into<Cow<'x, str>>) -> Self {
199        self.headers
200            .push(("Content-Transfer-Encoding".into(), Raw::new(value).into()));
201        self
202    }
203
204    /// Set custom headers of a MIME part.
205    pub fn header(
206        mut self,
207        header: impl Into<Cow<'x, str>>,
208        value: impl Into<HeaderType<'x>>,
209    ) -> Self {
210        self.headers.push((header.into(), value.into()));
211        self
212    }
213
214    /// Returns the part's size
215    pub fn size(&self) -> usize {
216        match &self.contents {
217            BodyPart::Text(b) => b.len(),
218            BodyPart::Binary(b) => b.len(),
219            BodyPart::Multipart(bl) => bl.iter().map(|b| b.size()).sum(),
220        }
221    }
222
223    /// Add a body part to a multipart/* MIME part.
224    pub fn add_part(&mut self, part: MimePart<'x>) {
225        if let BodyPart::Multipart(ref mut parts) = self.contents {
226            parts.push(part);
227        }
228    }
229
230    /// Write the MIME part to a writer.
231    pub fn write_part(self, mut output: impl Write) -> io::Result<usize> {
232        let mut stack = Vec::new();
233        let mut it = vec![self].into_iter();
234        let mut boundary: Option<Cow<'_, str>> = None;
235
236        loop {
237            while let Some(part) = it.next() {
238                if let Some(boundary) = boundary.as_ref() {
239                    output.write_all(b"\r\n--")?;
240                    output.write_all(boundary.as_bytes())?;
241                    output.write_all(b"\r\n")?;
242                }
243                match part.contents {
244                    BodyPart::Text(text) => {
245                        let mut is_attachment = false;
246                        let mut is_raw = part.headers.is_empty();
247
248                        for (header_name, header_value) in &part.headers {
249                            output.write_all(header_name.as_bytes())?;
250                            output.write_all(b": ")?;
251                            if !is_attachment && header_name == "Content-Disposition" {
252                                is_attachment = header_value
253                                    .as_content_type()
254                                    .map(|v| v.is_attachment())
255                                    .unwrap_or(false);
256                            } else if !is_raw && header_name == "Content-Transfer-Encoding" {
257                                is_raw = true;
258                            }
259                            header_value.write_header(&mut output, header_name.len() + 2)?;
260                        }
261                        if !is_raw {
262                            detect_encoding(text.as_bytes(), &mut output, !is_attachment)?;
263                        } else {
264                            if !part.headers.is_empty() {
265                                output.write_all(b"\r\n")?;
266                            }
267                            output.write_all(text.as_bytes())?;
268                        }
269                    }
270                    BodyPart::Binary(binary) => {
271                        let mut is_text = false;
272                        let mut is_attachment = false;
273                        let mut is_raw = part.headers.is_empty();
274
275                        for (header_name, header_value) in &part.headers {
276                            output.write_all(header_name.as_bytes())?;
277                            output.write_all(b": ")?;
278                            if !is_text && header_name == "Content-Type" {
279                                is_text = header_value
280                                    .as_content_type()
281                                    .map(|v| v.is_text())
282                                    .unwrap_or(false);
283                            } else if !is_attachment && header_name == "Content-Disposition" {
284                                is_attachment = header_value
285                                    .as_content_type()
286                                    .map(|v| v.is_attachment())
287                                    .unwrap_or(false);
288                            } else if !is_raw && header_name == "Content-Transfer-Encoding" {
289                                is_raw = true;
290                            }
291                            header_value.write_header(&mut output, header_name.len() + 2)?;
292                        }
293
294                        if !is_raw {
295                            if !is_text {
296                                output.write_all(b"Content-Transfer-Encoding: base64\r\n\r\n")?;
297                                base64_encode_mime(binary.as_ref(), &mut output, false)?;
298                            } else {
299                                detect_encoding(binary.as_ref(), &mut output, !is_attachment)?;
300                            }
301                        } else {
302                            if !part.headers.is_empty() {
303                                output.write_all(b"\r\n")?;
304                            }
305                            output.write_all(binary.as_ref())?;
306                        }
307                    }
308                    BodyPart::Multipart(parts) => {
309                        if boundary.is_some() {
310                            stack.push((it, boundary.take()));
311                        }
312
313                        let mut found_ct = false;
314                        for (header_name, header_value) in part.headers {
315                            output.write_all(header_name.as_bytes())?;
316                            output.write_all(b": ")?;
317
318                            if !found_ct && header_name.eq_ignore_ascii_case("Content-Type") {
319                                boundary = match header_value {
320                                    HeaderType::ContentType(mut ct) => {
321                                        let bpos = if let Some(pos) = ct
322                                            .attributes
323                                            .iter()
324                                            .position(|(a, _)| a.eq_ignore_ascii_case("boundary"))
325                                        {
326                                            pos
327                                        } else {
328                                            let pos = ct.attributes.len();
329                                            ct.attributes.push((
330                                                "boundary".into(),
331                                                make_boundary("_").into(),
332                                            ));
333                                            pos
334                                        };
335                                        ct.write_header(&mut output, 14)?;
336                                        ct.attributes.swap_remove(bpos).1.into()
337                                    }
338                                    HeaderType::Raw(raw) => {
339                                        if let Some(pos) = raw.raw.find("boundary=\"") {
340                                            if let Some(boundary) = raw.raw[pos..].split('"').nth(1)
341                                            {
342                                                Some(boundary.to_string().into())
343                                            } else {
344                                                Some(make_boundary("_").into())
345                                            }
346                                        } else {
347                                            let boundary = make_boundary("_");
348                                            output.write_all(raw.raw.as_bytes())?;
349                                            output.write_all(b"; boundary=\"")?;
350                                            output.write_all(boundary.as_bytes())?;
351                                            output.write_all(b"\"\r\n")?;
352                                            Some(boundary.into())
353                                        }
354                                    }
355                                    _ => panic!("Unsupported Content-Type header value."),
356                                };
357                                found_ct = true;
358                            } else {
359                                header_value.write_header(&mut output, header_name.len() + 2)?;
360                            }
361                        }
362
363                        if !found_ct {
364                            output.write_all(b"Content-Type: ")?;
365                            let boundary_ = make_boundary("_");
366                            ContentType::new("multipart/mixed")
367                                .attribute("boundary", &boundary_)
368                                .write_header(&mut output, 14)?;
369                            boundary = Some(boundary_.into());
370                        }
371
372                        output.write_all(b"\r\n")?;
373                        it = parts.into_iter();
374                    }
375                }
376            }
377            if let Some(boundary) = boundary {
378                output.write_all(b"\r\n--")?;
379                output.write_all(boundary.as_bytes())?;
380                output.write_all(b"--\r\n")?;
381            }
382            if let Some((prev_it, prev_boundary)) = stack.pop() {
383                it = prev_it;
384                boundary = prev_boundary;
385            } else {
386                break;
387            }
388        }
389        Ok(0)
390    }
391}
392
393fn detect_encoding(input: &[u8], mut output: impl Write, is_body: bool) -> io::Result<()> {
394    match get_encoding_type(input, false, is_body) {
395        EncodingType::Base64 => {
396            output.write_all(b"Content-Transfer-Encoding: base64\r\n\r\n")?;
397            base64_encode_mime(input, &mut output, false)?;
398        }
399        EncodingType::QuotedPrintable(_) => {
400            output.write_all(b"Content-Transfer-Encoding: quoted-printable\r\n\r\n")?;
401            quoted_printable_encode(input, &mut output, is_body)?;
402        }
403        EncodingType::None => {
404            output.write_all(b"Content-Transfer-Encoding: 7bit\r\n\r\n")?;
405            if is_body {
406                let mut prev_ch = 0;
407                for ch in input {
408                    if *ch == b'\n' && prev_ch != b'\r' {
409                        output.write_all(b"\r")?;
410                    }
411                    output.write_all(&[*ch])?;
412                    prev_ch = *ch;
413                }
414            } else {
415                output.write_all(input)?;
416            }
417        }
418    }
419    Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_detect_encoding() {
428        let mut output = Vec::new();
429        detect_encoding(b"a b c\r\n", &mut output, false).unwrap();
430        assert_eq!(output, b"Content-Transfer-Encoding: 7bit\r\n\r\na b c\r\n");
431
432        let mut output = Vec::new();
433        detect_encoding(
434            b"a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\r\n",
435            &mut output,
436            false,
437        )
438        .unwrap();
439        assert_eq!(output, b"Content-Transfer-Encoding: 7bit\r\n\r\na a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\r\n");
440
441        let mut output = Vec::new();
442        detect_encoding(
443            b"a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\r\n",
444            &mut output,
445            false,
446        )
447        .unwrap();
448        assert_eq!(output, b"Content-Transfer-Encoding: quoted-printable\r\n\r\na a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a =\r\na=0D=0A");
449
450        let mut output = Vec::new();
451        let long_line = "a".repeat(100);
452        detect_encoding(long_line.as_bytes(), &mut output, false).unwrap();
453        let expected = format!(
454            "Content-Transfer-Encoding: quoted-printable\r\n\r\n{}",
455            "a".repeat(76) + "=\r\n" + &"a".repeat(24)
456        );
457        assert_eq!(output, expected.as_bytes());
458    }
459}