Skip to main content

rustlavel_mail/
message.rs

1//! Building a message, and turning it into MIME.
2//!
3//! The builder collects errors instead of returning them, so a chain of
4//! `.to(...).cc(...).subject(...)` stays a chain; everything that went wrong is
5//! reported once, by name, when the message is validated or sent.
6
7use 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/// A file travelling with the message.
17#[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    /// An attachment whose type is guessed from the file extension.
26    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    /// Read a file from disk, keeping its name.
38    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    /// The `Content-Type` and `Content-Disposition` parameters for the name.
49    ///
50    /// A non-ASCII filename goes out as RFC 2231 `filename*`, which is what
51    /// modern clients read; an ASCII one stays a plain quoted string, which
52    /// everything reads.
53    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/// One email, before it becomes bytes.
63#[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    /// Addresses that would not parse, kept until validation so the builder
79    /// stays chainable.
80    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    /// A blind copy. The address reaches the server in `RCPT TO` and appears in
105    /// no header — that is the whole point, and getting it wrong discloses
106    /// every recipient to every other one.
107    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    /// Add a header of your own. `X-` headers, mostly.
149    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    /// Pin the `Date` header. Tests want a message that does not change between
156    /// runs; nothing else should call this.
157    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    /// Pin the `Message-ID`, for the same reason as [`Message::with_date`].
163    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    /// Pin the MIME boundary, for the same reason again.
169    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    /// Everyone the message is addressed to, headers included.
199    pub fn recipients(&self) -> Vec<&Address> {
200        self.to.iter().chain(&self.cc).chain(&self.bcc).collect()
201    }
202
203    /// The envelope: who the server is asked to deliver to, Bcc included.
204    pub fn envelope_recipients(&self) -> Vec<String> {
205        self.recipients().iter().map(|address| address.email().to_string()).collect()
206    }
207
208    /// Fill in a `From` when the message did not set one.
209    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    /// Everything that must be true before this can go anywhere.
217    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    /// Render the whole message: headers, then the MIME body.
239    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        // Bcc is deliberately absent: it travels in the envelope only.
262        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    /// The message as the DATA phase wants it: dot-stuffed, CRLF throughout.
281    pub fn to_smtp_data(&self) -> Result<String> {
282        Ok(dot_stuff(&self.to_mime()?))
283    }
284
285    /// Content headers and the body below them.
286    fn content(&self, boundary: &str) -> String {
287        if self.attachments.is_empty() {
288            return self.body_part(boundary);
289        }
290
291        // Anything with a file becomes multipart/mixed: the body first, then
292        // the files, so a client shows the message and offers the attachments.
293        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    /// The readable part of the message, with its own content headers.
323    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                // multipart/alternative, plainest first: a client picks the last
330                // part it can render, which is the rule RFC 2046 states.
331                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
349/// One `text/*` part: headers, a blank line, and a quoted-printable body.
350fn 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    // `quoted_printable` normalises the line endings on the way through, so a
356    // body written with `\n` still leaves here as CRLF.
357    out.push_str(&quoted_printable(body));
358    out
359}
360
361/// A small table, not a MIME database: enough for what people actually attach.
362fn 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    /// A message with everything variable pinned, so an expectation can be
390    /// written by hand and stay true tomorrow.
391    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 &amp; 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 &amp; 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        // `%PDF-1.4` in base64.
502        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        // Nothing outside ASCII may reach a header.
524        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}