use crate::error::{Error, Result};
use crate::util::{
normalize_email, normalize_header_key, normalize_non_empty, normalize_template_id,
serialize_data_map,
};
use serde::Serialize;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::convert::Infallible;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Email {
recipients: Recipients,
pub(crate) content: EmailContent,
pub(crate) from: Option<EmailAddress>,
pub(crate) reply_to: Option<EmailAddress>,
pub(crate) headers: BTreeMap<String, String>,
pub(crate) data: Map<String, Value>,
}
impl Email {
pub fn html<A>(to: A, subject: impl Into<String>, body: impl Into<String>) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
Self::html_many([to.try_into().map_err(Into::into)?], subject, body)
}
pub fn html_many<I>(to: I, subject: impl Into<String>, body: impl Into<String>) -> Result<Self>
where
I: IntoIterator<Item = EmailAddress>,
{
Ok(Self {
recipients: Recipients::many(to)?,
content: EmailContent::Html {
subject: normalize_non_empty(subject.into(), Error::InvalidSubject)?,
body: normalize_non_empty(body.into(), Error::InvalidBody)?,
},
from: None,
reply_to: None,
headers: BTreeMap::new(),
data: Map::new(),
})
}
pub fn template<A>(to: A, template_id: impl Into<String>) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
Self::template_many([to.try_into().map_err(Into::into)?], template_id)
}
pub fn template_many<I>(to: I, template_id: impl Into<String>) -> Result<Self>
where
I: IntoIterator<Item = EmailAddress>,
{
Ok(Self {
recipients: Recipients::many(to)?,
content: EmailContent::Template {
template_id: normalize_template_id(template_id.into())?,
},
from: None,
reply_to: None,
headers: BTreeMap::new(),
data: Map::new(),
})
}
pub fn from<A>(mut self, from: A) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
self.from = Some(from.try_into().map_err(Into::into)?);
Ok(self)
}
pub fn reply_to<A>(mut self, reply_to: A) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
self.reply_to = Some(reply_to.try_into().map_err(Into::into)?);
Ok(self)
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
let key = normalize_header_key(key.into())?;
let value = normalize_non_empty(value.into(), Error::InvalidHeaderValue)?;
self.headers.insert(key, value);
Ok(self)
}
pub fn with_data<T>(mut self, data: T) -> Result<Self>
where
T: Serialize,
{
self.data = serialize_data_map(data)?;
Ok(self)
}
pub fn recipients(&self) -> &[EmailAddress] {
self.recipients.as_slice()
}
pub fn from_address(&self) -> Option<&EmailAddress> {
self.from.as_ref()
}
pub fn reply_to_address(&self) -> Option<&EmailAddress> {
self.reply_to.as_ref()
}
pub fn subject(&self) -> Option<&str> {
match &self.content {
EmailContent::Html { subject, .. } => Some(subject),
EmailContent::Template { .. } => None,
}
}
pub fn body(&self) -> Option<&str> {
match &self.content {
EmailContent::Html { body, .. } => Some(body),
EmailContent::Template { .. } => None,
}
}
pub fn template_id(&self) -> Option<&str> {
match &self.content {
EmailContent::Html { .. } => None,
EmailContent::Template { template_id } => Some(template_id),
}
}
pub fn headers(&self) -> &BTreeMap<String, String> {
&self.headers
}
pub fn data(&self) -> &Map<String, Value> {
&self.data
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum EmailContent {
Html { subject: String, body: String },
Template { template_id: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recipients(Vec<EmailAddress>);
impl Recipients {
pub fn one<A>(recipient: A) -> Result<Self>
where
A: TryInto<EmailAddress>,
A::Error: Into<Error>,
{
Ok(Self(vec![recipient.try_into().map_err(Into::into)?]))
}
pub fn many<I>(recipients: I) -> Result<Self>
where
I: IntoIterator<Item = EmailAddress>,
{
let recipients: Vec<_> = recipients.into_iter().collect();
if recipients.is_empty() {
return Err(Error::MissingRecipients);
}
Ok(Self(recipients))
}
pub fn as_slice(&self) -> &[EmailAddress] {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct EmailAddress {
pub(crate) email: String,
pub(crate) name: Option<String>,
}
impl EmailAddress {
pub fn new(email: impl Into<String>) -> Result<Self> {
let email = normalize_email(email.into())?;
Ok(Self { email, name: None })
}
pub fn named(name: impl Into<String>, email: impl Into<String>) -> Result<Self> {
let name = normalize_non_empty(name.into(), Error::InvalidDisplayName)?;
let email = normalize_email(email.into())?;
Ok(Self {
email,
name: Some(name),
})
}
pub fn email(&self) -> &str {
&self.email
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
}
impl TryFrom<&str> for EmailAddress {
type Error = Error;
fn try_from(value: &str) -> Result<Self> {
Self::new(value)
}
}
impl TryFrom<String> for EmailAddress {
type Error = Error;
fn try_from(value: String) -> Result<Self> {
Self::new(value)
}
}
impl From<Infallible> for Error {
fn from(value: Infallible) -> Self {
match value {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wire::WireEmail;
use serde::Serialize;
use serde_json::json;
#[derive(Serialize)]
struct WelcomeData<'a> {
first_name: &'a str,
}
#[test]
fn html_email_validates_up_front() {
let email = Email::html("user@example.com", "Welcome", "<p>Hello from Plunk</p>")
.unwrap()
.from(EmailAddress::named("My App", "hello@example.com").unwrap())
.unwrap()
.reply_to("reply@example.com")
.unwrap()
.with_header("X-Test", "true")
.unwrap();
let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
assert_eq!(
json,
json!({
"to": "user@example.com",
"subject": "Welcome",
"body": "<p>Hello from Plunk</p>",
"from": {
"name": "My App",
"email": "hello@example.com"
},
"headers": {
"X-Test": "true"
},
"reply": "reply@example.com"
})
);
}
#[test]
fn template_email_serializes_cleanly() {
let recipients = vec![
EmailAddress::new("one@example.com").unwrap(),
EmailAddress::new("two@example.com").unwrap(),
];
let email = Email::template_many(recipients, "550e8400-e29b-41d4-a716-446655440000").unwrap();
let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
assert_eq!(
json,
json!({
"to": ["one@example.com", "two@example.com"],
"template": "550e8400-e29b-41d4-a716-446655440000"
})
);
}
#[test]
fn template_data_must_be_an_object() {
let error = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")
.unwrap()
.with_data(vec!["not", "an", "object"])
.unwrap_err();
assert!(matches!(error, Error::TemplateDataMustBeObject));
}
#[test]
fn typed_template_data_serializes_from_struct() {
let email = Email::template("user@example.com", "550e8400-e29b-41d4-a716-446655440000")
.unwrap()
.with_data(WelcomeData { first_name: "Ada" })
.unwrap();
let json = serde_json::to_value(WireEmail::from(&email)).unwrap();
assert_eq!(
json,
json!({
"to": "user@example.com",
"template": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"first_name": "Ada"
}
})
);
}
#[test]
fn rejects_invalid_email_at_construction_time() {
let error = Email::html("not-an-email", "Welcome", "<p>Hello</p>").unwrap_err();
assert!(matches!(error, Error::InvalidEmailAddress { .. }));
}
#[test]
fn rejects_empty_subject_at_construction_time() {
let error = Email::html("user@example.com", " ", "<p>Hello</p>").unwrap_err();
assert!(matches!(error, Error::InvalidSubject));
}
#[test]
fn rejects_empty_template_id_at_construction_time() {
let error = Email::template("user@example.com", " ").unwrap_err();
assert!(matches!(error, Error::InvalidTemplateId));
}
#[test]
fn rejects_non_uuid_template_id_at_construction_time() {
let error = Email::template("user@example.com", "free-trial").unwrap_err();
assert!(matches!(error, Error::InvalidTemplateIdFormat));
}
#[test]
fn email_accessors_are_consistent() {
let email = Email::html("user@example.com", "Welcome", "<p>Hello</p>")
.unwrap()
.from("hello@example.com")
.unwrap()
.reply_to(EmailAddress::named("Support", "reply@example.com").unwrap())
.unwrap()
.with_header("X-Test", "true")
.unwrap()
.with_data(serde_json::json!({ "first_name": "Ada" }))
.unwrap();
assert_eq!(
email.recipients(),
&[EmailAddress::new("user@example.com").unwrap()]
);
assert_eq!(
email.from_address(),
Some(&EmailAddress::new("hello@example.com").unwrap())
);
assert_eq!(
email.reply_to_address(),
Some(&EmailAddress::named("Support", "reply@example.com").unwrap())
);
assert_eq!(email.subject(), Some("Welcome"));
assert_eq!(email.body(), Some("<p>Hello</p>"));
assert_eq!(email.template_id(), None);
assert_eq!(email.headers().get("X-Test"), Some(&"true".to_string()));
assert_eq!(email.data().get("first_name"), Some(&json!("Ada")));
}
}