use std::sync::{Arc, Mutex};
use rainier_mail::{Address, Mailer};
use rainier_support::{BoxFuture, Result};
use crate::notification::Delivery;
pub trait Channel: Send + Sync + 'static {
fn name(&self) -> &'static str;
fn send<'a>(&'a self, delivery: &'a Delivery) -> BoxFuture<'a, Result<()>>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LogChannel;
impl Channel for LogChannel {
fn name(&self) -> &'static str {
"log"
}
fn send<'a>(&'a self, delivery: &'a Delivery) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
tracing::info!(
notification = delivery.notification,
to = %format_args!("{} {}", delivery.recipient_type, delivery.recipient_id),
body = delivery.text().unwrap_or("(no text form)"),
"notification"
);
Ok(())
})
}
}
#[derive(Debug, Default)]
pub struct MemoryChannel {
sent: Mutex<Vec<Recorded>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Recorded {
pub notification: &'static str,
pub recipient_id: String,
pub recipient_type: &'static str,
pub text: Option<String>,
}
impl MemoryChannel {
pub fn new() -> Self {
Self::default()
}
pub fn sent(&self) -> Vec<Recorded> {
self.lock().clone()
}
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn sent_to(&self, notification: &str, recipient_id: &str) -> bool {
self.lock().iter().any(|r| r.notification == notification && r.recipient_id == recipient_id)
}
pub fn clear(&self) {
self.lock().clear();
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Recorded>> {
self.sent.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
impl Channel for MemoryChannel {
fn name(&self) -> &'static str {
"memory"
}
fn send<'a>(&'a self, delivery: &'a Delivery) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
self.lock().push(Recorded {
notification: delivery.notification,
recipient_id: delivery.recipient_id.clone(),
recipient_type: delivery.recipient_type,
text: delivery.text().map(str::to_string),
});
Ok(())
})
}
}
pub struct MailChannel {
mailer: Arc<Mailer>,
}
impl MailChannel {
pub fn new(mailer: Arc<Mailer>) -> Self {
Self { mailer }
}
}
impl Channel for MailChannel {
fn name(&self) -> &'static str {
"mail"
}
fn send<'a>(&'a self, delivery: &'a Delivery) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let Some(mailable) = delivery.mail() else {
return Err(delivery.nothing_to_send("mail", "email"));
};
let mut message = self.mailer.render(mailable)?;
if message.envelope.to.is_empty() {
let Some(route) = &delivery.route else {
return Err(delivery.no_route("mail"));
};
message.envelope.to.push(Address::new(route.clone()));
}
self.mailer.deliver(message).await?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::notification::{Channels, Notifiable, Notification};
use rainier_mail::{Envelope, Mailable, Message};
use rainier_view::{MemoryEngine, ViewEngine};
fn views() -> Arc<dyn ViewEngine> {
Arc::new(MemoryEngine::new())
}
struct User {
id: u64,
email: Option<String>,
}
impl Notifiable for User {
fn notifiable_id(&self) -> String {
self.id.to_string()
}
fn notifiable_type(&self) -> &'static str {
"User"
}
fn route_for(&self, channel: &str) -> Option<String> {
match channel {
"mail" => self.email.clone(),
_ => Some(self.id.to_string()),
}
}
}
struct Welcome;
impl Notification<User> for Welcome {
fn notification_name(&self) -> &'static str {
"user.welcome"
}
fn via(&self, _: &User) -> Channels {
Channels::new().with::<MemoryChannel>()
}
fn to_text(&self, _: &User) -> Option<String> {
Some("welcome".to_string())
}
fn to_mail(&self, _: &User) -> Option<Box<dyn Mailable>> {
let mut message = Message::new(Envelope::new("Welcome"));
message.text = Some("welcome".into());
Some(Box::new(message))
}
}
#[tokio::test]
async fn the_memory_channel_records_what_it_was_given() {
let channel = MemoryChannel::new();
let user = User { id: 7, email: None };
channel.send(&Delivery::render(&Welcome, &user)).await.unwrap();
assert_eq!(channel.len(), 1);
assert!(channel.sent_to("user.welcome", "7"));
assert_eq!(channel.sent()[0].text.as_deref(), Some("welcome"));
}
#[tokio::test]
async fn the_log_channel_delivers_nothing_and_succeeds() {
let user = User { id: 7, email: None };
assert!(LogChannel.send(&Delivery::render(&Welcome, &user)).await.is_ok());
}
#[tokio::test]
async fn the_mail_channel_addresses_from_the_recipient() {
let mailer =
Arc::new(Mailer::fake(views()).with_default_from(Address::new("noreply@example.com")));
let channel = MailChannel::new(Arc::clone(&mailer));
let user = User { id: 7, email: Some("ada@example.com".into()) };
let delivery = Delivery::render(&Welcome, &user).routed_for("mail", &user);
channel.send(&delivery).await.unwrap();
let sent = mailer.sent();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].envelope.to[0].email, "ada@example.com");
}
#[tokio::test]
async fn the_mail_channel_refuses_a_recipient_with_no_address() {
let mailer =
Arc::new(Mailer::fake(views()).with_default_from(Address::new("noreply@example.com")));
let channel = MailChannel::new(mailer);
let user = User { id: 7, email: None };
let delivery = Delivery::render(&Welcome, &user).routed_for("mail", &user);
let err = channel.send(&delivery).await.unwrap_err();
assert!(err.message().contains("no `mail` address"), "{}", err.message());
}
#[tokio::test]
async fn a_notification_can_send_a_view_backed_mailable() {
struct Templated;
impl rainier_mail::Mailable for Templated {
fn envelope(&self) -> Envelope {
Envelope::new("Your post is live")
}
fn content(&self) -> Result<rainier_mail::Content> {
rainier_mail::Content::view("mail.welcome", serde_json::json!({ "name": "Ada" }))
}
}
impl Notification<User> for Templated {
fn notification_name(&self) -> &'static str {
"test.templated"
}
fn via(&self, _: &User) -> Channels {
Channels::new().with::<MailChannel>()
}
fn to_mail(&self, _: &User) -> Option<Box<dyn Mailable>> {
Some(Box::new(Templated))
}
}
let engine: Arc<dyn ViewEngine> =
Arc::new(MemoryEngine::new().with("mail.welcome", "<p>Hi {{ name }}</p>"));
let mailer =
Arc::new(Mailer::fake(engine).with_default_from(Address::new("noreply@example.com")));
let channel = MailChannel::new(Arc::clone(&mailer));
let user = User { id: 7, email: Some("ada@example.com".into()) };
channel.send(&Delivery::render(&Templated, &user).routed_for("mail", &user)).await.unwrap();
let sent = mailer.sent();
assert_eq!(sent[0].html.as_deref(), Some("<p>Hi Ada</p>"));
assert_eq!(sent[0].envelope.to[0].email, "ada@example.com");
}
#[tokio::test]
async fn a_redirected_notification_records_who_it_was_really_for() {
let mailer = Arc::new(
Mailer::fake(views())
.with_default_from(Address::new("noreply@example.com"))
.always_to(Address::new("staging@example.com")),
);
let channel = MailChannel::new(Arc::clone(&mailer));
let user = User { id: 7, email: Some("ada@example.com".into()) };
channel.send(&Delivery::render(&Welcome, &user).routed_for("mail", &user)).await.unwrap();
let sent = mailer.sent();
assert_eq!(sent[0].envelope.to[0].email, "staging@example.com");
assert!(
sent[0].headers.iter().any(|(name, value)| name == rainier_mail::ORIGINAL_TO
&& value.contains("ada@example.com")),
"{:?}",
sent[0].headers
);
}
#[tokio::test]
async fn the_mail_channel_says_so_when_the_notification_wrote_no_email() {
struct DataOnly;
impl Notification<User> for DataOnly {
fn notification_name(&self) -> &'static str {
"test.data-only"
}
fn via(&self, _: &User) -> Channels {
Channels::new()
}
}
let mailer =
Arc::new(Mailer::fake(views()).with_default_from(Address::new("noreply@example.com")));
let channel = MailChannel::new(mailer);
let user = User { id: 7, email: Some("a@b.c".into()) };
let err = channel
.send(&Delivery::render(&DataOnly, &user).routed_for("mail", &user))
.await
.unwrap_err();
assert!(err.message().contains("no email"), "{}", err.message());
assert!(err.message().contains("test.data-only"), "{}", err.message());
}
}