Skip to main content

mail_builder/
mime.rs

1/*
2 * Copyright Stalwart Labs Ltd. See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use std::{
13    borrow::Cow,
14    io::{self, Write},
15    time::{Duration, SystemTime, UNIX_EPOCH},
16};
17
18use rand::Rng;
19
20use crate::{
21    encoders::{
22        base64::base64_encode_mime,
23        encode::{EncodingType, get_encoding_type},
24        quoted_printable::quoted_printable_encode,
25    },
26    headers::{Header, HeaderType, content_type::ContentType, message_id::MessageId, raw::Raw, text::Text},
27};
28
29/// MIME part of an e-mail.
30#[derive(Clone, Debug)]
31pub struct MimePart<'x> {
32    pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
33    pub contents: BodyPart<'x>,
34}
35
36#[derive(Clone, Debug)]
37pub enum BodyPart<'x> {
38    Text(Cow<'x, str>),
39    Binary(Cow<'x, [u8]>),
40    Multipart(Vec<MimePart<'x>>),
41}
42
43impl<'x> From<&'x str> for BodyPart<'x> {
44    fn from(value: &'x str) -> Self {
45        BodyPart::Text(value.into())
46    }
47}
48
49impl<'x> From<&'x [u8]> for BodyPart<'x> {
50    fn from(value: &'x [u8]) -> Self {
51        BodyPart::Binary(value.into())
52    }
53}
54
55impl<'x> From<String> for BodyPart<'x> {
56    fn from(value: String) -> Self {
57        BodyPart::Text(value.into())
58    }
59}
60
61impl<'x> From<&'x String> for BodyPart<'x> {
62    fn from(value: &'x String) -> Self {
63        BodyPart::Text(value.as_str().into())
64    }
65}
66
67impl<'x> From<Cow<'x, str>> for BodyPart<'x> {
68    fn from(value: Cow<'x, str>) -> Self {
69        BodyPart::Text(value)
70    }
71}
72
73impl<'x> From<Vec<u8>> for BodyPart<'x> {
74    fn from(value: Vec<u8>) -> Self {
75        BodyPart::Binary(value.into())
76    }
77}
78
79impl<'x> From<Vec<MimePart<'x>>> for BodyPart<'x> {
80    fn from(value: Vec<MimePart<'x>>) -> Self {
81        BodyPart::Multipart(value)
82    }
83}
84
85impl<'x> From<&'x str> for ContentType<'x> {
86    fn from(value: &'x str) -> Self {
87        ContentType::new(value)
88    }
89}
90
91impl<'x> From<String> for ContentType<'x> {
92    fn from(value: String) -> Self {
93        ContentType::new(value)
94    }
95}
96
97impl<'x> From<&'x String> for ContentType<'x> {
98    fn from(value: &'x String) -> Self {
99        ContentType::new(value.as_str())
100    }
101}
102
103// PATCH
104// thread_local!(static COUNTER: Cell<u64> = Cell::new(0));
105
106pub fn make_boundary(separator: &str) -> String {
107    // PATCH
108    // let mut s = DefaultHasher::new();
109    // gethostname::gethostname().hash(&mut s);
110    // thread::current().id().hash(&mut s);
111    // let hash = s.finish();
112
113    // format!(
114    //     "{:x}{}{:x}{}{:x}",
115    //     SystemTime::now()
116    //         .duration_since(UNIX_EPOCH)
117    //         .unwrap_or_else(|_| Duration::new(0, 0))
118    //         .as_nanos(),
119    //     separator,
120    //     COUNTER.with(|c| {
121    //         hash.wrapping_add(c.replace(c.get() + 1))
122    //             .wrapping_mul(11400714819323198485u64)
123    //     }),
124    //     separator,
125    //     hash,
126    // )
127    // let mut s = DefaultHasher::new();
128    // gethostname::gethostname().hash(&mut s);
129    // thread::current().id().hash(&mut s);
130    // let hash = s.finish();
131
132    let unix_nano = SystemTime::now()
133        .duration_since(UNIX_EPOCH)
134        .unwrap_or_else(|_| Duration::new(0, 0))
135        .as_nanos();
136
137    let pid = rand::thread_rng().gen_range(0..999);
138    let rint = rand::thread_rng().gen_range(0..999);
139
140    return format!("{:x}{}{:x}{}{:x}", unix_nano, separator, pid, separator, rint);
141}
142
143impl<'x> MimePart<'x> {
144    /// Create a new MIME part.
145    pub fn new(content_type: impl Into<ContentType<'x>>, contents: impl Into<BodyPart<'x>>) -> Self {
146        let mut content_type = content_type.into();
147        let contents = contents.into();
148
149        if matches!(contents, BodyPart::Text(_)) && content_type.attributes.is_empty() {
150            content_type.attributes.push((Cow::from("charset"), Cow::from("utf-8")));
151        }
152
153        Self {
154            contents,
155            headers: vec![("Content-Type".into(), content_type.into())],
156        }
157    }
158
159    /// Create a new raw MIME part that includes both headers and body.
160    pub fn raw(contents: impl Into<BodyPart<'x>>) -> Self {
161        Self {
162            contents: contents.into(),
163            headers: vec![],
164        }
165    }
166
167    /// Set the attachment filename of a MIME part.
168    pub fn attachment(mut self, filename: impl Into<Cow<'x, str>>) -> Self {
169        self.headers.push((
170            "Content-Disposition".into(),
171            ContentType::new("attachment").attribute("filename", filename).into(),
172        ));
173        self
174    }
175
176    /// Set the MIME part as inline.
177    pub fn inline(mut self) -> Self {
178        self.headers
179            .push(("Content-Disposition".into(), ContentType::new("inline").into()));
180        self
181    }
182
183    /// Set the Content-Language header of a MIME part.
184    pub fn language(mut self, value: impl Into<Cow<'x, str>>) -> Self {
185        self.headers.push(("Content-Language".into(), Text::new(value).into()));
186        self
187    }
188
189    /// Set the Content-ID header of a MIME part.
190    pub fn cid(mut self, value: impl Into<Cow<'x, str>>) -> Self {
191        self.headers.push(("Content-ID".into(), MessageId::new(value).into()));
192        self
193    }
194
195    /// Set the Content-Location header of a MIME part.
196    pub fn location(mut self, value: impl Into<Cow<'x, str>>) -> Self {
197        self.headers.push(("Content-Location".into(), Raw::new(value).into()));
198        self
199    }
200
201    /// Disable automatic Content-Transfer-Encoding detection and treat this as a raw MIME part
202    pub fn transfer_encoding(mut self, value: impl Into<Cow<'x, str>>) -> Self {
203        self.headers
204            .push(("Content-Transfer-Encoding".into(), Raw::new(value).into()));
205        self
206    }
207
208    /// Set custom headers of a MIME part.
209    pub fn header(mut self, header: impl Into<Cow<'x, str>>, value: impl Into<HeaderType<'x>>) -> 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.as_content_type().map(|v| v.is_text()).unwrap_or(false);
280                            } else if !is_attachment && header_name == "Content-Disposition" {
281                                is_attachment = header_value
282                                    .as_content_type()
283                                    .map(|v| v.is_attachment())
284                                    .unwrap_or(false);
285                            } else if !is_raw && header_name == "Content-Transfer-Encoding" {
286                                is_raw = true;
287                            }
288                            header_value.write_header(&mut output, header_name.len() + 2)?;
289                        }
290
291                        if !is_raw {
292                            if !is_text {
293                                output.write_all(b"Content-Transfer-Encoding: base64\r\n\r\n")?;
294                                base64_encode_mime(binary.as_ref(), &mut output, false)?;
295                            } else {
296                                detect_encoding(binary.as_ref(), &mut output, !is_attachment)?;
297                            }
298                        } else {
299                            if !part.headers.is_empty() {
300                                output.write_all(b"\r\n")?;
301                            }
302                            output.write_all(binary.as_ref())?;
303                        }
304                    }
305                    BodyPart::Multipart(parts) => {
306                        if boundary.is_some() {
307                            stack.push((it, boundary.take()));
308                        }
309
310                        let mut found_ct = false;
311                        for (header_name, header_value) in part.headers {
312                            output.write_all(header_name.as_bytes())?;
313                            output.write_all(b": ")?;
314
315                            if !found_ct && header_name.eq_ignore_ascii_case("Content-Type") {
316                                boundary = match header_value {
317                                    HeaderType::ContentType(mut ct) => {
318                                        let bpos = if let Some(pos) = ct
319                                            .attributes
320                                            .iter()
321                                            .position(|(a, _)| a.eq_ignore_ascii_case("boundary"))
322                                        {
323                                            pos
324                                        } else {
325                                            let pos = ct.attributes.len();
326                                            ct.attributes.push(("boundary".into(), make_boundary("_").into()));
327                                            pos
328                                        };
329                                        ct.write_header(&mut output, 14)?;
330                                        ct.attributes.swap_remove(bpos).1.into()
331                                    }
332                                    HeaderType::Raw(raw) => {
333                                        if let Some(pos) = raw.raw.find("boundary=\"") {
334                                            if let Some(boundary) = raw.raw[pos..].split('"').nth(1) {
335                                                Some(boundary.to_string().into())
336                                            } else {
337                                                Some(make_boundary("_").into())
338                                            }
339                                        } else {
340                                            let boundary = make_boundary("_");
341                                            output.write_all(raw.raw.as_bytes())?;
342                                            output.write_all(b"; boundary=\"")?;
343                                            output.write_all(boundary.as_bytes())?;
344                                            output.write_all(b"\"\r\n")?;
345                                            Some(boundary.into())
346                                        }
347                                    }
348                                    _ => panic!("Unsupported Content-Type header value."),
349                                };
350                                found_ct = true;
351                            } else {
352                                header_value.write_header(&mut output, header_name.len() + 2)?;
353                            }
354                        }
355
356                        if !found_ct {
357                            output.write_all(b"Content-Type: ")?;
358                            let boundary_ = make_boundary("_");
359                            ContentType::new("multipart/mixed")
360                                .attribute("boundary", &boundary_)
361                                .write_header(&mut output, 14)?;
362                            boundary = Some(boundary_.into());
363                        }
364
365                        output.write_all(b"\r\n")?;
366                        it = parts.into_iter();
367                    }
368                }
369            }
370            if let Some(boundary) = boundary {
371                output.write_all(b"\r\n--")?;
372                output.write_all(boundary.as_bytes())?;
373                output.write_all(b"--\r\n")?;
374            }
375            if let Some((prev_it, prev_boundary)) = stack.pop() {
376                it = prev_it;
377                boundary = prev_boundary;
378            } else {
379                break;
380            }
381        }
382        Ok(0)
383    }
384}
385
386fn detect_encoding(input: &[u8], mut output: impl Write, is_body: bool) -> io::Result<()> {
387    match get_encoding_type(input, false, is_body) {
388        EncodingType::Base64 => {
389            output.write_all(b"Content-Transfer-Encoding: base64\r\n\r\n")?;
390            base64_encode_mime(input, &mut output, false)?;
391        }
392        EncodingType::QuotedPrintable(_) => {
393            output.write_all(b"Content-Transfer-Encoding: quoted-printable\r\n\r\n")?;
394            quoted_printable_encode(input, &mut output, false, is_body)?;
395        }
396        EncodingType::None => {
397            output.write_all(b"Content-Transfer-Encoding: 7bit\r\n\r\n")?;
398            if is_body {
399                let mut prev_ch = 0;
400                for ch in input {
401                    if *ch == b'\n' && prev_ch != b'\r' {
402                        output.write_all(b"\r")?;
403                    }
404                    output.write_all(&[*ch])?;
405                    prev_ch = *ch;
406                }
407            } else {
408                output.write_all(input)?;
409            }
410        }
411    }
412    Ok(())
413}