use std::fmt;
use crate::mail::{Email, EmailError, Mail, Mailable, lettre::Message};
#[cfg(feature = "notifications-broadcast")]
use super::broadcast::BroadcastNotifications;
use super::channel::{Channel, NotificationError};
use super::notification::{BroadcastContent, DatabaseContent, MailContent, Notification};
#[cfg(feature = "notifications-queue")]
use super::queue::{NotificationQueue, QueuedMail};
use super::recipient::Notifiable;
#[cfg(feature = "notifications-db")]
use super::store::DatabaseNotifications;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct Delivery {
channels: Vec<Channel>,
queued: Vec<Channel>,
}
impl Delivery {
#[must_use]
pub fn channels(&self) -> &[Channel] {
&self.channels
}
#[must_use]
pub fn reached(&self, channel: Channel) -> bool {
self.channels.contains(&channel)
}
#[must_use]
pub fn queued(&self) -> &[Channel] {
&self.queued
}
#[must_use]
pub fn is_queued(&self, channel: Channel) -> bool {
self.queued.contains(&channel)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.channels.is_empty() && self.queued.is_empty()
}
}
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct Notifier {
mail: Option<Mail>,
#[cfg(feature = "notifications-db")]
database: Option<DatabaseNotifications>,
#[cfg(feature = "notifications-broadcast")]
broadcast: Option<BroadcastNotifications>,
#[cfg(feature = "notifications-queue")]
queue: Option<NotificationQueue>,
}
impl fmt::Debug for Notifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut out = f.debug_struct("Notifier");
out.field("mail", &self.mail.is_some());
#[cfg(feature = "notifications-db")]
out.field("database", &self.database.is_some());
#[cfg(feature = "notifications-broadcast")]
out.field("broadcast", &self.broadcast.is_some());
#[cfg(feature = "notifications-queue")]
out.field("queue", &self.queue.is_some());
out.finish()
}
}
impl Notifier {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_mail(mut self, mail: Mail) -> Self {
self.mail = Some(mail);
self
}
#[must_use]
pub fn has_mail(&self) -> bool {
self.mail.is_some()
}
#[cfg(feature = "notifications-db")]
#[must_use]
pub fn with_database(mut self, database: DatabaseNotifications) -> Self {
self.database = Some(database);
self
}
#[cfg(feature = "notifications-db")]
#[must_use]
pub fn has_database(&self) -> bool {
self.database.is_some()
}
#[cfg(feature = "notifications-broadcast")]
#[must_use]
pub fn with_broadcast(mut self, broadcast: BroadcastNotifications) -> Self {
self.broadcast = Some(broadcast);
self
}
#[cfg(feature = "notifications-broadcast")]
#[must_use]
pub fn has_broadcast(&self) -> bool {
self.broadcast.is_some()
}
#[cfg(feature = "notifications-queue")]
#[must_use]
pub fn with_queue(mut self, queue: NotificationQueue) -> Self {
self.queue = Some(queue);
self
}
#[cfg(feature = "notifications-queue")]
#[must_use]
pub fn has_queue(&self) -> bool {
self.queue.is_some()
}
pub async fn send<N>(
&self,
to: &impl Notifiable,
notification: &N,
) -> Result<Delivery, NotificationError>
where
N: Notification + ?Sized,
{
let recipient = to.recipient();
let mut channels = Vec::new();
if let Some(content) = notification.to_database(&recipient) {
self.deliver_database(recipient.key(), &content).await?;
channels.push(Channel::Database);
}
if let Some(content) = notification.to_broadcast(&recipient)
&& self.deliver_broadcast(recipient.key(), &content)? > 0
{
channels.push(Channel::Broadcast);
}
if let Some(content) = notification.to_mail(&recipient) {
let mail = self.mail.as_ref().ok_or(NotificationError::NotConfigured {
channel: Channel::Mail,
})?;
let address =
recipient
.email_address()
.ok_or_else(|| NotificationError::NoAddress {
key: recipient.key().to_owned(),
})?;
mail.to(address).send(&AsMailable(&content)).await?;
channels.push(Channel::Mail);
}
Ok(Delivery {
channels,
queued: Vec::new(),
})
}
#[cfg(feature = "notifications-queue")]
pub async fn queue<N>(
&self,
to: &impl Notifiable,
notification: &N,
) -> Result<Delivery, NotificationError>
where
N: Notification + ?Sized,
{
let recipient = to.recipient();
let mut channels = Vec::new();
let mut queued = Vec::new();
if let Some(content) = notification.to_database(&recipient) {
self.deliver_database(recipient.key(), &content).await?;
channels.push(Channel::Database);
}
if let Some(content) = notification.to_broadcast(&recipient)
&& self.deliver_broadcast(recipient.key(), &content)? > 0
{
channels.push(Channel::Broadcast);
}
if let Some(content) = notification.to_mail(&recipient) {
let queue = self
.queue
.as_ref()
.ok_or(NotificationError::QueueNotConfigured)?;
let address =
recipient
.email_address()
.ok_or_else(|| NotificationError::NoAddress {
key: recipient.key().to_owned(),
})?;
queue.enqueue(&QueuedMail::new(address, &content)).await?;
queued.push(Channel::Mail);
}
Ok(Delivery { channels, queued })
}
#[cfg(feature = "notifications-db")]
async fn deliver_database(
&self,
key: &str,
content: &DatabaseContent,
) -> Result<(), NotificationError> {
let database = self
.database
.as_ref()
.ok_or(NotificationError::NotConfigured {
channel: Channel::Database,
})?;
database.store(key, content).await?;
Ok(())
}
#[cfg(not(feature = "notifications-db"))]
#[expect(
clippy::unused_async,
reason = "matches the feature-on signature, which awaits the database"
)]
async fn deliver_database(
&self,
key: &str,
content: &DatabaseContent,
) -> Result<(), NotificationError> {
let _ = (key, content);
Err(NotificationError::NotConfigured {
channel: Channel::Database,
})
}
#[cfg(feature = "notifications-broadcast")]
fn deliver_broadcast(
&self,
key: &str,
content: &BroadcastContent,
) -> Result<usize, NotificationError> {
let broadcast = self
.broadcast
.as_ref()
.ok_or(NotificationError::NotConfigured {
channel: Channel::Broadcast,
})?;
broadcast.push(key, content)
}
#[cfg(not(feature = "notifications-broadcast"))]
fn deliver_broadcast(
&self,
key: &str,
content: &BroadcastContent,
) -> Result<usize, NotificationError> {
let _ = (key, content);
Err(NotificationError::NotConfigured {
channel: Channel::Broadcast,
})
}
}
pub(super) struct AsMailable<'a>(pub(super) &'a MailContent);
impl Mailable for AsMailable<'_> {
fn build(&self, email: Email) -> Result<Message, EmailError> {
let email = email.subject(self.0.subject());
match self.0.html_body() {
Some(html) => email.alternative(self.0.text(), html),
None => email.plain(self.0.text()),
}
}
}
#[cfg(test)]
mod tests {
use super::super::recipient::Recipient;
use super::*;
use crate::mail::Mailer;
struct Mails;
impl Notification for Mails {
fn to_mail(&self, _recipient: &Recipient) -> Option<MailContent> {
Some(MailContent::new("subject", "body"))
}
}
struct Filed;
impl Notification for Filed {
fn to_mail(&self, _recipient: &Recipient) -> Option<MailContent> {
Some(MailContent::new("subject", "body"))
}
fn to_database(&self, _recipient: &Recipient) -> Option<DatabaseContent> {
Some(DatabaseContent::new("filed", serde_json::json!({})))
}
}
struct Pushed;
impl Notification for Pushed {
fn to_mail(&self, _recipient: &Recipient) -> Option<MailContent> {
Some(MailContent::new("subject", "body"))
}
fn to_broadcast(&self, _recipient: &Recipient) -> Option<BroadcastContent> {
Some(BroadcastContent::new("pushed", serde_json::json!({})))
}
}
struct Silent;
impl Notification for Silent {}
fn wired() -> (Mailer, Notifier) {
let mailer = Mailer::capture_ok();
let mail = Mail::new(mailer.clone(), "noreply@example.com".parse().unwrap());
(mailer, Notifier::new().with_mail(mail))
}
#[tokio::test]
async fn a_mail_notification_reaches_the_transport() {
let (mailer, notifier) = wired();
let ada = Recipient::new("user:1").email("ada@example.com");
let delivery = notifier.send(&ada, &Mails).await.unwrap();
assert!(delivery.reached(Channel::Mail));
assert_eq!(delivery.channels(), [Channel::Mail]);
assert_eq!(mailer.captured().await.unwrap().len(), 1);
}
#[cfg(feature = "notifications-queue")]
#[tokio::test]
async fn queueing_mail_with_no_queue_is_an_error_and_not_an_inline_send() {
let (mailer, notifier) = wired();
let ada = Recipient::new("user:1").email("ada@example.com");
let error = notifier.queue(&ada, &Mails).await.unwrap_err();
assert!(
matches!(error, NotificationError::QueueNotConfigured),
"got {error:?}"
);
assert!(
mailer.captured().await.unwrap().is_empty(),
"the mail must not have gone out inline instead"
);
}
#[cfg(feature = "notifications-queue")]
#[tokio::test]
async fn a_queued_channel_is_reported_as_queued_and_not_as_reached() {
let queued = Delivery {
channels: Vec::new(),
queued: vec![Channel::Mail],
};
assert!(queued.is_queued(Channel::Mail));
assert!(!queued.reached(Channel::Mail));
assert_eq!(queued.channels(), []);
assert!(
!queued.is_empty(),
"a job row waiting to run is not nothing"
);
}
#[tokio::test]
async fn a_notification_with_no_content_delivers_nothing_and_says_so() {
let (mailer, notifier) = wired();
let ada = Recipient::new("user:1").email("ada@example.com");
let delivery = notifier.send(&ada, &Silent).await.unwrap();
assert!(delivery.is_empty());
assert!(mailer.captured().await.unwrap().is_empty());
}
#[tokio::test]
async fn an_unconfigured_channel_is_an_error_and_not_a_skip() {
let ada = Recipient::new("user:1").email("ada@example.com");
let error = Notifier::new().send(&ada, &Mails).await.unwrap_err();
assert!(
matches!(
error,
NotificationError::NotConfigured {
channel: Channel::Mail
}
),
"got {error:?}"
);
}
#[tokio::test]
async fn wanting_mail_for_someone_with_no_address_is_an_error() {
let (mailer, notifier) = wired();
let error = notifier
.send(&Recipient::new("user:7"), &Mails)
.await
.unwrap_err();
match error {
NotificationError::NoAddress { key } => assert_eq!(key, "user:7"),
other => panic!("got {other:?}"),
}
assert!(mailer.captured().await.unwrap().is_empty());
}
#[tokio::test]
async fn a_transport_failure_is_reported_rather_than_swallowed() {
let mail = Mail::new(
Mailer::capture_error(),
"noreply@example.com".parse().unwrap(),
);
let notifier = Notifier::new().with_mail(mail);
let ada = Recipient::new("user:1").email("ada@example.com");
let error = notifier.send(&ada, &Mails).await.unwrap_err();
assert!(
matches!(error, NotificationError::Mail { .. }),
"got {error:?}"
);
}
#[tokio::test]
async fn an_invalid_recipient_address_does_not_panic() {
let (_mailer, notifier) = wired();
let broken = Recipient::new("user:1").email("not an address");
let error = notifier.send(&broken, &Mails).await.unwrap_err();
assert!(
matches!(error, NotificationError::Mail { .. }),
"got {error:?}"
);
}
#[tokio::test]
async fn an_inbox_notification_without_a_store_is_an_error_and_not_a_skip() {
let (mailer, notifier) = wired();
let ada = Recipient::new("user:1").email("ada@example.com");
let error = notifier.send(&ada, &Filed).await.unwrap_err();
assert!(
matches!(
error,
NotificationError::NotConfigured {
channel: Channel::Database
}
),
"got {error:?}"
);
assert!(mailer.captured().await.unwrap().is_empty());
}
#[tokio::test]
async fn a_broadcast_notification_without_a_channel_source_is_an_error_and_not_a_skip() {
let (mailer, notifier) = wired();
let ada = Recipient::new("user:1").email("ada@example.com");
let error = notifier.send(&ada, &Pushed).await.unwrap_err();
assert!(
matches!(
error,
NotificationError::NotConfigured {
channel: Channel::Broadcast
}
),
"got {error:?}"
);
assert!(mailer.captured().await.unwrap().is_empty());
}
#[cfg(feature = "notifications-broadcast")]
#[tokio::test]
async fn an_unconnected_recipient_is_not_a_delivery_and_not_a_failure() {
use super::super::broadcast::{BroadcastNotifications, PerRecipientChannels};
let (mailer, notifier) = wired();
let channels = PerRecipientChannels::new(8).unwrap();
let notifier = notifier.with_broadcast(BroadcastNotifications::new(channels.clone()));
let ada = Recipient::new("user:1").email("ada@example.com");
let delivery = notifier.send(&ada, &Pushed).await.unwrap();
assert_eq!(delivery.channels(), [Channel::Mail]);
let _connection = channels.subscribe("user:1");
let delivery = notifier.send(&ada, &Pushed).await.unwrap();
assert_eq!(delivery.channels(), [Channel::Broadcast, Channel::Mail]);
assert_eq!(mailer.captured().await.unwrap().len(), 2);
}
#[test]
fn debug_does_not_print_the_mailer() {
let (_mailer, notifier) = wired();
let rendered = format!("{notifier:?}");
let mut fields = vec!["mail: true"];
if cfg!(feature = "notifications-db") {
fields.push("database: false");
}
if cfg!(feature = "notifications-broadcast") {
fields.push("broadcast: false");
}
if cfg!(feature = "notifications-queue") {
fields.push("queue: false");
}
assert_eq!(rendered, format!("Notifier {{ {} }}", fields.join(", ")));
assert!(!rendered.contains("noreply@example.com"), "{rendered}");
}
#[test]
fn a_fresh_notifier_has_no_channels() {
assert!(!Notifier::new().has_mail());
#[cfg(feature = "notifications-db")]
assert!(!Notifier::new().has_database());
#[cfg(feature = "notifications-broadcast")]
assert!(!Notifier::new().has_broadcast());
assert!(Delivery::default().is_empty());
}
}