mod providers;
mod ses;
mod smtp;
use std::time::Duration;
use apiplant_core::EmailConfig;
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum EmailError {
#[error("email configuration: {0}")]
Config(String),
#[error("invalid message: {0}")]
Message(String),
#[error("email transport: {0}")]
Transport(String),
#[error("{provider} rejected the message ({status}): {body}")]
Provider {
provider: String,
status: u16,
body: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Address {
pub email: String,
pub name: String,
}
impl Address {
pub fn new(email: impl Into<String>) -> Self {
Address {
email: email.into(),
name: String::new(),
}
}
pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
Address {
email: email.into(),
name: name.into(),
}
}
pub fn parse(value: &str) -> Address {
let value = value.trim();
if let (Some(open), Some(close)) = (value.rfind('<'), value.rfind('>')) {
if open < close {
let email = value[open + 1..close].trim().to_string();
let name = value[..open].trim().trim_matches('"').trim().to_string();
return Address { email, name };
}
}
Address::new(value)
}
pub fn to_header(&self) -> String {
if self.name.is_empty() {
self.email.clone()
} else {
format!("{} <{}>", self.name, self.email)
}
}
}
impl<'de> Deserialize<'de> for Address {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Address, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Text(String),
Object {
#[serde(alias = "address")]
email: String,
#[serde(default)]
name: String,
},
}
Ok(match Raw::deserialize(deserializer)? {
Raw::Text(text) => Address::parse(&text),
Raw::Object { email, name } => Address { email, name },
})
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct Message {
#[serde(deserialize_with = "one_or_many")]
pub to: Vec<Address>,
#[serde(deserialize_with = "one_or_many")]
pub cc: Vec<Address>,
#[serde(deserialize_with = "one_or_many")]
pub bcc: Vec<Address>,
pub subject: String,
pub text: String,
pub html: String,
pub from: Option<Address>,
pub reply_to: Option<Address>,
}
impl Message {
pub fn to(recipient: impl Into<String>) -> Message {
Message {
to: vec![Address::parse(&recipient.into())],
..Message::default()
}
}
pub fn subject(mut self, subject: impl Into<String>) -> Message {
self.subject = subject.into();
self
}
pub fn text(mut self, body: impl Into<String>) -> Message {
self.text = body.into();
self
}
pub fn html(mut self, body: impl Into<String>) -> Message {
self.html = body.into();
self
}
fn resolve(&self, config: &EmailConfig) -> Result<Resolved, EmailError> {
let from = match &self.from {
Some(from) if !from.email.is_empty() => from.clone(),
_ => Address::named(config.from.clone(), config.from_name.clone()),
};
if from.email.is_empty() {
return Err(EmailError::Config(
"no sender: set `from` in [email], or per message".to_string(),
));
}
if self.to.iter().all(|a| a.email.is_empty()) {
return Err(EmailError::Message("no recipient".to_string()));
}
if self.subject.is_empty() && self.text.is_empty() && self.html.is_empty() {
return Err(EmailError::Message(
"nothing to send: give a subject, text or html".to_string(),
));
}
let reply_to = match &self.reply_to {
Some(reply_to) if !reply_to.email.is_empty() => Some(reply_to.clone()),
_ if !config.reply_to.is_empty() => Some(Address::parse(&config.reply_to)),
_ => None,
};
let strip = |list: &[Address]| -> Vec<Address> {
list.iter()
.filter(|a| !a.email.is_empty())
.cloned()
.collect()
};
Ok(Resolved {
from,
reply_to,
to: strip(&self.to),
cc: strip(&self.cc),
bcc: strip(&self.bcc),
subject: self.subject.clone(),
text: self.text.clone(),
html: self.html.clone(),
})
}
}
#[derive(Debug, Clone)]
pub(crate) struct Resolved {
pub from: Address,
pub reply_to: Option<Address>,
pub to: Vec<Address>,
pub cc: Vec<Address>,
pub bcc: Vec<Address>,
pub subject: String,
pub text: String,
pub html: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sent {
pub provider: String,
pub id: String,
pub recipients: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provider {
Smtp,
Ses,
SendGrid,
Brevo,
Mailjet,
Mailgun,
Postmark,
Resend,
}
impl Provider {
pub fn parse(value: &str) -> Option<Provider> {
match value.trim().to_ascii_lowercase().as_str() {
"smtp" => Some(Provider::Smtp),
"ses" | "aws" | "aws-ses" | "amazon-ses" => Some(Provider::Ses),
"sendgrid" => Some(Provider::SendGrid),
"brevo" | "sendinblue" | "mailinblue" => Some(Provider::Brevo),
"mailjet" => Some(Provider::Mailjet),
"mailgun" => Some(Provider::Mailgun),
"postmark" => Some(Provider::Postmark),
"resend" => Some(Provider::Resend),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Provider::Smtp => "smtp",
Provider::Ses => "ses",
Provider::SendGrid => "sendgrid",
Provider::Brevo => "brevo",
Provider::Mailjet => "mailjet",
Provider::Mailgun => "mailgun",
Provider::Postmark => "postmark",
Provider::Resend => "resend",
}
}
pub fn names() -> &'static str {
"none, smtp, ses, sendgrid, brevo, mailjet, mailgun, postmark, resend"
}
}
#[derive(Clone)]
pub struct Mailer {
provider: Provider,
config: EmailConfig,
transport: Transport,
}
#[derive(Clone)]
enum Transport {
Http(reqwest::Client),
Smtp(smtp::SmtpTransport),
}
impl std::fmt::Debug for Mailer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Mailer")
.field("provider", &self.provider.as_str())
.field("from", &self.config.from)
.finish()
}
}
impl Mailer {
pub fn from_config(config: &EmailConfig) -> Result<Option<Mailer>, EmailError> {
if !config.enabled() {
return Ok(None);
}
let provider = Provider::parse(&config.provider).ok_or_else(|| {
EmailError::Config(format!(
"unknown provider `{}`; expected one of: {}",
config.provider,
Provider::names()
))
})?;
if config.from.is_empty() {
return Err(EmailError::Config(
"set `from` in [email] — a provider needs a sender address".to_string(),
));
}
let timeout = Duration::from_secs(config.timeout_secs.max(1));
let transport = match provider {
Provider::Smtp => Transport::Smtp(smtp::build(&config.smtp, timeout)?),
_ => {
Self::check_credentials(provider, config)?;
let client = reqwest::Client::builder()
.timeout(timeout)
.user_agent(concat!("apiplant/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| EmailError::Config(e.to_string()))?;
Transport::Http(client)
}
};
Ok(Some(Mailer {
provider,
config: config.clone(),
transport,
}))
}
fn check_credentials(provider: Provider, config: &EmailConfig) -> Result<(), EmailError> {
let missing = |field: &str| {
Err(EmailError::Config(format!(
"[email] {field} is required for provider `{}`",
provider.as_str()
)))
};
if config.api_key.is_empty() {
return missing("api_key");
}
match provider {
Provider::Ses => {
if config.api_secret.is_empty() {
return missing("api_secret");
}
if config.region.is_empty() {
return missing("region");
}
}
Provider::Mailjet if config.api_secret.is_empty() => return missing("api_secret"),
Provider::Mailgun if config.domain.is_empty() => return missing("domain"),
_ => {}
}
Ok(())
}
pub fn provider(&self) -> Provider {
self.provider
}
pub async fn send(&self, message: &Message) -> Result<Sent, EmailError> {
let resolved = message.resolve(&self.config)?;
let recipients = resolved.to.len() + resolved.cc.len() + resolved.bcc.len();
let id = match (&self.transport, self.provider) {
(Transport::Smtp(transport), _) => smtp::send(transport, &resolved).await?,
(Transport::Http(client), Provider::Ses) => {
ses::send(client, &self.config, &resolved).await?
}
(Transport::Http(client), provider) => {
providers::send(client, provider, &self.config, &resolved).await?
}
};
tracing::info!(
provider = self.provider.as_str(),
recipients,
id = %id,
"sent email"
);
Ok(Sent {
provider: self.provider.as_str().to_string(),
id,
recipients,
})
}
}
fn one_or_many<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<Address>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
Many(Vec<Address>),
One(Address),
None,
}
Ok(match OneOrMany::deserialize(deserializer)? {
OneOrMany::Many(list) => list,
OneOrMany::One(one) => vec![one],
OneOrMany::None => Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn config(provider: &str) -> EmailConfig {
EmailConfig {
provider: provider.to_string(),
from: "no-reply@example.com".to_string(),
from_name: "Example".to_string(),
api_key: "key".to_string(),
api_secret: "secret".to_string(),
region: "eu-west-1".to_string(),
domain: "mg.example.com".to_string(),
..EmailConfig::default()
}
}
#[test]
fn provider_names_include_the_ones_people_actually_type() {
assert_eq!(Provider::parse("SendGrid"), Some(Provider::SendGrid));
assert_eq!(Provider::parse(" aws "), Some(Provider::Ses));
assert_eq!(Provider::parse("sendinblue"), Some(Provider::Brevo));
assert_eq!(Provider::parse("mailinblue"), Some(Provider::Brevo));
assert_eq!(Provider::parse("postal"), None);
}
#[test]
fn addresses_parse_from_every_spelling() {
assert_eq!(
Address::parse("ann@example.com"),
Address::new("ann@example.com")
);
assert_eq!(
Address::parse("Ann Lee <ann@example.com>"),
Address::named("ann@example.com", "Ann Lee")
);
assert_eq!(
Address::parse("\"Lee, Ann\" <ann@example.com>"),
Address::named("ann@example.com", "Lee, Ann")
);
assert_eq!(
Address::named("ann@example.com", "Ann").to_header(),
"Ann <ann@example.com>"
);
assert_eq!(
Address::new("ann@example.com").to_header(),
"ann@example.com"
);
}
#[test]
fn a_message_deserialises_from_the_json_a_function_writes() {
let message: Message = serde_json::from_str(
r#"{
"to": "ann@example.com",
"cc": [{"email": "bo@example.com", "name": "Bo"}],
"subject": "Hi",
"text": "Hello"
}"#,
)
.unwrap();
assert_eq!(message.to, vec![Address::new("ann@example.com")]);
assert_eq!(message.cc, vec![Address::named("bo@example.com", "Bo")]);
assert!(message.bcc.is_empty());
assert_eq!(message.subject, "Hi");
assert!(message.html.is_empty());
}
#[test]
fn resolve_fills_the_sender_in_from_config_and_a_message_may_override_it() {
let config = config("sendgrid");
let inherited = Message::to("ann@example.com")
.subject("Hi")
.text("Hello")
.resolve(&config)
.unwrap();
assert_eq!(
inherited.from,
Address::named("no-reply@example.com", "Example")
);
let mut overridden = Message::to("ann@example.com").subject("Hi");
overridden.from = Some(Address::new("sales@example.com"));
assert_eq!(
overridden.resolve(&config).unwrap().from.email,
"sales@example.com"
);
}
#[test]
fn resolve_rejects_messages_that_cannot_be_sent() {
let config = config("sendgrid");
let no_recipient = Message::default().subject("Hi").resolve(&config);
assert!(matches!(no_recipient, Err(EmailError::Message(_))));
let empty = Message::to("ann@example.com").resolve(&config);
assert!(matches!(empty, Err(EmailError::Message(_))));
let no_sender = Message::to("ann@example.com")
.subject("Hi")
.resolve(&EmailConfig::default());
assert!(matches!(no_sender, Err(EmailError::Config(_))));
}
#[test]
fn a_disabled_email_section_builds_no_mailer() {
assert!(Mailer::from_config(&EmailConfig::default())
.unwrap()
.is_none());
}
#[test]
fn missing_credentials_are_a_configuration_error() {
let unknown = Mailer::from_config(&config("mailchimp"));
assert!(matches!(unknown, Err(EmailError::Config(_))));
let mut no_key = config("sendgrid");
no_key.api_key.clear();
let err = Mailer::from_config(&no_key).unwrap_err().to_string();
assert!(err.contains("api_key"), "{err}");
let mut no_secret = config("mailjet");
no_secret.api_secret.clear();
assert!(Mailer::from_config(&no_secret)
.unwrap_err()
.to_string()
.contains("api_secret"));
let mut no_domain = config("mailgun");
no_domain.domain.clear();
assert!(Mailer::from_config(&no_domain)
.unwrap_err()
.to_string()
.contains("domain"));
let mut no_from = config("resend");
no_from.from.clear();
assert!(Mailer::from_config(&no_from)
.unwrap_err()
.to_string()
.contains("from"));
}
#[test]
fn debug_does_not_leak_credentials() {
let mailer = Mailer::from_config(&config("sendgrid")).unwrap().unwrap();
let printed = format!("{mailer:?}");
assert!(!printed.contains("key"), "{printed}");
assert!(printed.contains("sendgrid"), "{printed}");
}
}