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    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        // TODO: Content-Id? Hard to do without context on the machine
139        // name and other external data, so perhaps punt this indefinitely
140        // from this module?
141
142        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}