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(&mut self, content_type: &str, data: &[u8], opts: Option<&AttachmentOptions>) {
32        let is_inline = opts.map(|opt| opt.inline).unwrap_or(false);
33
34        let part = MimePart::new_binary(content_type, data, opts);
35
36        if is_inline {
37            self.inline.push(part);
38        } else {
39            self.attached.push(part);
40        }
41    }
42
43    pub fn attach_part(&mut self, part: MimePart<'a>) {
44        let is_inline = part
45            .headers()
46            .content_disposition()
47            .ok()
48            .and_then(|opt_cd| opt_cd.map(|cd| cd.value == "inline"))
49            .unwrap_or(false);
50        if is_inline {
51            self.inline.push(part);
52        } else {
53            self.attached.push(part);
54        }
55    }
56
57    pub fn build(self) -> Result<MimePart<'a>, MailParsingError> {
58        let text = self.text.as_deref().map(MimePart::new_text_plain);
59        let html = self.html.as_deref().map(MimePart::new_html);
60
61        let content_node = match (text, html) {
62            (Some(t), Some(h)) => MimePart::new_multipart(
63                "multipart/alternative",
64                vec![t, h],
65                if self.stable_content {
66                    Some("ma-boundary")
67                } else {
68                    None
69                },
70            ),
71            (Some(t), None) => t,
72            (None, Some(h)) => h,
73            (None, None) => {
74                return Err(MailParsingError::BuildError(
75                    "no text or html part was specified",
76                ))
77            }
78        };
79
80        let content_node = if !self.inline.is_empty() {
81            let mut parts = Vec::with_capacity(self.inline.len() + 1);
82            parts.push(content_node);
83            parts.extend(self.inline);
84            MimePart::new_multipart(
85                "multipart/related",
86                parts,
87                if self.stable_content {
88                    Some("mr-boundary")
89                } else {
90                    None
91                },
92            )
93        } else {
94            content_node
95        };
96
97        let mut root = if !self.attached.is_empty() {
98            let mut parts = Vec::with_capacity(self.attached.len() + 1);
99            parts.push(content_node);
100            parts.extend(self.attached);
101            MimePart::new_multipart(
102                "multipart/mixed",
103                parts,
104                if self.stable_content {
105                    Some("mm-boundary")
106                } else {
107                    None
108                },
109            )
110        } else {
111            content_node
112        };
113
114        root.headers_mut().headers.extend(self.headers.headers);
115
116        if root.headers().mime_version()?.is_none() {
117            root.headers_mut().set_mime_version("1.0");
118        }
119
120        if root.headers().date()?.is_none() {
121            if self.stable_content {
122                root.headers_mut().set_date(
123                    chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 10:52:37 +0200")
124                        .expect("test date to be valid"),
125                );
126            } else {
127                root.headers_mut().set_date(chrono::Utc::now());
128            };
129        }
130
131        // TODO: Content-Id? Hard to do without context on the machine
132        // name and other external data, so perhaps punt this indefinitely
133        // from this module?
134
135        Ok(root)
136    }
137}
138
139impl<'a> std::ops::Deref for MessageBuilder<'a> {
140    type Target = HeaderMap<'a>;
141    fn deref(&self) -> &HeaderMap<'a> {
142        &self.headers
143    }
144}
145
146impl<'a> std::ops::DerefMut for MessageBuilder<'a> {
147    fn deref_mut(&mut self) -> &mut HeaderMap<'a> {
148        &mut self.headers
149    }
150}
151
152#[cfg(test)]
153mod test {
154    use super::*;
155
156    #[test]
157    fn basic() {
158        let mut b = MessageBuilder::new();
159        b.set_stable_content(true);
160        b.set_subject("Hello there! 🍉");
161        b.text_plain("This is the body! 👻");
162        b.text_html("<b>this is html 🚀</b>");
163        let msg = b.build().unwrap();
164        k9::snapshot!(
165            msg.to_message_string(),
166            r#"
167Content-Type: multipart/alternative;\r
168\tboundary="ma-boundary"\r
169Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r
170Mime-Version: 1.0\r
171Date: Tue, 1 Jul 2003 10:52:37 +0200\r
172\r
173--ma-boundary\r
174Content-Type: text/plain;\r
175\tcharset="utf-8"\r
176Content-Transfer-Encoding: quoted-printable\r
177\r
178This is the body! =F0=9F=91=BB\r
179--ma-boundary\r
180Content-Type: text/html;\r
181\tcharset="utf-8"\r
182Content-Transfer-Encoding: quoted-printable\r
183\r
184<b>this is html =F0=9F=9A=80</b>\r
185--ma-boundary--\r
186
187"#
188        );
189    }
190}