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 headers: HeaderMap<'a>,
9 inline: Vec<MimePart<'a>>,
10 attached: Vec<MimePart<'a>>,
11 stable_content: bool,
12}
13
14impl<'a> MessageBuilder<'a> {
15 pub fn new() -> Self {
16 Self::default()
17 }
18
19 pub fn set_stable_content(&mut self, v: bool) {
20 self.stable_content = v;
21 }
22
23 pub fn text_plain(&mut self, text: &str) {
24 self.text.replace(text.to_string());
25 }
26
27 pub fn text_html(&mut self, html: &str) {
28 self.html.replace(html.to_string());
29 }
30
31 pub fn attach(
32 &mut self,
33 content_type: &str,
34 data: &[u8],
35 opts: Option<&AttachmentOptions>,
36 ) -> Result<(), MailParsingError> {
37 let is_inline = opts.map(|opt| opt.inline).unwrap_or(false);
38
39 let part = MimePart::new_binary(content_type, data, opts)?;
40
41 if is_inline {
42 self.inline.push(part);
43 } else {
44 self.attached.push(part);
45 }
46
47 Ok(())
48 }
49
50 pub fn attach_part(&mut self, part: MimePart<'a>) {
51 let is_inline = part
52 .headers()
53 .content_disposition()
54 .ok()
55 .and_then(|opt_cd| opt_cd.map(|cd| cd.value == "inline"))
56 .unwrap_or(false);
57 if is_inline {
58 self.inline.push(part);
59 } else {
60 self.attached.push(part);
61 }
62 }
63
64 pub fn build(self) -> Result<MimePart<'a>, MailParsingError> {
65 let text = self.text.as_deref().map(MimePart::new_text_plain);
66 let html = self.html.as_deref().map(MimePart::new_html);
67
68 let content_node = match (text, html) {
69 (Some(t), Some(h)) => MimePart::new_multipart(
70 "multipart/alternative",
71 vec![t?, h?],
72 if self.stable_content {
73 Some("ma-boundary")
74 } else {
75 None
76 },
77 )?,
78 (Some(t), None) => t?,
79 (None, Some(h)) => h?,
80 (None, None) => {
81 return Err(MailParsingError::BuildError(
82 "no text or html part was specified",
83 ))
84 }
85 };
86
87 let content_node = if !self.inline.is_empty() {
88 let mut parts = Vec::with_capacity(self.inline.len() + 1);
89 parts.push(content_node);
90 parts.extend(self.inline);
91 MimePart::new_multipart(
92 "multipart/related",
93 parts,
94 if self.stable_content {
95 Some("mr-boundary")
96 } else {
97 None
98 },
99 )?
100 } else {
101 content_node
102 };
103
104 let mut root = if !self.attached.is_empty() {
105 let mut parts = Vec::with_capacity(self.attached.len() + 1);
106 parts.push(content_node);
107 parts.extend(self.attached);
108 MimePart::new_multipart(
109 "multipart/mixed",
110 parts,
111 if self.stable_content {
112 Some("mm-boundary")
113 } else {
114 None
115 },
116 )?
117 } else {
118 content_node
119 };
120
121 root.headers_mut().headers.extend(self.headers.headers);
122
123 if root.headers().mime_version()?.is_none() {
124 root.headers_mut().set_mime_version("1.0")?;
125 }
126
127 if root.headers().date()?.is_none() {
128 if self.stable_content {
129 root.headers_mut().set_date(
130 chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 10:52:37 +0200")
131 .expect("test date to be valid"),
132 )?;
133 } else {
134 root.headers_mut().set_date(chrono::Utc::now())?;
135 };
136 }
137
138 Ok(root)
143 }
144}
145
146impl<'a> std::ops::Deref for MessageBuilder<'a> {
147 type Target = HeaderMap<'a>;
148 fn deref(&self) -> &HeaderMap<'a> {
149 &self.headers
150 }
151}
152
153impl<'a> std::ops::DerefMut for MessageBuilder<'a> {
154 fn deref_mut(&mut self) -> &mut HeaderMap<'a> {
155 &mut self.headers
156 }
157}
158
159#[cfg(test)]
160mod test {
161 use super::*;
162
163 #[test]
164 fn basic() {
165 let mut b = MessageBuilder::new();
166 b.set_stable_content(true);
167 b.set_subject("Hello there! ๐").unwrap();
168 b.text_plain("This is the body! ๐ป");
169 b.text_html("<b>this is html ๐</b>");
170 let msg = b.build().unwrap();
171 k9::snapshot!(
172 msg.to_message_string(),
173 r#"
174Content-Type: multipart/alternative;\r
175\tboundary="ma-boundary"\r
176Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
177Mime-Version: 1.0\r
178Date: Tue, 1 Jul 2003 10:52:37 +0200\r
179\r
180--ma-boundary\r
181Content-Type: text/plain;\r
182\tcharset="utf-8"\r
183Content-Transfer-Encoding: quoted-printable\r
184\r
185This is the body! =F0=9F=91=BB\r
186--ma-boundary\r
187Content-Type: text/html;\r
188\tcharset="utf-8"\r
189Content-Transfer-Encoding: quoted-printable\r
190\r
191<b>this is html =F0=9F=9A=80</b>\r
192--ma-boundary--\r
193
194"#
195 );
196 }
197
198 #[test]
199 fn utf8_attachment_name() {
200 let mut b = MessageBuilder::new();
201 b.set_stable_content(true);
202 b.set_subject("Hello there! ๐").unwrap();
203 b.text_plain("This is the body! ๐ป");
204 b.attach(
205 "text/plain",
206 b"hello",
207 Some(&AttachmentOptions {
208 content_id: None,
209 file_name: Some("ๆฅๆฌ่ชใฎๆทปไป.txt".to_string()),
210 inline: false,
211 }),
212 )
213 .unwrap();
214 let msg = b.build().unwrap();
215 k9::snapshot!(
216 msg.to_message_string(),
217 r#"
218Content-Type: multipart/mixed;\r
219\tboundary="mm-boundary"\r
220Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
221Mime-Version: 1.0\r
222Date: Tue, 1 Jul 2003 10:52:37 +0200\r
223\r
224--mm-boundary\r
225Content-Type: text/plain;\r
226\tcharset="utf-8"\r
227Content-Transfer-Encoding: quoted-printable\r
228\r
229This is the body! =F0=9F=91=BB\r
230--mm-boundary\r
231Content-Disposition: attachment;\r
232\tfilename*0*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98.;\r
233\tfilename*1*=txt\r
234Content-Type: text/plain;\r
235\tname="=?UTF-8?q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?="\r
236Content-Transfer-Encoding: base64\r
237\r
238aGVsbG8=\r
239--mm-boundary--\r
240
241"#
242 );
243 }
244}