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 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 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 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}