use crate::channel::Channel;
use crate::channels::{MailMessage, SlackMessage};
use crate::notifiable::Notifiable;
use crate::notification::Notification;
use crate::Error;
use serde::Serialize;
use std::env;
use std::sync::OnceLock;
use tracing::{error, info};
static CONFIG: OnceLock<NotificationConfig> = OnceLock::new();
#[derive(Clone, Default)]
pub struct NotificationConfig {
pub mail: Option<MailConfig>,
pub slack_webhook: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub enum MailDriver {
#[default]
Smtp,
Resend,
}
#[derive(Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub tls: bool,
}
#[derive(Clone)]
pub struct ResendConfig {
pub api_key: String,
}
#[derive(Clone)]
pub struct MailConfig {
pub driver: MailDriver,
pub from: String,
pub from_name: Option<String>,
pub smtp: Option<SmtpConfig>,
pub resend: Option<ResendConfig>,
}
impl NotificationConfig {
pub fn new() -> Self {
Self::default()
}
pub fn from_env() -> Self {
Self {
mail: MailConfig::from_env(),
slack_webhook: env::var("SLACK_WEBHOOK_URL").ok().filter(|s| !s.is_empty()),
}
}
pub fn mail(mut self, config: MailConfig) -> Self {
self.mail = Some(config);
self
}
pub fn slack_webhook(mut self, url: impl Into<String>) -> Self {
self.slack_webhook = Some(url.into());
self
}
}
impl MailConfig {
pub fn new(host: impl Into<String>, port: u16, from: impl Into<String>) -> Self {
Self {
driver: MailDriver::Smtp,
from: from.into(),
from_name: None,
smtp: Some(SmtpConfig {
host: host.into(),
port,
username: None,
password: None,
tls: true,
}),
resend: None,
}
}
pub fn resend(api_key: impl Into<String>, from: impl Into<String>) -> Self {
Self {
driver: MailDriver::Resend,
from: from.into(),
from_name: None,
smtp: None,
resend: Some(ResendConfig {
api_key: api_key.into(),
}),
}
}
pub fn from_env() -> Option<Self> {
let from = env::var("MAIL_FROM_ADDRESS")
.ok()
.filter(|s| !s.is_empty())?;
let from_name = env::var("MAIL_FROM_NAME").ok().filter(|s| !s.is_empty());
let driver_str = env::var("MAIL_DRIVER")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "smtp".into());
match driver_str.to_lowercase().as_str() {
"resend" => {
let api_key = env::var("RESEND_API_KEY").ok().filter(|s| !s.is_empty())?;
Some(Self {
driver: MailDriver::Resend,
from,
from_name,
smtp: None,
resend: Some(ResendConfig { api_key }),
})
}
_ => {
let host = env::var("MAIL_HOST").ok().filter(|s| !s.is_empty())?;
let port = env::var("MAIL_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587);
let username = env::var("MAIL_USERNAME").ok().filter(|s| !s.is_empty());
let password = env::var("MAIL_PASSWORD").ok().filter(|s| !s.is_empty());
let tls = env::var("MAIL_ENCRYPTION")
.map(|v| v.to_lowercase() != "none")
.unwrap_or(true);
Some(Self {
driver: MailDriver::Smtp,
from,
from_name,
smtp: Some(SmtpConfig {
host,
port,
username,
password,
tls,
}),
resend: None,
})
}
}
}
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
let smtp = self.smtp.get_or_insert(SmtpConfig {
host: String::new(),
port: 587,
username: None,
password: None,
tls: true,
});
smtp.username = Some(username.into());
smtp.password = Some(password.into());
self
}
pub fn from_name(mut self, name: impl Into<String>) -> Self {
self.from_name = Some(name.into());
self
}
pub fn no_tls(mut self) -> Self {
if let Some(ref mut smtp) = self.smtp {
smtp.tls = false;
}
self
}
}
#[derive(Serialize)]
struct ResendEmailPayload {
from: String,
to: Vec<String>,
subject: String,
#[serde(skip_serializing_if = "Option::is_none")]
html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cc: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
bcc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<String>,
}
pub struct NotificationDispatcher;
impl NotificationDispatcher {
pub fn configure(config: NotificationConfig) {
let _ = CONFIG.set(config);
}
pub fn config() -> Option<&'static NotificationConfig> {
CONFIG.get()
}
pub async fn send<N, T>(notifiable: &N, notification: T) -> Result<(), Error>
where
N: Notifiable + ?Sized,
T: Notification,
{
let channels = notification.via();
let notification_type = notification.notification_type();
info!(
notification = notification_type,
channels = ?channels,
"Dispatching notification"
);
for channel in channels {
match channel {
Channel::Mail => {
if let Some(mail) = notification.to_mail() {
Self::send_mail(notifiable, &mail).await?;
}
}
Channel::Database => {
if let Some(db_msg) = notification.to_database() {
Self::send_database(notifiable, &db_msg).await?;
}
}
Channel::Slack => {
if let Some(slack) = notification.to_slack() {
Self::send_slack(notifiable, &slack).await?;
}
}
Channel::Sms | Channel::Push => {
info!(channel = %channel, "Channel not implemented");
}
}
}
Ok(())
}
async fn send_mail<N: Notifiable + ?Sized>(
notifiable: &N,
message: &MailMessage,
) -> Result<(), Error> {
let to = notifiable
.route_notification_for(Channel::Mail)
.ok_or_else(|| Error::ChannelNotAvailable("No mail route configured".into()))?;
let config = CONFIG
.get()
.and_then(|c| c.mail.as_ref())
.ok_or_else(|| Error::ChannelNotAvailable("Mail not configured".into()))?;
info!(to = %to, subject = %message.subject, "Sending mail notification");
match config.driver {
MailDriver::Smtp => Self::send_mail_smtp(&to, message, config).await,
MailDriver::Resend => Self::send_mail_resend(&to, message, config).await,
}
}
async fn send_mail_smtp(
to: &str,
message: &MailMessage,
config: &MailConfig,
) -> Result<(), Error> {
let smtp = config
.smtp
.as_ref()
.ok_or_else(|| Error::mail("SMTP config missing for SMTP driver"))?;
use lettre::message::{header::ContentType, Mailbox};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
let from: Mailbox = if let Some(ref name) = config.from_name {
format!("{} <{}>", name, config.from)
.parse()
.map_err(|e| Error::mail(format!("Invalid from address: {e}")))?
} else {
config
.from
.parse()
.map_err(|e| Error::mail(format!("Invalid from address: {e}")))?
};
let to_mailbox: Mailbox = to
.parse()
.map_err(|e| Error::mail(format!("Invalid to address: {e}")))?;
let mut email_builder = Message::builder()
.from(from)
.to(to_mailbox)
.subject(&message.subject);
if let Some(ref reply_to) = message.reply_to {
let reply_to_mailbox: Mailbox = reply_to
.parse()
.map_err(|e| Error::mail(format!("Invalid reply-to address: {e}")))?;
email_builder = email_builder.reply_to(reply_to_mailbox);
}
for cc in &message.cc {
let cc_mailbox: Mailbox = cc
.parse()
.map_err(|e| Error::mail(format!("Invalid CC address: {e}")))?;
email_builder = email_builder.cc(cc_mailbox);
}
for bcc in &message.bcc {
let bcc_mailbox: Mailbox = bcc
.parse()
.map_err(|e| Error::mail(format!("Invalid BCC address: {e}")))?;
email_builder = email_builder.bcc(bcc_mailbox);
}
let email = if let Some(ref html) = message.html {
email_builder
.header(ContentType::TEXT_HTML)
.body(html.clone())
.map_err(|e| Error::mail(format!("Failed to build email: {e}")))?
} else {
email_builder
.header(ContentType::TEXT_PLAIN)
.body(message.body.clone())
.map_err(|e| Error::mail(format!("Failed to build email: {e}")))?
};
let transport = if smtp.tls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
.map_err(|e| Error::mail(format!("Failed to create transport: {e}")))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host)
};
let transport = transport.port(smtp.port);
let transport = if let (Some(ref user), Some(ref pass)) = (&smtp.username, &smtp.password) {
transport.credentials(Credentials::new(user.clone(), pass.clone()))
} else {
transport
};
let mailer = transport.build();
mailer
.send(email)
.await
.map_err(|e| Error::mail(format!("Failed to send email: {e}")))?;
info!(to = %to, "Mail notification sent via SMTP");
Ok(())
}
async fn send_mail_resend(
to: &str,
message: &MailMessage,
config: &MailConfig,
) -> Result<(), Error> {
let resend = config
.resend
.as_ref()
.ok_or_else(|| Error::mail("Resend config missing for Resend driver"))?;
let from = message.from.clone().unwrap_or_else(|| {
if let Some(ref name) = config.from_name {
format!("{} <{}>", name, config.from)
} else {
config.from.clone()
}
});
let payload = ResendEmailPayload {
from,
to: vec![to.to_string()],
subject: message.subject.clone(),
html: message.html.clone(),
text: if message.html.is_some() {
None
} else {
Some(message.body.clone())
},
cc: message.cc.clone(),
bcc: message.bcc.clone(),
reply_to: message.reply_to.clone(),
};
let client = reqwest::Client::new();
let response = client
.post("https://api.resend.com/emails")
.bearer_auth(&resend.api_key)
.json(&payload)
.send()
.await
.map_err(|e| Error::mail(format!("Resend HTTP request failed: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(status = %status, body = %body, "Resend API error");
return Err(Error::mail(format!("Resend API error {status}: {body}")));
}
info!(to = %to, "Mail notification sent via Resend");
Ok(())
}
async fn send_database<N: Notifiable + ?Sized>(
notifiable: &N,
message: &crate::channels::DatabaseMessage,
) -> Result<(), Error> {
let notifiable_id = notifiable.notifiable_id();
let notifiable_type = notifiable.notifiable_type();
info!(
notifiable_id = %notifiable_id,
notification_type = %message.notification_type,
"Storing database notification"
);
info!(
notifiable_id = %notifiable_id,
notifiable_type = %notifiable_type,
notification_type = %message.notification_type,
data = ?message.data,
"Database notification stored (placeholder)"
);
Ok(())
}
async fn send_slack<N: Notifiable + ?Sized>(
notifiable: &N,
message: &SlackMessage,
) -> Result<(), Error> {
let webhook_url = notifiable
.route_notification_for(Channel::Slack)
.or_else(|| CONFIG.get().and_then(|c| c.slack_webhook.clone()))
.ok_or_else(|| Error::ChannelNotAvailable("No Slack webhook configured".into()))?;
info!(channel = ?message.channel, "Sending Slack notification");
let client = reqwest::Client::new();
let response = client
.post(&webhook_url)
.json(message)
.send()
.await
.map_err(|e| Error::slack(format!("HTTP request failed: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(status = %status, body = %body, "Slack webhook failed");
return Err(Error::slack(format!("Slack returned {status}: {body}")));
}
info!("Slack notification sent");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn test_mail_config_smtp_builder() {
let config = MailConfig::new("smtp.example.com", 587, "noreply@example.com")
.credentials("user", "pass")
.from_name("My App");
assert!(matches!(config.driver, MailDriver::Smtp));
assert_eq!(config.from, "noreply@example.com");
assert_eq!(config.from_name, Some("My App".to_string()));
let smtp = config.smtp.as_ref().unwrap();
assert_eq!(smtp.host, "smtp.example.com");
assert_eq!(smtp.port, 587);
assert_eq!(smtp.username, Some("user".to_string()));
assert_eq!(smtp.password, Some("pass".to_string()));
assert!(smtp.tls);
assert!(config.resend.is_none());
}
#[test]
fn test_mail_config_resend_builder() {
let config = MailConfig::resend("re_123456", "noreply@example.com").from_name("My App");
assert!(matches!(config.driver, MailDriver::Resend));
assert_eq!(config.from, "noreply@example.com");
assert_eq!(config.from_name, Some("My App".to_string()));
let resend = config.resend.as_ref().unwrap();
assert_eq!(resend.api_key, "re_123456");
assert!(config.smtp.is_none());
}
#[test]
fn test_mail_config_no_tls() {
let config = MailConfig::new("smtp.example.com", 587, "noreply@example.com").no_tls();
let smtp = config.smtp.as_ref().unwrap();
assert!(!smtp.tls);
}
#[test]
fn test_notification_config_default() {
let config = NotificationConfig::default();
assert!(config.mail.is_none());
assert!(config.slack_webhook.is_none());
}
fn with_env_vars<F: FnOnce()>(vars: &[(&str, &str)], f: F) {
for (key, val) in vars {
unsafe { env::set_var(key, val) };
}
f();
for (key, _) in vars {
unsafe { env::remove_var(key) };
}
}
fn clean_mail_env() {
let keys = [
"MAIL_DRIVER",
"MAIL_FROM_ADDRESS",
"MAIL_FROM_NAME",
"MAIL_HOST",
"MAIL_PORT",
"MAIL_USERNAME",
"MAIL_PASSWORD",
"MAIL_ENCRYPTION",
"RESEND_API_KEY",
];
for key in keys {
unsafe { env::remove_var(key) };
}
}
#[test]
#[serial]
fn test_mail_config_smtp_from_env() {
clean_mail_env();
with_env_vars(
&[
("MAIL_FROM_ADDRESS", "noreply@example.com"),
("MAIL_FROM_NAME", "Test App"),
("MAIL_HOST", "smtp.example.com"),
("MAIL_PORT", "465"),
("MAIL_USERNAME", "user@example.com"),
("MAIL_PASSWORD", "secret"),
("MAIL_ENCRYPTION", "tls"),
],
|| {
let config = MailConfig::from_env().expect("should parse SMTP config");
assert!(matches!(config.driver, MailDriver::Smtp));
assert_eq!(config.from, "noreply@example.com");
assert_eq!(config.from_name, Some("Test App".to_string()));
let smtp = config.smtp.as_ref().expect("smtp config present");
assert_eq!(smtp.host, "smtp.example.com");
assert_eq!(smtp.port, 465);
assert_eq!(smtp.username, Some("user@example.com".to_string()));
assert_eq!(smtp.password, Some("secret".to_string()));
assert!(smtp.tls);
assert!(config.resend.is_none());
},
);
}
#[test]
#[serial]
fn test_mail_config_resend_from_env() {
clean_mail_env();
with_env_vars(
&[
("MAIL_DRIVER", "resend"),
("MAIL_FROM_ADDRESS", "noreply@example.com"),
("MAIL_FROM_NAME", "Test App"),
("RESEND_API_KEY", "re_test_123456"),
],
|| {
let config = MailConfig::from_env().expect("should parse Resend config");
assert!(matches!(config.driver, MailDriver::Resend));
assert_eq!(config.from, "noreply@example.com");
assert_eq!(config.from_name, Some("Test App".to_string()));
let resend = config.resend.as_ref().expect("resend config present");
assert_eq!(resend.api_key, "re_test_123456");
assert!(config.smtp.is_none());
},
);
}
#[test]
#[serial]
fn test_mail_config_default_driver() {
clean_mail_env();
with_env_vars(
&[
("MAIL_FROM_ADDRESS", "noreply@example.com"),
("MAIL_HOST", "smtp.example.com"),
],
|| {
let config = MailConfig::from_env().expect("should default to SMTP");
assert!(matches!(config.driver, MailDriver::Smtp));
assert_eq!(config.smtp.as_ref().unwrap().host, "smtp.example.com");
assert_eq!(config.smtp.as_ref().unwrap().port, 587); },
);
}
#[test]
#[serial]
fn test_mail_config_resend_missing_api_key() {
clean_mail_env();
with_env_vars(
&[
("MAIL_DRIVER", "resend"),
("MAIL_FROM_ADDRESS", "noreply@example.com"),
],
|| {
let config = MailConfig::from_env();
assert!(
config.is_none(),
"should return None when RESEND_API_KEY missing"
);
},
);
}
#[test]
fn test_resend_payload_serialization() {
let payload = ResendEmailPayload {
from: "sender@example.com".into(),
to: vec!["recipient@example.com".into()],
subject: "Test".into(),
html: Some("<p>Hello</p>".into()),
text: None,
cc: vec![],
bcc: vec![],
reply_to: None,
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["from"], "sender@example.com");
assert_eq!(json["to"][0], "recipient@example.com");
assert_eq!(json["subject"], "Test");
assert_eq!(json["html"], "<p>Hello</p>");
assert!(json.get("text").is_none());
assert!(json.get("cc").is_none());
assert!(json.get("bcc").is_none());
assert!(json.get("reply_to").is_none());
}
#[test]
fn test_resend_payload_text_fallback() {
let payload = ResendEmailPayload {
from: "sender@example.com".into(),
to: vec!["recipient@example.com".into()],
subject: "Test".into(),
html: None,
text: Some("Plain text body".into()),
cc: vec!["cc@example.com".into()],
bcc: vec!["bcc@example.com".into()],
reply_to: Some("reply@example.com".into()),
};
let json = serde_json::to_value(&payload).unwrap();
assert!(json.get("html").is_none());
assert_eq!(json["text"], "Plain text body");
assert_eq!(json["cc"][0], "cc@example.com");
assert_eq!(json["bcc"][0], "bcc@example.com");
assert_eq!(json["reply_to"], "reply@example.com");
}
}