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    /// The plain-text body, before any transfer encoding.
183    ///
184    /// What the `log` transport prints. Quoted-printable is correct on the
185    /// wire and useless on a terminal: it breaks any line over 76 columns with
186    /// a soft `=`, which lands inside a long link and makes the URL somebody
187    /// copies a different URL from the one that was sent.
188    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    /// Everyone the message is addressed to, headers included.
205    pub fn recipients(&self) -> Vec<&Address> {
206        self.to.iter().chain(&self.cc).chain(&self.bcc).collect()
207    }
208
209    /// The envelope: who the server is asked to deliver to, Bcc included.
210    pub fn envelope_recipients(&self) -> Vec<String> {
211        self.recipients().iter().map(|address| address.email().to_string()).collect()
212    }
213
214    /// Fill in a `From` when the message did not set one.
215    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    /// Everything that must be true before this can go anywhere.
223    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    /// Render the whole message: headers, then the MIME body.
245    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        // Bcc is deliberately absent: it travels in the envelope only.
268        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    /// The message as the DATA phase wants it: dot-stuffed, CRLF throughout.
287    pub fn to_smtp_data(&self) -> Result<String> {
288        Ok(dot_stuff(&self.to_mime()?))
289    }
290
291    /// Content headers and the body below them.
292    fn content(&self, boundary: &str) -> String {
293        if self.attachments.is_empty() {
294            return self.body_part(boundary);
295        }
296
297        // Anything with a file becomes multipart/mixed: the body first, then
298        // the files, so a client shows the message and offers the attachments.
299        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    /// The readable part of the message, with its own content headers.
329    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                // multipart/alternative, plainest first: a client picks the last
336                // part it can render, which is the rule RFC 2046 states.
337                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
355/// One `text/*` part: headers, a blank line, and a quoted-printable body.
356fn 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    // `quoted_printable` normalises the line endings on the way through, so a
362    // body written with `\n` still leaves here as CRLF.
363    out.push_str(&quoted_printable(body));
364    out
365}
366
367/// A small table, not a MIME database: enough for what people actually attach.
368fn 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    /// A message with everything variable pinned, so an expectation can be
396    /// written by hand and stay true tomorrow.
397    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 &amp; 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 &amp; 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        // `%PDF-1.4` in base64.
508        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        // Nothing outside ASCII may reach a header.
530        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}