1#![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#[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 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 pub fn message_id(self, value: impl Into<MessageId<'x>>) -> Self {
92 self.header("Message-ID", value.into())
93 }
94
95 pub fn in_reply_to(self, value: impl Into<MessageId<'x>>) -> Self {
97 self.header("In-Reply-To", value.into())
98 }
99
100 pub fn references(self, value: impl Into<MessageId<'x>>) -> Self {
102 self.header("References", value.into())
103 }
104
105 pub fn sender(self, value: impl Into<Address<'x>>) -> Self {
107 self.header("Sender", value.into())
108 }
109
110 pub fn from(self, value: impl Into<Address<'x>>) -> Self {
112 self.header("From", value.into())
113 }
114
115 pub fn to(self, value: impl Into<Address<'x>>) -> Self {
117 self.header("To", value.into())
118 }
119
120 pub fn cc(self, value: impl Into<Address<'x>>) -> Self {
122 self.header("Cc", value.into())
123 }
124
125 pub fn bcc(self, value: impl Into<Address<'x>>) -> Self {
127 self.header("Bcc", value.into())
128 }
129
130 pub fn reply_to(self, value: impl Into<Address<'x>>) -> Self {
132 self.header("Reply-To", value.into())
133 }
134
135 pub fn subject(self, value: impl Into<Text<'x>>) -> Self {
137 self.header("Subject", value.into())
138 }
139
140 pub fn date(self, value: impl Into<Date>) -> Self {
143 self.header("Date", value.into())
144 }
145
146 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 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 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 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 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 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 pub fn body(mut self, value: MimePart<'x>) -> Self {
216 self.body = Some(value);
217 self
218 }
219
220 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 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 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 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 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 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}