1use crate::address::{Address, IntoAddress, address_list};
8use crate::encode::{
9 base64_body, dot_stuff, encode_word, header_line, percent_encode, quoted_printable,
10 rfc5322_date, sanitize_header_text, unique_token,
11};
12use rustlavel_core::{Error, Result};
13use std::path::Path;
14use std::time::SystemTime;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Attachment {
19 pub filename: String,
20 pub content_type: String,
21 pub bytes: Vec<u8>,
22}
23
24impl Attachment {
25 pub fn new(filename: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Attachment {
27 let filename = sanitize_header_text(&filename.into());
28 let content_type = guess_content_type(&filename).to_string();
29 Attachment { filename, content_type, bytes: bytes.into() }
30 }
31
32 pub fn with_content_type(mut self, content_type: impl Into<String>) -> Attachment {
33 self.content_type = sanitize_header_text(&content_type.into());
34 self
35 }
36
37 pub fn from_path(path: impl AsRef<Path>) -> Result<Attachment> {
39 let path = path.as_ref();
40 let bytes = std::fs::read(path).map_err(|error| {
41 Error::msg(format!("cannot attach `{}`: {error}", path.display()))
42 })?;
43 let filename =
44 path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
45 Ok(Attachment::new(filename, bytes))
46 }
47
48 fn name_parameter(&self, keyword: &str) -> String {
54 if self.filename.is_ascii() {
55 format!("; {keyword}=\"{}\"", self.filename.replace('"', ""))
56 } else {
57 format!("; {keyword}*=UTF-8''{}", percent_encode(&self.filename))
58 }
59 }
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct Message {
65 from: Option<Address>,
66 to: Vec<Address>,
67 cc: Vec<Address>,
68 bcc: Vec<Address>,
69 reply_to: Vec<Address>,
70 subject: String,
71 text: Option<String>,
72 html: Option<String>,
73 attachments: Vec<Attachment>,
74 headers: Vec<(String, String)>,
75 date: Option<String>,
76 message_id: Option<String>,
77 boundary: Option<String>,
78 errors: Vec<String>,
81}
82
83impl Message {
84 pub fn new() -> Message {
85 Message::default()
86 }
87
88 pub fn from(mut self, address: impl IntoAddress) -> Message {
89 match address.into_address() {
90 Ok(address) => self.from = Some(address),
91 Err(error) => self.errors.push(format!("From: {error}")),
92 }
93 self
94 }
95
96 pub fn to(self, address: impl IntoAddress) -> Message {
97 self.push("To", address, |message| &mut message.to)
98 }
99
100 pub fn cc(self, address: impl IntoAddress) -> Message {
101 self.push("Cc", address, |message| &mut message.cc)
102 }
103
104 pub fn bcc(self, address: impl IntoAddress) -> Message {
108 self.push("Bcc", address, |message| &mut message.bcc)
109 }
110
111 pub fn reply_to(self, address: impl IntoAddress) -> Message {
112 self.push("Reply-To", address, |message| &mut message.reply_to)
113 }
114
115 fn push(
116 mut self,
117 field: &str,
118 address: impl IntoAddress,
119 select: fn(&mut Message) -> &mut Vec<Address>,
120 ) -> Message {
121 match address.into_address() {
122 Ok(address) => select(&mut self).push(address),
123 Err(error) => self.errors.push(format!("{field}: {error}")),
124 }
125 self
126 }
127
128 pub fn subject(mut self, subject: impl Into<String>) -> Message {
129 self.subject = sanitize_header_text(&subject.into());
130 self
131 }
132
133 pub fn text(mut self, body: impl Into<String>) -> Message {
134 self.text = Some(body.into());
135 self
136 }
137
138 pub fn html(mut self, body: impl Into<String>) -> Message {
139 self.html = Some(body.into());
140 self
141 }
142
143 pub fn attach(mut self, attachment: Attachment) -> Message {
144 self.attachments.push(attachment);
145 self
146 }
147
148 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Message {
150 self.headers
151 .push((sanitize_header_text(&name.into()), sanitize_header_text(&value.into())));
152 self
153 }
154
155 pub fn with_date(mut self, date: impl Into<String>) -> Message {
158 self.date = Some(sanitize_header_text(&date.into()));
159 self
160 }
161
162 pub fn with_message_id(mut self, id: impl Into<String>) -> Message {
164 self.message_id = Some(sanitize_header_text(&id.into()));
165 self
166 }
167
168 pub fn with_boundary(mut self, boundary: impl Into<String>) -> Message {
170 self.boundary = Some(sanitize_header_text(&boundary.into()));
171 self
172 }
173
174 pub fn sender(&self) -> Option<&Address> {
175 self.from.as_ref()
176 }
177
178 pub fn subject_text(&self) -> &str {
179 &self.subject
180 }
181
182 pub fn text_body(&self) -> Option<&str> {
183 self.text.as_deref()
184 }
185
186 pub fn html_body(&self) -> Option<&str> {
187 self.html.as_deref()
188 }
189
190 pub fn attachments(&self) -> &[Attachment] {
191 &self.attachments
192 }
193
194 pub fn to_addresses(&self) -> &[Address] {
195 &self.to
196 }
197
198 pub fn recipients(&self) -> Vec<&Address> {
200 self.to.iter().chain(&self.cc).chain(&self.bcc).collect()
201 }
202
203 pub fn envelope_recipients(&self) -> Vec<String> {
205 self.recipients().iter().map(|address| address.email().to_string()).collect()
206 }
207
208 pub(crate) fn default_from(mut self, address: Address) -> Message {
210 if self.from.is_none() {
211 self.from = Some(address);
212 }
213 self
214 }
215
216 pub fn validate(&self) -> Result<()> {
218 if !self.errors.is_empty() {
219 return Err(Error::msg(format!(
220 "this message has an invalid address — {}",
221 self.errors.join("; ")
222 )));
223 }
224 if self.from.is_none() {
225 return Err(Error::msg(
226 "this message has no From address. Set one with `.from(...)`, or set \
227 mail.from.address in your configuration.",
228 ));
229 }
230 if self.recipients().is_empty() {
231 return Err(Error::msg(
232 "this message has no recipients. Add one with `.to(...)`, `.cc(...)` or `.bcc(...)`.",
233 ));
234 }
235 Ok(())
236 }
237
238 pub fn to_mime(&self) -> Result<String> {
240 self.validate()?;
241
242 let from = self.from.as_ref().expect("validated above");
243 let boundary = self.boundary.clone().unwrap_or_else(unique_token);
244
245 let mut out = String::with_capacity(1024 + self.attachments.iter().map(|a| a.bytes.len() * 4 / 3).sum::<usize>());
246
247 out.push_str(&header_line(
248 "Date",
249 self.date.as_deref().unwrap_or(&rfc5322_date(SystemTime::now())),
250 ));
251 out.push_str(&header_line("From", &from.to_header()));
252 if !self.to.is_empty() {
253 out.push_str(&header_line("To", &address_list(&self.to)));
254 }
255 if !self.cc.is_empty() {
256 out.push_str(&header_line("Cc", &address_list(&self.cc)));
257 }
258 if !self.reply_to.is_empty() {
259 out.push_str(&header_line("Reply-To", &address_list(&self.reply_to)));
260 }
261 out.push_str(&header_line("Subject", &encode_word(&self.subject)));
263 out.push_str(&header_line(
264 "Message-ID",
265 &self
266 .message_id
267 .clone()
268 .unwrap_or_else(|| format!("<{}@{}>", unique_token(), from.domain())),
269 ));
270 out.push_str(&header_line("MIME-Version", "1.0"));
271
272 for (name, value) in &self.headers {
273 out.push_str(&header_line(name, &encode_word(value)));
274 }
275
276 out.push_str(&self.content(&boundary));
277 Ok(out)
278 }
279
280 pub fn to_smtp_data(&self) -> Result<String> {
282 Ok(dot_stuff(&self.to_mime()?))
283 }
284
285 fn content(&self, boundary: &str) -> String {
287 if self.attachments.is_empty() {
288 return self.body_part(boundary);
289 }
290
291 let mixed = format!("{boundary}_mixed");
294 let mut out = String::new();
295 out.push_str(&header_line("Content-Type", &format!("multipart/mixed; boundary=\"{mixed}\"")));
296 out.push_str("\r\n");
297 out.push_str("This is a message in MIME format.\r\n");
298
299 out.push_str(&format!("\r\n--{mixed}\r\n"));
300 out.push_str(&self.body_part(boundary));
301
302 for attachment in &self.attachments {
303 out.push_str(&format!("\r\n--{mixed}\r\n"));
304 out.push_str(&header_line(
305 "Content-Type",
306 &format!("{}{}", attachment.content_type, attachment.name_parameter("name")),
307 ));
308 out.push_str(&header_line("Content-Transfer-Encoding", "base64"));
309 out.push_str(&header_line(
310 "Content-Disposition",
311 &format!("attachment{}", attachment.name_parameter("filename")),
312 ));
313 out.push_str("\r\n");
314 out.push_str(&base64_body(&attachment.bytes));
315 out.push_str("\r\n");
316 }
317
318 out.push_str(&format!("\r\n--{mixed}--\r\n"));
319 out
320 }
321
322 fn body_part(&self, boundary: &str) -> String {
324 match (&self.text, &self.html) {
325 (Some(text), None) => text_part("text/plain", text),
326 (None, Some(html)) => text_part("text/html", html),
327 (None, None) => text_part("text/plain", ""),
328 (Some(text), Some(html)) => {
329 let alternative = format!("{boundary}_alt");
332 let mut out = String::new();
333 out.push_str(&header_line(
334 "Content-Type",
335 &format!("multipart/alternative; boundary=\"{alternative}\""),
336 ));
337 out.push_str("\r\n");
338 out.push_str(&format!("--{alternative}\r\n"));
339 out.push_str(&text_part("text/plain", text));
340 out.push_str(&format!("\r\n--{alternative}\r\n"));
341 out.push_str(&text_part("text/html", html));
342 out.push_str(&format!("\r\n--{alternative}--\r\n"));
343 out
344 }
345 }
346 }
347}
348
349fn text_part(content_type: &str, body: &str) -> String {
351 let mut out = String::new();
352 out.push_str(&header_line("Content-Type", &format!("{content_type}; charset=\"utf-8\"")));
353 out.push_str(&header_line("Content-Transfer-Encoding", "quoted-printable"));
354 out.push_str("\r\n");
355 out.push_str("ed_printable(body));
358 out
359}
360
361fn guess_content_type(filename: &str) -> &'static str {
363 let extension =
364 filename.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default();
365
366 match extension.as_str() {
367 "pdf" => "application/pdf",
368 "txt" | "log" => "text/plain",
369 "csv" => "text/csv",
370 "html" | "htm" => "text/html",
371 "json" => "application/json",
372 "xml" => "application/xml",
373 "zip" => "application/zip",
374 "gz" => "application/gzip",
375 "png" => "image/png",
376 "jpg" | "jpeg" => "image/jpeg",
377 "gif" => "image/gif",
378 "svg" => "image/svg+xml",
379 "webp" => "image/webp",
380 "ics" => "text/calendar",
381 _ => "application/octet-stream",
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn fixed(message: Message) -> Message {
392 message
393 .with_date("Fri, 13 Feb 2009 23:31:30 +0000")
394 .with_message_id("<abc123@example.com>")
395 .with_boundary("RL")
396 }
397
398 #[test]
399 fn a_text_only_message_is_a_single_plain_part() {
400 let mime = fixed(
401 Message::new()
402 .from(("Ada Lovelace", "ada@example.com"))
403 .to("grace@example.com")
404 .subject("Your receipt")
405 .text("Thank you.\nThe Team"),
406 )
407 .to_mime()
408 .unwrap();
409
410 assert_eq!(
411 mime,
412 "Date: Fri, 13 Feb 2009 23:31:30 +0000\r\n\
413 From: Ada Lovelace <ada@example.com>\r\n\
414 To: grace@example.com\r\n\
415 Subject: Your receipt\r\n\
416 Message-ID: <abc123@example.com>\r\n\
417 MIME-Version: 1.0\r\n\
418 Content-Type: text/plain; charset=\"utf-8\"\r\n\
419 Content-Transfer-Encoding: quoted-printable\r\n\
420 \r\n\
421 Thank you.\r\n\
422 The Team"
423 );
424 }
425
426 #[test]
427 fn an_html_only_message_is_a_single_html_part() {
428 let mime = fixed(
429 Message::new()
430 .from("ada@example.com")
431 .to("grace@example.com")
432 .subject("Hello")
433 .html("<p>Hello & welcome</p>"),
434 )
435 .to_mime()
436 .unwrap();
437
438 assert!(mime.contains("Content-Type: text/html; charset=\"utf-8\"\r\n"), "{mime}");
439 assert!(!mime.contains("multipart"), "{mime}");
440 assert!(mime.ends_with("\r\n\r\n<p>Hello & welcome</p>"), "{mime}");
441 }
442
443 #[test]
444 fn a_message_with_neither_body_is_still_a_valid_empty_plain_part() {
445 let mime = fixed(Message::new().from("ada@example.com").to("grace@example.com"))
446 .to_mime()
447 .unwrap();
448
449 assert!(mime.ends_with("Content-Transfer-Encoding: quoted-printable\r\n\r\n"), "{mime}");
450 }
451
452 #[test]
453 fn text_and_html_become_multipart_alternative_with_plain_text_first() {
454 let mime = fixed(
455 Message::new()
456 .from("ada@example.com")
457 .to("grace@example.com")
458 .subject("Hello")
459 .text("Hello there")
460 .html("<p>Hello there</p>"),
461 )
462 .to_mime()
463 .unwrap();
464
465 assert!(
466 mime.contains("Content-Type: multipart/alternative; boundary=\"RL_alt\"\r\n"),
467 "{mime}"
468 );
469 assert!(mime.ends_with("--RL_alt--\r\n"), "{mime}");
470
471 let plain_at = mime.find("text/plain").unwrap();
472 let html_at = mime.find("text/html").unwrap();
473 assert!(plain_at < html_at, "the plain part must come first");
474
475 assert!(mime.contains("--RL_alt\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n"));
476 assert!(mime.contains("quoted-printable\r\n\r\nHello there\r\n--RL_alt\r\n"));
477 assert!(mime.contains("quoted-printable\r\n\r\n<p>Hello there</p>\r\n--RL_alt--"));
478 }
479
480 #[test]
481 fn an_attachment_wraps_the_body_in_multipart_mixed() {
482 let mime = fixed(
483 Message::new()
484 .from("ada@example.com")
485 .to("grace@example.com")
486 .subject("Invoice")
487 .text("Attached.")
488 .attach(Attachment::new("invoice.pdf", b"%PDF-1.4".to_vec())),
489 )
490 .to_mime()
491 .unwrap();
492
493 assert!(mime.contains("Content-Type: multipart/mixed; boundary=\"RL_mixed\"\r\n"), "{mime}");
494 assert!(mime.contains("\r\n--RL_mixed\r\nContent-Type: text/plain; charset=\"utf-8\""));
495 assert!(mime.contains("Content-Type: application/pdf; name=\"invoice.pdf\"\r\n"), "{mime}");
496 assert!(mime.contains("Content-Transfer-Encoding: base64\r\n"));
497 assert!(
498 mime.contains("Content-Disposition: attachment; filename=\"invoice.pdf\"\r\n"),
499 "{mime}"
500 );
501 assert!(mime.contains("\r\n\r\nJVBERi0xLjQ=\r\n"), "{mime}");
503 assert!(mime.ends_with("\r\n--RL_mixed--\r\n"));
504 }
505
506 #[test]
507 fn a_non_ascii_subject_and_name_travel_as_encoded_words() {
508 let mime = fixed(
509 Message::new()
510 .from(("Bj\u{f6}rn Str\u{f6}m", "bjorn@example.com"))
511 .to("grace@example.com")
512 .subject("Gr\u{fc}\u{df}e aus M\u{fc}nchen")
513 .text("hi"),
514 )
515 .to_mime()
516 .unwrap();
517
518 assert!(mime.contains("From: =?UTF-8?B?QmrDtnJuIFN0csO2bQ==?= <bjorn@example.com>\r\n"), "{mime}");
519 assert!(
520 mime.contains("Subject: =?UTF-8?B?R3LDvMOfZSBhdXMgTcO8bmNoZW4=?=\r\n"),
521 "{mime}"
522 );
523 for line in mime.split("\r\n").take_while(|line| !line.is_empty()) {
525 assert!(line.is_ascii(), "non-ASCII in a header: {line}");
526 }
527 }
528
529 #[test]
530 fn a_bcc_recipient_reaches_the_envelope_and_never_a_header() {
531 let message = fixed(
532 Message::new()
533 .from("ada@example.com")
534 .to("grace@example.com")
535 .cc("alan@example.com")
536 .bcc("secret@example.com")
537 .subject("Hi")
538 .text("hi"),
539 );
540
541 let mime = message.to_mime().unwrap();
542 assert!(mime.contains("Cc: alan@example.com\r\n"));
543 assert!(!mime.contains("secret@example.com"), "a Bcc leaked into the headers");
544
545 assert_eq!(
546 message.envelope_recipients(),
547 vec!["grace@example.com", "alan@example.com", "secret@example.com"]
548 );
549 }
550
551 #[test]
552 fn a_subject_cannot_inject_a_header() {
553 let mime = fixed(
554 Message::new()
555 .from("ada@example.com")
556 .to("grace@example.com")
557 .subject("Receipt\r\nBcc: evil@example.com")
558 .text("hi"),
559 )
560 .to_mime()
561 .unwrap();
562
563 assert!(mime.contains("Subject: Receipt Bcc: evil@example.com\r\n"), "{mime}");
564 assert!(!mime.contains("\r\nBcc:"), "the injected header survived");
565 }
566
567 #[test]
568 fn the_smtp_form_is_dot_stuffed() {
569 let data = fixed(
570 Message::new()
571 .from("ada@example.com")
572 .to("grace@example.com")
573 .subject("Ellipsis")
574 .text("ok\n.\nnot the end"),
575 )
576 .to_smtp_data()
577 .unwrap();
578
579 assert!(data.contains("ok\r\n..\r\nnot the end"), "{data}");
580 }
581
582 #[test]
583 fn a_message_without_a_sender_or_a_recipient_says_which_is_missing() {
584 let no_from = Message::new().to("grace@example.com").validate().unwrap_err().to_string();
585 assert!(no_from.contains("mail.from.address"), "{no_from}");
586
587 let no_to = Message::new().from("ada@example.com").validate().unwrap_err().to_string();
588 assert!(no_to.contains("no recipients"), "{no_to}");
589 }
590
591 #[test]
592 fn a_bad_address_is_reported_at_validation_naming_the_field() {
593 let error = Message::new()
594 .from("ada@example.com")
595 .to("not-an-address")
596 .validate()
597 .unwrap_err()
598 .to_string();
599
600 assert!(error.contains("To:"), "{error}");
601 assert!(error.contains("not-an-address"), "{error}");
602 }
603
604 #[test]
605 fn a_non_ascii_filename_uses_the_rfc_2231_form() {
606 let mime = fixed(
607 Message::new()
608 .from("ada@example.com")
609 .to("grace@example.com")
610 .text("see attached")
611 .attach(Attachment::new("r\u{e9}sum\u{e9}.pdf", b"x".to_vec())),
612 )
613 .to_mime()
614 .unwrap();
615
616 assert!(mime.contains("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf"), "{mime}");
617 }
618
619 #[test]
620 fn content_types_are_guessed_from_the_extension() {
621 assert_eq!(Attachment::new("a.pdf", vec![]).content_type, "application/pdf");
622 assert_eq!(Attachment::new("a.PNG", vec![]).content_type, "image/png");
623 assert_eq!(Attachment::new("mystery", vec![]).content_type, "application/octet-stream");
624 assert_eq!(
625 Attachment::new("a.bin", vec![]).with_content_type("application/x-thing").content_type,
626 "application/x-thing"
627 );
628 }
629
630 #[test]
631 fn a_default_sender_only_applies_when_the_message_has_none() {
632 let configured = Address::named("Rustlavel", "no-reply@example.com").unwrap();
633
634 let filled = Message::new().to("g@example.com").default_from(configured.clone());
635 assert_eq!(filled.sender(), Some(&configured));
636
637 let explicit =
638 Message::new().from("ada@example.com").to("g@example.com").default_from(configured);
639 assert_eq!(explicit.sender().unwrap().email(), "ada@example.com");
640 }
641}