Skip to main content

plunk_rs/
email.rs

1use crate::error::{Error, Result};
2use crate::util::{
3    normalize_email, normalize_header_key, normalize_non_empty, normalize_template_id,
4    serialize_data_map,
5};
6use serde::Serialize;
7use serde_json::{Map, Value};
8use std::collections::BTreeMap;
9use std::convert::Infallible;
10
11/// A validated email ready to send.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Email {
14    recipients: Recipients,
15    pub(crate) content: EmailContent,
16    pub(crate) from: Option<EmailAddress>,
17    pub(crate) reply_to: Option<EmailAddress>,
18    pub(crate) headers: BTreeMap<String, String>,
19    pub(crate) data: Map<String, Value>,
20}
21
22impl Email {
23    /// Creates an HTML email for a single recipient.
24    pub fn html<A>(to: A, subject: impl Into<String>, body: impl Into<String>) -> Result<Self>
25    where
26        A: TryInto<EmailAddress>,
27        A::Error: Into<Error>,
28    {
29        Self::html_many([to.try_into().map_err(Into::into)?], subject, body)
30    }
31
32    /// Creates an HTML email for multiple recipients.
33    pub fn html_many<I>(to: I, subject: impl Into<String>, body: impl Into<String>) -> Result<Self>
34    where
35        I: IntoIterator<Item = EmailAddress>,
36    {
37        Ok(Self {
38            recipients: Recipients::many(to)?,
39            content: EmailContent::Html {
40                subject: normalize_non_empty(subject.into(), Error::InvalidSubject)?,
41                body: normalize_non_empty(body.into(), Error::InvalidBody)?,
42            },
43            from: None,
44            reply_to: None,
45            headers: BTreeMap::new(),
46            data: Map::new(),
47        })
48    }
49
50    /// Creates a template email for a single recipient.
51    ///
52    /// `template_id` must be the Plunk template UUID, not the template name.
53    pub fn template<A>(to: A, template_id: impl Into<String>) -> Result<Self>
54    where
55        A: TryInto<EmailAddress>,
56        A::Error: Into<Error>,
57    {
58        Self::template_many([to.try_into().map_err(Into::into)?], template_id)
59    }
60
61    /// Creates a template email for multiple recipients.
62    ///
63    /// `template_id` must be the Plunk template UUID, not the template name.
64    pub fn template_many<I>(to: I, template_id: impl Into<String>) -> Result<Self>
65    where
66        I: IntoIterator<Item = EmailAddress>,
67    {
68        Ok(Self {
69            recipients: Recipients::many(to)?,
70            content: EmailContent::Template {
71                template_id: normalize_template_id(template_id.into())?,
72            },
73            from: None,
74            reply_to: None,
75            headers: BTreeMap::new(),
76            data: Map::new(),
77        })
78    }
79
80    pub fn from<A>(mut self, from: A) -> Result<Self>
81    where
82        A: TryInto<EmailAddress>,
83        A::Error: Into<Error>,
84    {
85        self.from = Some(from.try_into().map_err(Into::into)?);
86        Ok(self)
87    }
88
89    pub fn reply_to<A>(mut self, reply_to: A) -> Result<Self>
90    where
91        A: TryInto<EmailAddress>,
92        A::Error: Into<Error>,
93    {
94        self.reply_to = Some(reply_to.try_into().map_err(Into::into)?);
95        Ok(self)
96    }
97
98    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
99        let key = normalize_header_key(key.into())?;
100        let value = normalize_non_empty(value.into(), Error::InvalidHeaderValue)?;
101        self.headers.insert(key, value);
102        Ok(self)
103    }
104
105    pub fn with_data<T>(mut self, data: T) -> Result<Self>
106    where
107        T: Serialize,
108    {
109        self.data = serialize_data_map(data)?;
110        Ok(self)
111    }
112
113    pub fn recipients(&self) -> &[EmailAddress] {
114        self.recipients.as_slice()
115    }
116
117    pub fn from_address(&self) -> Option<&EmailAddress> {
118        self.from.as_ref()
119    }
120
121    pub fn reply_to_address(&self) -> Option<&EmailAddress> {
122        self.reply_to.as_ref()
123    }
124
125    pub fn subject(&self) -> Option<&str> {
126        match &self.content {
127            EmailContent::Html { subject, .. } => Some(subject),
128            EmailContent::Template { .. } => None,
129        }
130    }
131
132    pub fn body(&self) -> Option<&str> {
133        match &self.content {
134            EmailContent::Html { body, .. } => Some(body),
135            EmailContent::Template { .. } => None,
136        }
137    }
138
139    pub fn template_id(&self) -> Option<&str> {
140        match &self.content {
141            EmailContent::Html { .. } => None,
142            EmailContent::Template { template_id } => Some(template_id),
143        }
144    }
145
146    pub fn headers(&self) -> &BTreeMap<String, String> {
147        &self.headers
148    }
149
150    pub fn data(&self) -> &Map<String, Value> {
151        &self.data
152    }
153}
154
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub(crate) enum EmailContent {
157    Html { subject: String, body: String },
158    Template { template_id: String },
159}
160
161/// A validated recipient list.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct Recipients(Vec<EmailAddress>);
164
165impl Recipients {
166    pub fn one<A>(recipient: A) -> Result<Self>
167    where
168        A: TryInto<EmailAddress>,
169        A::Error: Into<Error>,
170    {
171        Ok(Self(vec![recipient.try_into().map_err(Into::into)?]))
172    }
173
174    pub fn many<I>(recipients: I) -> Result<Self>
175    where
176        I: IntoIterator<Item = EmailAddress>,
177    {
178        let recipients: Vec<_> = recipients.into_iter().collect();
179        if recipients.is_empty() {
180            return Err(Error::MissingRecipients);
181        }
182        Ok(Self(recipients))
183    }
184
185    pub fn as_slice(&self) -> &[EmailAddress] {
186        &self.0
187    }
188}
189
190/// A validated email address with an optional display name.
191#[derive(Clone, Debug, PartialEq, Eq, Hash)]
192pub struct EmailAddress {
193    pub(crate) email: String,
194    pub(crate) name: Option<String>,
195}
196
197impl EmailAddress {
198    pub fn new(email: impl Into<String>) -> Result<Self> {
199        let email = normalize_email(email.into())?;
200        Ok(Self { email, name: None })
201    }
202
203    pub fn named(name: impl Into<String>, email: impl Into<String>) -> Result<Self> {
204        let name = normalize_non_empty(name.into(), Error::InvalidDisplayName)?;
205        let email = normalize_email(email.into())?;
206        Ok(Self {
207            email,
208            name: Some(name),
209        })
210    }
211
212    pub fn email(&self) -> &str {
213        &self.email
214    }
215
216    pub fn name(&self) -> Option<&str> {
217        self.name.as_deref()
218    }
219}
220
221impl TryFrom<&str> for EmailAddress {
222    type Error = Error;
223
224    fn try_from(value: &str) -> Result<Self> {
225        Self::new(value)
226    }
227}
228
229impl TryFrom<String> for EmailAddress {
230    type Error = Error;
231
232    fn try_from(value: String) -> Result<Self> {
233        Self::new(value)
234    }
235}
236
237impl From<Infallible> for Error {
238    fn from(value: Infallible) -> Self {
239        match value {}
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::wire::WireEmail;
247    use serde::Serialize;
248    use serde_json::json;
249
250    #[derive(Serialize)]
251    struct WelcomeData<'a> {
252        first_name: &'a str,
253    }
254
255    #[test]
256    fn html_email_validates_up_front() {
257        let email = Email::html("user@example.com", "Welcome", "<p>Hello from Plunk</p>")
258            .unwrap()
259            .from(EmailAddress::named("My App", "hello@example.com").unwrap())
260            .unwrap()
261            .reply_to("reply@example.com")
262            .unwrap()
263            .with_header("X-Test", "true")
264            .unwrap();
265
266        let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
267
268        assert_eq!(
269            json,
270            json!({
271                "to": "user@example.com",
272                "subject": "Welcome",
273                "body": "<p>Hello from Plunk</p>",
274                "from": {
275                    "name": "My App",
276                    "email": "hello@example.com"
277                },
278                "headers": {
279                    "X-Test": "true"
280                },
281                "reply": "reply@example.com"
282            })
283        );
284    }
285
286    #[test]
287    fn template_email_serializes_cleanly() {
288        let recipients = vec![
289            EmailAddress::new("one@example.com").unwrap(),
290            EmailAddress::new("two@example.com").unwrap(),
291        ];
292        let email = Email::template_many(recipients, "550e8400-e29b-41d4-a716-446655440000").unwrap();
293
294        let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
295
296        assert_eq!(
297            json,
298            json!({
299                "to": ["one@example.com", "two@example.com"],
300                "template": "550e8400-e29b-41d4-a716-446655440000"
301            })
302        );
303    }
304
305    #[test]
306    fn template_data_must_be_an_object() {
307        let error = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")
308            .unwrap()
309            .with_data(vec!["not", "an", "object"])
310            .unwrap_err();
311
312        assert!(matches!(error, Error::TemplateDataMustBeObject));
313    }
314
315    #[test]
316    fn typed_template_data_serializes_from_struct() {
317        let email = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")
318            .unwrap()
319            .with_data(WelcomeData { first_name: "Ada" })
320            .unwrap();
321
322        let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
323
324        assert_eq!(
325            json,
326            json!({
327                "to": "user@example.com",
328                "template": "550e8400-e29b-41d4-a716-446655440000",
329                "data": {
330                    "first_name": "Ada"
331                }
332            })
333        );
334    }
335
336    #[test]
337    fn rejects_invalid_email_at_construction_time() {
338        let error = Email::html("not-an-email", "Welcome", "<p>Hello</p>").unwrap_err();
339
340        assert!(matches!(error, Error::InvalidEmailAddress { .. }));
341    }
342
343    #[test]
344    fn rejects_empty_subject_at_construction_time() {
345        let error = Email::html("user@example.com", "   ", "<p>Hello</p>").unwrap_err();
346
347        assert!(matches!(error, Error::InvalidSubject));
348    }
349
350    #[test]
351    fn rejects_empty_template_id_at_construction_time() {
352        let error = Email::template("user@example.com", "   ").unwrap_err();
353
354        assert!(matches!(error, Error::InvalidTemplateId));
355    }
356
357    #[test]
358    fn rejects_non_uuid_template_id_at_construction_time() {
359        let error = Email::template("user@example.com", "free-trial").unwrap_err();
360
361        assert!(matches!(error, Error::InvalidTemplateIdFormat));
362    }
363
364    #[test]
365    fn email_accessors_are_consistent() {
366        let email = Email::html("user@example.com", "Welcome", "<p>Hello</p>")
367            .unwrap()
368            .from("hello@example.com")
369            .unwrap()
370            .reply_to(EmailAddress::named("Support", "reply@example.com").unwrap())
371            .unwrap()
372            .with_header("X-Test", "true")
373            .unwrap()
374            .with_data(serde_json::json!({ "first_name": "Ada" }))
375            .unwrap();
376
377        assert_eq!(
378            email.recipients(),
379            &[EmailAddress::new("user@example.com").unwrap()]
380        );
381        assert_eq!(
382            email.from_address(),
383            Some(&EmailAddress::new("hello@example.com").unwrap())
384        );
385        assert_eq!(
386            email.reply_to_address(),
387            Some(&EmailAddress::named("Support", "reply@example.com").unwrap())
388        );
389        assert_eq!(email.subject(), Some("Welcome"));
390        assert_eq!(email.body(), Some("<p>Hello</p>"));
391        assert_eq!(email.template_id(), None);
392        assert_eq!(email.headers().get("X-Test"), Some(&"true".to_string()));
393        assert_eq!(email.data().get("first_name"), Some(&json!("Ada")));
394    }
395}