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#[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 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 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 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 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#[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#[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}