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("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("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("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("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
195    #[test]
196    fn basic() {
197        let mut b = MessageBuilder::new();
198        b.set_stable_content(true);
199        b.set_subject("Hello there! ๐Ÿ‰").unwrap();
200        b.text_plain("This is the body! ๐Ÿ‘ป");
201        b.text_html("<b>this is html ๐Ÿš€</b>");
202        let msg = b.build().unwrap();
203        k9::snapshot!(
204            msg.to_message_string(),
205            r#"
206Content-Type: multipart/alternative;\r
207\tboundary="ma-boundary"\r
208Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
209Mime-Version: 1.0\r
210Date: Tue, 1 Jul 2003 10:52:37 +0200\r
211\r
212--ma-boundary\r
213Content-Type: text/plain;\r
214\tcharset="utf-8"\r
215Content-Transfer-Encoding: quoted-printable\r
216\r
217This is the body! =F0=9F=91=BB\r
218--ma-boundary\r
219Content-Type: text/html;\r
220\tcharset="utf-8"\r
221Content-Transfer-Encoding: quoted-printable\r
222\r
223<b>this is html =F0=9F=9A=80</b>\r
224--ma-boundary--\r
225
226"#
227        );
228    }
229
230    #[test]
231    fn amp() {
232        let mut b = MessageBuilder::new();
233        b.set_stable_content(true);
234        b.set_subject("Hello there! ๐Ÿ‰").unwrap();
235        b.text_plain("This is the body! ๐Ÿ‘ป");
236        b.text_html("<b>this is html ๐Ÿš€</b>");
237        b.text_amp_html(
238            &r#"<!doctype html>
239<html โšก4email>
240<head>
241  <meta charset="utf-8">
242  <style amp4email-boilerplate>body{visibility:hidden}</style>
243  <script async src="https://cdn.ampproject.org/v0.js"></script>
244</head>
245<body>
246Hello World in AMP!
247</body>
248</html>
249"#
250            .replace("\n", "\r\n"),
251        );
252        let msg = b.build().unwrap();
253        k9::snapshot!(
254            msg.to_message_string(),
255            r#"
256Content-Type: multipart/alternative;\r
257\tboundary="ma-boundary"\r
258Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
259Mime-Version: 1.0\r
260Date: Tue, 1 Jul 2003 10:52:37 +0200\r
261\r
262--ma-boundary\r
263Content-Type: text/plain;\r
264\tcharset="utf-8"\r
265Content-Transfer-Encoding: quoted-printable\r
266\r
267This is the body! =F0=9F=91=BB\r
268--ma-boundary\r
269Content-Type: text/x-amp-html;\r
270\tcharset="utf-8"\r
271Content-Transfer-Encoding: quoted-printable\r
272\r
273<!doctype html>\r
274<html =E2=9A=A14email>\r
275<head>\r
276  <meta charset=3D"utf-8">\r
277  <style amp4email-boilerplate>body{visibility:hidden}</style>\r
278  <script async src=3D"https://cdn.ampproject.org/v0.js"></script>\r
279</head>\r
280<body>\r
281Hello World in AMP!\r
282</body>\r
283</html>\r
284--ma-boundary\r
285Content-Type: text/html;\r
286\tcharset="utf-8"\r
287Content-Transfer-Encoding: quoted-printable\r
288\r
289<b>this is html =F0=9F=9A=80</b>\r
290--ma-boundary--\r
291
292"#
293        );
294    }
295
296    #[test]
297    fn utf8_attachment_name() {
298        let mut b = MessageBuilder::new();
299        b.set_stable_content(true);
300        b.set_subject("Hello there! ๐Ÿ‰").unwrap();
301        b.text_plain("This is the body! ๐Ÿ‘ป");
302        b.attach(
303            "text/plain",
304            b"hello",
305            Some(&AttachmentOptions {
306                content_id: None,
307                file_name: Some("ๆ—ฅๆœฌ่ชžใฎๆทปไป˜.txt".to_string()),
308                inline: false,
309            }),
310        )
311        .unwrap();
312        let msg = b.build().unwrap();
313        k9::snapshot!(
314            msg.to_message_string(),
315            r#"
316Content-Type: multipart/mixed;\r
317\tboundary="mm-boundary"\r
318Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
319Mime-Version: 1.0\r
320Date: Tue, 1 Jul 2003 10:52:37 +0200\r
321\r
322--mm-boundary\r
323Content-Type: text/plain;\r
324\tcharset="utf-8"\r
325Content-Transfer-Encoding: quoted-printable\r
326\r
327This is the body! =F0=9F=91=BB\r
328--mm-boundary\r
329Content-Disposition: attachment;\r
330\tfilename*0*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98.;\r
331\tfilename*1*=txt\r
332Content-Type: text/plain;\r
333\tname="=?UTF-8?q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?="\r
334Content-Transfer-Encoding: base64\r
335\r
336aGVsbG8=\r
337--mm-boundary--\r
338
339"#
340        );
341    }
342}