Skip to main content

rainier_mail/
message.rs

1//! The message model — [`Address`], [`Envelope`], [`Content`], [`Attachment`]
2//! and the assembled [`Message`].
3
4use rainier_support::{Error, Result};
5use serde::{Deserialize, Serialize};
6
7/// An email address, with an optional display name.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Address {
10    /// The address itself.
11    pub email: String,
12    /// The display name, if any.
13    pub name: Option<String>,
14}
15
16impl Address {
17    /// A bare address.
18    pub fn new(email: impl Into<String>) -> Self {
19        Self { email: email.into(), name: None }
20    }
21
22    /// An address with a display name.
23    pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
24        Self { email: email.into(), name: Some(name.into()) }
25    }
26
27    /// Render as a header value: `Ada Lovelace <ada@example.com>`.
28    ///
29    /// A display name containing a quote, a newline or a comma is dropped
30    /// rather than escaped. Names come from user data often enough that
31    /// getting the escaping subtly wrong would be a header-injection hole, and
32    /// an email that arrives without a display name is a much smaller problem
33    /// than one that arrives with an extra `Bcc:`.
34    pub fn to_header(&self) -> String {
35        match &self.name {
36            Some(name) if is_safe_display_name(name) => format!("{name} <{}>", self.email),
37            _ => self.email.clone(),
38        }
39    }
40
41    /// Whether this looks like a deliverable address.
42    pub fn is_valid(&self) -> bool {
43        let Some((local, domain)) = self.email.split_once('@') else {
44            return false;
45        };
46        !local.is_empty()
47            && domain.contains('.')
48            && !domain.starts_with('.')
49            && !domain.ends_with('.')
50            && !self.email.chars().any(|c| c.is_whitespace() || c == '\r' || c == '\n')
51    }
52}
53
54fn is_safe_display_name(name: &str) -> bool {
55    !name.is_empty()
56        && !name
57            .chars()
58            .any(|c| c == '"' || c == '<' || c == '>' || c == ',' || c == ':' || c.is_control())
59}
60
61impl From<&str> for Address {
62    fn from(email: &str) -> Self {
63        Address::new(email)
64    }
65}
66
67impl From<String> for Address {
68    fn from(email: String) -> Self {
69        Address::new(email)
70    }
71}
72
73impl std::fmt::Display for Address {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(&self.to_header())
76    }
77}
78
79/// Who a message is from, to, and about.
80#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Envelope {
82    /// The sender. Filled from configuration when the mailable leaves it out.
83    pub from: Option<Address>,
84    /// Where replies should go.
85    pub reply_to: Vec<Address>,
86    /// Primary recipients.
87    pub to: Vec<Address>,
88    /// Carbon copies.
89    pub cc: Vec<Address>,
90    /// Blind carbon copies.
91    pub bcc: Vec<Address>,
92    /// The subject line.
93    pub subject: String,
94}
95
96impl Envelope {
97    /// An envelope with a subject and no recipients yet.
98    pub fn new(subject: impl Into<String>) -> Self {
99        Self { subject: subject.into(), ..Self::default() }
100    }
101
102    /// Set the sender.
103    pub fn from(mut self, address: impl Into<Address>) -> Self {
104        self.from = Some(address.into());
105        self
106    }
107
108    /// Add a recipient.
109    pub fn to(mut self, address: impl Into<Address>) -> Self {
110        self.to.push(address.into());
111        self
112    }
113
114    /// Add a carbon copy.
115    pub fn cc(mut self, address: impl Into<Address>) -> Self {
116        self.cc.push(address.into());
117        self
118    }
119
120    /// Add a blind carbon copy.
121    pub fn bcc(mut self, address: impl Into<Address>) -> Self {
122        self.bcc.push(address.into());
123        self
124    }
125
126    /// Add a reply-to address.
127    pub fn reply_to(mut self, address: impl Into<Address>) -> Self {
128        self.reply_to.push(address.into());
129        self
130    }
131
132    /// Every address that will actually receive the message.
133    pub fn recipients(&self) -> Vec<&Address> {
134        self.to.iter().chain(&self.cc).chain(&self.bcc).collect()
135    }
136
137    /// Whether anyone will receive it.
138    pub fn has_recipients(&self) -> bool {
139        !self.recipients().is_empty()
140    }
141}
142
143/// A message's body: either a rendered view or literal text.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub enum Content {
146    /// Render this view for the HTML body, optionally with a plain-text twin.
147    View {
148        /// The HTML template's name.
149        html: String,
150        /// The plain-text template's name, if there is one.
151        text: Option<String>,
152        /// The data both are rendered with.
153        data: serde_json::Value,
154    },
155    /// Literal bodies, already rendered.
156    Literal {
157        /// The HTML body, if any.
158        html: Option<String>,
159        /// The plain-text body, if any.
160        text: Option<String>,
161    },
162}
163
164impl Content {
165    /// An HTML body from a view.
166    pub fn view(name: impl Into<String>, data: impl Serialize) -> Result<Self> {
167        Ok(Content::View { html: name.into(), text: None, data: serde_json::to_value(data)? })
168    }
169
170    /// Add a plain-text alternative view.
171    pub fn with_text_view(mut self, name: impl Into<String>) -> Self {
172        if let Content::View { text, .. } = &mut self {
173            *text = Some(name.into());
174        }
175        self
176    }
177
178    /// A literal HTML body.
179    pub fn html(body: impl Into<String>) -> Self {
180        Content::Literal { html: Some(body.into()), text: None }
181    }
182
183    /// A literal plain-text body.
184    pub fn text(body: impl Into<String>) -> Self {
185        Content::Literal { html: None, text: Some(body.into()) }
186    }
187}
188
189/// A file travelling with a message.
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct Attachment {
192    /// The name the recipient sees.
193    pub file_name: String,
194    /// Its MIME type.
195    pub content_type: String,
196    /// Its bytes.
197    pub bytes: Vec<u8>,
198}
199
200impl Attachment {
201    /// An attachment from bytes already in hand.
202    pub fn from_bytes(
203        file_name: impl Into<String>,
204        content_type: impl Into<String>,
205        bytes: impl Into<Vec<u8>>,
206    ) -> Self {
207        Self { file_name: file_name.into(), content_type: content_type.into(), bytes: bytes.into() }
208    }
209
210    /// An attachment read from disk, guessing the type from the extension.
211    pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
212        let path = path.as_ref();
213        let bytes = std::fs::read(path)
214            .map_err(|e| Error::internal(format!("could not attach {}: {e}", path.display())))?;
215
216        let file_name = path
217            .file_name()
218            .map(|name| name.to_string_lossy().into_owned())
219            .unwrap_or_else(|| "attachment".to_string());
220
221        let content_type = guess_content_type(&file_name);
222        Ok(Self { file_name, content_type, bytes })
223    }
224
225    /// The attachment's size in bytes.
226    pub fn size(&self) -> usize {
227        self.bytes.len()
228    }
229
230    /// Its content, base64-encoded for a MIME part.
231    pub fn to_base64(&self) -> String {
232        use base64::Engine as _;
233        base64::engine::general_purpose::STANDARD.encode(&self.bytes)
234    }
235}
236
237fn guess_content_type(file_name: &str) -> String {
238    let extension =
239        file_name.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default();
240
241    match extension.as_str() {
242        "pdf" => "application/pdf",
243        "png" => "image/png",
244        "jpg" | "jpeg" => "image/jpeg",
245        "gif" => "image/gif",
246        "svg" => "image/svg+xml",
247        "csv" => "text/csv",
248        "txt" => "text/plain",
249        "html" | "htm" => "text/html",
250        "json" => "application/json",
251        "zip" => "application/zip",
252        // The generic fallback: better than guessing wrong, and every client
253        // handles it.
254        _ => "application/octet-stream",
255    }
256    .to_string()
257}
258
259/// A fully assembled message, ready for a transport.
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261pub struct Message {
262    /// Who it is from, to and about.
263    pub envelope: Envelope,
264    /// The rendered HTML body, if any.
265    pub html: Option<String>,
266    /// The rendered plain-text body, if any.
267    pub text: Option<String>,
268    /// Files travelling with it.
269    pub attachments: Vec<Attachment>,
270    /// Extra headers.
271    pub headers: Vec<(String, String)>,
272}
273
274impl Message {
275    /// A message with `envelope` and no body yet.
276    pub fn new(envelope: Envelope) -> Self {
277        Self { envelope, html: None, text: None, attachments: Vec::new(), headers: Vec::new() }
278    }
279
280    /// Add an extra header. Values containing CR or LF are rejected, since
281    /// that is how header injection works.
282    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
283        let (name, value) = (name.into(), value.into());
284        if !name.contains(['\r', '\n']) && !value.contains(['\r', '\n']) {
285            self.headers.push((name, value));
286        }
287        self
288    }
289
290    /// Whether the message has any body at all.
291    pub fn has_body(&self) -> bool {
292        self.html.is_some() || self.text.is_some()
293    }
294
295    /// The body a text-only client would see: the plain-text part if there is
296    /// one, otherwise the HTML stripped of its tags.
297    pub fn text_body(&self) -> String {
298        match (&self.text, &self.html) {
299            (Some(text), _) => text.clone(),
300            (None, Some(html)) => strip_tags(html),
301            (None, None) => String::new(),
302        }
303    }
304
305    /// Check the message is deliverable.
306    pub fn validate(&self) -> Result<()> {
307        if !self.envelope.has_recipients() {
308            return Err(Error::internal("the message has no recipients"));
309        }
310        if self.envelope.from.is_none() {
311            return Err(Error::internal(
312                "the message has no sender — set one on the mailable or in `mail.from`",
313            ));
314        }
315        for address in self.envelope.recipients() {
316            if !address.is_valid() {
317                return Err(Error::internal(format!(
318                    "`{}` is not a deliverable address",
319                    address.email
320                )));
321            }
322        }
323        if !self.has_body() {
324            return Err(Error::internal("the message has no body"));
325        }
326        Ok(())
327    }
328}
329
330/// A crude tag stripper for the plain-text fallback.
331///
332/// Not a converter: it produces something readable, not something pretty. A
333/// mailable that cares should supply its own text view.
334fn strip_tags(html: &str) -> String {
335    let mut out = String::with_capacity(html.len());
336    let mut inside = false;
337
338    for character in html.chars() {
339        match character {
340            '<' => inside = true,
341            '>' => {
342                inside = false;
343                // A tag boundary is a word boundary; without this,
344                // `<p>a</p><p>b</p>` would run together as `ab`.
345                if !out.ends_with(char::is_whitespace) && !out.is_empty() {
346                    out.push(' ');
347                }
348            }
349            other if !inside => out.push(other),
350            _ => {}
351        }
352    }
353    out.split_whitespace().collect::<Vec<_>>().join(" ")
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn addresses_render_with_and_without_a_name() {
362        assert_eq!(Address::new("ada@example.com").to_header(), "ada@example.com");
363        assert_eq!(
364            Address::named("ada@example.com", "Ada Lovelace").to_header(),
365            "Ada Lovelace <ada@example.com>"
366        );
367    }
368
369    #[test]
370    fn a_hostile_display_name_is_dropped_rather_than_escaped() {
371        // Header injection: a name carrying a newline could add a `Bcc:`.
372        for hostile in ["Ada\r\nBcc: attacker@evil.test", "Ada \"quoted\"", "a<b>", "a,b", "a:b"] {
373            let rendered = Address::named("ada@example.com", hostile).to_header();
374            assert_eq!(rendered, "ada@example.com", "for `{hostile}`");
375        }
376    }
377
378    #[test]
379    fn address_validity() {
380        assert!(Address::new("ada@example.com").is_valid());
381        assert!(Address::new("a.b+c@sub.example.co.uk").is_valid());
382
383        for bad in ["", "ada", "@example.com", "ada@example", "ada @example.com", "a@.com", "a@b."]
384        {
385            assert!(!Address::new(bad).is_valid(), "`{bad}` should be invalid");
386        }
387        assert!(!Address::new("ada@example.com\r\nBcc: x@y.z").is_valid());
388    }
389
390    #[test]
391    fn an_envelope_collects_every_recipient() {
392        let envelope = Envelope::new("Hello")
393            .from(Address::named("app@example.com", "App"))
394            .to("a@example.com")
395            .cc("b@example.com")
396            .bcc("c@example.com")
397            .reply_to("support@example.com");
398
399        assert_eq!(envelope.recipients().len(), 3);
400        assert!(envelope.has_recipients());
401        assert_eq!(envelope.reply_to.len(), 1);
402        assert_eq!(envelope.subject, "Hello");
403    }
404
405    #[test]
406    fn an_empty_envelope_has_no_recipients() {
407        assert!(!Envelope::new("Hello").has_recipients());
408    }
409
410    #[test]
411    fn content_variants() {
412        let view = Content::view("mail.welcome", serde_json::json!({ "name": "Ada" }))
413            .unwrap()
414            .with_text_view("mail.welcome_text");
415
416        let Content::View { html, text, data } = &view else { panic!("expected a view") };
417        assert_eq!(html, "mail.welcome");
418        assert_eq!(text.as_deref(), Some("mail.welcome_text"));
419        assert_eq!(data["name"], "Ada");
420
421        assert_eq!(
422            Content::html("<p>hi</p>"),
423            Content::Literal { html: Some("<p>hi</p>".into()), text: None }
424        );
425    }
426
427    #[test]
428    fn attachments_carry_their_bytes_and_type() {
429        let attachment = Attachment::from_bytes("report.pdf", "application/pdf", b"%PDF".to_vec());
430
431        assert_eq!(attachment.size(), 4);
432        assert_eq!(attachment.to_base64(), "JVBERg==");
433    }
434
435    #[test]
436    fn content_types_are_guessed_from_the_extension() {
437        assert_eq!(guess_content_type("a.pdf"), "application/pdf");
438        assert_eq!(guess_content_type("a.PNG"), "image/png");
439        assert_eq!(guess_content_type("a.jpeg"), "image/jpeg");
440        assert_eq!(guess_content_type("noextension"), "application/octet-stream");
441        assert_eq!(guess_content_type("a.unknownext"), "application/octet-stream");
442    }
443
444    fn deliverable() -> Message {
445        let mut message =
446            Message::new(Envelope::new("Hi").from("app@example.com").to("ada@example.com"));
447        message.text = Some("Hello".into());
448        message
449    }
450
451    #[test]
452    fn a_deliverable_message_validates() {
453        assert!(deliverable().validate().is_ok());
454    }
455
456    #[test]
457    fn validation_catches_each_way_a_message_can_be_undeliverable() {
458        let mut no_recipients = deliverable();
459        no_recipients.envelope.to.clear();
460        assert!(no_recipients.validate().unwrap_err().message().contains("recipients"));
461
462        let mut no_sender = deliverable();
463        no_sender.envelope.from = None;
464        assert!(no_sender.validate().unwrap_err().message().contains("sender"));
465
466        let mut no_body = deliverable();
467        no_body.text = None;
468        assert!(no_body.validate().unwrap_err().message().contains("body"));
469
470        let mut bad_address = deliverable();
471        bad_address.envelope.to = vec![Address::new("not-an-address")];
472        assert!(bad_address.validate().unwrap_err().message().contains("deliverable"));
473    }
474
475    #[test]
476    fn headers_with_newlines_are_refused() {
477        let message = deliverable()
478            .with_header("X-Ok", "fine")
479            .with_header("X-Bad", "value\r\nBcc: attacker@evil.test");
480
481        assert_eq!(message.headers.len(), 1);
482        assert_eq!(message.headers[0].0, "X-Ok");
483    }
484
485    #[test]
486    fn the_text_body_falls_back_to_stripped_html() {
487        let mut message = deliverable();
488        message.text = None;
489        message.html = Some("<p>Hello</p><p>Ada</p>".into());
490
491        assert_eq!(message.text_body(), "Hello Ada");
492    }
493
494    #[test]
495    fn an_explicit_text_body_wins_over_the_html() {
496        let mut message = deliverable();
497        message.html = Some("<p>html</p>".into());
498        message.text = Some("plain".into());
499
500        assert_eq!(message.text_body(), "plain");
501    }
502
503    #[test]
504    fn stripping_tags_keeps_word_boundaries() {
505        assert_eq!(strip_tags("<h1>A</h1><p>B</p>"), "A B");
506        assert_eq!(strip_tags("no tags here"), "no tags here");
507        assert_eq!(strip_tags("<br/>"), "");
508    }
509}