Skip to main content

mail_builder/
lib.rs

1//! `mail_builder` is a flexible **e-mail builder** written in Rust. It includes
2//! the following features:
3//!
4//! - Generates messages conforming to the Internet Message Format standard
5//!   (*RFC 5322*).
6//! - Full **MIME** support (*RFC 2045 – 2049*) with automatic selection of the
7//!   most optimal transfer encoding for each body part.
8//! - RFC 2047 encoded words and RFC 2231 extended parameters for non-ASCII
9//!   subjects, names and file names, folded to comply with the 78-column
10//!   recommendation (and the 998-column limit).
11//! - Header values are sanitised: control characters and CR/LF that could inject
12//!   extra header fields are removed or encoded before they reach the output.
13//! - MIME boundaries are random and are checked against the part contents.
14//! - No `unsafe`, no external dependencies.
15//!
16//! This library builds messages; it does not send or parse them.
17//!
18//! # Usage
19//!
20//! Build a simple e-mail message with a text body and one attachment:
21//!
22//! ```rust
23//! use mail_builder::MessageBuilder;
24//!
25//! // Build a simple text message with a single attachment
26//! let eml = MessageBuilder::new()
27//!     .from(("John Doe", "john@doe.com"))
28//!     .to("jane@doe.com")
29//!     .subject("Hello, world!")
30//!     .text_body("Message contents go here.")
31//!     .attachment("image/png", "image.png", [1, 2, 3, 4].as_slice())
32//!     .to_string()
33//!     .unwrap();
34//!
35//! // Print raw message
36//! println!("{eml}");
37//! ```
38//!
39//! More complex messages with grouped addresses, inline parts and
40//! `multipart/alternative` sections can also be built:
41//!
42//! ```rust
43//! use mail_builder::{MessageBuilder, headers::url::URL};
44//! use std::fs::File;
45//!
46//! // Build a multipart message with text and HTML bodies, inline parts and
47//! // attachments.
48//! MessageBuilder::new()
49//!     .from(("John Doe", "john@doe.com"))
50//!     .to(vec![
51//!         ("Antoine de Saint-Exupéry", "antoine@exupery.com"),
52//!         ("안녕하세요 세계", "test@test.com"),
53//!         ("Xin chào", "addr@addr.com"),
54//!     ])
55//!     .bcc(vec![(
56//!         "My Group",
57//!         vec![
58//!             ("ASCII name", "addr1@addr7.com"),
59//!             ("ハロー・ワールド", "addr2@addr6.com"),
60//!         ],
61//!     )])
62//!     .subject("Testing multipart messages")
63//!     .in_reply_to(vec!["message-id-1", "message-id-2"])
64//!     .header("List-Archive", URL::new("http://example.com/archive"))
65//!     .text_body("This is the text body!\n")
66//!     .html_body("<p>HTML body with <img src=\"cid:my-image\"/>!</p>")
67//!     .inline("image/png", "cid:my-image", [0, 1, 2, 3, 4, 5].as_slice())
68//!     .attachment("text/plain", "my fíle.txt", "Attachment contents go here.")
69//!     .attachment(
70//!         "text/plain",
71//!         "ハロー・ワールド",
72//!         b"Binary contents go here.".as_slice(),
73//!     )
74//!     .write_to(File::create("message.eml").unwrap())
75//!     .unwrap();
76//! ```
77//!
78//! Nested MIME body structures can be created using the `body` method:
79//!
80//! ```rust
81//! use mail_builder::{MessageBuilder, headers::address::Address, mime::MimePart};
82//! use std::fs::File;
83//!
84//! MessageBuilder::new()
85//!     .from(Address::new_address("John Doe".into(), "john@doe.com"))
86//!     .to(Address::new_address("Jane Doe".into(), "jane@doe.com"))
87//!     .subject("Nested multipart message")
88//!     .body(MimePart::new(
89//!         "multipart/mixed",
90//!         vec![
91//!             MimePart::new("text/plain", "Part A contents go here...").inline(),
92//!             MimePart::new(
93//!                 "multipart/alternative",
94//!                 vec![
95//!                     MimePart::new("text/plain", "Part B contents go here...").inline(),
96//!                     MimePart::new("text/html", "<p>Part C contents go here...</p>").inline(),
97//!                 ],
98//!             ),
99//!             MimePart::new("image/jpeg", "Part D contents go here...".as_bytes())
100//!                 .attachment("image_D.jpg"),
101//!         ],
102//!     ))
103//!     .write_to(File::create("nested-message.eml").unwrap())
104//!     .unwrap();
105//! ```
106//!
107//! # Notes
108//!
109//! - Text values are written as `7bit` when possible, otherwise as
110//!   quoted-printable or Base64, whichever is shorter. Bare CR/LF in a body are
111//!   normalised to CRLF.
112//! - `Raw` header values are the only escape hatch: they are folded but otherwise
113//!   written as given, so callers are responsible for their contents.
114//! - Generated `Message-ID` headers use a fixed `localhost` domain instead of the
115//!   machine's host name, so building a message never leaks the local host name.
116#![forbid(unsafe_code)]
117mod encoders;
118pub mod headers;
119pub mod mime;
120pub mod writer;
121
122use std::{
123    borrow::Cow,
124    io::{self, Write},
125};
126
127use headers::{
128    Header, HeaderType,
129    address::Address,
130    content_type::ContentType,
131    date::Date,
132    message_id::{MessageId, generate_message_id_header},
133    text::Text,
134};
135use mime::{BodyPart, MimePart, estimated_headers_len, write_header_name};
136use writer::{IO_BUFFER_MAX, IO_BUFFER_MIN};
137pub use writer::{IoWriter, Writer};
138
139const GENERATED_HEADERS_ESTIMATE: usize = 160;
140
141#[inline(always)]
142fn buffer_len(estimate: usize) -> usize {
143    estimate.clamp(IO_BUFFER_MIN, IO_BUFFER_MAX)
144}
145const MESSAGE_ESTIMATE_FLOOR: usize = 256;
146const BOUNDARY_OVERHEAD: usize = 128;
147
148/// The hostname placed in generated `Message-ID` headers.
149///
150/// A fixed value is used instead of the machine's hostname so that building a
151/// message never leaks the local host name (nor triggers a DNS lookup).
152fn local_hostname() -> &'static str {
153    "localhost"
154}
155
156/// Builds an RFC5322 compliant MIME email message.
157#[derive(Clone, Debug)]
158pub struct MessageBuilder<'x> {
159    pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
160    pub html_body: Option<MimePart<'x>>,
161    pub text_body: Option<MimePart<'x>>,
162    pub attachments: Option<Vec<MimePart<'x>>>,
163    pub body: Option<MimePart<'x>>,
164}
165
166impl Default for MessageBuilder<'_> {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172impl<'x> MessageBuilder<'x> {
173    /// Create a new MessageBuilder.
174    pub fn new() -> Self {
175        MessageBuilder {
176            headers: Vec::new(),
177            html_body: None,
178            text_body: None,
179            attachments: None,
180            body: None,
181        }
182    }
183
184    /// Set the Message-ID header. If no Message-ID header is set, one will be
185    /// generated automatically.
186    pub fn message_id(self, value: impl Into<MessageId<'x>>) -> Self {
187        self.header("Message-ID", value.into())
188    }
189
190    /// Set the In-Reply-To header.
191    pub fn in_reply_to(self, value: impl Into<MessageId<'x>>) -> Self {
192        self.header("In-Reply-To", value.into())
193    }
194
195    /// Set the References header.
196    pub fn references(self, value: impl Into<MessageId<'x>>) -> Self {
197        self.header("References", value.into())
198    }
199
200    /// Set the Sender header.
201    pub fn sender(self, value: impl Into<Address<'x>>) -> Self {
202        self.header("Sender", value.into())
203    }
204
205    /// Set the From header.
206    pub fn from(self, value: impl Into<Address<'x>>) -> Self {
207        self.header("From", value.into())
208    }
209
210    /// Set the To header.
211    pub fn to(self, value: impl Into<Address<'x>>) -> Self {
212        self.header("To", value.into())
213    }
214
215    /// Set the Cc header.
216    pub fn cc(self, value: impl Into<Address<'x>>) -> Self {
217        self.header("Cc", value.into())
218    }
219
220    /// Set the Bcc header.
221    pub fn bcc(self, value: impl Into<Address<'x>>) -> Self {
222        self.header("Bcc", value.into())
223    }
224
225    /// Set the Reply-To header.
226    pub fn reply_to(self, value: impl Into<Address<'x>>) -> Self {
227        self.header("Reply-To", value.into())
228    }
229
230    /// Set the Subject header.
231    pub fn subject(self, value: impl Into<Text<'x>>) -> Self {
232        self.header("Subject", value.into())
233    }
234
235    /// Set the Date header. If no Date header is set, one will be generated
236    /// automatically.
237    pub fn date(self, value: impl Into<Date>) -> Self {
238        self.header("Date", value.into())
239    }
240
241    /// Add a custom header.
242    pub fn header(mut self, header: impl Into<Cow<'x, str>>, value: impl Into<HeaderType<'x>>) -> Self {
243        self.headers.push((header.into(), value.into()));
244        self
245    }
246
247    /// Set custom headers.
248    pub fn headers<T, U, V>(mut self, header: T, values: U) -> Self
249    where
250        T: Into<Cow<'x, str>>,
251        U: IntoIterator<Item = V>,
252        V: Into<HeaderType<'x>>,
253    {
254        let header = header.into();
255
256        for value in values {
257            self.headers.push((header.clone(), value.into()));
258        }
259
260        self
261    }
262
263    /// Set the plain text body of the message. Note that only one plain text body
264    /// per message can be set using this function.
265    /// To build more complex MIME body structures, use the `body` method instead.
266    pub fn text_body(mut self, value: impl Into<Cow<'x, str>>) -> Self {
267        self.text_body = Some(MimePart::new("text/plain", BodyPart::Text(value.into())));
268        self
269    }
270
271    /// Set the HTML body of the message. Note that only one HTML body
272    /// per message can be set using this function.
273    /// To build more complex MIME body structures, use the `body` method instead.
274    pub fn html_body(mut self, value: impl Into<Cow<'x, str>>) -> Self {
275        self.html_body = Some(MimePart::new("text/html", BodyPart::Text(value.into())));
276        self
277    }
278
279    /// Add a binary attachment to the message.
280    pub fn attachment(
281        mut self,
282        content_type: impl Into<ContentType<'x>>,
283        filename: impl Into<Cow<'x, str>>,
284        value: impl Into<BodyPart<'x>>,
285    ) -> Self {
286        self.attachments
287            .get_or_insert_with(Vec::new)
288            .push(MimePart::new(content_type, value).attachment(filename));
289        self
290    }
291
292    /// Add an inline binary to the message.
293    pub fn inline(
294        mut self,
295        content_type: impl Into<ContentType<'x>>,
296        cid: impl Into<Cow<'x, str>>,
297        value: impl Into<BodyPart<'x>>,
298    ) -> Self {
299        self.attachments
300            .get_or_insert_with(Vec::new)
301            .push(MimePart::new(content_type, value).inline().cid(cid));
302        self
303    }
304
305    /// Set a custom MIME body structure.
306    pub fn body(mut self, value: MimePart<'x>) -> Self {
307        self.body = Some(value);
308        self
309    }
310
311    /// Build the message into any [`Writer`], such as a `Vec<u8>`.
312    pub fn serialize(self, output: &mut impl Writer) {
313        output.reserve(self.estimated_len());
314
315        let mut has_date = false;
316        let mut has_message_id = false;
317        let mut has_mime_version = false;
318
319        for (header_name, header_value) in &self.headers {
320            if !has_date && header_name.eq_ignore_ascii_case("Date") {
321                has_date = true;
322            } else if !has_message_id && header_name.eq_ignore_ascii_case("Message-ID") {
323                has_message_id = true;
324            } else if !has_mime_version && header_name.eq_ignore_ascii_case("MIME-Version") {
325                has_mime_version = true;
326            }
327
328            write_header_name(header_name, output);
329            header_value.write_header(output, header_name.len() + 2);
330        }
331
332        if !has_message_id {
333            output.write(b"Message-ID: ");
334            generate_message_id_header(output, local_hostname());
335            output.write(b"\r\n");
336        }
337
338        if !has_date {
339            output.write(b"Date: ");
340            Date::now().write_rfc822(output);
341            output.write(b"\r\n");
342        }
343
344        if !has_mime_version {
345            output.write(b"MIME-Version: 1.0\r\n");
346        }
347
348        self.write_body_parts(output)
349    }
350
351    fn estimated_len(&self) -> usize {
352        estimated_headers_len(&self.headers) + GENERATED_HEADERS_ESTIMATE + self.estimated_body_len()
353    }
354
355    fn estimated_body_len(&self) -> usize {
356        let estimate = match &self.body {
357            Some(body) => body.estimated_len(),
358            None => {
359                let mut estimate = 0;
360                let mut parts = 0;
361                for part in [self.text_body.as_ref(), self.html_body.as_ref()].into_iter().flatten() {
362                    estimate += part.estimated_len() + BOUNDARY_OVERHEAD;
363                    parts += 1;
364                }
365                if let Some(attachments) = &self.attachments {
366                    for part in attachments {
367                        estimate += part.estimated_len() + BOUNDARY_OVERHEAD;
368                    }
369                    parts += attachments.len();
370                }
371                if parts > 1 {
372                    estimate += GENERATED_HEADERS_ESTIMATE;
373                }
374                estimate
375            }
376        };
377        estimate.max(MESSAGE_ESTIMATE_FLOOR)
378    }
379
380    /// Build the message body without headers into any [`Writer`].
381    pub fn serialize_body(self, output: &mut impl Writer) {
382        output.reserve(self.estimated_body_len());
383        self.write_body_parts(output)
384    }
385
386    fn write_body_parts(self, output: &mut impl Writer) {
387        (if let Some(body) = self.body {
388            body
389        } else {
390            match (self.text_body, self.html_body, self.attachments) {
391                (Some(text), Some(html), Some(attachments)) => {
392                    let mut parts = Vec::with_capacity(attachments.len() + 1);
393                    parts.push(MimePart::new("multipart/alternative", vec![text, html]));
394                    parts.extend(attachments);
395
396                    MimePart::new("multipart/mixed", parts)
397                }
398                (Some(text), Some(html), None) => MimePart::new("multipart/alternative", vec![text, html]),
399                (Some(text), None, Some(attachments)) => {
400                    let mut parts = Vec::with_capacity(attachments.len() + 1);
401                    parts.push(text);
402                    parts.extend(attachments);
403                    MimePart::new("multipart/mixed", parts)
404                }
405                (Some(text), None, None) => text,
406                (None, Some(html), Some(attachments)) => {
407                    let mut parts = Vec::with_capacity(attachments.len() + 1);
408                    parts.push(html);
409                    parts.extend(attachments);
410                    MimePart::new("multipart/mixed", parts)
411                }
412                (None, Some(html), None) => html,
413                (None, None, Some(attachments)) => MimePart::new("multipart/mixed", attachments),
414                (None, None, None) => MimePart::new("text/plain", "\n"),
415            }
416        })
417        .write_part(output);
418    }
419
420    /// Build the message and stream it to a [`std::io::Write`].
421    pub fn write_to(self, output: impl Write) -> io::Result<()> {
422        let mut writer = IoWriter::with_capacity(buffer_len(self.estimated_len()), output);
423        self.serialize(&mut writer);
424        writer.into_result()
425    }
426
427    /// Write the message body without headers to a [`std::io::Write`].
428    pub fn write_body(self, output: impl Write) -> io::Result<()> {
429        let mut writer = IoWriter::with_capacity(buffer_len(self.estimated_body_len()), output);
430        self.serialize_body(&mut writer);
431        writer.into_result()
432    }
433
434    /// Build the message to a `Vec<u8>`.
435    pub fn to_vec(self) -> io::Result<Vec<u8>> {
436        let mut output = Vec::with_capacity(self.estimated_len());
437        self.serialize(&mut output);
438        Ok(output)
439    }
440
441    /// Build the message to a `String`.
442    ///
443    /// Returns an error if the message does not contain valid UTF-8, which can
444    /// happen when a raw MIME part is set to binary contents.
445    pub fn to_string(self) -> io::Result<String> {
446        let mut output = Vec::with_capacity(self.estimated_len());
447        self.serialize(&mut output);
448        String::from_utf8(output).map_err(io::Error::other)
449    }
450}