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}