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> {
189 self.text.as_deref()
190 }
191
192 pub fn html_body(&self) -> Option<&str> {
193 self.html.as_deref()
194 }
195
196 pub fn attachments(&self) -> &[Attachment] {
197 &self.attachments
198 }
199
200 pub fn to_addresses(&self) -> &[Address] {
201 &self.to
202 }
203
204 pub fn recipients(&self) -> Vec<&Address> {
206 self.to.iter().chain(&self.cc).chain(&self.bcc).collect()
207 }
208
209 pub fn envelope_recipients(&self) -> Vec<String> {
211 self.recipients().iter().map(|address| address.email().to_string()).collect()
212 }
213
214 pub(crate) fn default_from(mut self, address: Address) -> Message {
216 if self.from.is_none() {
217 self.from = Some(address);
218 }
219 self
220 }
221
222 pub fn validate(&self) -> Result<()> {
224 if !self.errors.is_empty() {
225 return Err(Error::msg(format!(
226 "this message has an invalid address — {}",
227 self.errors.join("; ")
228 )));
229 }
230 if self.from.is_none() {
231 return Err(Error::msg(
232 "this message has no From address. Set one with `.from(...)`, or set \
233 mail.from.address in your configuration.",
234 ));
235 }
236 if self.recipients().is_empty() {
237 return Err(Error::msg(
238 "this message has no recipients. Add one with `.to(...)`, `.cc(...)` or `.bcc(...)`.",
239 ));
240 }
241 Ok(())
242 }
243
244 pub fn to_mime(&self) -> Result<String> {
246 self.validate()?;
247
248 let from = self.from.as_ref().expect("validated above");
249 let boundary = self.boundary.clone().unwrap_or_else(unique_token);
250
251 let mut out = String::with_capacity(1024 + self.attachments.iter().map(|a| a.bytes.len() * 4 / 3).sum::<usize>());
252
253 out.push_str(&header_line(
254 "Date",
255 self.date.as_deref().unwrap_or(&rfc5322_date(SystemTime::now())),
256 ));
257 out.push_str(&header_line("From", &from.to_header()));
258 if !self.to.is_empty() {
259 out.push_str(&header_line("To", &address_list(&self.to)));
260 }
261 if !self.cc.is_empty() {
262 out.push_str(&header_line("Cc", &address_list(&self.cc)));
263 }
264 if !self.reply_to.is_empty() {
265 out.push_str(&header_line("Reply-To", &address_list(&self.reply_to)));
266 }
267 out.push_str(&header_line("Subject", &encode_word(&self.subject)));
269 out.push_str(&header_line(
270 "Message-ID",
271 &self
272 .message_id
273 .clone()
274 .unwrap_or_else(|| format!("<{}@{}>", unique_token(), from.domain())),
275 ));
276 out.push_str(&header_line("MIME-Version", "1.0"));
277
278 for (name, value) in &self.headers {
279 out.push_str(&header_line(name, &encode_word(value)));
280 }
281
282 out.push_str(&self.content(&boundary));
283 Ok(out)
284 }
285
286 pub fn to_smtp_data(&self) -> Result<String> {
288 Ok(dot_stuff(&self.to_mime()?))
289 }
290
291 fn content(&self, boundary: &str) -> String {
293 if self.attachments.is_empty() {
294 return self.body_part(boundary);
295 }
296
297 let mixed = format!("{boundary}_mixed");
300 let mut out = String::new();
301 out.push_str(&header_line("Content-Type", &format!("multipart/mixed; boundary=\"{mixed}\"")));
302 out.push_str("\r\n");
303 out.push_str("This is a message in MIME format.\r\n");
304
305 out.push_str(&format!("\r\n--{mixed}\r\n"));
306 out.push_str(&self.body_part(boundary));
307
308 for attachment in &self.attachments {
309 out.push_str(&format!("\r\n--{mixed}\r\n"));
310 out.push_str(&header_line(
311 "Content-Type",
312 &format!("{}{}", attachment.content_type, attachment.name_parameter("name")),
313 ));
314 out.push_str(&header_line("Content-Transfer-Encoding", "base64"));
315 out.push_str(&header_line(
316 "Content-Disposition",
317 &format!("attachment{}", attachment.name_parameter("filename")),
318 ));
319 out.push_str("\r\n");
320 out.push_str(&base64_body(&attachment.bytes));
321 out.push_str("\r\n");
322 }
323
324 out.push_str(&format!("\r\n--{mixed}--\r\n"));
325 out
326 }
327
328 fn body_part(&self, boundary: &str) -> String {
330 match (&self.text, &self.html) {
331 (Some(text), None) => text_part("text/plain", text),
332 (None, Some(html)) => text_part("text/html", html),
333 (None, None) => text_part("text/plain", ""),
334 (Some(text), Some(html)) => {
335 let alternative = format!("{boundary}_alt");
338 let mut out = String::new();
339 out.push_str(&header_line(
340 "Content-Type",
341 &format!("multipart/alternative; boundary=\"{alternative}\""),
342 ));
343 out.push_str("\r\n");
344 out.push_str(&format!("--{alternative}\r\n"));
345 out.push_str(&text_part("text/plain", text));
346 out.push_str(&format!("\r\n--{alternative}\r\n"));
347 out.push_str(&text_part("text/html", html));
348 out.push_str(&format!("\r\n--{alternative}--\r\n"));
349 out
350 }
351 }
352 }
353}
354
355fn text_part(content_type: &str, body: &str) -> String {
357 let mut out = String::new();
358 out.push_str(&header_line("Content-Type", &format!("{content_type}; charset=\"utf-8\"")));
359 out.push_str(&header_line("Content-Transfer-Encoding", "quoted-printable"));
360 out.push_str("\r\n");
361 out.push_str("ed_printable(body));
364 out
365}
366
367fn guess_content_type(filename: &str) -> &'static str {
369 let extension =
370 filename.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default();
371
372 match extension.as_str() {
373 "pdf" => "application/pdf",
374 "txt" | "log" => "text/plain",
375 "csv" => "text/csv",
376 "html" | "htm" => "text/html",
377 "json" => "application/json",
378 "xml" => "application/xml",
379 "zip" => "application/zip",
380 "gz" => "application/gzip",
381 "png" => "image/png",
382 "jpg" | "jpeg" => "image/jpeg",
383 "gif" => "image/gif",
384 "svg" => "image/svg+xml",
385 "webp" => "image/webp",
386 "ics" => "text/calendar",
387 _ => "application/octet-stream",
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn fixed(message: Message) -> Message {
398 message
399 .with_date("Fri, 13 Feb 2009 23:31:30 +0000")
400 .with_message_id("<abc123@example.com>")
401 .with_boundary("RL")
402 }
403
404 #[test]
405 fn a_text_only_message_is_a_single_plain_part() {
406 let mime = fixed(
407 Message::new()
408 .from(("Ada Lovelace", "ada@example.com"))
409 .to("grace@example.com")
410 .subject("Your receipt")
411 .text("Thank you.\nThe Team"),
412 )
413 .to_mime()
414 .unwrap();
415
416 assert_eq!(
417 mime,
418 "Date: Fri, 13 Feb 2009 23:31:30 +0000\r\n\
419 From: Ada Lovelace <ada@example.com>\r\n\
420 To: grace@example.com\r\n\
421 Subject: Your receipt\r\n\
422 Message-ID: <abc123@example.com>\r\n\
423 MIME-Version: 1.0\r\n\
424 Content-Type: text/plain; charset=\"utf-8\"\r\n\
425 Content-Transfer-Encoding: quoted-printable\r\n\
426 \r\n\
427 Thank you.\r\n\
428 The Team"
429 );
430 }
431
432 #[test]
433 fn an_html_only_message_is_a_single_html_part() {
434 let mime = fixed(
435 Message::new()
436 .from("ada@example.com")
437 .to("grace@example.com")
438 .subject("Hello")
439 .html("<p>Hello & welcome</p>"),
440 )
441 .to_mime()
442 .unwrap();
443
444 assert!(mime.contains("Content-Type: text/html; charset=\"utf-8\"\r\n"), "{mime}");
445 assert!(!mime.contains("multipart"), "{mime}");
446 assert!(mime.ends_with("\r\n\r\n<p>Hello & welcome</p>"), "{mime}");
447 }
448
449 #[test]
450 fn a_message_with_neither_body_is_still_a_valid_empty_plain_part() {
451 let mime = fixed(Message::new().from("ada@example.com").to("grace@example.com"))
452 .to_mime()
453 .unwrap();
454
455 assert!(mime.ends_with("Content-Transfer-Encoding: quoted-printable\r\n\r\n"), "{mime}");
456 }
457
458 #[test]
459 fn text_and_html_become_multipart_alternative_with_plain_text_first() {
460 let mime = fixed(
461 Message::new()
462 .from("ada@example.com")
463 .to("grace@example.com")
464 .subject("Hello")
465 .text("Hello there")
466 .html("<p>Hello there</p>"),
467 )
468 .to_mime()
469 .unwrap();
470
471 assert!(
472 mime.contains("Content-Type: multipart/alternative; boundary=\"RL_alt\"\r\n"),
473 "{mime}"
474 );
475 assert!(mime.ends_with("--RL_alt--\r\n"), "{mime}");
476
477 let plain_at = mime.find("text/plain").unwrap();
478 let html_at = mime.find("text/html").unwrap();
479 assert!(plain_at < html_at, "the plain part must come first");
480
481 assert!(mime.contains("--RL_alt\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n"));
482 assert!(mime.contains("quoted-printable\r\n\r\nHello there\r\n--RL_alt\r\n"));
483 assert!(mime.contains("quoted-printable\r\n\r\n<p>Hello there</p>\r\n--RL_alt--"));
484 }
485
486 #[test]
487 fn an_attachment_wraps_the_body_in_multipart_mixed() {
488 let mime = fixed(
489 Message::new()
490 .from("ada@example.com")
491 .to("grace@example.com")
492 .subject("Invoice")
493 .text("Attached.")
494 .attach(Attachment::new("invoice.pdf", b"%PDF-1.4".to_vec())),
495 )
496 .to_mime()
497 .unwrap();
498
499 assert!(mime.contains("Content-Type: multipart/mixed; boundary=\"RL_mixed\"\r\n"), "{mime}");
500 assert!(mime.contains("\r\n--RL_mixed\r\nContent-Type: text/plain; charset=\"utf-8\""));
501 assert!(mime.contains("Content-Type: application/pdf; name=\"invoice.pdf\"\r\n"), "{mime}");
502 assert!(mime.contains("Content-Transfer-Encoding: base64\r\n"));
503 assert!(
504 mime.contains("Content-Disposition: attachment; filename=\"invoice.pdf\"\r\n"),
505 "{mime}"
506 );
507 assert!(mime.contains("\r\n\r\nJVBERi0xLjQ=\r\n"), "{mime}");
509 assert!(mime.ends_with("\r\n--RL_mixed--\r\n"));
510 }
511
512 #[test]
513 fn a_non_ascii_subject_and_name_travel_as_encoded_words() {
514 let mime = fixed(
515 Message::new()
516 .from(("Bj\u{f6}rn Str\u{f6}m", "bjorn@example.com"))
517 .to("grace@example.com")
518 .subject("Gr\u{fc}\u{df}e aus M\u{fc}nchen")
519 .text("hi"),
520 )
521 .to_mime()
522 .unwrap();
523
524 assert!(mime.contains("From: =?UTF-8?B?QmrDtnJuIFN0csO2bQ==?= <bjorn@example.com>\r\n"), "{mime}");
525 assert!(
526 mime.contains("Subject: =?UTF-8?B?R3LDvMOfZSBhdXMgTcO8bmNoZW4=?=\r\n"),
527 "{mime}"
528 );
529 for line in mime.split("\r\n").take_while(|line| !line.is_empty()) {
531 assert!(line.is_ascii(), "non-ASCII in a header: {line}");
532 }
533 }
534
535 #[test]
536 fn a_bcc_recipient_reaches_the_envelope_and_never_a_header() {
537 let message = fixed(
538 Message::new()
539 .from("ada@example.com")
540 .to("grace@example.com")
541 .cc("alan@example.com")
542 .bcc("secret@example.com")
543 .subject("Hi")
544 .text("hi"),
545 );
546
547 let mime = message.to_mime().unwrap();
548 assert!(mime.contains("Cc: alan@example.com\r\n"));
549 assert!(!mime.contains("secret@example.com"), "a Bcc leaked into the headers");
550
551 assert_eq!(
552 message.envelope_recipients(),
553 vec!["grace@example.com", "alan@example.com", "secret@example.com"]
554 );
555 }
556
557 #[test]
558 fn a_subject_cannot_inject_a_header() {
559 let mime = fixed(
560 Message::new()
561 .from("ada@example.com")
562 .to("grace@example.com")
563 .subject("Receipt\r\nBcc: evil@example.com")
564 .text("hi"),
565 )
566 .to_mime()
567 .unwrap();
568
569 assert!(mime.contains("Subject: Receipt Bcc: evil@example.com\r\n"), "{mime}");
570 assert!(!mime.contains("\r\nBcc:"), "the injected header survived");
571 }
572
573 #[test]
574 fn the_smtp_form_is_dot_stuffed() {
575 let data = fixed(
576 Message::new()
577 .from("ada@example.com")
578 .to("grace@example.com")
579 .subject("Ellipsis")
580 .text("ok\n.\nnot the end"),
581 )
582 .to_smtp_data()
583 .unwrap();
584
585 assert!(data.contains("ok\r\n..\r\nnot the end"), "{data}");
586 }
587
588 #[test]
589 fn a_message_without_a_sender_or_a_recipient_says_which_is_missing() {
590 let no_from = Message::new().to("grace@example.com").validate().unwrap_err().to_string();
591 assert!(no_from.contains("mail.from.address"), "{no_from}");
592
593 let no_to = Message::new().from("ada@example.com").validate().unwrap_err().to_string();
594 assert!(no_to.contains("no recipients"), "{no_to}");
595 }
596
597 #[test]
598 fn a_bad_address_is_reported_at_validation_naming_the_field() {
599 let error = Message::new()
600 .from("ada@example.com")
601 .to("not-an-address")
602 .validate()
603 .unwrap_err()
604 .to_string();
605
606 assert!(error.contains("To:"), "{error}");
607 assert!(error.contains("not-an-address"), "{error}");
608 }
609
610 #[test]
611 fn a_non_ascii_filename_uses_the_rfc_2231_form() {
612 let mime = fixed(
613 Message::new()
614 .from("ada@example.com")
615 .to("grace@example.com")
616 .text("see attached")
617 .attach(Attachment::new("r\u{e9}sum\u{e9}.pdf", b"x".to_vec())),
618 )
619 .to_mime()
620 .unwrap();
621
622 assert!(mime.contains("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf"), "{mime}");
623 }
624
625 #[test]
626 fn content_types_are_guessed_from_the_extension() {
627 assert_eq!(Attachment::new("a.pdf", vec![]).content_type, "application/pdf");
628 assert_eq!(Attachment::new("a.PNG", vec![]).content_type, "image/png");
629 assert_eq!(Attachment::new("mystery", vec![]).content_type, "application/octet-stream");
630 assert_eq!(
631 Attachment::new("a.bin", vec![]).with_content_type("application/x-thing").content_type,
632 "application/x-thing"
633 );
634 }
635
636 #[test]
637 fn a_default_sender_only_applies_when_the_message_has_none() {
638 let configured = Address::named("Rustlavel", "no-reply@example.com").unwrap();
639
640 let filled = Message::new().to("g@example.com").default_from(configured.clone());
641 assert_eq!(filled.sender(), Some(&configured));
642
643 let explicit =
644 Message::new().from("ada@example.com").to("g@example.com").default_from(configured);
645 assert_eq!(explicit.sender().unwrap().email(), "ada@example.com");
646 }
647}