use crate::channel::Channel;
use crate::channels::{DatabaseMessage, InAppMessage, MailMessage, SlackMessage, WhatsAppMessage};
use crate::notifiable::{DatabaseNotificationStore, Notifiable};
use crate::notification::Notification;
use crate::Error;
use serde::Serialize;
use std::env;
use std::sync::{Arc, OnceLock};
use tracing::{error, info, warn};
static CONFIG: OnceLock<NotificationConfig> = OnceLock::new();
#[derive(Clone, Default)]
pub struct NotificationConfig {
pub mail: Option<MailConfig>,
pub slack_webhook: Option<String>,
pub whatsapp_enabled: bool,
pub in_app: Option<InAppConfig>,
pub database_store: Option<Arc<dyn DatabaseNotificationStore>>,
}
#[derive(Clone)]
pub struct InAppConfig {
pub broker: Arc<ferro_broadcast::Broadcaster>,
pub store: Arc<dyn DatabaseNotificationStore>,
}
#[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()),
whatsapp_enabled: env::var("WHATSAPP_ENABLED")
.ok()
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false),
in_app: None,
database_store: None,
}
}
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
}
pub fn with_whatsapp_enabled(mut self, enabled: bool) -> Self {
self.whatsapp_enabled = enabled;
self
}
pub fn with_in_app(mut self, config: InAppConfig) -> Self {
self.in_app = Some(config);
self
}
pub fn with_database_store(mut self, store: Arc<dyn DatabaseNotificationStore>) -> Self {
self.database_store = Some(store);
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 {
if !matches!(self.driver, MailDriver::Smtp) {
warn!("MailConfig::credentials called on non-SMTP driver; ignoring");
return self;
}
let smtp = self.smtp.get_or_insert_with(|| 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 ResendAttachment {
filename: String,
content: String,
}
#[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>,
#[serde(skip_serializing_if = "Vec::is_empty")]
attachments: Vec<ResendAttachment>,
}
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::WhatsApp => {
if let Some(wa) = notification.to_whatsapp() {
Self::send_whatsapp(notifiable, &wa).await?;
}
}
Channel::InApp => {
if let Some(in_app) = notification.to_in_app() {
Self::send_in_app(notifiable, &in_app).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, Attachment, Mailbox, MultiPart, SinglePart};
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 message.attachments.is_empty() {
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}")))?
}
} else {
let body_part = if let Some(ref html) = message.html {
SinglePart::html(html.clone())
} else {
SinglePart::plain(message.body.clone())
};
let mut mp = MultiPart::mixed().singlepart(body_part);
for att in &message.attachments {
let ct = ContentType::parse(&att.content_type).map_err(|e| {
Error::mail(format!("Invalid content-type '{}': {e}", att.content_type))
})?;
let part = Attachment::new(att.filename.clone()).body(att.content.clone(), ct);
mp = mp.singlepart(part);
}
email_builder
.multipart(mp)
.map_err(|e| Error::mail(format!("Failed to build multipart 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()
}
});
use base64::Engine;
let attachments: Vec<ResendAttachment> = message
.attachments
.iter()
.map(|att| ResendAttachment {
filename: att.filename.clone(),
content: base64::engine::general_purpose::STANDARD.encode(&att.content),
})
.collect();
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(),
attachments,
};
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}")));
}
let resend_id = match response.json::<serde_json::Value>().await {
Ok(body) => body
.get("id")
.and_then(|v| v.as_str())
.map(str::to_owned)
.unwrap_or_else(|| "<no-id>".to_string()),
Err(e) => {
warn!(error = %e, "Resend response parse failed; continuing without id");
"<unparseable>".to_string()
}
};
info!(to = %to, resend_id = %resend_id, "Mail notification sent via Resend");
Ok(())
}
async fn send_database<N: Notifiable + ?Sized>(
notifiable: &N,
message: &DatabaseMessage,
) -> Result<(), Error> {
let notifiable_id = notifiable.notifiable_id();
let notifiable_type = notifiable.notifiable_type();
if let Some(store) = CONFIG.get().and_then(|c| c.database_store.as_ref()) {
store
.store(
¬ifiable_id,
notifiable_type,
&message.notification_type,
message,
)
.await?;
info!(
notifiable_id = %notifiable_id,
notification_type = %message.notification_type,
"Database notification stored"
);
} else {
warn!(
notifiable_id = %notifiable_id,
notifiable_type = %notifiable_type,
notification_type = %message.notification_type,
data = ?message.data,
"Database notification dropped — no store configured. \
Call NotificationConfig::with_database_store() at startup."
);
}
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(())
}
async fn send_whatsapp<N: Notifiable + ?Sized>(
notifiable: &N,
message: &WhatsAppMessage,
) -> Result<(), Error> {
let enabled = CONFIG.get().map(|c| c.whatsapp_enabled).unwrap_or(false);
if !enabled {
info!("WhatsApp channel not configured (WHATSAPP_ENABLED=false)");
return Ok(());
}
let phone = notifiable
.route_notification_for(Channel::WhatsApp)
.ok_or_else(|| Error::ChannelNotAvailable("No WhatsApp route configured".into()))?;
info!(to = %phone, "Sending WhatsApp notification");
let result = ferro_whatsapp::WhatsApp::send(&phone, message.message.clone()).await?;
info!(to = %phone, wamid = %result.wamid, "WhatsApp notification sent");
Ok(())
}
async fn send_in_app<N: Notifiable + ?Sized>(
notifiable: &N,
message: &InAppMessage,
) -> Result<(), Error> {
let cfg = match CONFIG.get().and_then(|c| c.in_app.as_ref()) {
Some(c) => c,
None => {
info!("InApp channel not configured");
return Ok(());
}
};
let notifiable_id = notifiable.notifiable_id();
let notifiable_type = notifiable.notifiable_type();
let db_msg = inapp_to_database_message(message);
cfg.store
.store(
¬ifiable_id,
notifiable_type,
&message.notification_type,
&db_msg,
)
.await?;
let channel = format!("user.{notifiable_id}");
let event = format!("Notification.{}", message.notification_type);
cfg.broker
.broadcast(&channel, &event, &message.data)
.await
.map_err(|e| Error::broadcast(e.to_string()))?;
info!(
notifiable_id = %notifiable_id,
notification_type = %message.notification_type,
"InApp notification persisted and broadcast"
);
Ok(())
}
}
fn inapp_to_database_message(msg: &InAppMessage) -> DatabaseMessage {
use std::collections::HashMap;
let data: HashMap<String, serde_json::Value> = if let serde_json::Value::Object(map) = &msg.data
{
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
} else {
let mut m = HashMap::new();
m.insert("payload".to_string(), msg.data.clone());
m
};
DatabaseMessage::new(&msg.notification_type).with_data(data)
}
#[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());
assert!(!config.whatsapp_enabled);
assert!(config.in_app.is_none());
assert!(config.database_store.is_none());
}
#[test]
fn test_notification_config_with_database_store_builder() {
use crate::channels::DatabaseMessage;
use crate::notifiable::DatabaseNotificationStore;
use async_trait::async_trait;
struct NoopStore;
#[async_trait]
impl DatabaseNotificationStore for NoopStore {
async fn store(
&self,
_: &str,
_: &str,
_: &str,
_: &DatabaseMessage,
) -> Result<(), Error> {
Ok(())
}
async fn mark_as_read(&self, _: &str) -> Result<(), Error> {
Ok(())
}
async fn unread(&self, _: &str) -> Result<Vec<crate::StoredNotification>, Error> {
Ok(vec![])
}
}
let store: Arc<dyn DatabaseNotificationStore> = Arc::new(NoopStore);
let config = NotificationConfig::new().with_database_store(store);
assert!(config.database_store.is_some());
}
#[test]
#[serial]
fn test_notification_config_whatsapp_enabled_from_env() {
unsafe { env::remove_var("WHATSAPP_ENABLED") };
with_env_vars(&[("WHATSAPP_ENABLED", "true")], || {
let config = NotificationConfig::from_env();
assert!(config.whatsapp_enabled);
});
}
#[test]
#[serial]
fn test_notification_config_whatsapp_disabled_when_env_false() {
unsafe { env::remove_var("WHATSAPP_ENABLED") };
with_env_vars(&[("WHATSAPP_ENABLED", "false")], || {
let config = NotificationConfig::from_env();
assert!(!config.whatsapp_enabled);
});
}
#[test]
#[serial]
fn test_notification_config_whatsapp_disabled_when_env_unset() {
unsafe { env::remove_var("WHATSAPP_ENABLED") };
let config = NotificationConfig::from_env();
assert!(!config.whatsapp_enabled);
}
#[test]
#[serial]
fn test_notification_config_whatsapp_disabled_when_env_garbage() {
unsafe { env::remove_var("WHATSAPP_ENABLED") };
with_env_vars(&[("WHATSAPP_ENABLED", "yes-please")], || {
let config = NotificationConfig::from_env();
assert!(
!config.whatsapp_enabled,
"non-bool string must fall back to false"
);
});
}
#[test]
fn test_notification_config_with_whatsapp_enabled_builder() {
let config = NotificationConfig::new().with_whatsapp_enabled(true);
assert!(config.whatsapp_enabled);
let config2 = NotificationConfig::new().with_whatsapp_enabled(false);
assert!(!config2.whatsapp_enabled);
}
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,
attachments: vec![],
};
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());
assert!(json.get("attachments").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()),
attachments: vec![],
};
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");
}
#[test]
fn test_resend_payload_no_attachments_omits_field() {
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,
attachments: vec![],
};
let json = serde_json::to_value(&payload).unwrap();
assert!(
json.get("attachments").is_none(),
"Empty attachments must not appear in serialized payload (byte-identical-to-today guarantee)"
);
}
#[test]
fn test_resend_payload_with_attachments_serializes_base64() {
let payload = ResendEmailPayload {
from: "sender@example.com".into(),
to: vec!["recipient@example.com".into()],
subject: "Test".into(),
html: None,
text: Some("body".into()),
cc: vec![],
bcc: vec![],
reply_to: None,
attachments: vec![ResendAttachment {
filename: "hi.txt".into(),
content: "aGVsbG8=".into(),
}],
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["attachments"][0]["filename"], "hi.txt");
assert_eq!(json["attachments"][0]["content"], "aGVsbG8=");
assert_eq!(json["attachments"].as_array().unwrap().len(), 1);
}
#[test]
fn test_base64_encoding_uses_standard_alphabet() {
use base64::Engine;
let encoded =
base64::engine::general_purpose::STANDARD.encode(b"Many hands make light work.");
assert_eq!(encoded, "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu");
}
#[test]
fn test_send_whatsapp_disabled_returns_ok_without_calling_init() {
let config = NotificationConfig::default();
assert!(
!config.whatsapp_enabled,
"Default whatsapp_enabled must be false so dispatch path is gated"
);
}
#[test]
fn test_smtp_multipart_path_compiles_with_attachment() {
use crate::channels::MailMessage;
let mail = MailMessage::new()
.subject("Test")
.body("Hello")
.attachment("test.txt", "text/plain", b"hello".to_vec())
.expect("under-limit attachment must succeed");
assert_eq!(mail.attachments.len(), 1);
assert_eq!(mail.attachments[0].content_type, "text/plain");
}
#[test]
fn test_inapp_to_database_message_object_data_flattens() {
use crate::channels::{InAppMessage, InAppSeverity};
let msg = InAppMessage::new("OrderShipped")
.data(serde_json::json!({"order_id": 42, "tracking": "ABC"}))
.severity(InAppSeverity::Success);
let db = inapp_to_database_message(&msg);
assert_eq!(db.notification_type, "OrderShipped");
assert_eq!(db.get("order_id"), Some(&serde_json::json!(42)));
assert_eq!(db.get("tracking"), Some(&serde_json::json!("ABC")));
assert!(
db.get("payload").is_none(),
"object data must NOT be wrapped under 'payload'"
);
}
#[test]
fn test_inapp_to_database_message_non_object_wraps_under_payload() {
use crate::channels::InAppMessage;
let msg = InAppMessage::new("Heartbeat").data(serde_json::json!("ping"));
let db = inapp_to_database_message(&msg);
assert_eq!(db.notification_type, "Heartbeat");
assert_eq!(db.get("payload"), Some(&serde_json::json!("ping")));
}
#[tokio::test]
async fn test_send_database_calls_store_when_configured() {
use crate::channels::DatabaseMessage;
use crate::notifiable::DatabaseNotificationStore;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingStore {
calls: AtomicUsize,
}
#[async_trait]
impl DatabaseNotificationStore for CountingStore {
async fn store(
&self,
_: &str,
_: &str,
_: &str,
_: &DatabaseMessage,
) -> Result<(), Error> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn mark_as_read(&self, _: &str) -> Result<(), Error> {
Ok(())
}
async fn unread(&self, _: &str) -> Result<Vec<crate::StoredNotification>, Error> {
Ok(vec![])
}
}
let store = Arc::new(CountingStore {
calls: AtomicUsize::new(0),
});
let msg = DatabaseMessage::new("Test").data("k", "v");
store
.store("user_id", "User", &msg.notification_type, &msg)
.await
.unwrap();
assert_eq!(store.calls.load(Ordering::SeqCst), 1);
}
}