use cratefield_core::{Database, DbError, Recipient, Row, Statement};
use cratefield_i18n::LanguageIdentifier;
use sea_query::{Alias, Expr, Order, Query};
use serde::{Deserialize, Serialize};
pub(crate) const SUBSCRIPTIONS: &str = "notifications_subscriptions";
pub(crate) const PREFERENCES: &str = "notifications_preferences";
pub(crate) const OUTBOX: &str = "notifications_outbox";
pub(crate) const DEAD_LETTERS: &str = "notifications_dead_letters";
pub(crate) const INBOX: &str = "notifications_inbox";
pub(crate) const EMAIL_TARGETS: &str = "notifications_email_targets";
pub(crate) const EMAIL_SENDS: &str = "notifications_email_sends";
pub(crate) const EMAIL_SUPPRESSED: &str = "notifications_email_suppressed";
pub(crate) const LOCALES: &str = "notifications_locales";
fn iden(name: &str) -> Alias {
Alias::new(name)
}
fn corrupt(table: &str, id: &str, what: &str) -> DbError {
DbError::Query(format!("{table} row {id} has no usable {what}"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
Apns,
Fcm,
Webpush,
}
impl Transport {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Transport::Apns => "apns",
Transport::Fcm => "fcm",
Transport::Webpush => "webpush",
}
}
#[must_use]
pub fn of(recipient: &Recipient) -> Self {
match recipient {
Recipient::Apns { .. } => Transport::Apns,
Recipient::Fcm { .. } => Transport::Fcm,
Recipient::WebPush { .. } => Transport::Webpush,
}
}
fn parse(raw: &str) -> Option<Self> {
match raw {
"apns" => Some(Transport::Apns),
"fcm" => Some(Transport::Fcm),
"webpush" => Some(Transport::Webpush),
_ => None,
}
}
}
impl std::fmt::Display for Transport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn canonical(recipient: &Recipient) -> String {
match recipient {
Recipient::Apns { device_token } => format!("apns:{device_token}"),
Recipient::Fcm { registration_token } => format!("fcm:{registration_token}"),
Recipient::WebPush {
endpoint, p256dh, ..
} => format!("webpush:{endpoint}\u{0}{p256dh}"),
}
}
#[must_use]
pub(crate) fn recipient_hash(recipient: &Recipient) -> String {
use sha2::{Digest as _, Sha256};
use std::fmt::Write as _;
let digest = Sha256::digest(canonical(recipient).as_bytes());
let mut hex = String::with_capacity(64);
for byte in digest {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Subscription {
pub id: String,
pub account_id: String,
pub transport: Transport,
pub recipient: Recipient,
pub app_id: Option<String>,
pub app_version: Option<String>,
pub locale: Option<LanguageIdentifier>,
pub created_at: String,
pub last_seen_at: String,
}
const SUBSCRIPTION_COLUMNS: [&str; 9] = [
"id",
"account_id",
"transport",
"recipient_json",
"app_id",
"app_version",
"locale",
"created_at",
"last_seen_at",
];
fn read_subscription(row: &Row) -> Result<Subscription, DbError> {
let id = row
.get::<String>("id")
.ok_or_else(|| corrupt(SUBSCRIPTIONS, "?", "id"))?;
let raw = row
.get::<String>("recipient_json")
.ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "recipient"))?;
let recipient: Recipient = serde_json::from_str(&raw)
.map_err(|_| corrupt(SUBSCRIPTIONS, &id, "recipient (not the port's JSON form)"))?;
let transport = row
.get::<String>("transport")
.as_deref()
.and_then(Transport::parse)
.ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "transport"))?;
Ok(Subscription {
account_id: row
.get::<String>("account_id")
.ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "account"))?,
transport,
recipient,
app_id: row.get::<Option<String>>("app_id").flatten(),
app_version: row.get::<Option<String>>("app_version").flatten(),
locale: row
.get::<String>("locale")
.as_deref()
.and_then(cratefield_i18n::parse_locale),
created_at: row.get::<String>("created_at").unwrap_or_default(),
last_seen_at: row.get::<String>("last_seen_at").unwrap_or_default(),
id,
})
}
fn select_subscriptions() -> sea_query::SelectStatement {
let mut select = Query::select();
select
.columns(SUBSCRIPTION_COLUMNS.map(iden))
.from(iden(SUBSCRIPTIONS));
select
}
pub(crate) struct NewSubscription<'a> {
pub id: &'a str,
pub account_id: &'a str,
pub recipient: &'a Recipient,
pub app_id: Option<&'a str>,
pub app_version: Option<&'a str>,
pub user_agent: Option<&'a str>,
pub locale: Option<&'a str>,
pub now: &'a str,
pub rehome_cutoff: &'a str,
pub rehome_limit: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Upserted {
Created(String),
Refreshed(String),
Rehomed {
id: String,
previous_account_id: String,
},
RehomeRefused,
}
async fn owner_of(
db: &dyn Database,
transport: Transport,
hash: &str,
) -> Result<Option<(String, String)>, DbError> {
let mut select = Query::select();
select
.columns([iden("id"), iden("account_id")])
.from(iden(SUBSCRIPTIONS))
.and_where(Expr::col(iden("transport")).eq(transport.as_str()))
.and_where(Expr::col(iden("recipient_hash")).eq(hash));
let Some(row) = db
.query(&Statement::render(&select))
.await?
.first()
.cloned()
else {
return Ok(None);
};
let id = row
.get::<String>("id")
.ok_or_else(|| corrupt(SUBSCRIPTIONS, "?", "id"))?;
let account_id = row
.get::<String>("account_id")
.ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "account"))?;
Ok(Some((id, account_id)))
}
async fn rehomes_since(
db: &dyn Database,
account_id: &str,
cutoff: &str,
limit: usize,
) -> Result<usize, DbError> {
let mut select = Query::select();
select
.column(iden("id"))
.from(iden(SUBSCRIPTIONS))
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("rehomed_at")).gt(cutoff))
.limit(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1));
Ok(db.query(&Statement::render(&select)).await?.rows.len())
}
fn refresh_statement(
id: &str,
new: &NewSubscription<'_>,
recipient_json: &str,
rehomed_from: Option<&str>,
) -> Statement {
let mut update = Query::update();
update
.table(iden(SUBSCRIPTIONS))
.value(iden("account_id"), new.account_id)
.value(iden("recipient_json"), recipient_json)
.value(iden("app_id"), new.app_id)
.value(iden("app_version"), new.app_version)
.value(iden("user_agent"), new.user_agent)
.value(iden("last_seen_at"), new.now)
.and_where(Expr::col(iden("id")).eq(id));
if let Some(locale) = new.locale {
update.value(iden("locale"), locale);
}
if let Some(previous) = rehomed_from {
update
.value(iden("rehomed_at"), new.now)
.and_where(Expr::col(iden("account_id")).eq(previous));
}
Statement::render(&update)
}
pub(crate) async fn upsert_subscription(
db: &dyn Database,
new: &NewSubscription<'_>,
) -> Result<Upserted, DbError> {
let transport = Transport::of(new.recipient);
let hash = recipient_hash(new.recipient);
let recipient_json = serde_json::to_string(new.recipient)
.map_err(|err| DbError::Execute(format!("recipient does not serialise: {err}")))?;
if let Some(existing) = owner_of(db, transport, &hash).await? {
return take_over(db, new, &recipient_json, existing).await;
}
let mut insert = Query::insert();
insert
.into_table(iden(SUBSCRIPTIONS))
.columns([
"id",
"account_id",
"transport",
"recipient_json",
"recipient_hash",
"app_id",
"app_version",
"user_agent",
"locale",
"created_at",
"last_seen_at",
])
.values_panic([
new.id.into(),
new.account_id.into(),
transport.as_str().into(),
recipient_json.as_str().into(),
hash.as_str().into(),
new.app_id.into(),
new.app_version.into(),
new.user_agent.into(),
new.locale.into(),
new.now.into(),
new.now.into(),
]);
match db.execute(&Statement::render(&insert)).await {
Ok(_) => Ok(Upserted::Created(new.id.to_owned())),
Err(err) => match owner_of(db, transport, &hash).await? {
Some(existing) => take_over(db, new, &recipient_json, existing).await,
None => Err(err),
},
}
}
async fn take_over(
db: &dyn Database,
new: &NewSubscription<'_>,
recipient_json: &str,
existing: (String, String),
) -> Result<Upserted, DbError> {
let (id, owner) = existing;
if owner == new.account_id {
db.execute(&refresh_statement(&id, new, recipient_json, None))
.await?;
return Ok(Upserted::Refreshed(id));
}
if rehomes_since(db, new.account_id, new.rehome_cutoff, new.rehome_limit).await?
>= new.rehome_limit
{
return Ok(Upserted::RehomeRefused);
}
let affected = db
.execute(&refresh_statement(&id, new, recipient_json, Some(&owner)))
.await?;
if affected == 0 {
return match owner_of(
db,
Transport::of(new.recipient),
&recipient_hash(new.recipient),
)
.await?
{
Some((id, now_owner)) if now_owner == new.account_id => Ok(Upserted::Refreshed(id)),
_ => Ok(Upserted::RehomeRefused),
};
}
Ok(Upserted::Rehomed {
id,
previous_account_id: owner,
})
}
pub(crate) async fn subscriptions_for_account(
db: &dyn Database,
account_id: &str,
) -> Result<Vec<Subscription>, DbError> {
let mut select = select_subscriptions();
select
.and_where(Expr::col(iden("account_id")).eq(account_id))
.order_by(iden("created_at"), Order::Asc)
.order_by(iden("id"), Order::Asc);
db.query(&Statement::render(&select))
.await?
.rows
.iter()
.map(read_subscription)
.collect()
}
pub(crate) async fn subscription_by_id(
db: &dyn Database,
id: &str,
) -> Result<Option<Subscription>, DbError> {
let mut select = select_subscriptions();
select.and_where(Expr::col(iden("id")).eq(id));
match db.query(&Statement::render(&select)).await?.first() {
Some(row) => read_subscription(row).map(Some),
None => Ok(None),
}
}
pub(crate) async fn delete_subscription_for_account(
db: &dyn Database,
id: &str,
account_id: &str,
) -> Result<bool, DbError> {
let mut delete = Query::delete();
delete
.from_table(iden(SUBSCRIPTIONS))
.and_where(Expr::col(iden("id")).eq(id))
.and_where(Expr::col(iden("account_id")).eq(account_id));
Ok(db.execute(&Statement::render(&delete)).await? == 1)
}
#[must_use]
pub(crate) fn delete_subscription_statement(id: &str) -> Statement {
let mut delete = Query::delete();
delete
.from_table(iden(SUBSCRIPTIONS))
.and_where(Expr::col(iden("id")).eq(id));
Statement::render(&delete)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Channels {
pub push: bool,
pub in_app: bool,
pub email: bool,
}
impl Channels {
pub(crate) fn all(enabled: bool) -> Self {
Self {
push: enabled,
in_app: enabled,
email: enabled,
}
}
}
fn flag(row: &Row, column: &str) -> bool {
row.get::<i64>(column).unwrap_or(0) != 0
}
pub(crate) async fn preferences_for_account(
db: &dyn Database,
account_id: &str,
) -> Result<Vec<(String, Channels)>, DbError> {
let mut select = Query::select();
select
.columns([
iden("category"),
iden("push"),
iden("in_app"),
iden("email"),
])
.from(iden(PREFERENCES))
.and_where(Expr::col(iden("account_id")).eq(account_id))
.order_by(iden("category"), Order::Asc);
db.query(&Statement::render(&select))
.await?
.rows
.iter()
.map(|row| {
let category = row
.get::<String>("category")
.ok_or_else(|| corrupt(PREFERENCES, account_id, "category"))?;
Ok((
category,
Channels {
push: flag(row, "push"),
in_app: flag(row, "in_app"),
email: flag(row, "email"),
},
))
})
.collect()
}
pub(crate) async fn preference(
db: &dyn Database,
account_id: &str,
category: &str,
) -> Result<Option<Channels>, DbError> {
let mut select = Query::select();
select
.columns([iden("push"), iden("in_app"), iden("email")])
.from(iden(PREFERENCES))
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("category")).eq(category));
Ok(db
.query(&Statement::render(&select))
.await?
.first()
.map(|row| Channels {
push: flag(row, "push"),
in_app: flag(row, "in_app"),
email: flag(row, "email"),
}))
}
#[must_use]
pub(crate) fn write_preference_statement(
account_id: &str,
category: &str,
channels: Channels,
exists: bool,
now: &str,
) -> Statement {
let bit = |on: bool| i64::from(on);
if exists {
let mut update = Query::update();
update
.table(iden(PREFERENCES))
.value(iden("push"), bit(channels.push))
.value(iden("in_app"), bit(channels.in_app))
.value(iden("email"), bit(channels.email))
.value(iden("updated_at"), now)
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("category")).eq(category));
return Statement::render(&update);
}
let mut insert = Query::insert();
insert
.into_table(iden(PREFERENCES))
.columns([
"account_id",
"category",
"push",
"in_app",
"email",
"updated_at",
])
.values_panic([
account_id.into(),
category.into(),
bit(channels.push).into(),
bit(channels.in_app).into(),
bit(channels.email).into(),
now.into(),
]);
Statement::render(&insert)
}
pub(crate) async fn account_locale(
db: &dyn Database,
account_id: &str,
) -> Result<Option<LanguageIdentifier>, DbError> {
let mut select = Query::select();
select
.column(iden("locale"))
.from(iden(LOCALES))
.and_where(Expr::col(iden("account_id")).eq(account_id));
Ok(db
.query(&Statement::render(&select))
.await?
.first()
.and_then(|row| row.get::<String>("locale"))
.as_deref()
.and_then(cratefield_i18n::parse_locale))
}
pub(crate) async fn set_account_locale(
db: &dyn Database,
account_id: &str,
locale: &str,
now: &str,
) -> Result<(), DbError> {
let existing = account_locale(db, account_id).await?.is_some();
let statement = if existing {
let mut update = Query::update();
update
.table(iden(LOCALES))
.value(iden("locale"), locale)
.value(iden("updated_at"), now)
.and_where(Expr::col(iden("account_id")).eq(account_id));
Statement::render(&update)
} else {
let mut insert = Query::insert();
insert
.into_table(iden(LOCALES))
.columns(["account_id", "locale", "updated_at"])
.values_panic([account_id.into(), locale.into(), now.into()]);
Statement::render(&insert)
};
db.execute(&statement).await?;
Ok(())
}
#[must_use]
pub(crate) fn delete_outbox_statement(id: &str) -> Statement {
let mut delete = Query::delete();
delete
.from_table(iden(OUTBOX))
.and_where(Expr::col(iden("id")).eq(id));
Statement::render(&delete)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeadLetterReason {
Rejected,
NotConfigured,
AttemptsExhausted,
Malformed,
}
impl DeadLetterReason {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
DeadLetterReason::Rejected => "rejected",
DeadLetterReason::NotConfigured => "not_configured",
DeadLetterReason::AttemptsExhausted => "attempts_exhausted",
DeadLetterReason::Malformed => "malformed",
}
}
}
pub(crate) async fn outbox_created_at(
db: &dyn Database,
id: &str,
) -> Result<Option<String>, DbError> {
let mut select = Query::select();
select
.column(iden("created_at"))
.from(iden(OUTBOX))
.and_where(Expr::col(iden("id")).eq(id));
Ok(db
.query(&Statement::render(&select))
.await?
.first()
.and_then(|row| row.get::<String>("created_at")))
}
pub(crate) struct DeadLetter<'a> {
pub reason: DeadLetterReason,
pub last_error: &'a str,
pub attempts: i64,
pub created_at: &'a str,
pub failed_at: &'a str,
}
#[must_use]
pub(crate) fn dead_letter_statement(
record: &cratefield_core::OutboxRecord,
dead: &DeadLetter<'_>,
) -> Statement {
let account_id = payload_account(&record.payload);
let mut insert = Query::insert();
insert
.into_table(iden(DEAD_LETTERS))
.columns([
"id",
"account_id",
"topic",
"payload",
"attempts",
"reason",
"last_error",
"created_at",
"failed_at",
])
.values_panic([
record.id.as_str().into(),
account_id.as_deref().into(),
record.topic.as_str().into(),
record.payload.as_str().into(),
dead.attempts.into(),
dead.reason.as_str().into(),
cratefield_core::scrub_text(dead.last_error).into(),
dead.created_at.into(),
dead.failed_at.into(),
]);
Statement::render(&insert)
}
fn payload_account(payload: &str) -> Option<String> {
#[derive(Deserialize)]
struct AccountOnly {
account_id: String,
}
serde_json::from_str::<AccountOnly>(payload)
.ok()
.map(|job| job.account_id)
.filter(|account| !account.is_empty())
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct InboxItem {
pub id: String,
pub notification_id: String,
pub category: String,
pub title: String,
pub body: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub data: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dir: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub loc_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub loc_args: Option<serde_json::Value>,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub read_at: Option<String>,
}
pub(crate) struct NewInboxRow<'a> {
pub id: &'a str,
pub account_id: &'a str,
pub notification_id: &'a str,
pub category: &'a str,
pub title: &'a str,
pub body: &'a str,
pub url: Option<&'a str>,
pub icon: Option<&'a str>,
pub data_json: &'a str,
pub locale: Option<&'a str>,
pub loc_key: Option<&'a str>,
pub loc_args_json: Option<&'a str>,
pub now: &'a str,
}
#[must_use]
pub(crate) fn insert_inbox_statement(row: &NewInboxRow<'_>) -> Statement {
let mut insert = Query::insert();
insert
.into_table(iden(INBOX))
.columns([
"id",
"account_id",
"notification_id",
"category",
"title",
"body",
"url",
"icon",
"data_json",
"locale",
"loc_key",
"loc_args_json",
"created_at",
])
.values_panic([
row.id.into(),
row.account_id.into(),
row.notification_id.into(),
row.category.into(),
row.title.into(),
row.body.into(),
row.url.into(),
row.icon.into(),
row.data_json.into(),
row.locale.into(),
row.loc_key.into(),
row.loc_args_json.into(),
row.now.into(),
]);
Statement::render(&insert)
}
pub(crate) async fn inbox_page(
db: &dyn Database,
account_id: &str,
before: Option<(&str, &str)>,
unread_only: bool,
limit: u64,
) -> Result<Vec<InboxItem>, DbError> {
let mut select = Query::select();
select
.columns([
iden("id"),
iden("notification_id"),
iden("category"),
iden("title"),
iden("body"),
iden("url"),
iden("icon"),
iden("data_json"),
iden("locale"),
iden("loc_key"),
iden("loc_args_json"),
iden("created_at"),
iden("read_at"),
])
.from(iden(INBOX))
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("archived_at")).is_null())
.order_by(iden("created_at"), Order::Desc)
.order_by(iden("id"), Order::Desc)
.limit(limit);
if let Some((at, id)) = before {
select.and_where(
Expr::col(iden("created_at"))
.lt(at)
.or(Expr::col(iden("created_at"))
.eq(at)
.and(Expr::col(iden("id")).lt(id))),
);
}
if unread_only {
select.and_where(Expr::col(iden("read_at")).is_null());
}
db.query(&Statement::render(&select))
.await?
.rows
.iter()
.map(read_inbox_item)
.collect()
}
fn read_inbox_item(row: &Row) -> Result<InboxItem, DbError> {
let locale = row
.get::<String>("locale")
.as_deref()
.and_then(cratefield_i18n::parse_locale);
let id = row
.get::<String>("id")
.ok_or_else(|| DbError::Query(format!("{INBOX} row has no id")))?;
let data = row
.get::<String>("data_json")
.as_deref()
.map_or_else(|| Ok(serde_json::Value::Null), serde_json::from_str)
.map_err(|err| DbError::Query(format!("{INBOX} row {id} has unreadable data: {err}")))?;
Ok(InboxItem {
notification_id: row
.get::<String>("notification_id")
.ok_or_else(|| corrupt(INBOX, &id, "notification_id"))?,
category: row
.get::<String>("category")
.ok_or_else(|| corrupt(INBOX, &id, "category"))?,
title: row
.get::<String>("title")
.ok_or_else(|| corrupt(INBOX, &id, "title"))?,
body: row
.get::<String>("body")
.ok_or_else(|| corrupt(INBOX, &id, "body"))?,
url: row.get::<String>("url"),
icon: row.get::<String>("icon"),
data,
dir: locale
.as_ref()
.map(|locale| cratefield_i18n::direction(locale).as_str()),
locale: locale.map(|locale| locale.to_string()),
loc_key: row.get::<String>("loc_key"),
loc_args: row
.get::<String>("loc_args_json")
.as_deref()
.and_then(|raw| serde_json::from_str(raw).ok()),
created_at: row
.get::<String>("created_at")
.ok_or_else(|| corrupt(INBOX, &id, "created_at"))?,
read_at: row.get::<String>("read_at"),
id,
})
}
pub(crate) async fn unread_count(db: &dyn Database, account_id: &str) -> Result<i64, DbError> {
let mut select = Query::select();
select
.expr_as(Expr::col(iden("id")).count(), iden("unread"))
.from(iden(INBOX))
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("archived_at")).is_null())
.and_where(Expr::col(iden("read_at")).is_null());
Ok(db
.query(&Statement::render(&select))
.await?
.first()
.and_then(|row| row.get::<i64>("unread"))
.unwrap_or(0))
}
pub(crate) async fn mark_read(
db: &dyn Database,
id: &str,
account_id: &str,
now: &str,
) -> Result<bool, DbError> {
let mut exists = Query::select();
exists
.column(iden("id"))
.from(iden(INBOX))
.and_where(Expr::col(iden("id")).eq(id))
.and_where(Expr::col(iden("account_id")).eq(account_id));
if db
.query(&Statement::render(&exists))
.await?
.first()
.is_none()
{
return Ok(false);
}
let mut update = Query::update();
update
.table(iden(INBOX))
.value(iden("read_at"), now)
.and_where(Expr::col(iden("id")).eq(id))
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("read_at")).is_null());
db.execute(&Statement::render(&update)).await?;
Ok(true)
}
pub(crate) async fn mark_all_read(
db: &dyn Database,
account_id: &str,
now: &str,
) -> Result<u64, DbError> {
let mut update = Query::update();
update
.table(iden(INBOX))
.value(iden("read_at"), now)
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("read_at")).is_null());
db.execute(&Statement::render(&update)).await
}
pub(crate) async fn archive(
db: &dyn Database,
id: &str,
account_id: &str,
now: &str,
) -> Result<bool, DbError> {
let mut update = Query::update();
update
.table(iden(INBOX))
.value(iden("archived_at"), now)
.and_where(Expr::col(iden("id")).eq(id))
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("archived_at")).is_null());
Ok(db.execute(&Statement::render(&update)).await? == 1)
}
pub(crate) async fn prune_inbox(db: &dyn Database, older_than: &str) -> Result<u64, DbError> {
let mut delete = Query::delete();
delete
.from_table(iden(INBOX))
.and_where(Expr::col(iden("created_at")).lt(older_than))
.and_where(
Expr::col(iden("read_at"))
.is_not_null()
.or(Expr::col(iden("archived_at")).is_not_null()),
);
db.execute(&Statement::render(&delete)).await
}
#[derive(Debug, Clone)]
pub(crate) struct EmailTarget {
pub email: String,
pub verified_at: Option<String>,
pub unsubscribed_at: Option<String>,
}
impl EmailTarget {
pub(crate) fn mailable(&self) -> bool {
self.verified_at.is_some() && self.unsubscribed_at.is_none()
}
}
pub(crate) async fn email_target(
db: &dyn Database,
account_id: &str,
) -> Result<Option<EmailTarget>, DbError> {
let mut select = Query::select();
select
.columns([iden("email"), iden("verified_at"), iden("unsubscribed_at")])
.from(iden(EMAIL_TARGETS))
.and_where(Expr::col(iden("account_id")).eq(account_id));
let rows = db.query(&Statement::render(&select)).await?;
let Some(row) = rows.first() else {
return Ok(None);
};
Ok(Some(EmailTarget {
email: row
.get::<String>("email")
.ok_or_else(|| corrupt(EMAIL_TARGETS, account_id, "email"))?,
verified_at: row.get::<String>("verified_at"),
unsubscribed_at: row.get::<String>("unsubscribed_at"),
}))
}
pub(crate) async fn set_email_target(
db: &dyn Database,
account_id: &str,
email: &str,
verified: bool,
now: &str,
) -> Result<(), DbError> {
let existing = email_target(db, account_id).await?;
let verified_at = verified.then(|| now.to_owned());
let statement = if let Some(current) = existing {
let mut update = Query::update();
update
.table(iden(EMAIL_TARGETS))
.value(iden("email"), email)
.value(iden("updated_at"), now)
.and_where(Expr::col(iden("account_id")).eq(account_id));
if current.email == email {
if let Some(at) = verified_at {
update.value(iden("verified_at"), at);
}
} else {
update
.value(iden("verified_at"), verified_at)
.value(iden("unsubscribed_at"), None::<String>)
.value(iden("unsubscribed_reason"), None::<String>);
}
Statement::render(&update)
} else {
let mut insert = Query::insert();
insert
.into_table(iden(EMAIL_TARGETS))
.columns(["account_id", "email", "verified_at", "updated_at"])
.values_panic([
account_id.into(),
email.into(),
verified_at.into(),
now.into(),
]);
Statement::render(&insert)
};
db.execute(&statement).await?;
Ok(())
}
pub(crate) async fn suppress_address(
db: &dyn Database,
email: &str,
reason: &str,
now: &str,
) -> Result<u64, DbError> {
let mut update = Query::update();
update
.table(iden(EMAIL_TARGETS))
.value(iden("unsubscribed_at"), now)
.value(iden("unsubscribed_reason"), reason)
.value(iden("updated_at"), now)
.and_where(Expr::col(iden("email")).eq(email))
.and_where(Expr::col(iden("unsubscribed_at")).is_null());
db.execute(&Statement::render(&update)).await
}
pub(crate) async fn unsubscribe_all(
db: &dyn Database,
account_id: &str,
reason: &str,
now: &str,
) -> Result<bool, DbError> {
let mut update = Query::update();
update
.table(iden(EMAIL_TARGETS))
.value(iden("unsubscribed_at"), now)
.value(iden("unsubscribed_reason"), reason)
.value(iden("updated_at"), now)
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("unsubscribed_at")).is_null());
Ok(db.execute(&Statement::render(&update)).await? == 1)
}
pub(crate) async fn emails_since(
db: &dyn Database,
account_id: &str,
category: &str,
since: &str,
) -> Result<i64, DbError> {
let mut select = Query::select();
select
.expr_as(Expr::col(iden("id")).count(), iden("sent"))
.from(iden(EMAIL_SENDS))
.and_where(Expr::col(iden("account_id")).eq(account_id))
.and_where(Expr::col(iden("category")).eq(category))
.and_where(Expr::col(iden("sent_at")).gte(since));
Ok(db
.query(&Statement::render(&select))
.await?
.first()
.and_then(|row| row.get::<i64>("sent"))
.unwrap_or(0))
}
#[must_use]
pub(crate) fn record_email_statement(
id: &str,
account_id: &str,
category: &str,
now: &str,
) -> Statement {
let mut insert = Query::insert();
insert
.into_table(iden(EMAIL_SENDS))
.columns(["id", "account_id", "category", "sent_at"])
.values_panic([id.into(), account_id.into(), category.into(), now.into()]);
Statement::render(&insert)
}
pub(crate) async fn prune_email_sends(db: &dyn Database, older_than: &str) -> Result<u64, DbError> {
let mut delete = Query::delete();
delete
.from_table(iden(EMAIL_SENDS))
.and_where(Expr::col(iden("sent_at")).lt(older_than));
db.execute(&Statement::render(&delete)).await
}
pub(crate) struct SuppressedBurst {
pub account_id: String,
pub category: String,
pub count: usize,
pub ids: Vec<String>,
pub first_suppressed_at: String,
}
pub(crate) async fn email_suppression_exists(
db: &dyn Database,
notification_id: &str,
) -> Result<bool, DbError> {
let mut select = Query::select();
select
.column(iden("id"))
.from(iden(EMAIL_SUPPRESSED))
.and_where(Expr::col(iden("notification_id")).eq(notification_id));
Ok(!db.query(&Statement::render(&select)).await?.rows.is_empty())
}
pub(crate) async fn record_email_suppression(
db: &dyn Database,
id: &str,
account_id: &str,
category: &str,
notification_id: &str,
now: &str,
) -> Result<(), DbError> {
let mut insert = Query::insert();
insert
.into_table(iden(EMAIL_SUPPRESSED))
.columns([
"id",
"account_id",
"category",
"notification_id",
"suppressed_at",
])
.values_panic([
id.into(),
account_id.into(),
category.into(),
notification_id.into(),
now.into(),
]);
db.execute(&Statement::render(&insert)).await?;
Ok(())
}
pub(crate) async fn suppressed_email_bursts(
db: &dyn Database,
limit: u64,
) -> Result<Vec<SuppressedBurst>, DbError> {
let mut select = Query::select();
select
.columns([
iden("id"),
iden("account_id"),
iden("category"),
iden("suppressed_at"),
])
.from(iden(EMAIL_SUPPRESSED))
.order_by(iden("suppressed_at"), Order::Asc)
.limit(limit);
let mut bursts: Vec<SuppressedBurst> = Vec::new();
for row in db.query(&Statement::render(&select)).await?.rows {
let row_id = row
.get::<String>("id")
.ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "id"))?;
let account_id = row
.get::<String>("account_id")
.ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "account_id"))?;
let category = row
.get::<String>("category")
.ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "category"))?;
let suppressed_at = row
.get::<String>("suppressed_at")
.ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "suppressed_at"))?;
match bursts
.iter_mut()
.find(|burst| burst.account_id == account_id && burst.category == category)
{
Some(burst) => {
burst.count += 1;
burst.ids.push(row_id);
}
None => bursts.push(SuppressedBurst {
account_id,
category,
count: 1,
ids: vec![row_id],
first_suppressed_at: suppressed_at,
}),
}
}
Ok(bursts)
}
#[must_use]
pub(crate) fn clear_email_suppression_statement(ids: &[String]) -> Statement {
let mut delete = Query::delete();
delete
.from_table(iden(EMAIL_SUPPRESSED))
.and_where(Expr::col(iden("id")).is_in(ids.to_vec()));
Statement::render(&delete)
}
pub(crate) async fn prune_email_suppressed(
db: &dyn Database,
older_than: &str,
) -> Result<u64, DbError> {
let mut delete = Query::delete();
delete
.from_table(iden(EMAIL_SUPPRESSED))
.and_where(Expr::col(iden("suppressed_at")).lt(older_than));
db.execute(&Statement::render(&delete)).await
}