Skip to main content

arcature/mail/
message.rs

1//! Email message builder over lettre, with attachments.
2//!
3//! [`Email`] is a thin `#[derive(Clone)]` wrapper around
4//! [`lettre::message::MessageBuilder`]. It chains `from`/`to`/`cc`/`bcc`/
5//! `subject` and terminates with a body method (`plain`/`html`/`alternative`/
6//! `mixed`/`plain_with_attachments`/`alternative_with_attachments`) that
7//! produces a [`lettre::Message`].
8
9use std::fmt;
10
11use lettre::message::header::ContentType;
12use lettre::message::{Attachment, Mailbox, Message, MessageBuilder, MultiPart, SinglePart};
13
14use crate::mail::error::EmailError;
15#[cfg(feature = "views")]
16use crate::mail::error::MailViewError;
17
18/// A thin wrapper around [`lettre::message::MessageBuilder`] for the
19/// ergonomic email construction API.
20#[derive(Clone)]
21pub struct Email {
22    builder: MessageBuilder,
23}
24
25impl Email {
26    /// Start a new email builder.
27    #[must_use]
28    pub fn builder() -> Self {
29        Self {
30            builder: Message::builder(),
31        }
32    }
33
34    /// Construct an `Email` from an existing `MessageBuilder` (escape hatch).
35    #[must_use]
36    pub fn from_builder(builder: MessageBuilder) -> Self {
37        Self { builder }
38    }
39
40    /// Set the `From` address.
41    #[must_use]
42    pub fn from(mut self, mailbox: Mailbox) -> Self {
43        self.builder = self.builder.from(mailbox);
44        self
45    }
46
47    /// Set the `Reply-To` address.
48    #[must_use]
49    pub fn reply_to(mut self, mailbox: Mailbox) -> Self {
50        self.builder = self.builder.reply_to(mailbox);
51        self
52    }
53
54    /// Add a `To` recipient. May be called repeatedly.
55    #[must_use]
56    pub fn to(mut self, mailbox: Mailbox) -> Self {
57        self.builder = self.builder.to(mailbox);
58        self
59    }
60
61    /// Add a `Cc` recipient. May be called repeatedly.
62    #[must_use]
63    pub fn cc(mut self, mailbox: Mailbox) -> Self {
64        self.builder = self.builder.cc(mailbox);
65        self
66    }
67
68    /// Add a `Bcc` recipient. May be called repeatedly.
69    #[must_use]
70    pub fn bcc(mut self, mailbox: Mailbox) -> Self {
71        self.builder = self.builder.bcc(mailbox);
72        self
73    }
74
75    /// Set the `Subject`.
76    #[must_use]
77    pub fn subject(mut self, subject: impl Into<String>) -> Self {
78        self.builder = self.builder.subject(subject);
79        self
80    }
81
82    /// Terminate the builder with a plain-text body.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`EmailError::Build`] if lettre cannot build the message.
87    pub fn plain(self, body: impl Into<String>) -> Result<Message, EmailError> {
88        let body: String = body.into();
89        self.builder
90            .header(ContentType::TEXT_PLAIN)
91            .body(body)
92            .map_err(EmailError::build)
93    }
94
95    /// Terminate the builder with an HTML body.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`EmailError::Build`] if lettre cannot build the message.
100    pub fn html(self, body: impl Into<String>) -> Result<Message, EmailError> {
101        let html_part = SinglePart::builder()
102            .header(ContentType::TEXT_HTML)
103            .body(body.into());
104        self.builder
105            .singlepart(html_part)
106            .map_err(EmailError::build)
107    }
108
109    /// Terminate the builder with an alternative plain/HTML body (the client
110    /// picks whichever it can render).
111    ///
112    /// # Errors
113    ///
114    /// Returns [`EmailError::Build`] if lettre cannot build the message.
115    pub fn alternative(
116        self,
117        plain: impl Into<String>,
118        html: impl Into<String>,
119    ) -> Result<Message, EmailError> {
120        let multipart = MultiPart::alternative_plain_html(plain.into(), html.into());
121        self.builder.multipart(multipart).map_err(EmailError::build)
122    }
123
124    /// Terminate the builder with a `multipart/mixed` body (a multipart body
125    /// plus attachments).
126    ///
127    /// # Errors
128    ///
129    /// Returns [`EmailError::Build`] if lettre cannot build the message.
130    pub fn mixed(
131        self,
132        body: MultiPart,
133        attachments: Vec<EmailAttachment>,
134    ) -> Result<Message, EmailError> {
135        let mut mixed = MultiPart::mixed().multipart(body);
136        for attachment in attachments {
137            mixed = mixed.singlepart(attachment.into_lettre());
138        }
139        self.builder.multipart(mixed).map_err(EmailError::build)
140    }
141
142    /// Terminate the builder with a plain body plus attachments.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`EmailError::Build`] if lettre cannot build the message, or
147    /// [`EmailError::ContentType`] if an attachment content type is invalid.
148    pub fn plain_with_attachments(
149        self,
150        body: impl Into<String>,
151        attachments: Vec<EmailAttachment>,
152    ) -> Result<Message, EmailError> {
153        let body = MultiPart::alternative_plain_html(body.into(), String::new());
154        self.mixed(body, attachments)
155    }
156
157    /// Terminate the builder with an alternative plain/HTML body plus
158    /// attachments.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`EmailError::Build`] if lettre cannot build the message.
163    pub fn alternative_with_attachments(
164        self,
165        plain: impl Into<String>,
166        html: impl Into<String>,
167        attachments: Vec<EmailAttachment>,
168    ) -> Result<Message, EmailError> {
169        let body = MultiPart::alternative_plain_html(plain.into(), html.into());
170        self.mixed(body, attachments)
171    }
172
173    /// Consume the wrapper and return the underlying `MessageBuilder` (escape
174    /// hatch).
175    #[must_use]
176    pub fn into_builder(self) -> MessageBuilder {
177        self.builder
178    }
179}
180
181/// An email attachment: filename, body bytes, and MIME content type.
182pub struct EmailAttachment {
183    filename: String,
184    body: Vec<u8>,
185    content_type: ContentType,
186}
187
188impl EmailAttachment {
189    /// Create an attachment.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`EmailError::ContentType`] if `content_type` is not a valid
194    /// MIME type string.
195    pub fn new(
196        filename: impl Into<String>,
197        body: Vec<u8>,
198        content_type: &str,
199    ) -> Result<Self, EmailError> {
200        let content_type = ContentType::parse(content_type).map_err(EmailError::content_type)?;
201        Ok(Self {
202            filename: filename.into(),
203            body,
204            content_type,
205        })
206    }
207
208    pub(crate) fn into_lettre(self) -> SinglePart {
209        Attachment::new(self.filename).body(self.body, self.content_type)
210    }
211}
212
213impl fmt::Debug for EmailAttachment {
214    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215        formatter
216            .debug_struct("EmailAttachment")
217            .field("filename", &self.filename)
218            .field("content_type", &self.content_type)
219            .field("body_len", &self.body.len())
220            .finish_non_exhaustive()
221    }
222}
223
224/// Terminators that render the message body from compiled templates.
225///
226/// # One pair of templates, both halves of one mail
227///
228/// A `multipart/alternative` mail carries the same message twice, and the two
229/// copies drifting apart is the ordinary way mail templating goes wrong: the
230/// HTML half gets the new wording and the plain half keeps the old, and only
231/// the readers on the text client ever see it. These terminators take the two
232/// templates together and render them in one call, so a change to a message
233/// is a change to a pair.
234///
235/// The two halves are separate templates rather than one, because escaping is
236/// chosen by the extension: the `.html` template escapes its values and the
237/// `.txt` one does not. Rendering a text body through an HTML template would
238/// send `&#38;` to someone reading plain text.
239#[cfg(feature = "views")]
240impl Email {
241    /// Terminate the builder with an alternative plain/HTML body rendered
242    /// from a pair of templates.
243    ///
244    /// The argument order matches [`Email::alternative`]: plain first.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`MailViewError::Render`] if either template fails to render,
249    /// or [`MailViewError::Build`] if lettre cannot build the message.
250    ///
251    /// ```
252    /// use arcature::mail::Email;
253    /// use arcature::view::Template;
254    ///
255    /// #[derive(Template)]
256    /// #[template(
257    ///     source = "Hello {{ name }}, your invoice is ready.",
258    ///     ext = "txt",
259    ///     askama = arcature::askama
260    /// )]
261    /// struct InvoiceText {
262    ///     name: String,
263    /// }
264    ///
265    /// #[derive(Template)]
266    /// #[template(
267    ///     source = "<p>Hello {{ name }}, your invoice is ready.</p>",
268    ///     ext = "html",
269    ///     askama = arcature::askama
270    /// )]
271    /// struct InvoiceHtml {
272    ///     name: String,
273    /// }
274    ///
275    /// let message = Email::builder()
276    ///     .from("Billing <billing@example.com>".parse().unwrap())
277    ///     .to("ada@example.com".parse().unwrap())
278    ///     .subject("Your invoice")
279    ///     .templated(
280    ///         &InvoiceText { name: "Ada".into() },
281    ///         &InvoiceHtml { name: "Ada".into() },
282    ///     )
283    ///     .unwrap();
284    ///
285    /// let raw = String::from_utf8(message.formatted()).unwrap();
286    /// assert!(raw.contains("multipart/alternative"));
287    /// ```
288    pub fn templated<P, H>(self, plain: &P, html: &H) -> Result<Message, MailViewError>
289    where
290        P: crate::view::Template,
291        H: crate::view::Template,
292    {
293        let (plain, html) = render_pair(plain, html)?;
294        Ok(self.alternative(plain, html)?)
295    }
296
297    /// Terminate the builder with a template-rendered alternative body plus
298    /// attachments.
299    ///
300    /// # Errors
301    ///
302    /// Returns [`MailViewError::Render`] if either template fails to render,
303    /// or [`MailViewError::Build`] if lettre cannot build the message.
304    pub fn templated_with_attachments<P, H>(
305        self,
306        plain: &P,
307        html: &H,
308        attachments: Vec<EmailAttachment>,
309    ) -> Result<Message, MailViewError>
310    where
311        P: crate::view::Template,
312        H: crate::view::Template,
313    {
314        let (plain, html) = render_pair(plain, html)?;
315        Ok(self.alternative_with_attachments(plain, html, attachments)?)
316    }
317}
318
319/// Render both halves, or fail before a half-built message exists.
320#[cfg(feature = "views")]
321fn render_pair<P, H>(plain: &P, html: &H) -> Result<(String, String), crate::view::ViewError>
322where
323    P: crate::view::Template,
324    H: crate::view::Template,
325{
326    let plain = plain.render().map_err(crate::view::ViewError::from)?;
327    let html = html.render().map_err(crate::view::ViewError::from)?;
328    Ok((plain, html))
329}
330
331#[cfg(all(test, feature = "views"))]
332mod template_tests {
333    use super::*;
334    use crate::view::Template;
335
336    #[derive(Template)]
337    #[template(source = "Hello {{ name }}, 3 < 4.", ext = "txt")]
338    struct Text {
339        name: &'static str,
340    }
341
342    #[derive(Template)]
343    #[template(source = "<p>Hello {{ name }}.</p>", ext = "html")]
344    struct Html {
345        name: &'static str,
346    }
347
348    fn envelope() -> Email {
349        Email::builder()
350            .from("billing@example.com".parse().unwrap())
351            .to("ada@example.com".parse().unwrap())
352            .subject("Your invoice")
353    }
354
355    /// One call fills both halves, and each half is escaped according to its
356    /// own extension: the HTML body turns `<` into an entity, the text body
357    /// leaves it alone.
358    #[test]
359    fn one_template_pair_fills_both_halves() {
360        let message = envelope()
361            .templated(&Text { name: "Ada" }, &Html { name: "A<B" })
362            .unwrap();
363        let raw = String::from_utf8(message.formatted()).unwrap();
364
365        assert!(raw.contains("multipart/alternative"), "{raw}");
366        assert!(raw.contains("text/plain"), "{raw}");
367        assert!(raw.contains("text/html"), "{raw}");
368        assert!(
369            raw.contains("Hello Ada, 3 < 4."),
370            "text half missing: {raw}"
371        );
372        assert!(
373            raw.contains("&#60;") || raw.contains("&lt;"),
374            "the HTML half was not escaped: {raw}"
375        );
376        assert!(
377            !raw.contains("<p>Hello A<B"),
378            "the HTML half kept a raw angle bracket from data: {raw}"
379        );
380    }
381
382    #[test]
383    fn attachments_ride_along_with_a_templated_body() {
384        let attachment =
385            EmailAttachment::new("invoice.txt", b"total: 1".to_vec(), "text/plain").unwrap();
386        let message = envelope()
387            .templated_with_attachments(
388                &Text { name: "Ada" },
389                &Html { name: "Ada" },
390                vec![attachment],
391            )
392            .unwrap();
393        let raw = String::from_utf8(message.formatted()).unwrap();
394
395        assert!(raw.contains("multipart/mixed"), "{raw}");
396        assert!(raw.contains("invoice.txt"), "{raw}");
397    }
398
399    /// A template that cannot render stops before a message exists, and the
400    /// framework error it converts into carries no template text.
401    #[test]
402    fn a_failing_template_never_becomes_a_message() {
403        let failure = envelope()
404            .templated(
405                &crate::view::test_support::Unformattable::default(),
406                &Html { name: "Ada" },
407            )
408            .unwrap_err();
409
410        assert!(matches!(failure, MailViewError::Render { .. }));
411
412        let framework = crate::Error::from(failure);
413        assert_eq!(framework.status(), 500);
414        let rendered = framework.to_string();
415        assert!(
416            !rendered.contains("secret-template-text"),
417            "the template's text survived into the framework error: {rendered}"
418        );
419    }
420}