Skip to main content

mail_builder/
lib.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7#![doc = include_str!("../README.md")]
8#![deny(rust_2018_idioms)]
9#![forbid(unsafe_code)]
10pub mod encoders;
11pub mod headers;
12pub mod mime;
13pub mod writer;
14
15use std::{
16    borrow::Cow,
17    io::{self, Write},
18};
19
20#[cfg(feature = "gethostname")]
21use std::sync::OnceLock;
22
23use headers::{
24    Header, HeaderType,
25    address::Address,
26    content_type::ContentType,
27    date::Date,
28    message_id::{MessageId, generate_message_id_header},
29    text::Text,
30};
31use mime::{BodyPart, MimePart, estimated_headers_len, write_header_name};
32use writer::{IO_BUFFER_MAX, IO_BUFFER_MIN};
33pub use writer::{IoWriter, Writer};
34
35const GENERATED_HEADERS_ESTIMATE: usize = 160;
36
37#[inline(always)]
38fn buffer_len(estimate: usize) -> usize {
39    estimate.clamp(IO_BUFFER_MIN, IO_BUFFER_MAX)
40}
41const MESSAGE_ESTIMATE_FLOOR: usize = 256;
42const BOUNDARY_OVERHEAD: usize = 128;
43
44#[cfg(feature = "gethostname")]
45fn local_hostname() -> &'static str {
46    static HOSTNAME: OnceLock<String> = OnceLock::new();
47    HOSTNAME
48        .get_or_init(|| {
49            gethostname::gethostname()
50                .into_string()
51                .unwrap_or_else(|_| "localhost".to_string())
52        })
53        .as_str()
54}
55
56#[cfg(not(feature = "gethostname"))]
57fn local_hostname() -> &'static str {
58    "localhost"
59}
60
61/// Builds an RFC5322 compliant MIME email message.
62#[derive(Clone, Debug)]
63pub struct MessageBuilder<'x> {
64    pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
65    pub html_body: Option<MimePart<'x>>,
66    pub text_body: Option<MimePart<'x>>,
67    pub attachments: Option<Vec<MimePart<'x>>>,
68    pub body: Option<MimePart<'x>>,
69}
70
71impl Default for MessageBuilder<'_> {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl<'x> MessageBuilder<'x> {
78    /// Create a new MessageBuilder.
79    pub fn new() -> Self {
80        MessageBuilder {
81            headers: Vec::new(),
82            html_body: None,
83            text_body: None,
84            attachments: None,
85            body: None,
86        }
87    }
88
89    /// Set the Message-ID header. If no Message-ID header is set, one will be
90    /// generated automatically.
91    pub fn message_id(self, value: impl Into<MessageId<'x>>) -> Self {
92        self.header("Message-ID", value.into())
93    }
94
95    /// Set the In-Reply-To header.
96    pub fn in_reply_to(self, value: impl Into<MessageId<'x>>) -> Self {
97        self.header("In-Reply-To", value.into())
98    }
99
100    /// Set the References header.
101    pub fn references(self, value: impl Into<MessageId<'x>>) -> Self {
102        self.header("References", value.into())
103    }
104
105    /// Set the Sender header.
106    pub fn sender(self, value: impl Into<Address<'x>>) -> Self {
107        self.header("Sender", value.into())
108    }
109
110    /// Set the From header.
111    pub fn from(self, value: impl Into<Address<'x>>) -> Self {
112        self.header("From", value.into())
113    }
114
115    /// Set the To header.
116    pub fn to(self, value: impl Into<Address<'x>>) -> Self {
117        self.header("To", value.into())
118    }
119
120    /// Set the Cc header.
121    pub fn cc(self, value: impl Into<Address<'x>>) -> Self {
122        self.header("Cc", value.into())
123    }
124
125    /// Set the Bcc header.
126    pub fn bcc(self, value: impl Into<Address<'x>>) -> Self {
127        self.header("Bcc", value.into())
128    }
129
130    /// Set the Reply-To header.
131    pub fn reply_to(self, value: impl Into<Address<'x>>) -> Self {
132        self.header("Reply-To", value.into())
133    }
134
135    /// Set the Subject header.
136    pub fn subject(self, value: impl Into<Text<'x>>) -> Self {
137        self.header("Subject", value.into())
138    }
139
140    /// Set the Date header. If no Date header is set, one will be generated
141    /// automatically.
142    pub fn date(self, value: impl Into<Date>) -> Self {
143        self.header("Date", value.into())
144    }
145
146    /// Add a custom header.
147    pub fn header(
148        mut self,
149        header: impl Into<Cow<'x, str>>,
150        value: impl Into<HeaderType<'x>>,
151    ) -> Self {
152        self.headers.push((header.into(), value.into()));
153        self
154    }
155
156    /// Set custom headers.
157    pub fn headers<T, U, V>(mut self, header: T, values: U) -> Self
158    where
159        T: Into<Cow<'x, str>>,
160        U: IntoIterator<Item = V>,
161        V: Into<HeaderType<'x>>,
162    {
163        let header = header.into();
164
165        for value in values {
166            self.headers.push((header.clone(), value.into()));
167        }
168
169        self
170    }
171
172    /// Set the plain text body of the message. Note that only one plain text body
173    /// per message can be set using this function.
174    /// To build more complex MIME body structures, use the `body` method instead.
175    pub fn text_body(mut self, value: impl Into<Cow<'x, str>>) -> Self {
176        self.text_body = Some(MimePart::new("text/plain", BodyPart::Text(value.into())));
177        self
178    }
179
180    /// Set the HTML body of the message. Note that only one HTML body
181    /// per message can be set using this function.
182    /// To build more complex MIME body structures, use the `body` method instead.
183    pub fn html_body(mut self, value: impl Into<Cow<'x, str>>) -> Self {
184        self.html_body = Some(MimePart::new("text/html", BodyPart::Text(value.into())));
185        self
186    }
187
188    /// Add a binary attachment to the message.
189    pub fn attachment(
190        mut self,
191        content_type: impl Into<ContentType<'x>>,
192        filename: impl Into<Cow<'x, str>>,
193        value: impl Into<BodyPart<'x>>,
194    ) -> Self {
195        self.attachments
196            .get_or_insert_with(Vec::new)
197            .push(MimePart::new(content_type, value).attachment(filename));
198        self
199    }
200
201    /// Add an inline binary to the message.
202    pub fn inline(
203        mut self,
204        content_type: impl Into<ContentType<'x>>,
205        cid: impl Into<Cow<'x, str>>,
206        value: impl Into<BodyPart<'x>>,
207    ) -> Self {
208        self.attachments
209            .get_or_insert_with(Vec::new)
210            .push(MimePart::new(content_type, value).inline().cid(cid));
211        self
212    }
213
214    /// Set a custom MIME body structure.
215    pub fn body(mut self, value: MimePart<'x>) -> Self {
216        self.body = Some(value);
217        self
218    }
219
220    /// Build the message into any [`Writer`], such as a `Vec<u8>`.
221    pub fn serialize(self, output: &mut impl Writer) {
222        output.reserve(self.estimated_len());
223
224        let mut has_date = false;
225        let mut has_message_id = false;
226        let mut has_mime_version = false;
227
228        for (header_name, header_value) in &self.headers {
229            if !has_date && header_name == "Date" {
230                has_date = true;
231            } else if !has_message_id && header_name == "Message-ID" {
232                has_message_id = true;
233            } else if !has_mime_version && header_name == "MIME-Version" {
234                has_mime_version = true;
235            }
236
237            write_header_name(header_name, output);
238            header_value.write_header(output, header_name.len() + 2);
239        }
240
241        if !has_message_id {
242            output.write(b"Message-ID: ");
243            generate_message_id_header(output, local_hostname());
244            output.write(b"\r\n");
245        }
246
247        if !has_date {
248            output.write(b"Date: ");
249            Date::now().write_rfc822(output);
250            output.write(b"\r\n");
251        }
252
253        if !has_mime_version {
254            output.write(b"MIME-Version: 1.0\r\n");
255        }
256
257        self.write_body_parts(output)
258    }
259
260    fn estimated_len(&self) -> usize {
261        estimated_headers_len(&self.headers)
262            + GENERATED_HEADERS_ESTIMATE
263            + self.estimated_body_len()
264    }
265
266    fn estimated_body_len(&self) -> usize {
267        let estimate = match &self.body {
268            Some(body) => body.estimated_len(),
269            None => {
270                let mut estimate = 0;
271                let mut parts = 0;
272                for part in [self.text_body.as_ref(), self.html_body.as_ref()]
273                    .into_iter()
274                    .flatten()
275                {
276                    estimate += part.estimated_len() + BOUNDARY_OVERHEAD;
277                    parts += 1;
278                }
279                if let Some(attachments) = &self.attachments {
280                    for part in attachments {
281                        estimate += part.estimated_len() + BOUNDARY_OVERHEAD;
282                    }
283                    parts += attachments.len();
284                }
285                if parts > 1 {
286                    estimate += GENERATED_HEADERS_ESTIMATE;
287                }
288                estimate
289            }
290        };
291        estimate.max(MESSAGE_ESTIMATE_FLOOR)
292    }
293
294    /// Build the message body without headers into any [`Writer`].
295    pub fn serialize_body(self, output: &mut impl Writer) {
296        output.reserve(self.estimated_body_len());
297        self.write_body_parts(output)
298    }
299
300    fn write_body_parts(self, output: &mut impl Writer) {
301        (if let Some(body) = self.body {
302            body
303        } else {
304            match (self.text_body, self.html_body, self.attachments) {
305                (Some(text), Some(html), Some(attachments)) => {
306                    let mut parts = Vec::with_capacity(attachments.len() + 1);
307                    parts.push(MimePart::new("multipart/alternative", vec![text, html]));
308                    parts.extend(attachments);
309
310                    MimePart::new("multipart/mixed", parts)
311                }
312                (Some(text), Some(html), None) => {
313                    MimePart::new("multipart/alternative", vec![text, html])
314                }
315                (Some(text), None, Some(attachments)) => {
316                    let mut parts = Vec::with_capacity(attachments.len() + 1);
317                    parts.push(text);
318                    parts.extend(attachments);
319                    MimePart::new("multipart/mixed", parts)
320                }
321                (Some(text), None, None) => text,
322                (None, Some(html), Some(attachments)) => {
323                    let mut parts = Vec::with_capacity(attachments.len() + 1);
324                    parts.push(html);
325                    parts.extend(attachments);
326                    MimePart::new("multipart/mixed", parts)
327                }
328                (None, Some(html), None) => html,
329                (None, None, Some(attachments)) => MimePart::new("multipart/mixed", attachments),
330                (None, None, None) => MimePart::new("text/plain", "\n"),
331            }
332        })
333        .write_part(output);
334    }
335
336    /// Build the message and stream it to a [`std::io::Write`].
337    pub fn write_to(self, output: impl Write) -> io::Result<()> {
338        let mut writer = IoWriter::with_capacity(buffer_len(self.estimated_len()), output);
339        self.serialize(&mut writer);
340        writer.into_result()
341    }
342
343    /// Write the message body without headers to a [`std::io::Write`].
344    pub fn write_body(self, output: impl Write) -> io::Result<()> {
345        let mut writer = IoWriter::with_capacity(buffer_len(self.estimated_body_len()), output);
346        self.serialize_body(&mut writer);
347        writer.into_result()
348    }
349
350    /// Build message to a Vec<u8>.
351    pub fn write_to_vec(self) -> io::Result<Vec<u8>> {
352        let mut output = Vec::with_capacity(self.estimated_len());
353        self.serialize(&mut output);
354        Ok(output)
355    }
356
357    /// Build message to a String.
358    pub fn write_to_string(self) -> io::Result<String> {
359        let mut output = Vec::with_capacity(self.estimated_len());
360        self.serialize(&mut output);
361        String::from_utf8(output).map_err(io::Error::other)
362    }
363}
364
365#[cfg(test)]
366mod tests {
367
368    use mail_parser::MessageParser;
369
370    use crate::{
371        MessageBuilder,
372        headers::{address::Address, url::URL},
373        mime::MimePart,
374    };
375
376    #[test]
377    fn build_nested_message() {
378        let output = MessageBuilder::new()
379            .from(Address::new_address("John Doe".into(), "john@doe.com"))
380            .to(Address::new_address("Jane Doe".into(), "jane@doe.com"))
381            .subject("RFC 8621 Section 4.1.4 test")
382            .body(MimePart::new(
383                "multipart/mixed",
384                vec![
385                    MimePart::new("text/plain", "Part A contents go here...").inline(),
386                    MimePart::new(
387                        "multipart/mixed",
388                        vec![
389                            MimePart::new(
390                                "multipart/alternative",
391                                vec![
392                                    MimePart::new(
393                                        "multipart/mixed",
394                                        vec![
395                                            MimePart::new(
396                                                "text/plain",
397                                                "Part B contents go here...",
398                                            )
399                                            .inline(),
400                                            MimePart::new(
401                                                "image/jpeg",
402                                                "Part C contents go here...".as_bytes(),
403                                            )
404                                            .inline(),
405                                            MimePart::new(
406                                                "text/plain",
407                                                "Part D contents go here...",
408                                            )
409                                            .inline(),
410                                        ],
411                                    ),
412                                    MimePart::new(
413                                        "multipart/related",
414                                        vec![
415                                            MimePart::new(
416                                                "text/html",
417                                                "Part E contents go here...",
418                                            )
419                                            .inline(),
420                                            MimePart::new(
421                                                "image/jpeg",
422                                                "Part F contents go here...".as_bytes(),
423                                            ),
424                                        ],
425                                    ),
426                                ],
427                            ),
428                            MimePart::new("image/jpeg", "Part G contents go here...".as_bytes())
429                                .attachment("image_G.jpg"),
430                            MimePart::new(
431                                "application/x-excel",
432                                "Part H contents go here...".as_bytes(),
433                            ),
434                            MimePart::new(
435                                "x-message/rfc822",
436                                "Part J contents go here...".as_bytes(),
437                            ),
438                        ],
439                    ),
440                    MimePart::new("text/plain", "Part K contents go here...").inline(),
441                ],
442            ))
443            .write_to_vec()
444            .unwrap();
445        MessageParser::new().parse(&output).unwrap();
446    }
447
448    #[test]
449    fn build_message() {
450        let output = MessageBuilder::new()
451            .from(("John Doe", "john@doe.com"))
452            .to(vec![
453                ("Antoine de Saint-Exupéry", "antoine@exupery.com"),
454                ("안녕하세요 세계", "test@test.com"),
455                ("Xin chào", "addr@addr.com"),
456            ])
457            .bcc(vec![
458                (
459                    "Привет, мир",
460                    vec![
461                        ("ASCII recipient", "addr1@addr7.com"),
462                        ("ハロー・ワールド", "addr2@addr6.com"),
463                        ("áéíóú", "addr3@addr5.com"),
464                        ("Γειά σου Κόσμε", "addr4@addr4.com"),
465                    ],
466                ),
467                (
468                    "Hello world",
469                    vec![
470                        ("שלום עולם", "addr5@addr3.com"),
471                        ("¡El ñandú comió ñoquis!", "addr6@addr2.com"),
472                        ("Recipient", "addr7@addr1.com"),
473                    ],
474                ),
475            ])
476            .header("List-Archive", URL::new("http://example.com/archive"))
477            .subject("Hello world!")
478            .text_body("Hello, world!\n".repeat(20))
479            .html_body("<p>¡Hola Mundo!</p>".repeat(20))
480            .inline("image/png", "cid:image", [0, 1, 2, 3, 4, 5].as_ref())
481            .attachment("text/plain", "my fíle.txt", "안녕하세요 세계".repeat(20))
482            .attachment(
483                "text/plain",
484                "ハロー・ワールド",
485                "ハロー・ワールド".repeat(20).into_bytes(),
486            )
487            .write_to_vec()
488            .unwrap();
489        MessageParser::new().parse(&output).unwrap();
490    }
491}