plunk-rs 0.1.2

Async Rust client for the Plunk transactional email API
Documentation
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;

/// A validated email ready to send.
#[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 {
    /// Creates an HTML email for a single recipient.
    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)
    }

    /// Creates an HTML email for multiple recipients.
    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(),
        })
    }

    /// Creates a template email for a single recipient.
    ///
    /// `template_id` must be the Plunk template UUID, not the template name.
    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)
    }

    /// Creates a template email for multiple recipients.
    ///
    /// `template_id` must be the Plunk template UUID, not the template name.
    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 },
}

/// A validated recipient list.
#[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
    }
}

/// A validated email address with an optional display name.
#[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")));
    }
}