use async_trait::async_trait;
use serde::Serialize;
use std::time::Duration;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Message {
pub to: String,
pub from: String,
pub reply_to: Option<String>,
pub subject: String,
pub html: String,
pub text: String,
pub idempotency_key: Option<String>,
pub tags: Vec<String>,
pub headers: Vec<(String, String)>,
}
impl Message {
#[must_use]
pub fn new(
to: impl Into<String>,
from: impl Into<String>,
subject: impl Into<String>,
text: impl Into<String>,
html: impl Into<String>,
) -> Self {
Self {
to: to.into(),
from: from.into(),
reply_to: None,
subject: subject.into(),
html: html.into(),
text: text.into(),
idempotency_key: None,
tags: Vec::new(),
headers: Vec::new(),
}
}
#[must_use]
pub fn reply_to(mut self, address: impl Into<String>) -> Self {
self.reply_to = Some(address.into());
self
}
#[must_use]
pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}
#[must_use]
pub fn tags<I, T>(mut self, tags: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<String>,
{
self.tags = tags.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendOutcome {
Sent { id: String },
NotConfigured,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MailError {
Unauthorized,
DomainNotVerified { domain: String },
Invalid { detail: String },
RateLimited { retry_after: Option<Duration> },
Upstream(String),
Transport(String),
}
impl std::fmt::Display for MailError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let scrub = crate::logging::scrub_text;
match self {
Self::Unauthorized => {
f.write_str("mailer rejected the request as unauthorized (check the API key)")
}
Self::DomainNotVerified { domain } => write!(
f,
"sending domain {:?} is not verified with the mailer",
scrub(domain)
),
Self::Invalid { detail } => {
write!(
f,
"mailer rejected the message as invalid: {}",
scrub(detail)
)
}
Self::RateLimited { retry_after } => write!(
f,
"mailer rate limited the request; retry after {retry_after:?}"
),
Self::Upstream(message) => write!(f, "mailer upstream error: {}", scrub(message)),
Self::Transport(message) => write!(f, "mailer transport error: {}", scrub(message)),
}
}
}
impl std::error::Error for MailError {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MailProvider {
pub name: String,
pub configured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub healthy: Option<bool>,
}
impl MailProvider {
#[must_use]
pub fn new(name: impl Into<String>, configured: bool) -> Self {
Self {
name: name.into(),
configured,
healthy: None,
}
}
}
#[async_trait]
pub trait Mailer: Send + Sync {
async fn send(&self, message: Message) -> Result<SendOutcome, MailError>;
fn providers(&self) -> Vec<MailProvider> {
vec![MailProvider::new("mailer", true)]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_sanitizes_the_provider_text() {
let error = MailError::Invalid {
detail: "validation_error: `to` must be a valid address: alice@example.test".to_owned(),
};
let text = error.to_string();
assert!(!text.contains('@'), "{text}");
assert!(!text.contains("alice"), "{text}");
assert!(text.contains("[subject_hash:"), "{text}");
let error = MailError::Upstream(
"POST https://api.resend.com/emails?key=live-abcdef failed".to_owned(),
);
let text = error.to_string();
assert!(!text.contains("live-abcdef"), "{text}");
assert!(text.contains("?[redacted]"), "{text}");
assert_eq!(
MailError::Unauthorized.to_string(),
"mailer rejected the request as unauthorized (check the API key)"
);
assert_eq!(
MailError::RateLimited { retry_after: None }.to_string(),
"mailer rate limited the request; retry after None"
);
assert!(format!("{error:?}").contains("live-abcdef"));
}
#[test]
fn a_domain_that_is_an_address_is_still_scrubbed() {
let error = MailError::DomainNotVerified {
domain: "no-reply@send.example.test".to_owned(),
};
let text = error.to_string();
assert!(!text.contains('@'), "{text}");
assert!(text.contains("[subject_hash:"), "{text}");
}
}