mailparsing/
builder.rs

1use crate::mimepart::AttachmentOptions;
2use crate::{HeaderMap, MailParsingError, MimePart};
3
4#[derive(Default)]
5pub struct MessageBuilder<'a> {
6    text: Option<String>,
7    html: Option<String>,
8    // <https://amp.dev/documentation/guides-and-tutorials/email/learn/email-spec/amp-email-structure>
9    amp_html: Option<String>,
10    headers: HeaderMap<'a>,
11    inline: Vec<MimePart<'a>>,
12    attached: Vec<MimePart<'a>>,
13    stable_content: bool,
14}
15
16impl<'a> MessageBuilder<'a> {
17    pub fn new() -> Self {
18        Self::default()
19    }
20
21    pub fn set_stable_content(&mut self, v: bool) {
22        self.stable_content = v;
23    }
24
25    pub fn text_plain(&mut self, text: &str) {
26        self.text.replace(text.to_string());
27    }
28
29    pub fn text_html(&mut self, html: &str) {
30        self.html.replace(html.to_string());
31    }
32
33    pub fn text_amp_html(&mut self, html: &str) {
34        self.amp_html.replace(html.to_string());
35    }
36
37    pub fn attach(
38        &mut self,
39        content_type: &str,
40        data: &[u8],
41        opts: Option<&AttachmentOptions>,
42    ) -> Result<(), MailParsingError> {
43        let is_inline = opts.map(|opt| opt.inline).unwrap_or(false);
44
45        let part = MimePart::new_binary(content_type, data, opts)?;
46
47        if is_inline {
48            self.inline.push(part);
49        } else {
50            self.attached.push(part);
51        }
52
53        Ok(())
54    }
55
56    pub fn attach_part(&mut self, part: MimePart<'a>) {
57        let is_inline = part
58            .headers()
59            .content_disposition()
60            .ok()
61            .and_then(|opt_cd| opt_cd.map(|cd| cd.value == "inline"))
62            .unwrap_or(false);
63        if is_inline {
64            self.inline.push(part);
65        } else {
66            self.attached.push(part);
67        }
68    }
69
70    pub fn build(self) -> Result<MimePart<'a>, MailParsingError> {
71        let text = self.text.as_deref().map(MimePart::new_text_plain);
72        let html = self.html.as_deref().map(MimePart::new_html);
73        let amp_html = self
74            .amp_html
75            .as_deref()
76            .map(|html| MimePart::new_text("text/x-amp-html", html));
77
78        // Phrase the alternative parts.
79        // Note that, when there are both HTML and AMP HTML parts,
80        // we are careful to NOT place the amp part as the last part
81        // as the AMP docs recommend that we keep the regular HTML
82        // part as the last part as some clients can only render
83        // the last alternative part(!)
84        let content_node = match (text, html, amp_html) {
85            (Some(t), Some(h), Some(amp)) => MimePart::new_multipart(
86                "multipart/alternative",
87                vec![t?, amp?, h?],
88                if self.stable_content {
89                    Some(b"ma-boundary")
90                } else {
91                    None
92                },
93            )?,
94            (Some(first), Some(second), None)
95            | (None, Some(second), Some(first))
96            | (Some(first), None, Some(second)) => MimePart::new_multipart(
97                "multipart/alternative",
98                vec![first?, second?],
99                if self.stable_content {
100                    Some(b"ma-boundary")
101                } else {
102                    None
103                },
104            )?,
105            (Some(only), None, None) | (None, Some(only), None) => only?,
106            (None, None, Some(_amp)) => {
107                return Err(MailParsingError::BuildError(
108                    "the AMP email spec requires at least one non-amp part \
109                        to be present in the message",
110                ))
111            }
112            (None, None, None) => {
113                return Err(MailParsingError::BuildError(
114                    "no text or html part was specified",
115                ))
116            }
117        };
118
119        let content_node = if !self.inline.is_empty() {
120            let mut parts = Vec::with_capacity(self.inline.len() + 1);
121            parts.push(content_node);
122            parts.extend(self.inline);
123            MimePart::new_multipart(
124                "multipart/related",
125                parts,
126                if self.stable_content {
127                    Some(b"mr-boundary")
128                } else {
129                    None
130                },
131            )?
132        } else {
133            content_node
134        };
135
136        let mut root = if !self.attached.is_empty() {
137            let mut parts = Vec::with_capacity(self.attached.len() + 1);
138            parts.push(content_node);
139            parts.extend(self.attached);
140            MimePart::new_multipart(
141                "multipart/mixed",
142                parts,
143                if self.stable_content {
144                    Some(b"mm-boundary")
145                } else {
146                    None
147                },
148            )?
149        } else {
150            content_node
151        };
152
153        root.headers_mut().headers.extend(self.headers.headers);
154
155        if root.headers().mime_version()?.is_none() {
156            root.headers_mut().set_mime_version("1.0")?;
157        }
158
159        if root.headers().date()?.is_none() {
160            if self.stable_content {
161                root.headers_mut().set_date(
162                    chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 10:52:37 +0200")
163                        .expect("test date to be valid"),
164                )?;
165            } else {
166                root.headers_mut().set_date(chrono::Utc::now())?;
167            };
168        }
169
170        // TODO: Content-Id? Hard to do without context on the machine
171        // name and other external data, so perhaps punt this indefinitely
172        // from this module?
173
174        Ok(root)
175    }
176}
177
178impl<'a> std::ops::Deref for MessageBuilder<'a> {
179    type Target = HeaderMap<'a>;
180    fn deref(&self) -> &HeaderMap<'a> {
181        &self.headers
182    }
183}
184
185impl<'a> std::ops::DerefMut for MessageBuilder<'a> {
186    fn deref_mut(&mut self) -> &mut HeaderMap<'a> {
187        &mut self.headers
188    }
189}
190
191#[cfg(test)]
192mod test {
193    use super::*;
194    use bstr::BString;
195
196    #[test]
197    fn basic() {
198        let mut b = MessageBuilder::new();
199        b.set_stable_content(true);
200        b.set_subject("Hello there! ๐Ÿ‰").unwrap();
201        b.text_plain("This is the body! ๐Ÿ‘ป");
202        b.text_html("<b>this is html ๐Ÿš€</b>");
203        let msg = b.build().unwrap();
204        k9::snapshot!(
205            BString::from(msg.to_message_bytes()),
206            r#"
207Content-Type: multipart/alternative;\r
208\tboundary="ma-boundary"\r
209Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
210Mime-Version: 1.0\r
211Date: Tue, 1 Jul 2003 10:52:37 +0200\r
212\r
213--ma-boundary\r
214Content-Type: text/plain;\r
215\tcharset="utf-8"\r
216Content-Transfer-Encoding: quoted-printable\r
217\r
218This is the body! =F0=9F=91=BB\r
219--ma-boundary\r
220Content-Type: text/html;\r
221\tcharset="utf-8"\r
222Content-Transfer-Encoding: quoted-printable\r
223\r
224<b>this is html =F0=9F=9A=80</b>\r
225--ma-boundary--\r
226
227"#
228        );
229    }
230
231    #[test]
232    fn amp() {
233        let mut b = MessageBuilder::new();
234        b.set_stable_content(true);
235        b.set_subject("Hello there! ๐Ÿ‰").unwrap();
236        b.text_plain("This is the body! ๐Ÿ‘ป");
237        b.text_html("<b>this is html ๐Ÿš€</b>");
238        b.text_amp_html(
239            &r#"<!doctype html>
240<html โšก4email>
241<head>
242  <meta charset="utf-8">
243  <style amp4email-boilerplate>body{visibility:hidden}</style>
244  <script async src="https://cdn.ampproject.org/v0.js"></script>
245</head>
246<body>
247Hello World in AMP!
248</body>
249</html>
250"#
251            .replace("\n", "\r\n"),
252        );
253        let msg = b.build().unwrap();
254        k9::snapshot!(
255            BString::from(msg.to_message_bytes()),
256            r#"
257Content-Type: multipart/alternative;\r
258\tboundary="ma-boundary"\r
259Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
260Mime-Version: 1.0\r
261Date: Tue, 1 Jul 2003 10:52:37 +0200\r
262\r
263--ma-boundary\r
264Content-Type: text/plain;\r
265\tcharset="utf-8"\r
266Content-Transfer-Encoding: quoted-printable\r
267\r
268This is the body! =F0=9F=91=BB\r
269--ma-boundary\r
270Content-Type: text/x-amp-html;\r
271\tcharset="utf-8"\r
272Content-Transfer-Encoding: quoted-printable\r
273\r
274<!doctype html>\r
275<html =E2=9A=A14email>\r
276<head>\r
277  <meta charset=3D"utf-8">\r
278  <style amp4email-boilerplate>body{visibility:hidden}</style>\r
279  <script async src=3D"https://cdn.ampproject.org/v0.js"></script>\r
280</head>\r
281<body>\r
282Hello World in AMP!\r
283</body>\r
284</html>\r
285--ma-boundary\r
286Content-Type: text/html;\r
287\tcharset="utf-8"\r
288Content-Transfer-Encoding: quoted-printable\r
289\r
290<b>this is html =F0=9F=9A=80</b>\r
291--ma-boundary--\r
292
293"#
294        );
295    }
296
297    #[test]
298    fn utf8_attachment_name() {
299        let mut b = MessageBuilder::new();
300        b.set_stable_content(true);
301        b.set_subject("Hello there! ๐Ÿ‰").unwrap();
302        b.text_plain("This is the body! ๐Ÿ‘ป");
303        b.attach(
304            "text/plain",
305            b"hello",
306            Some(&AttachmentOptions {
307                content_id: None,
308                file_name: Some("ๆ—ฅๆœฌ่ชžใฎๆทปไป˜.txt".into()),
309                inline: false,
310            }),
311        )
312        .unwrap();
313        let msg = b.build().unwrap();
314        k9::snapshot!(
315            BString::from(msg.to_message_bytes()),
316            r#"
317Content-Type: multipart/mixed;\r
318\tboundary="mm-boundary"\r
319Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
320Mime-Version: 1.0\r
321Date: Tue, 1 Jul 2003 10:52:37 +0200\r
322\r
323--mm-boundary\r
324Content-Type: text/plain;\r
325\tcharset="utf-8"\r
326Content-Transfer-Encoding: quoted-printable\r
327\r
328This is the body! =F0=9F=91=BB\r
329--mm-boundary\r
330Content-Disposition: attachment;\r
331\tfilename*0*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98.;\r
332\tfilename*1*=txt\r
333Content-Type: text/plain;\r
334\tname="=?UTF-8?q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?="\r
335Content-Transfer-Encoding: base64\r
336\r
337aGVsbG8=\r
338--mm-boundary--\r
339
340"#
341        );
342    }
343}