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
16/// A thin wrapper around [`lettre::message::MessageBuilder`] for the
17/// ergonomic email construction API.
18#[derive(Clone)]
19pub struct Email {
20    builder: MessageBuilder,
21}
22
23impl Email {
24    /// Start a new email builder.
25    #[must_use]
26    pub fn builder() -> Self {
27        Self {
28            builder: Message::builder(),
29        }
30    }
31
32    /// Construct an `Email` from an existing `MessageBuilder` (escape hatch).
33    #[must_use]
34    pub fn from_builder(builder: MessageBuilder) -> Self {
35        Self { builder }
36    }
37
38    /// Set the `From` address.
39    #[must_use]
40    pub fn from(mut self, mailbox: Mailbox) -> Self {
41        self.builder = self.builder.from(mailbox);
42        self
43    }
44
45    /// Set the `Reply-To` address.
46    #[must_use]
47    pub fn reply_to(mut self, mailbox: Mailbox) -> Self {
48        self.builder = self.builder.reply_to(mailbox);
49        self
50    }
51
52    /// Add a `To` recipient. May be called repeatedly.
53    #[must_use]
54    pub fn to(mut self, mailbox: Mailbox) -> Self {
55        self.builder = self.builder.to(mailbox);
56        self
57    }
58
59    /// Add a `Cc` recipient. May be called repeatedly.
60    #[must_use]
61    pub fn cc(mut self, mailbox: Mailbox) -> Self {
62        self.builder = self.builder.cc(mailbox);
63        self
64    }
65
66    /// Add a `Bcc` recipient. May be called repeatedly.
67    #[must_use]
68    pub fn bcc(mut self, mailbox: Mailbox) -> Self {
69        self.builder = self.builder.bcc(mailbox);
70        self
71    }
72
73    /// Set the `Subject`.
74    #[must_use]
75    pub fn subject(mut self, subject: impl Into<String>) -> Self {
76        self.builder = self.builder.subject(subject);
77        self
78    }
79
80    /// Terminate the builder with a plain-text body.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`EmailError::Build`] if lettre cannot build the message.
85    pub fn plain(self, body: impl Into<String>) -> Result<Message, EmailError> {
86        let body: String = body.into();
87        self.builder
88            .header(ContentType::TEXT_PLAIN)
89            .body(body)
90            .map_err(EmailError::build)
91    }
92
93    /// Terminate the builder with an HTML body.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`EmailError::Build`] if lettre cannot build the message.
98    pub fn html(self, body: impl Into<String>) -> Result<Message, EmailError> {
99        let html_part = SinglePart::builder()
100            .header(ContentType::TEXT_HTML)
101            .body(body.into());
102        self.builder
103            .singlepart(html_part)
104            .map_err(EmailError::build)
105    }
106
107    /// Terminate the builder with an alternative plain/HTML body (the client
108    /// picks whichever it can render).
109    ///
110    /// # Errors
111    ///
112    /// Returns [`EmailError::Build`] if lettre cannot build the message.
113    pub fn alternative(
114        self,
115        plain: impl Into<String>,
116        html: impl Into<String>,
117    ) -> Result<Message, EmailError> {
118        let multipart = MultiPart::alternative_plain_html(plain.into(), html.into());
119        self.builder.multipart(multipart).map_err(EmailError::build)
120    }
121
122    /// Terminate the builder with a `multipart/mixed` body (a multipart body
123    /// plus attachments).
124    ///
125    /// # Errors
126    ///
127    /// Returns [`EmailError::Build`] if lettre cannot build the message.
128    pub fn mixed(
129        self,
130        body: MultiPart,
131        attachments: Vec<EmailAttachment>,
132    ) -> Result<Message, EmailError> {
133        let mut mixed = MultiPart::mixed().multipart(body);
134        for attachment in attachments {
135            mixed = mixed.singlepart(attachment.into_lettre());
136        }
137        self.builder.multipart(mixed).map_err(EmailError::build)
138    }
139
140    /// Terminate the builder with a plain body plus attachments.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`EmailError::Build`] if lettre cannot build the message, or
145    /// [`EmailError::ContentType`] if an attachment content type is invalid.
146    pub fn plain_with_attachments(
147        self,
148        body: impl Into<String>,
149        attachments: Vec<EmailAttachment>,
150    ) -> Result<Message, EmailError> {
151        let body = MultiPart::alternative_plain_html(body.into(), String::new());
152        self.mixed(body, attachments)
153    }
154
155    /// Terminate the builder with an alternative plain/HTML body plus
156    /// attachments.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`EmailError::Build`] if lettre cannot build the message.
161    pub fn alternative_with_attachments(
162        self,
163        plain: impl Into<String>,
164        html: impl Into<String>,
165        attachments: Vec<EmailAttachment>,
166    ) -> Result<Message, EmailError> {
167        let body = MultiPart::alternative_plain_html(plain.into(), html.into());
168        self.mixed(body, attachments)
169    }
170
171    /// Consume the wrapper and return the underlying `MessageBuilder` (escape
172    /// hatch).
173    #[must_use]
174    pub fn into_builder(self) -> MessageBuilder {
175        self.builder
176    }
177}
178
179/// An email attachment: filename, body bytes, and MIME content type.
180pub struct EmailAttachment {
181    filename: String,
182    body: Vec<u8>,
183    content_type: ContentType,
184}
185
186impl EmailAttachment {
187    /// Create an attachment.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`EmailError::ContentType`] if `content_type` is not a valid
192    /// MIME type string.
193    pub fn new(
194        filename: impl Into<String>,
195        body: Vec<u8>,
196        content_type: &str,
197    ) -> Result<Self, EmailError> {
198        let content_type = ContentType::parse(content_type).map_err(EmailError::content_type)?;
199        Ok(Self {
200            filename: filename.into(),
201            body,
202            content_type,
203        })
204    }
205
206    pub(crate) fn into_lettre(self) -> SinglePart {
207        Attachment::new(self.filename).body(self.body, self.content_type)
208    }
209}
210
211impl fmt::Debug for EmailAttachment {
212    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213        formatter
214            .debug_struct("EmailAttachment")
215            .field("filename", &self.filename)
216            .field("content_type", &self.content_type)
217            .field("body_len", &self.body.len())
218            .finish_non_exhaustive()
219    }
220}