use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Address {
pub email: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Address {
pub fn new(email: impl Into<String>) -> Self {
Self { email: email.into(), name: None }
}
pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
Self { email: email.into(), name: Some(name.into()) }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attachment {
pub filename: String,
pub content_type: String,
pub bytes: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
pub from: Address,
pub to: Vec<Address>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cc: Vec<Address>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bcc: Vec<Address>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reply_to: Vec<Address>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub return_path: Option<String>,
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub html: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SendOutcome {
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MailerError {
InvalidMessage(String),
Unauthorized(String),
Rejected(String),
Transport(String),
RateLimited(String),
Unknown(String),
}
impl MailerError {
pub fn invalid(message: impl Into<String>) -> Self {
MailerError::InvalidMessage(message.into())
}
pub fn unauthorized(message: impl Into<String>) -> Self {
MailerError::Unauthorized(message.into())
}
pub fn rejected(message: impl Into<String>) -> Self {
MailerError::Rejected(message.into())
}
pub fn transport(message: impl Into<String>) -> Self {
MailerError::Transport(message.into())
}
pub fn rate_limited(message: impl Into<String>) -> Self {
MailerError::RateLimited(message.into())
}
pub fn unknown(message: impl Into<String>) -> Self {
MailerError::Unknown(message.into())
}
pub fn message(&self) -> &str {
match self {
MailerError::InvalidMessage(m)
| MailerError::Unauthorized(m)
| MailerError::Rejected(m)
| MailerError::Transport(m)
| MailerError::RateLimited(m)
| MailerError::Unknown(m) => m,
}
}
}
impl std::fmt::Display for MailerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tag = match self {
MailerError::InvalidMessage(_) => "invalid-message",
MailerError::Unauthorized(_) => "unauthorized",
MailerError::Rejected(_) => "rejected",
MailerError::Transport(_) => "transport",
MailerError::RateLimited(_) => "rate-limited",
MailerError::Unknown(_) => "unknown",
};
write!(f, "mailer {}: {}", tag, self.message())
}
}
impl std::error::Error for MailerError {}
pub type BatchOutcome = Result<SendOutcome, MailerError>;
pub trait MailerPlugin: Send + Sync {
fn name(&self) -> &str;
fn send(&self, msg: Message) -> Result<SendOutcome, MailerError>;
fn send_batch(&self, msgs: Vec<Message>) -> Vec<BatchOutcome> {
msgs.into_iter().map(|m| self.send(m)).collect()
}
fn is_healthy(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn address_constructors() {
let a = Address::new("a@b");
assert_eq!(a.email, "a@b");
assert!(a.name.is_none());
let b = Address::named("a@b", "Alice");
assert_eq!(b.name.as_deref(), Some("Alice"));
}
#[test]
fn mailer_error_helpers_and_display() {
let e = MailerError::invalid("no recipient");
assert!(matches!(e, MailerError::InvalidMessage(_)));
assert_eq!(e.message(), "no recipient");
assert!(e.to_string().contains("invalid-message"));
assert!(e.to_string().contains("no recipient"));
let t = MailerError::transport("dns fail");
assert!(matches!(t, MailerError::Transport(_)));
}
struct LoopMailer;
impl MailerPlugin for LoopMailer {
fn name(&self) -> &str { "loop" }
fn send(&self, msg: Message) -> Result<SendOutcome, MailerError> {
if msg.to.is_empty() {
return Err(MailerError::invalid("no recipient"));
}
Ok(SendOutcome { id: format!("loop-{}", msg.to[0].email) })
}
}
#[test]
fn default_batch_preserves_order_and_errors() {
let m = LoopMailer;
let out = m.send_batch(vec![
Message { to: vec![Address::new("a@b")], ..Default::default() },
Message { to: vec![], ..Default::default() },
Message { to: vec![Address::new("c@d")], ..Default::default() },
]);
assert_eq!(out.len(), 3);
assert_eq!(out[0].as_ref().unwrap().id, "loop-a@b");
assert!(matches!(
out[1].as_ref().unwrap_err(),
MailerError::InvalidMessage(_)
));
assert_eq!(out[2].as_ref().unwrap().id, "loop-c@d");
}
}