use std::sync::{Arc, OnceLock};
use std::time::Duration;
use cratefield_core::{
Database, DbError, Kid, MailError, Message as Mail, ModuleConfig, ModuleContext, Notification,
Outbox, OutboxRecord, Payload, Push, PushError, PushOutcome, Scope, SendOutcome, Statement,
};
use cratefield_i18n::{Catalog, LanguageIdentifier, direction};
use futures_util::StreamExt as _;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::clock::{now_iso, plus_secs};
use crate::locale::{self, Localised};
use crate::message::{Localizable, Message};
use crate::store::{self, Channels, DeadLetterReason, Subscription, Transport};
use crate::{Category, Settings};
pub const TOPIC_SEND: &str = "notifications.send";
pub(crate) const TOPIC_EMAIL: &str = "notifications.email";
pub const EVENT_REQUESTED: &str = "notifications.requested";
pub const EVENT_SUBSCRIPTION_PRUNED: &str = "notifications.subscription_pruned";
pub const EVENT_SUBSCRIPTION_REHOMED: &str = "notifications.subscription_rehomed";
const BACKOFF_BASE_SECS: u64 = 30;
const BACKOFF_MAX_SECS: u64 = 3_600;
pub(crate) const LEASE_SECS: i64 = 300;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct SendJob {
pub notification_id: String,
pub account_id: String,
pub category: String,
pub subscription_id: String,
pub notification: Notification,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub localizable: Option<Localizable>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct EmailJob {
pub notification_id: String,
pub account_id: String,
pub category: String,
pub notification: Notification,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub localizable: Option<Localizable>,
}
type Room = (String, Vec<u8>);
#[derive(Clone, Copy)]
struct InboxWrite<'a> {
account_id: &'a str,
notification_id: &'a str,
category: &'a Category,
notification: &'a Notification,
localizable: Option<&'a Localizable>,
locale: Option<&'a str>,
now: &'a str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Skipped {
PreferenceOff,
NoSubscriptions,
}
#[derive(Debug, Clone)]
pub struct Enqueued {
notification_id: String,
statements: Vec<Statement>,
devices: usize,
inbox: bool,
announcement: Option<(String, Vec<u8>)>,
skipped: Option<Skipped>,
category: String,
missing: Vec<crate::locale::MissingTranslation>,
}
impl Enqueued {
#[must_use]
pub fn statements(&self) -> &[Statement] {
&self.statements
}
#[must_use]
pub fn into_statements(self) -> Vec<Statement> {
self.statements
}
#[must_use]
pub fn notification_id(&self) -> &str {
&self.notification_id
}
#[must_use]
pub fn len(&self) -> usize {
self.devices
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.devices == 0
}
#[must_use]
pub fn wrote_inbox(&self) -> bool {
self.inbox
}
#[must_use]
pub fn skipped(&self) -> Option<Skipped> {
self.skipped
}
#[must_use]
pub fn has_missing_translations(&self) -> bool {
!self.missing.is_empty()
}
}
#[derive(Debug)]
pub enum NotifyError {
UnknownCategory(String),
InvalidData,
NotMounted,
MissingPort(&'static str),
NoCatalog(String),
Database(DbError),
}
impl std::fmt::Display for NotifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NotifyError::UnknownCategory(category) => write!(
f,
"notifications: category {category:?} is not declared by this venture"
),
NotifyError::InvalidData => f.write_str(
"notifications: Notification::data must be a JSON object or absent, because the \
module adds `category` and `notification_id` to it",
),
NotifyError::NotMounted => f.write_str(
"notifications: the module's router has not been built, so it has no ports yet",
),
NotifyError::MissingPort(port) => {
write!(f, "notifications: this deployment provides no {port} port")
}
NotifyError::NoCatalog(key) => write!(
f,
"notifications: message {key:?} is localizable, but this venture configured no \
catalog — call `.catalog(..)` on the module, or send a rendered Notification"
),
NotifyError::Database(err) => write!(f, "notifications: {err}"),
}
}
}
impl std::error::Error for NotifyError {}
impl From<DbError> for NotifyError {
fn from(err: DbError) -> Self {
NotifyError::Database(err)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DrainReport {
pub claimed: usize,
pub delivered: usize,
pub retried: usize,
pub pruned: usize,
pub dead_lettered: usize,
pub dropped: usize,
pub failed: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
Delivered,
Retried,
Pruned,
DeadLettered,
Dropped,
}
impl DrainReport {
fn record(&mut self, outcome: Outcome) {
let counter = match outcome {
Outcome::Delivered => &mut self.delivered,
Outcome::Retried => &mut self.retried,
Outcome::Pruned => &mut self.pruned,
Outcome::DeadLettered => &mut self.dead_lettered,
Outcome::Dropped => &mut self.dropped,
};
*counter += 1;
}
}
#[derive(Clone)]
pub struct Notifier {
pub(crate) cell: Arc<OnceLock<Arc<ModuleContext>>>,
pub(crate) settings_cell: Arc<OnceLock<Settings>>,
pub(crate) settings_at_handout: Settings,
}
impl std::fmt::Debug for Notifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Notifier")
.field("mounted", &self.cell.get().is_some())
.field("categories", &self.settings().categories.len())
.finish_non_exhaustive()
}
}
impl Notifier {
fn ctx(&self) -> Result<&Arc<ModuleContext>, NotifyError> {
self.cell.get().ok_or(NotifyError::NotMounted)
}
pub(crate) fn settings(&self) -> &Settings {
self.settings_cell
.get()
.unwrap_or(&self.settings_at_handout)
}
fn category(&self, name: &str) -> Result<&Category, NotifyError> {
self.settings()
.categories
.iter()
.find(|category| category.name == name)
.ok_or_else(|| NotifyError::UnknownCategory(name.to_owned()))
}
fn now(&self) -> String {
now_iso(self.cell.get().and_then(|ctx| ctx.ports.clock.as_ref()))
}
fn new_id(&self) -> String {
crate::clock::new_id(self.cell.get().and_then(|ctx| ctx.ports.id_gen.as_ref()))
}
#[must_use]
pub fn context(&self) -> Option<&Arc<ModuleContext>> {
self.cell.get()
}
fn max_attempts(&self, ctx: &ModuleContext) -> i64 {
let configured = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config)
.get_u32("MAX_ATTEMPTS", self.settings().max_attempts);
i64::from(configured.max(1))
}
fn drain_batch(&self, ctx: &ModuleContext) -> u64 {
let settings = self.settings();
let configured = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config).get_u32(
"DRAIN_BATCH",
u32::try_from(settings.drain_batch).unwrap_or(u32::MAX),
);
u64::from(configured).max(1)
}
fn drain_concurrency(&self, ctx: &ModuleContext) -> usize {
let configured = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config)
.get_u32("DRAIN_CONCURRENCY", self.settings().drain_concurrency);
usize::try_from(configured.max(1)).unwrap_or(1)
}
async fn in_app_allowed(
&self,
db: &dyn Database,
account_id: &str,
category: &Category,
) -> Result<bool, DbError> {
if !category.defaults.in_app {
return Ok(false);
}
Ok(store::preference(db, account_id, &category.name)
.await?
.is_none_or(|channels: Channels| channels.in_app))
}
#[allow(
clippy::too_many_arguments,
reason = "one row's worth of columns, named rather than bundled"
)]
fn push_row(
&self,
account_id: &str,
notification_id: &str,
category: &Category,
notification: &Notification,
localizable: Option<&Localizable>,
subscription_id: &str,
now: &str,
) -> Result<Statement, NotifyError> {
let job = SendJob {
notification_id: notification_id.to_owned(),
account_id: account_id.to_owned(),
category: category.name.clone(),
subscription_id: subscription_id.to_owned(),
notification: notification.clone(),
localizable: localizable.cloned(),
};
let payload = serde_json::to_string(&job).map_err(|err| {
NotifyError::Database(DbError::Execute(format!(
"notification does not serialise: {err}"
)))
})?;
Ok(Outbox::new(store::OUTBOX).enqueue_statement(
&self.new_id(),
TOPIC_SEND,
&payload,
Some(account_id),
now,
))
}
fn email_row(
&self,
account_id: &str,
notification_id: &str,
category: &Category,
notification: &Notification,
localizable: Option<&Localizable>,
now: &str,
) -> Result<Statement, NotifyError> {
let job = EmailJob {
notification_id: notification_id.to_owned(),
account_id: account_id.to_owned(),
category: category.name.clone(),
notification: notification.clone(),
localizable: localizable.cloned(),
};
let payload = serde_json::to_string(&job).map_err(|err| {
NotifyError::Database(DbError::Execute(format!(
"notification does not serialise: {err}"
)))
})?;
Ok(Outbox::new(store::OUTBOX).enqueue_statement(
&self.new_id(),
TOPIC_EMAIL,
&payload,
Some(account_id),
now,
))
}
async fn email_allowed(
&self,
db: &dyn Database,
account_id: &str,
category: &Category,
) -> Result<bool, DbError> {
if !category.defaults.email {
return Ok(false);
}
let wanted = store::preference(db, account_id, &category.name)
.await?
.is_none_or(|channels: Channels| channels.email);
if !wanted {
return Ok(false);
}
Ok(store::email_target(db, account_id)
.await?
.is_some_and(|target| target.mailable()))
}
fn inbox_row(&self, write: &InboxWrite<'_>) -> Result<(Statement, Room), NotifyError> {
let InboxWrite {
account_id,
notification_id,
category,
notification,
localizable,
locale,
now,
} = *write;
let serialisation = |err: serde_json::Error| {
NotifyError::Database(DbError::Execute(format!(
"notification does not serialise: {err}"
)))
};
let inbox_id = self.new_id();
let announcement = (
format!("{INBOX_ROOM_PREFIX}{account_id}"),
serde_json::to_vec(&json!({
"type": "notification",
"id": inbox_id,
"notification_id": notification_id,
"category": category.name,
"title": notification.title,
"body": notification.body,
"url": notification.url,
"locale": locale,
"dir": locale
.and_then(cratefield_i18n::parse_locale)
.map(|locale| direction(&locale).as_str()),
"created_at": now,
}))
.map_err(serialisation)?,
);
let loc_args = localizable
.filter(|message| !message.args.is_empty())
.map(|message| serde_json::Value::Object(message.args.clone()).to_string());
let statement = store::insert_inbox_statement(&store::NewInboxRow {
id: &inbox_id,
account_id,
notification_id,
category: &category.name,
title: ¬ification.title,
body: ¬ification.body,
url: notification.url.as_deref(),
icon: notification.icon.as_deref(),
data_json: &serde_json::to_string(¬ification.data).map_err(serialisation)?,
locale,
loc_key: localizable.map(|message| message.key.as_str()),
loc_args_json: loc_args.as_deref(),
now,
});
Ok((statement, announcement))
}
async fn push_allowed(
&self,
db: &dyn Database,
account_id: &str,
category: &Category,
) -> Result<bool, DbError> {
Ok(store::preference(db, account_id, &category.name)
.await?
.map_or(category.defaults.push, |channels: Channels| channels.push))
}
fn venture_locale(&self) -> LanguageIdentifier {
self.cell.get().map_or_else(
|| self.settings().venture_locale(),
|ctx| self.settings().configured_locale(ctx),
)
}
async fn account_locale(
&self,
db: &dyn Database,
account_id: &str,
) -> Result<LanguageIdentifier, DbError> {
Ok(locale::resolve(
None,
store::account_locale(db, account_id).await?,
&self.venture_locale(),
))
}
fn catalog(&self, key: &str) -> Result<Arc<dyn Catalog>, NotifyError> {
self.settings()
.catalog
.clone()
.ok_or_else(|| NotifyError::NoCatalog(key.to_owned()))
}
pub async fn notify(
&self,
db: &dyn Database,
account_id: &str,
category: &str,
message: impl Into<Message>,
) -> Result<Enqueued, NotifyError> {
let category = self.category(category)?.clone();
let notification_id = self.new_id();
let (notification, localizable, rendered, missing) = match message.into() {
Message::Rendered(notification) => (*notification, None, None, Vec::new()),
Message::Localizable(message) => {
let catalog = self.catalog(&message.key)?;
let asked = self.account_locale(db, account_id).await?;
let Localised {
notification,
locale,
missing,
..
} = locale::render(&*catalog, &message, &asked, false);
(notification, Some(*message), Some(locale), missing)
}
};
let notification = prepare(notification, &category, ¬ification_id)?;
let locale_tag = rendered.as_ref().map(ToString::to_string);
let now = self.now();
let mut statements = Vec::new();
let inbox = self.in_app_allowed(db, account_id, &category).await?;
let mut announcement = None;
if inbox {
let (statement, room) = self.inbox_row(&InboxWrite {
account_id,
notification_id: ¬ification_id,
category: &category,
notification: ¬ification,
localizable: localizable.as_ref(),
locale: locale_tag.as_deref(),
now: &now,
})?;
statements.push(statement);
announcement = Some(room);
}
if self.email_allowed(db, account_id, &category).await? {
statements.push(self.email_row(
account_id,
¬ification_id,
&category,
¬ification,
localizable.as_ref(),
&now,
)?);
}
if !self.push_allowed(db, account_id, &category).await? {
return Ok(Enqueued {
notification_id,
statements,
devices: 0,
inbox,
announcement,
skipped: Some(Skipped::PreferenceOff),
category: category.name,
missing,
});
}
let subscriptions = store::subscriptions_for_account(db, account_id).await?;
if subscriptions.is_empty() {
return Ok(Enqueued {
notification_id,
statements,
devices: 0,
inbox,
announcement,
skipped: Some(Skipped::NoSubscriptions),
category: category.name,
missing,
});
}
statements.reserve(subscriptions.len());
for subscription in &subscriptions {
statements.push(self.push_row(
account_id,
¬ification_id,
&category,
¬ification,
localizable.as_ref(),
&subscription.id,
&now,
)?);
}
Ok(Enqueued {
devices: subscriptions.len(),
notification_id,
statements,
inbox,
announcement,
skipped: None,
category: category.name,
missing,
})
}
pub async fn notify_now(
&self,
db: &dyn Database,
scope: &Scope,
account_id: &str,
category: &str,
message: impl Into<Message>,
) -> Result<Enqueued, NotifyError> {
let enqueued = self.notify(db, account_id, category, message).await?;
if !enqueued.statements().is_empty() {
db.batch_atomic(enqueued.statements()).await?;
}
self.announce(scope, &enqueued).await;
if !enqueued.is_empty() {
self.deliver_now(scope);
}
Ok(enqueued)
}
pub async fn announce(&self, scope: &Scope, enqueued: &Enqueued) {
if !enqueued.missing.is_empty()
&& let Some(ctx) = self.cell.get()
{
locale::report_missing(ctx, scope, &enqueued.category, &enqueued.missing);
}
let Some((room, payload)) = enqueued.announcement.as_ref() else {
return;
};
let Some(realtime) = self.cell.get().and_then(|ctx| ctx.ports.realtime.clone()) else {
return;
};
if let Err(err) = realtime.broadcast(room, payload).await {
tracing::warn!(
error = %err,
request = %scope.request_id,
"the inbox row is written; announcing it live failed"
);
}
}
pub fn deliver_now(&self, scope: &Scope) {
let notifier = self.clone();
let scope_for_drain = scope.clone();
scope.defer.wait_until(Box::pin(async move {
match notifier.drain(&scope_for_drain).await {
Ok(report) if report.claimed > 0 => {
tracing::debug!(?report, "notifications drained");
}
Ok(_) => {}
Err(err) => tracing::error!(error = %err, "notifications drain failed"),
}
}));
}
pub async fn drain(&self, scope: &Scope) -> Result<DrainReport, NotifyError> {
self.drain_with(self.ctx()?, scope).await
}
pub async fn drain_with(
&self,
ctx: &ModuleContext,
scope: &Scope,
) -> Result<DrainReport, NotifyError> {
let db = ctx
.ports
.db
.clone()
.ok_or(NotifyError::MissingPort("Database"))?;
let push = ctx
.ports
.push
.clone()
.ok_or(NotifyError::MissingPort("Push"))?;
let now = now_iso(ctx.ports.clock.as_ref());
let lease_until = plus_secs(&now, LEASE_SECS);
let records = Outbox::new(store::OUTBOX)
.claim_due(&*db, &now, &lease_until, self.drain_batch(ctx))
.await?;
let mut report = DrainReport {
claimed: records.len(),
..DrainReport::default()
};
let (db, push, now) = (&*db, &*push, now.as_str());
let pending: Vec<_> = records
.iter()
.map(|record| async move {
(
record.id.as_str(),
self.deliver_one(ctx, db, push, scope, record, now).await,
)
})
.collect();
let mut deliveries =
futures_util::stream::iter(pending).buffer_unordered(self.drain_concurrency(ctx));
while let Some((row, result)) = deliveries.next().await {
match result {
Ok(outcome) => report.record(outcome),
Err(error) => {
report.failed += 1;
tracing::error!(row = %row, %error, "delivering a notification failed");
}
}
}
if let Err(error) = self.summarise_suppressed(ctx, db, now).await {
tracing::error!(%error, "sending the suppressed-email summaries failed");
}
Ok(report)
}
async fn summarise_suppressed(
&self,
ctx: &ModuleContext,
db: &dyn Database,
now: &str,
) -> Result<(), NotifyError> {
let cap = self.email_max_per_window(ctx);
if cap == 0 {
return Ok(());
}
let since = plus_secs(now, -self.email_window(ctx));
let limit = self.drain_batch(ctx);
for burst in store::suppressed_email_bursts(db, limit).await? {
if store::emails_since(db, &burst.account_id, &burst.category, &since).await?
>= i64::from(cap)
{
continue;
}
let burst_ids = burst.ids.clone();
let clear = || store::clear_email_suppression_statement(&burst_ids);
let (Some(category), Some(target)) = (
self.category(&burst.category).cloned().ok(),
store::email_target(db, &burst.account_id)
.await?
.filter(store::EmailTarget::mailable),
) else {
db.execute(&clear()).await?;
continue;
};
if !self.email_allowed(db, &burst.account_id, &category).await? {
db.execute(&clear()).await?;
continue;
}
let Some(mailer) = ctx.ports.mailer.clone() else {
tracing::warn!(
account = %burst.account_id,
category = %burst.category,
"a suppressed-email summary is due but no Mailer port is wired"
);
continue;
};
let mail = self
.compose_summary(ctx, db, &burst, &category, &target.email)
.await?;
match mailer.send(mail).await {
Ok(SendOutcome::Sent { .. }) => {
db.batch_atomic(&[
store::record_email_statement(
&self.new_id(),
&burst.account_id,
&burst.category,
now,
),
clear(),
])
.await?;
}
outcome => {
tracing::warn!(
account = %burst.account_id,
category = %burst.category,
?outcome,
"the suppressed-email summary could not be sent; it stays queued"
);
}
}
}
Ok(())
}
async fn compose_summary(
&self,
ctx: &ModuleContext,
db: &dyn Database,
burst: &store::SuppressedBurst,
category: &Category,
to: &str,
) -> Result<Mail, NotifyError> {
let locale = self.account_locale(db, &burst.account_id).await?;
let title = format!("You have {} new notifications", burst.count);
let notification = Notification::new(
title,
format!(
"{} notifications arrived while your email limit was full. They are in your \
in-app notifications.",
burst.count
),
);
let job = EmailJob {
notification_id: format!(
"summary-{}-{}-{}",
burst.account_id, burst.category, burst.first_suppressed_at
),
account_id: burst.account_id.clone(),
category: burst.category.clone(),
notification: notification.clone(),
localizable: None,
};
Ok(Self::compose(
ctx,
&job,
category,
to,
¬ification,
&locale,
None,
))
}
async fn deliver_one(
&self,
ctx: &ModuleContext,
db: &dyn Database,
push: &dyn Push,
scope: &Scope,
record: &OutboxRecord,
now: &str,
) -> Result<Outcome, NotifyError> {
if record.topic == TOPIC_EMAIL {
return self.deliver_email(ctx, db, scope, record, now).await;
}
let Ok(job) = serde_json::from_str::<SendJob>(&record.payload) else {
tracing::error!(row = %record.id, "outbox row is not a notifications job");
self.dead_letter(
db,
record,
DeadLetterReason::Malformed,
"outbox payload is not a notifications job",
now,
)
.await?;
return Ok(Outcome::DeadLettered);
};
let Some(subscription) = store::subscription_by_id(db, &job.subscription_id).await? else {
db.execute(&store::delete_outbox_statement(&record.id))
.await?;
return Ok(Outcome::Dropped);
};
if subscription.account_id != job.account_id {
tracing::info!(
row = %record.id,
subscription = %subscription.id,
"dropped a notification whose device now belongs to another account"
);
db.execute(&store::delete_outbox_statement(&record.id))
.await?;
return Ok(Outcome::Dropped);
}
let Ok(category) = self.category(&job.category).cloned() else {
db.execute(&store::delete_outbox_statement(&record.id))
.await?;
return Ok(Outcome::Dropped);
};
if !self.push_allowed(db, &job.account_id, &category).await? {
db.execute(&store::delete_outbox_statement(&record.id))
.await?;
return Ok(Outcome::Dropped);
}
let locale = locale::resolve(
subscription.locale.clone(),
store::account_locale(db, &job.account_id).await?,
&self.venture_locale(),
);
let notification =
self.localise_push(ctx, scope, &category, &job, subscription.transport, &locale)?;
match push.send(&subscription.recipient, ¬ification).await {
Ok(PushOutcome::Delivered { .. }) => {
db.execute(&store::delete_outbox_statement(&record.id))
.await?;
Ok(Outcome::Delivered)
}
Ok(PushOutcome::NotConfigured) => {
self.dead_letter(
db,
record,
DeadLetterReason::NotConfigured,
&format!(
"no adapter is configured for {}: the module is mounted without the \
transport it needs",
subscription.transport
),
now,
)
.await?;
Ok(Outcome::DeadLettered)
}
Err(PushError::Unregistered) => {
self.prune(ctx, db, scope, record, &subscription).await?;
Ok(Outcome::Pruned)
}
Err(PushError::Rejected(message)) => {
self.dead_letter(db, record, DeadLetterReason::Rejected, &message, now)
.await?;
Ok(Outcome::DeadLettered)
}
Err(PushError::Transient {
message,
retry_after,
}) => {
self.retry_or_give_up(ctx, db, record, &message, retry_after, now)
.await
}
}
}
fn localise_push(
&self,
ctx: &ModuleContext,
scope: &Scope,
category: &Category,
job: &SendJob,
transport: Transport,
locale: &LanguageIdentifier,
) -> Result<Notification, NotifyError> {
let Some(message) = job.localizable.as_ref() else {
return Ok(job.notification.clone());
};
let catalog = self.catalog(&message.key)?;
let native = category.render.sends_loc_keys() && transport != Transport::Webpush;
let asked = if native && !category.render.renders_for_recipient() {
self.venture_locale()
} else {
locale.clone()
};
let rendered = locale::render(&*catalog, message, &asked, false);
locale::report_missing(ctx, scope, &category.name, &rendered.missing);
let mut notification = prepare(rendered.notification, category, &job.notification_id)?;
if native {
notification.loc.clone_from(&message.loc);
}
Ok(notification)
}
async fn localise_mail(
&self,
ctx: &ModuleContext,
db: &dyn Database,
scope: &Scope,
job: &EmailJob,
category: &Category,
to: &str,
) -> Result<Mail, NotifyError> {
let asked = self.account_locale(db, &job.account_id).await?;
let Some(message) = job.localizable.as_ref() else {
return Ok(Self::compose(
ctx,
job,
category,
to,
&job.notification,
&asked,
None,
));
};
let catalog = self.catalog(&message.key)?;
let rendered = locale::render(&*catalog, message, &asked, true);
locale::report_missing(ctx, scope, &category.name, &rendered.missing);
let notification = prepare(rendered.notification, category, &job.notification_id)?;
Ok(Self::compose(
ctx,
job,
category,
to,
¬ification,
&rendered.locale,
rendered.subject,
))
}
async fn retry_or_give_up(
&self,
ctx: &ModuleContext,
db: &dyn Database,
record: &OutboxRecord,
message: &str,
retry_after: Option<Duration>,
now: &str,
) -> Result<Outcome, NotifyError> {
let attempts = record.attempts.saturating_add(1);
if attempts >= self.max_attempts(ctx) {
self.dead_letter(
db,
record,
DeadLetterReason::AttemptsExhausted,
&format!("gave up after {attempts} attempts: {message}"),
now,
)
.await?;
return Ok(Outcome::DeadLettered);
}
let delay = backoff(attempts).max(retry_after.unwrap_or(Duration::ZERO));
let seconds = i64::try_from(delay.as_secs())
.unwrap_or_else(|_| i64::try_from(BACKOFF_MAX_SECS).unwrap_or(i64::MAX));
Outbox::new(store::OUTBOX)
.retry_later(db, &record.id, &plus_secs(now, seconds))
.await?;
Ok(Outcome::Retried)
}
async fn deliver_email(
&self,
ctx: &ModuleContext,
db: &dyn Database,
scope: &Scope,
record: &OutboxRecord,
now: &str,
) -> Result<Outcome, NotifyError> {
let Ok(job) = serde_json::from_str::<EmailJob>(&record.payload) else {
tracing::error!(row = %record.id, "outbox row is not a notifications email job");
self.dead_letter(
db,
record,
DeadLetterReason::Malformed,
"outbox payload is not a notifications email job",
now,
)
.await?;
return Ok(Outcome::DeadLettered);
};
let Some((category, target)) = self.email_still_wanted(ctx, db, &job, now).await? else {
db.execute(&store::delete_outbox_statement(&record.id))
.await?;
return Ok(Outcome::Dropped);
};
let Some(mailer) = ctx.ports.mailer.clone() else {
self.dead_letter(
db,
record,
DeadLetterReason::NotConfigured,
"the venture opted a category into email and wired no Mailer port",
now,
)
.await?;
return Ok(Outcome::DeadLettered);
};
let mail = self
.localise_mail(ctx, db, scope, &job, &category, &target.email)
.await?;
match mailer.send(mail).await {
Ok(SendOutcome::Sent { .. }) => {
db.batch_atomic(&[
store::record_email_statement(
&self.new_id(),
&job.account_id,
&category.name,
now,
),
store::delete_outbox_statement(&record.id),
])
.await?;
Ok(Outcome::Delivered)
}
Ok(SendOutcome::NotConfigured) => {
self.dead_letter(
db,
record,
DeadLetterReason::NotConfigured,
"the mailer has no API key or no verified sending domain",
now,
)
.await?;
Ok(Outcome::DeadLettered)
}
Err(
err @ (MailError::Unauthorized
| MailError::DomainNotVerified { .. }
| MailError::Invalid { .. }),
) => {
self.dead_letter(
db,
record,
DeadLetterReason::Rejected,
&err.to_string(),
now,
)
.await?;
Ok(Outcome::DeadLettered)
}
Err(err) => {
let retry_after = match &err {
MailError::RateLimited { retry_after } => *retry_after,
_ => None,
};
self.retry_or_give_up(ctx, db, record, &err.to_string(), retry_after, now)
.await
}
}
}
async fn email_still_wanted(
&self,
ctx: &ModuleContext,
db: &dyn Database,
job: &EmailJob,
now: &str,
) -> Result<Option<(Category, store::EmailTarget)>, NotifyError> {
let Ok(category) = self.category(&job.category).cloned() else {
return Ok(None);
};
if !self.email_allowed(db, &job.account_id, &category).await? {
return Ok(None);
}
let Some(target) = store::email_target(db, &job.account_id).await? else {
return Ok(None);
};
let cap = self.email_max_per_window(ctx);
let since = plus_secs(now, -self.email_window(ctx));
if cap == 0
|| store::emails_since(db, &job.account_id, &category.name, &since).await?
>= i64::from(cap)
{
tracing::info!(
account = %job.account_id,
category = %category.name,
"email suppressed: the category is at its cooldown cap for this account"
);
if cap > 0 && !store::email_suppression_exists(db, &job.notification_id).await? {
store::record_email_suppression(
db,
&self.new_id(),
&job.account_id,
&category.name,
&job.notification_id,
now,
)
.await?;
}
return Ok(None);
}
Ok(Some((category, target)))
}
fn email_window(&self, ctx: &ModuleContext) -> i64 {
i64::from(
ModuleConfig::new(crate::MODULE_NAME, &*ctx.config)
.get_u32("EMAIL_WINDOW_SECS", self.settings().email_window_secs),
)
}
fn email_max_per_window(&self, ctx: &ModuleContext) -> u32 {
ModuleConfig::new(crate::MODULE_NAME, &*ctx.config)
.get_u32("EMAIL_MAX_PER_WINDOW", self.settings().email_max_per_window)
}
#[allow(
clippy::too_many_arguments,
reason = "one mail's worth of inputs, named rather than bundled"
)]
fn compose(
ctx: &ModuleContext,
job: &EmailJob,
category: &Category,
to: &str,
notification: &Notification,
locale: &LanguageIdentifier,
localised_subject: Option<String>,
) -> Mail {
let cfg = ModuleConfig::new(crate::MODULE_NAME, &*ctx.config);
let from = cfg.get_str(
"MAIL_FROM",
&format!("no-reply@send.{}", ctx.venture.domain),
);
let base = cfg.get_str("PUBLIC_URL", &format!("https://{}", ctx.venture.domain));
let one_click = Self::unsubscribe_url(ctx, &base, &job.account_id, &category.name);
let one_click_all = Self::unsubscribe_url(ctx, &base, &job.account_id, UNSUBSCRIBE_ALL);
let settings = format!("{base}/settings/notifications");
let unsubscribe = one_click.clone().unwrap_or_else(|| settings.clone());
let unsubscribe_all = one_click_all.unwrap_or(settings);
let subject = localised_subject
.or_else(|| category.subject_template.clone())
.unwrap_or_else(|| notification.title.clone());
let mut data = serde_json::to_value(crate::mail::EmailMail {
venture: ctx.venture.name.clone(),
subject: subject.clone(),
title: notification.title.clone(),
body: notification.body.clone(),
url: notification.url.clone(),
lang: locale.to_string(),
dir: direction(locale).as_str().to_owned(),
unsubscribe: unsubscribe.clone(),
unsubscribe_all: unsubscribe_all.clone(),
})
.unwrap_or_else(|_| json!({}));
cratefield_mail_templates::attach_theme(&mut data, &ctx.venture, &*ctx.config);
let rendered = crate::mail::render(&ctx.templates, &data, &locale.to_string())
.unwrap_or_else(|error| {
tracing::error!(%error, "notification email template failed; sending plain");
cratefield_core::Rendered {
subject: subject.clone(),
html: format!(
"<p>{}</p><p>{}</p><p><a href=\"{}\">Stop receiving these</a></p>",
escape(¬ification.title),
escape(¬ification.body),
escape(&unsubscribe),
),
text: format!(
"{}\n\n{}\n\nTo stop receiving these, open:\n{}\n\nTo stop every \
notification email:\n{}\n",
notification.title, notification.body, unsubscribe, unsubscribe_all
),
}
});
let mut message = Mail::new(to, from, rendered.subject, rendered.text, rendered.html)
.header("Content-Language", locale.to_string())
.idempotency_key(format!("{}-email", job.notification_id))
.tags(["notifications"]);
if let Some(url) = one_click {
message = message
.header("List-Unsubscribe", format!("<{url}>"))
.header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");
}
if let Some(reply_to) = cfg.get_opt("MAIL_REPLY_TO") {
message = message.reply_to(reply_to);
}
message
}
fn unsubscribe_url(
ctx: &ModuleContext,
base: &str,
account_id: &str,
category: &str,
) -> Option<String> {
let signer = ctx.ports.signer.as_ref()?;
let token = signer.sign(&Payload {
purpose: PURPOSE_UNSUBSCRIBE.to_owned(),
subject: format!("{account_id}:{category}"),
exp: None,
kid: Kid::Cur,
});
Some(format!(
"{base}/v1/notifications/email/unsubscribe?token={token}"
))
}
async fn prune(
&self,
ctx: &ModuleContext,
db: &dyn Database,
scope: &Scope,
record: &OutboxRecord,
subscription: &Subscription,
) -> Result<(), NotifyError> {
db.batch_atomic(&[
store::delete_subscription_statement(&subscription.id),
store::delete_outbox_statement(&record.id),
])
.await?;
tracing::info!(
subscription = %subscription.id,
transport = %subscription.transport,
"pruned a subscription the provider reported as gone"
);
ctx.events.emit_in(
scope,
EVENT_SUBSCRIPTION_PRUNED,
json!({
"subscription_id": subscription.id,
"account_id": subscription.account_id,
"transport": subscription.transport.as_str(),
}),
);
Ok(())
}
async fn dead_letter(
&self,
db: &dyn Database,
record: &OutboxRecord,
reason: DeadLetterReason,
last_error: &str,
now: &str,
) -> Result<(), NotifyError> {
tracing::error!(
row = %record.id,
reason = reason.as_str(),
error = last_error,
"notification dead-lettered"
);
let attempts = record.attempts.saturating_add(1);
let created_at = store::outbox_created_at(db, &record.id)
.await?
.unwrap_or_else(|| now.to_owned());
db.batch_atomic(&[
store::dead_letter_statement(
record,
&store::DeadLetter {
reason,
last_error,
attempts,
created_at: &created_at,
failed_at: now,
},
),
store::delete_outbox_statement(&record.id),
])
.await?;
Ok(())
}
}
fn prepare(
mut notification: Notification,
category: &Category,
notification_id: &str,
) -> Result<Notification, NotifyError> {
let mut data = match notification.data {
Value::Null => serde_json::Map::new(),
Value::Object(map) => map,
_ => return Err(NotifyError::InvalidData),
};
data.insert("category".to_owned(), json!(category.name));
data.insert("notification_id".to_owned(), json!(notification_id));
notification.data = Value::Object(data);
if !category.badge {
notification.badge = None;
}
Ok(notification)
}
pub(crate) const INBOX_ROOM_PREFIX: &str = "notifications:";
pub(crate) const UNSUBSCRIBE_ALL: &str = "all";
pub(crate) const PURPOSE_UNSUBSCRIBE: &str = "notifications.unsubscribe";
pub(crate) fn escape(raw: &str) -> String {
raw.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn backoff(attempts: i64) -> Duration {
let exponent = u32::try_from(attempts.saturating_sub(1).max(0)).unwrap_or(u32::MAX);
let secs = BACKOFF_BASE_SECS
.checked_shl(exponent.min(16))
.unwrap_or(BACKOFF_MAX_SECS)
.min(BACKOFF_MAX_SECS);
Duration::from_secs(secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_doubles_and_stops_at_an_hour() {
assert_eq!(backoff(1), Duration::from_secs(30));
assert_eq!(backoff(2), Duration::from_secs(60));
assert_eq!(backoff(3), Duration::from_secs(120));
assert_eq!(backoff(9), Duration::from_secs(3_600));
assert_eq!(backoff(64), Duration::from_secs(3_600));
}
#[test]
fn data_always_carries_the_category_and_the_notification_id() {
let category = Category::new("booking");
let mut notification = Notification::new("Booked", "See you Tuesday");
notification.data = json!({ "booking_id": "b-1" });
let prepared = prepare(notification, &category, "01JNOTIF").expect("prepared");
assert_eq!(prepared.data["booking_id"], "b-1");
assert_eq!(prepared.data["category"], "booking");
assert_eq!(prepared.data["notification_id"], "01JNOTIF");
}
#[test]
fn a_badge_survives_only_for_a_category_that_asked_for_one() {
let mut notification = Notification::new("a", "b");
notification.badge = Some(7);
let plain = prepare(notification.clone(), &Category::new("booking"), "id").expect("plain");
assert_eq!(plain.badge, None, "no badge counts by default");
let opted =
prepare(notification, &Category::new("booking").badge(true), "id").expect("opted");
assert_eq!(opted.badge, Some(7));
}
#[test]
fn a_non_object_data_is_refused_rather_than_silently_replaced() {
let mut notification = Notification::new("a", "b");
notification.data = json!(["not", "an", "object"]);
assert!(matches!(
prepare(notification, &Category::new("booking"), "id"),
Err(NotifyError::InvalidData)
));
}
#[test]
fn a_job_payload_carries_no_recipient() {
let job = SendJob {
notification_id: "n".to_owned(),
account_id: "a".to_owned(),
category: "booking".to_owned(),
subscription_id: "s".to_owned(),
notification: Notification::new("a", "b"),
localizable: None,
};
let json = serde_json::to_string(&job).expect("serialises");
assert!(!json.contains("device_token"), "{json}");
assert!(!json.contains("endpoint"), "{json}");
assert!(!json.contains("recipient"), "{json}");
}
#[test]
fn a_rendered_callers_payload_is_the_one_it_always_was() {
let job = SendJob {
notification_id: "n".to_owned(),
account_id: "a".to_owned(),
category: "booking".to_owned(),
subscription_id: "s".to_owned(),
notification: Notification::new("Booked", "See you Tuesday"),
localizable: None,
};
let json = serde_json::to_string(&job).expect("serialises");
assert!(!json.contains("localizable"), "{json}");
let old = r#"{"notification_id":"n","account_id":"a","category":"booking",
"subscription_id":"s","notification":{"title":"Booked","body":"Tuesday"}}"#;
let parsed: SendJob = serde_json::from_str(old).expect("an older payload still reads");
assert!(parsed.localizable.is_none());
assert_eq!(parsed.notification.title, "Booked");
}
#[test]
fn a_localizable_payload_carries_the_message_and_no_recipient() {
let job = SendJob {
notification_id: "n".to_owned(),
account_id: "a".to_owned(),
category: "booking".to_owned(),
subscription_id: "s".to_owned(),
notification: Notification::new("Booked", "Tuesday"),
localizable: Some(Localizable::new("booking-confirmed").arg("coach", "Sari")),
};
let json = serde_json::to_string(&job).expect("serialises");
assert!(json.contains("booking-confirmed"), "{json}");
assert!(!json.contains("device_token"), "{json}");
assert!(!json.contains("recipient"), "{json}");
let back: SendJob = serde_json::from_str(&json).expect("round trips");
assert_eq!(back, job);
}
}