1use cratefield_core::{Database, DbError, Recipient, Row, Statement};
10use cratefield_i18n::LanguageIdentifier;
11use sea_query::{Alias, Expr, Order, Query};
12use serde::{Deserialize, Serialize};
13
14pub(crate) const SUBSCRIPTIONS: &str = "notifications_subscriptions";
17pub(crate) const PREFERENCES: &str = "notifications_preferences";
19pub(crate) const OUTBOX: &str = "notifications_outbox";
21pub(crate) const DEAD_LETTERS: &str = "notifications_dead_letters";
23pub(crate) const INBOX: &str = "notifications_inbox";
25pub(crate) const EMAIL_TARGETS: &str = "notifications_email_targets";
27pub(crate) const EMAIL_SENDS: &str = "notifications_email_sends";
29pub(crate) const EMAIL_SUPPRESSED: &str = "notifications_email_suppressed";
32pub(crate) const LOCALES: &str = "notifications_locales";
34
35fn iden(name: &str) -> Alias {
36 Alias::new(name)
37}
38
39fn corrupt(table: &str, id: &str, what: &str) -> DbError {
42 DbError::Query(format!("{table} row {id} has no usable {what}"))
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
53#[serde(rename_all = "lowercase")]
54pub enum Transport {
55 Apns,
56 Fcm,
57 Webpush,
58}
59
60impl Transport {
61 #[must_use]
63 pub fn as_str(self) -> &'static str {
64 match self {
65 Transport::Apns => "apns",
66 Transport::Fcm => "fcm",
67 Transport::Webpush => "webpush",
68 }
69 }
70
71 #[must_use]
74 pub fn of(recipient: &Recipient) -> Self {
75 match recipient {
76 Recipient::Apns { .. } => Transport::Apns,
77 Recipient::Fcm { .. } => Transport::Fcm,
78 Recipient::WebPush { .. } => Transport::Webpush,
79 }
80 }
81
82 fn parse(raw: &str) -> Option<Self> {
83 match raw {
84 "apns" => Some(Transport::Apns),
85 "fcm" => Some(Transport::Fcm),
86 "webpush" => Some(Transport::Webpush),
87 _ => None,
88 }
89 }
90}
91
92impl std::fmt::Display for Transport {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 f.write_str(self.as_str())
95 }
96}
97
98fn canonical(recipient: &Recipient) -> String {
103 match recipient {
104 Recipient::Apns { device_token } => format!("apns:{device_token}"),
105 Recipient::Fcm { registration_token } => format!("fcm:{registration_token}"),
106 Recipient::WebPush {
107 endpoint, p256dh, ..
108 } => format!("webpush:{endpoint}\u{0}{p256dh}"),
109 }
110}
111
112#[must_use]
116pub(crate) fn recipient_hash(recipient: &Recipient) -> String {
117 use sha2::{Digest as _, Sha256};
118 use std::fmt::Write as _;
119
120 let digest = Sha256::digest(canonical(recipient).as_bytes());
121 let mut hex = String::with_capacity(64);
122 for byte in digest {
123 let _ = write!(hex, "{byte:02x}");
124 }
125 hex
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub(crate) struct Subscription {
131 pub id: String,
132 pub account_id: String,
133 pub transport: Transport,
134 pub recipient: Recipient,
135 pub app_id: Option<String>,
136 pub app_version: Option<String>,
137 pub locale: Option<LanguageIdentifier>,
143 pub created_at: String,
144 pub last_seen_at: String,
145}
146
147const SUBSCRIPTION_COLUMNS: [&str; 9] = [
148 "id",
149 "account_id",
150 "transport",
151 "recipient_json",
152 "app_id",
153 "app_version",
154 "locale",
155 "created_at",
156 "last_seen_at",
157];
158
159fn read_subscription(row: &Row) -> Result<Subscription, DbError> {
160 let id = row
161 .get::<String>("id")
162 .ok_or_else(|| corrupt(SUBSCRIPTIONS, "?", "id"))?;
163 let raw = row
164 .get::<String>("recipient_json")
165 .ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "recipient"))?;
166 let recipient: Recipient = serde_json::from_str(&raw)
167 .map_err(|_| corrupt(SUBSCRIPTIONS, &id, "recipient (not the port's JSON form)"))?;
168 let transport = row
169 .get::<String>("transport")
170 .as_deref()
171 .and_then(Transport::parse)
172 .ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "transport"))?;
173 Ok(Subscription {
174 account_id: row
175 .get::<String>("account_id")
176 .ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "account"))?,
177 transport,
178 recipient,
179 app_id: row.get::<Option<String>>("app_id").flatten(),
180 app_version: row.get::<Option<String>>("app_version").flatten(),
181 locale: row
182 .get::<String>("locale")
183 .as_deref()
184 .and_then(cratefield_i18n::parse_locale),
185 created_at: row.get::<String>("created_at").unwrap_or_default(),
186 last_seen_at: row.get::<String>("last_seen_at").unwrap_or_default(),
187 id,
188 })
189}
190
191fn select_subscriptions() -> sea_query::SelectStatement {
192 let mut select = Query::select();
193 select
194 .columns(SUBSCRIPTION_COLUMNS.map(iden))
195 .from(iden(SUBSCRIPTIONS));
196 select
197}
198
199pub(crate) struct NewSubscription<'a> {
201 pub id: &'a str,
202 pub account_id: &'a str,
203 pub recipient: &'a Recipient,
204 pub app_id: Option<&'a str>,
205 pub app_version: Option<&'a str>,
206 pub user_agent: Option<&'a str>,
207 pub locale: Option<&'a str>,
212 pub now: &'a str,
213 pub rehome_cutoff: &'a str,
215 pub rehome_limit: usize,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
224pub(crate) enum Upserted {
225 Created(String),
227 Refreshed(String),
229 Rehomed {
234 id: String,
235 previous_account_id: String,
236 },
237 RehomeRefused,
242}
243
244async fn owner_of(
246 db: &dyn Database,
247 transport: Transport,
248 hash: &str,
249) -> Result<Option<(String, String)>, DbError> {
250 let mut select = Query::select();
251 select
252 .columns([iden("id"), iden("account_id")])
253 .from(iden(SUBSCRIPTIONS))
254 .and_where(Expr::col(iden("transport")).eq(transport.as_str()))
255 .and_where(Expr::col(iden("recipient_hash")).eq(hash));
256 let Some(row) = db
257 .query(&Statement::render(&select))
258 .await?
259 .first()
260 .cloned()
261 else {
262 return Ok(None);
263 };
264 let id = row
265 .get::<String>("id")
266 .ok_or_else(|| corrupt(SUBSCRIPTIONS, "?", "id"))?;
267 let account_id = row
268 .get::<String>("account_id")
269 .ok_or_else(|| corrupt(SUBSCRIPTIONS, &id, "account"))?;
270 Ok(Some((id, account_id)))
271}
272
273async fn rehomes_since(
276 db: &dyn Database,
277 account_id: &str,
278 cutoff: &str,
279 limit: usize,
280) -> Result<usize, DbError> {
281 let mut select = Query::select();
285 select
286 .column(iden("id"))
287 .from(iden(SUBSCRIPTIONS))
288 .and_where(Expr::col(iden("account_id")).eq(account_id))
289 .and_where(Expr::col(iden("rehomed_at")).gt(cutoff))
290 .limit(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1));
291 Ok(db.query(&Statement::render(&select)).await?.rows.len())
292}
293
294fn refresh_statement(
298 id: &str,
299 new: &NewSubscription<'_>,
300 recipient_json: &str,
301 rehomed_from: Option<&str>,
302) -> Statement {
303 let mut update = Query::update();
304 update
305 .table(iden(SUBSCRIPTIONS))
306 .value(iden("account_id"), new.account_id)
307 .value(iden("recipient_json"), recipient_json)
308 .value(iden("app_id"), new.app_id)
309 .value(iden("app_version"), new.app_version)
310 .value(iden("user_agent"), new.user_agent)
311 .value(iden("last_seen_at"), new.now)
312 .and_where(Expr::col(iden("id")).eq(id));
313 if let Some(locale) = new.locale {
316 update.value(iden("locale"), locale);
317 }
318 if let Some(previous) = rehomed_from {
319 update
320 .value(iden("rehomed_at"), new.now)
321 .and_where(Expr::col(iden("account_id")).eq(previous));
324 }
325 Statement::render(&update)
326}
327
328pub(crate) async fn upsert_subscription(
346 db: &dyn Database,
347 new: &NewSubscription<'_>,
348) -> Result<Upserted, DbError> {
349 let transport = Transport::of(new.recipient);
350 let hash = recipient_hash(new.recipient);
351 let recipient_json = serde_json::to_string(new.recipient)
352 .map_err(|err| DbError::Execute(format!("recipient does not serialise: {err}")))?;
353
354 if let Some(existing) = owner_of(db, transport, &hash).await? {
355 return take_over(db, new, &recipient_json, existing).await;
356 }
357
358 let mut insert = Query::insert();
359 insert
360 .into_table(iden(SUBSCRIPTIONS))
361 .columns([
362 "id",
363 "account_id",
364 "transport",
365 "recipient_json",
366 "recipient_hash",
367 "app_id",
368 "app_version",
369 "user_agent",
370 "locale",
371 "created_at",
372 "last_seen_at",
373 ])
374 .values_panic([
375 new.id.into(),
376 new.account_id.into(),
377 transport.as_str().into(),
378 recipient_json.as_str().into(),
379 hash.as_str().into(),
380 new.app_id.into(),
381 new.app_version.into(),
382 new.user_agent.into(),
383 new.locale.into(),
384 new.now.into(),
385 new.now.into(),
386 ]);
387 match db.execute(&Statement::render(&insert)).await {
388 Ok(_) => Ok(Upserted::Created(new.id.to_owned())),
389 Err(err) => match owner_of(db, transport, &hash).await? {
390 Some(existing) => take_over(db, new, &recipient_json, existing).await,
393 None => Err(err),
394 },
395 }
396}
397
398async fn take_over(
401 db: &dyn Database,
402 new: &NewSubscription<'_>,
403 recipient_json: &str,
404 existing: (String, String),
405) -> Result<Upserted, DbError> {
406 let (id, owner) = existing;
407 if owner == new.account_id {
408 db.execute(&refresh_statement(&id, new, recipient_json, None))
409 .await?;
410 return Ok(Upserted::Refreshed(id));
411 }
412
413 if rehomes_since(db, new.account_id, new.rehome_cutoff, new.rehome_limit).await?
417 >= new.rehome_limit
418 {
419 return Ok(Upserted::RehomeRefused);
420 }
421
422 let affected = db
423 .execute(&refresh_statement(&id, new, recipient_json, Some(&owner)))
424 .await?;
425 if affected == 0 {
426 return match owner_of(
431 db,
432 Transport::of(new.recipient),
433 &recipient_hash(new.recipient),
434 )
435 .await?
436 {
437 Some((id, now_owner)) if now_owner == new.account_id => Ok(Upserted::Refreshed(id)),
438 _ => Ok(Upserted::RehomeRefused),
439 };
440 }
441 Ok(Upserted::Rehomed {
442 id,
443 previous_account_id: owner,
444 })
445}
446
447pub(crate) async fn subscriptions_for_account(
453 db: &dyn Database,
454 account_id: &str,
455) -> Result<Vec<Subscription>, DbError> {
456 let mut select = select_subscriptions();
457 select
458 .and_where(Expr::col(iden("account_id")).eq(account_id))
459 .order_by(iden("created_at"), Order::Asc)
460 .order_by(iden("id"), Order::Asc);
461 db.query(&Statement::render(&select))
462 .await?
463 .rows
464 .iter()
465 .map(read_subscription)
466 .collect()
467}
468
469pub(crate) async fn subscription_by_id(
475 db: &dyn Database,
476 id: &str,
477) -> Result<Option<Subscription>, DbError> {
478 let mut select = select_subscriptions();
479 select.and_where(Expr::col(iden("id")).eq(id));
480 match db.query(&Statement::render(&select)).await?.first() {
481 Some(row) => read_subscription(row).map(Some),
482 None => Ok(None),
483 }
484}
485
486pub(crate) async fn delete_subscription_for_account(
495 db: &dyn Database,
496 id: &str,
497 account_id: &str,
498) -> Result<bool, DbError> {
499 let mut delete = Query::delete();
500 delete
501 .from_table(iden(SUBSCRIPTIONS))
502 .and_where(Expr::col(iden("id")).eq(id))
503 .and_where(Expr::col(iden("account_id")).eq(account_id));
504 Ok(db.execute(&Statement::render(&delete)).await? == 1)
505}
506
507#[must_use]
510pub(crate) fn delete_subscription_statement(id: &str) -> Statement {
511 let mut delete = Query::delete();
512 delete
513 .from_table(iden(SUBSCRIPTIONS))
514 .and_where(Expr::col(iden("id")).eq(id));
515 Statement::render(&delete)
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub(crate) struct Channels {
526 pub push: bool,
527 pub in_app: bool,
528 pub email: bool,
529}
530
531impl Channels {
532 pub(crate) fn all(enabled: bool) -> Self {
533 Self {
534 push: enabled,
535 in_app: enabled,
536 email: enabled,
537 }
538 }
539}
540
541fn flag(row: &Row, column: &str) -> bool {
542 row.get::<i64>(column).unwrap_or(0) != 0
543}
544
545pub(crate) async fn preferences_for_account(
558 db: &dyn Database,
559 account_id: &str,
560) -> Result<Vec<(String, Channels)>, DbError> {
561 let mut select = Query::select();
562 select
563 .columns([
564 iden("category"),
565 iden("push"),
566 iden("in_app"),
567 iden("email"),
568 ])
569 .from(iden(PREFERENCES))
570 .and_where(Expr::col(iden("account_id")).eq(account_id))
571 .order_by(iden("category"), Order::Asc);
572 db.query(&Statement::render(&select))
573 .await?
574 .rows
575 .iter()
576 .map(|row| {
577 let category = row
578 .get::<String>("category")
579 .ok_or_else(|| corrupt(PREFERENCES, account_id, "category"))?;
580 Ok((
581 category,
582 Channels {
583 push: flag(row, "push"),
584 in_app: flag(row, "in_app"),
585 email: flag(row, "email"),
586 },
587 ))
588 })
589 .collect()
590}
591
592pub(crate) async fn preference(
599 db: &dyn Database,
600 account_id: &str,
601 category: &str,
602) -> Result<Option<Channels>, DbError> {
603 let mut select = Query::select();
604 select
605 .columns([iden("push"), iden("in_app"), iden("email")])
606 .from(iden(PREFERENCES))
607 .and_where(Expr::col(iden("account_id")).eq(account_id))
608 .and_where(Expr::col(iden("category")).eq(category));
609 Ok(db
610 .query(&Statement::render(&select))
611 .await?
612 .first()
613 .map(|row| Channels {
614 push: flag(row, "push"),
615 in_app: flag(row, "in_app"),
616 email: flag(row, "email"),
617 }))
618}
619
620#[must_use]
626pub(crate) fn write_preference_statement(
627 account_id: &str,
628 category: &str,
629 channels: Channels,
630 exists: bool,
631 now: &str,
632) -> Statement {
633 let bit = |on: bool| i64::from(on);
634 if exists {
635 let mut update = Query::update();
636 update
637 .table(iden(PREFERENCES))
638 .value(iden("push"), bit(channels.push))
639 .value(iden("in_app"), bit(channels.in_app))
640 .value(iden("email"), bit(channels.email))
641 .value(iden("updated_at"), now)
642 .and_where(Expr::col(iden("account_id")).eq(account_id))
643 .and_where(Expr::col(iden("category")).eq(category));
644 return Statement::render(&update);
645 }
646 let mut insert = Query::insert();
647 insert
648 .into_table(iden(PREFERENCES))
649 .columns([
650 "account_id",
651 "category",
652 "push",
653 "in_app",
654 "email",
655 "updated_at",
656 ])
657 .values_panic([
658 account_id.into(),
659 category.into(),
660 bit(channels.push).into(),
661 bit(channels.in_app).into(),
662 bit(channels.email).into(),
663 now.into(),
664 ]);
665 Statement::render(&insert)
666}
667
668pub(crate) async fn account_locale(
683 db: &dyn Database,
684 account_id: &str,
685) -> Result<Option<LanguageIdentifier>, DbError> {
686 let mut select = Query::select();
687 select
688 .column(iden("locale"))
689 .from(iden(LOCALES))
690 .and_where(Expr::col(iden("account_id")).eq(account_id));
691 Ok(db
692 .query(&Statement::render(&select))
693 .await?
694 .first()
695 .and_then(|row| row.get::<String>("locale"))
696 .as_deref()
697 .and_then(cratefield_i18n::parse_locale))
698}
699
700pub(crate) async fn set_account_locale(
712 db: &dyn Database,
713 account_id: &str,
714 locale: &str,
715 now: &str,
716) -> Result<(), DbError> {
717 let existing = account_locale(db, account_id).await?.is_some();
718 let statement = if existing {
719 let mut update = Query::update();
720 update
721 .table(iden(LOCALES))
722 .value(iden("locale"), locale)
723 .value(iden("updated_at"), now)
724 .and_where(Expr::col(iden("account_id")).eq(account_id));
725 Statement::render(&update)
726 } else {
727 let mut insert = Query::insert();
728 insert
729 .into_table(iden(LOCALES))
730 .columns(["account_id", "locale", "updated_at"])
731 .values_panic([account_id.into(), locale.into(), now.into()]);
732 Statement::render(&insert)
733 };
734 db.execute(&statement).await?;
735 Ok(())
736}
737
738#[must_use]
746pub(crate) fn delete_outbox_statement(id: &str) -> Statement {
747 let mut delete = Query::delete();
748 delete
749 .from_table(iden(OUTBOX))
750 .and_where(Expr::col(iden("id")).eq(id));
751 Statement::render(&delete)
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum DeadLetterReason {
758 Rejected,
760 NotConfigured,
764 AttemptsExhausted,
766 Malformed,
768}
769
770impl DeadLetterReason {
771 #[must_use]
772 pub fn as_str(self) -> &'static str {
773 match self {
774 DeadLetterReason::Rejected => "rejected",
775 DeadLetterReason::NotConfigured => "not_configured",
776 DeadLetterReason::AttemptsExhausted => "attempts_exhausted",
777 DeadLetterReason::Malformed => "malformed",
778 }
779 }
780}
781
782pub(crate) async fn outbox_created_at(
790 db: &dyn Database,
791 id: &str,
792) -> Result<Option<String>, DbError> {
793 let mut select = Query::select();
794 select
795 .column(iden("created_at"))
796 .from(iden(OUTBOX))
797 .and_where(Expr::col(iden("id")).eq(id));
798 Ok(db
799 .query(&Statement::render(&select))
800 .await?
801 .first()
802 .and_then(|row| row.get::<String>("created_at")))
803}
804
805pub(crate) struct DeadLetter<'a> {
807 pub reason: DeadLetterReason,
808 pub last_error: &'a str,
811 pub attempts: i64,
817 pub created_at: &'a str,
820 pub failed_at: &'a str,
822}
823
824#[must_use]
840pub(crate) fn dead_letter_statement(
841 record: &cratefield_core::OutboxRecord,
842 dead: &DeadLetter<'_>,
843) -> Statement {
844 let account_id = payload_account(&record.payload);
845 let mut insert = Query::insert();
846 insert
847 .into_table(iden(DEAD_LETTERS))
848 .columns([
849 "id",
850 "account_id",
851 "topic",
852 "payload",
853 "attempts",
854 "reason",
855 "last_error",
856 "created_at",
857 "failed_at",
858 ])
859 .values_panic([
860 record.id.as_str().into(),
861 account_id.as_deref().into(),
862 record.topic.as_str().into(),
863 record.payload.as_str().into(),
864 dead.attempts.into(),
865 dead.reason.as_str().into(),
866 cratefield_core::scrub_text(dead.last_error).into(),
867 dead.created_at.into(),
868 dead.failed_at.into(),
869 ]);
870 Statement::render(&insert)
871}
872
873fn payload_account(payload: &str) -> Option<String> {
888 #[derive(Deserialize)]
889 struct AccountOnly {
890 account_id: String,
891 }
892 serde_json::from_str::<AccountOnly>(payload)
893 .ok()
894 .map(|job| job.account_id)
895 .filter(|account| !account.is_empty())
896}
897
898#[derive(Debug, Clone, Serialize)]
903pub(crate) struct InboxItem {
904 pub id: String,
905 pub notification_id: String,
906 pub category: String,
907 pub title: String,
908 pub body: String,
909 #[serde(skip_serializing_if = "Option::is_none")]
910 pub url: Option<String>,
911 #[serde(skip_serializing_if = "Option::is_none")]
912 pub icon: Option<String>,
913 pub data: serde_json::Value,
914 #[serde(skip_serializing_if = "Option::is_none")]
917 pub locale: Option<String>,
918 #[serde(skip_serializing_if = "Option::is_none")]
926 pub dir: Option<&'static str>,
927 #[serde(skip_serializing_if = "Option::is_none")]
931 pub loc_key: Option<String>,
932 #[serde(skip_serializing_if = "Option::is_none")]
933 pub loc_args: Option<serde_json::Value>,
934 pub created_at: String,
935 #[serde(skip_serializing_if = "Option::is_none")]
936 pub read_at: Option<String>,
937}
938
939pub(crate) struct NewInboxRow<'a> {
941 pub id: &'a str,
942 pub account_id: &'a str,
943 pub notification_id: &'a str,
944 pub category: &'a str,
945 pub title: &'a str,
946 pub body: &'a str,
947 pub url: Option<&'a str>,
948 pub icon: Option<&'a str>,
949 pub data_json: &'a str,
950 pub locale: Option<&'a str>,
953 pub loc_key: Option<&'a str>,
954 pub loc_args_json: Option<&'a str>,
955 pub now: &'a str,
956}
957
958#[must_use]
964pub(crate) fn insert_inbox_statement(row: &NewInboxRow<'_>) -> Statement {
965 let mut insert = Query::insert();
966 insert
967 .into_table(iden(INBOX))
968 .columns([
969 "id",
970 "account_id",
971 "notification_id",
972 "category",
973 "title",
974 "body",
975 "url",
976 "icon",
977 "data_json",
978 "locale",
979 "loc_key",
980 "loc_args_json",
981 "created_at",
982 ])
983 .values_panic([
984 row.id.into(),
985 row.account_id.into(),
986 row.notification_id.into(),
987 row.category.into(),
988 row.title.into(),
989 row.body.into(),
990 row.url.into(),
991 row.icon.into(),
992 row.data_json.into(),
993 row.locale.into(),
994 row.loc_key.into(),
995 row.loc_args_json.into(),
996 row.now.into(),
997 ]);
998 Statement::render(&insert)
999}
1000
1001pub(crate) async fn inbox_page(
1018 db: &dyn Database,
1019 account_id: &str,
1020 before: Option<(&str, &str)>,
1021 unread_only: bool,
1022 limit: u64,
1023) -> Result<Vec<InboxItem>, DbError> {
1024 let mut select = Query::select();
1025 select
1026 .columns([
1027 iden("id"),
1028 iden("notification_id"),
1029 iden("category"),
1030 iden("title"),
1031 iden("body"),
1032 iden("url"),
1033 iden("icon"),
1034 iden("data_json"),
1035 iden("locale"),
1036 iden("loc_key"),
1037 iden("loc_args_json"),
1038 iden("created_at"),
1039 iden("read_at"),
1040 ])
1041 .from(iden(INBOX))
1042 .and_where(Expr::col(iden("account_id")).eq(account_id))
1043 .and_where(Expr::col(iden("archived_at")).is_null())
1044 .order_by(iden("created_at"), Order::Desc)
1045 .order_by(iden("id"), Order::Desc)
1046 .limit(limit);
1047 if let Some((at, id)) = before {
1048 select.and_where(
1052 Expr::col(iden("created_at"))
1053 .lt(at)
1054 .or(Expr::col(iden("created_at"))
1055 .eq(at)
1056 .and(Expr::col(iden("id")).lt(id))),
1057 );
1058 }
1059 if unread_only {
1060 select.and_where(Expr::col(iden("read_at")).is_null());
1061 }
1062 db.query(&Statement::render(&select))
1063 .await?
1064 .rows
1065 .iter()
1066 .map(read_inbox_item)
1067 .collect()
1068}
1069
1070fn read_inbox_item(row: &Row) -> Result<InboxItem, DbError> {
1072 let locale = row
1073 .get::<String>("locale")
1074 .as_deref()
1075 .and_then(cratefield_i18n::parse_locale);
1076 let id = row
1077 .get::<String>("id")
1078 .ok_or_else(|| DbError::Query(format!("{INBOX} row has no id")))?;
1079 let data = row
1080 .get::<String>("data_json")
1081 .as_deref()
1082 .map_or_else(|| Ok(serde_json::Value::Null), serde_json::from_str)
1083 .map_err(|err| DbError::Query(format!("{INBOX} row {id} has unreadable data: {err}")))?;
1084 Ok(InboxItem {
1085 notification_id: row
1086 .get::<String>("notification_id")
1087 .ok_or_else(|| corrupt(INBOX, &id, "notification_id"))?,
1088 category: row
1089 .get::<String>("category")
1090 .ok_or_else(|| corrupt(INBOX, &id, "category"))?,
1091 title: row
1092 .get::<String>("title")
1093 .ok_or_else(|| corrupt(INBOX, &id, "title"))?,
1094 body: row
1095 .get::<String>("body")
1096 .ok_or_else(|| corrupt(INBOX, &id, "body"))?,
1097 url: row.get::<String>("url"),
1098 icon: row.get::<String>("icon"),
1099 data,
1100 dir: locale
1104 .as_ref()
1105 .map(|locale| cratefield_i18n::direction(locale).as_str()),
1106 locale: locale.map(|locale| locale.to_string()),
1107 loc_key: row.get::<String>("loc_key"),
1108 loc_args: row
1109 .get::<String>("loc_args_json")
1110 .as_deref()
1111 .and_then(|raw| serde_json::from_str(raw).ok()),
1112 created_at: row
1113 .get::<String>("created_at")
1114 .ok_or_else(|| corrupt(INBOX, &id, "created_at"))?,
1115 read_at: row.get::<String>("read_at"),
1116 id,
1117 })
1118}
1119
1120pub(crate) async fn unread_count(db: &dyn Database, account_id: &str) -> Result<i64, DbError> {
1126 let mut select = Query::select();
1127 select
1128 .expr_as(Expr::col(iden("id")).count(), iden("unread"))
1129 .from(iden(INBOX))
1130 .and_where(Expr::col(iden("account_id")).eq(account_id))
1131 .and_where(Expr::col(iden("archived_at")).is_null())
1132 .and_where(Expr::col(iden("read_at")).is_null());
1133 Ok(db
1134 .query(&Statement::render(&select))
1135 .await?
1136 .first()
1137 .and_then(|row| row.get::<i64>("unread"))
1138 .unwrap_or(0))
1139}
1140
1141pub(crate) async fn mark_read(
1152 db: &dyn Database,
1153 id: &str,
1154 account_id: &str,
1155 now: &str,
1156) -> Result<bool, DbError> {
1157 let mut exists = Query::select();
1158 exists
1159 .column(iden("id"))
1160 .from(iden(INBOX))
1161 .and_where(Expr::col(iden("id")).eq(id))
1162 .and_where(Expr::col(iden("account_id")).eq(account_id));
1163 if db
1164 .query(&Statement::render(&exists))
1165 .await?
1166 .first()
1167 .is_none()
1168 {
1169 return Ok(false);
1170 }
1171 let mut update = Query::update();
1172 update
1173 .table(iden(INBOX))
1174 .value(iden("read_at"), now)
1175 .and_where(Expr::col(iden("id")).eq(id))
1176 .and_where(Expr::col(iden("account_id")).eq(account_id))
1177 .and_where(Expr::col(iden("read_at")).is_null());
1178 db.execute(&Statement::render(&update)).await?;
1179 Ok(true)
1180}
1181
1182pub(crate) async fn mark_all_read(
1189 db: &dyn Database,
1190 account_id: &str,
1191 now: &str,
1192) -> Result<u64, DbError> {
1193 let mut update = Query::update();
1194 update
1195 .table(iden(INBOX))
1196 .value(iden("read_at"), now)
1197 .and_where(Expr::col(iden("account_id")).eq(account_id))
1198 .and_where(Expr::col(iden("read_at")).is_null());
1199 db.execute(&Statement::render(&update)).await
1200}
1201
1202pub(crate) async fn archive(
1209 db: &dyn Database,
1210 id: &str,
1211 account_id: &str,
1212 now: &str,
1213) -> Result<bool, DbError> {
1214 let mut update = Query::update();
1215 update
1216 .table(iden(INBOX))
1217 .value(iden("archived_at"), now)
1218 .and_where(Expr::col(iden("id")).eq(id))
1219 .and_where(Expr::col(iden("account_id")).eq(account_id))
1220 .and_where(Expr::col(iden("archived_at")).is_null());
1221 Ok(db.execute(&Statement::render(&update)).await? == 1)
1222}
1223
1224pub(crate) async fn prune_inbox(db: &dyn Database, older_than: &str) -> Result<u64, DbError> {
1235 let mut delete = Query::delete();
1236 delete
1237 .from_table(iden(INBOX))
1238 .and_where(Expr::col(iden("created_at")).lt(older_than))
1239 .and_where(
1240 Expr::col(iden("read_at"))
1241 .is_not_null()
1242 .or(Expr::col(iden("archived_at")).is_not_null()),
1243 );
1244 db.execute(&Statement::render(&delete)).await
1245}
1246
1247#[derive(Debug, Clone)]
1252pub(crate) struct EmailTarget {
1253 pub email: String,
1254 pub verified_at: Option<String>,
1255 pub unsubscribed_at: Option<String>,
1256}
1257
1258impl EmailTarget {
1259 pub(crate) fn mailable(&self) -> bool {
1266 self.verified_at.is_some() && self.unsubscribed_at.is_none()
1267 }
1268}
1269
1270pub(crate) async fn email_target(
1276 db: &dyn Database,
1277 account_id: &str,
1278) -> Result<Option<EmailTarget>, DbError> {
1279 let mut select = Query::select();
1280 select
1281 .columns([iden("email"), iden("verified_at"), iden("unsubscribed_at")])
1282 .from(iden(EMAIL_TARGETS))
1283 .and_where(Expr::col(iden("account_id")).eq(account_id));
1284 let rows = db.query(&Statement::render(&select)).await?;
1285 let Some(row) = rows.first() else {
1286 return Ok(None);
1287 };
1288 Ok(Some(EmailTarget {
1289 email: row
1290 .get::<String>("email")
1291 .ok_or_else(|| corrupt(EMAIL_TARGETS, account_id, "email"))?,
1292 verified_at: row.get::<String>("verified_at"),
1293 unsubscribed_at: row.get::<String>("unsubscribed_at"),
1294 }))
1295}
1296
1297pub(crate) async fn set_email_target(
1304 db: &dyn Database,
1305 account_id: &str,
1306 email: &str,
1307 verified: bool,
1308 now: &str,
1309) -> Result<(), DbError> {
1310 let existing = email_target(db, account_id).await?;
1311 let verified_at = verified.then(|| now.to_owned());
1312 let statement = if let Some(current) = existing {
1313 let mut update = Query::update();
1314 update
1315 .table(iden(EMAIL_TARGETS))
1316 .value(iden("email"), email)
1317 .value(iden("updated_at"), now)
1318 .and_where(Expr::col(iden("account_id")).eq(account_id));
1319 if current.email == email {
1320 if let Some(at) = verified_at {
1324 update.value(iden("verified_at"), at);
1325 }
1326 } else {
1327 update
1330 .value(iden("verified_at"), verified_at)
1331 .value(iden("unsubscribed_at"), None::<String>)
1332 .value(iden("unsubscribed_reason"), None::<String>);
1333 }
1334 Statement::render(&update)
1335 } else {
1336 let mut insert = Query::insert();
1337 insert
1338 .into_table(iden(EMAIL_TARGETS))
1339 .columns(["account_id", "email", "verified_at", "updated_at"])
1340 .values_panic([
1341 account_id.into(),
1342 email.into(),
1343 verified_at.into(),
1344 now.into(),
1345 ]);
1346 Statement::render(&insert)
1347 };
1348 db.execute(&statement).await?;
1349 Ok(())
1350}
1351
1352pub(crate) async fn suppress_address(
1377 db: &dyn Database,
1378 email: &str,
1379 reason: &str,
1380 now: &str,
1381) -> Result<u64, DbError> {
1382 let mut update = Query::update();
1383 update
1384 .table(iden(EMAIL_TARGETS))
1385 .value(iden("unsubscribed_at"), now)
1386 .value(iden("unsubscribed_reason"), reason)
1387 .value(iden("updated_at"), now)
1388 .and_where(Expr::col(iden("email")).eq(email))
1389 .and_where(Expr::col(iden("unsubscribed_at")).is_null());
1390 db.execute(&Statement::render(&update)).await
1391}
1392
1393pub(crate) async fn unsubscribe_all(
1394 db: &dyn Database,
1395 account_id: &str,
1396 reason: &str,
1397 now: &str,
1398) -> Result<bool, DbError> {
1399 let mut update = Query::update();
1400 update
1401 .table(iden(EMAIL_TARGETS))
1402 .value(iden("unsubscribed_at"), now)
1403 .value(iden("unsubscribed_reason"), reason)
1404 .value(iden("updated_at"), now)
1405 .and_where(Expr::col(iden("account_id")).eq(account_id))
1406 .and_where(Expr::col(iden("unsubscribed_at")).is_null());
1407 Ok(db.execute(&Statement::render(&update)).await? == 1)
1408}
1409
1410pub(crate) async fn emails_since(
1416 db: &dyn Database,
1417 account_id: &str,
1418 category: &str,
1419 since: &str,
1420) -> Result<i64, DbError> {
1421 let mut select = Query::select();
1422 select
1423 .expr_as(Expr::col(iden("id")).count(), iden("sent"))
1424 .from(iden(EMAIL_SENDS))
1425 .and_where(Expr::col(iden("account_id")).eq(account_id))
1426 .and_where(Expr::col(iden("category")).eq(category))
1427 .and_where(Expr::col(iden("sent_at")).gte(since));
1428 Ok(db
1429 .query(&Statement::render(&select))
1430 .await?
1431 .first()
1432 .and_then(|row| row.get::<i64>("sent"))
1433 .unwrap_or(0))
1434}
1435
1436#[must_use]
1438pub(crate) fn record_email_statement(
1439 id: &str,
1440 account_id: &str,
1441 category: &str,
1442 now: &str,
1443) -> Statement {
1444 let mut insert = Query::insert();
1445 insert
1446 .into_table(iden(EMAIL_SENDS))
1447 .columns(["id", "account_id", "category", "sent_at"])
1448 .values_panic([id.into(), account_id.into(), category.into(), now.into()]);
1449 Statement::render(&insert)
1450}
1451
1452pub(crate) async fn prune_email_sends(db: &dyn Database, older_than: &str) -> Result<u64, DbError> {
1458 let mut delete = Query::delete();
1459 delete
1460 .from_table(iden(EMAIL_SENDS))
1461 .and_where(Expr::col(iden("sent_at")).lt(older_than));
1462 db.execute(&Statement::render(&delete)).await
1463}
1464
1465pub(crate) struct SuppressedBurst {
1471 pub account_id: String,
1472 pub category: String,
1473 pub count: usize,
1475 pub ids: Vec<String>,
1481 pub first_suppressed_at: String,
1484}
1485
1486pub(crate) async fn email_suppression_exists(
1496 db: &dyn Database,
1497 notification_id: &str,
1498) -> Result<bool, DbError> {
1499 let mut select = Query::select();
1500 select
1501 .column(iden("id"))
1502 .from(iden(EMAIL_SUPPRESSED))
1503 .and_where(Expr::col(iden("notification_id")).eq(notification_id));
1504 Ok(!db.query(&Statement::render(&select)).await?.rows.is_empty())
1505}
1506
1507pub(crate) async fn record_email_suppression(
1513 db: &dyn Database,
1514 id: &str,
1515 account_id: &str,
1516 category: &str,
1517 notification_id: &str,
1518 now: &str,
1519) -> Result<(), DbError> {
1520 let mut insert = Query::insert();
1521 insert
1522 .into_table(iden(EMAIL_SUPPRESSED))
1523 .columns([
1524 "id",
1525 "account_id",
1526 "category",
1527 "notification_id",
1528 "suppressed_at",
1529 ])
1530 .values_panic([
1531 id.into(),
1532 account_id.into(),
1533 category.into(),
1534 notification_id.into(),
1535 now.into(),
1536 ]);
1537 db.execute(&Statement::render(&insert)).await?;
1538 Ok(())
1539}
1540
1541pub(crate) async fn suppressed_email_bursts(
1555 db: &dyn Database,
1556 limit: u64,
1557) -> Result<Vec<SuppressedBurst>, DbError> {
1558 let mut bursts: Vec<SuppressedBurst> = Vec::new();
1567 let mut after: Option<(String, String)> = None;
1568 loop {
1569 let page = read_suppressed_page(db, limit, after.as_ref()).await?;
1570 if page.is_empty() {
1571 break;
1572 }
1573 let full = u64::try_from(page.len()).unwrap_or(u64::MAX) == limit;
1574 let last = page.last().expect("a page just checked as non-empty");
1575 for row in &page {
1576 match bursts
1577 .iter_mut()
1578 .find(|burst| burst.account_id == row.account_id && burst.category == row.category)
1579 {
1580 Some(burst) => {
1581 burst.count += 1;
1582 burst.ids.push(row.id.clone());
1583 }
1584 None => bursts.push(SuppressedBurst {
1585 account_id: row.account_id.clone(),
1586 category: row.category.clone(),
1587 count: 1,
1588 ids: vec![row.id.clone()],
1589 first_suppressed_at: row.suppressed_at.clone(),
1590 }),
1591 }
1592 }
1593 let open = (last.account_id.clone(), last.category.clone());
1594 after = Some((last.suppressed_at.clone(), last.id.clone()));
1595 if !full {
1596 break;
1598 }
1599 let next = read_suppressed_page(db, 1, after.as_ref()).await?;
1602 let continues = next
1603 .first()
1604 .is_some_and(|row| (row.account_id.clone(), row.category.clone()) == open);
1605 if !continues {
1606 break;
1607 }
1608 }
1609 Ok(bursts)
1610}
1611
1612struct SuppressedRow {
1614 id: String,
1615 account_id: String,
1616 category: String,
1617 suppressed_at: String,
1618}
1619
1620async fn read_suppressed_page(
1624 db: &dyn Database,
1625 limit: u64,
1626 after: Option<&(String, String)>,
1627) -> Result<Vec<SuppressedRow>, DbError> {
1628 let mut select = Query::select();
1629 select
1630 .columns([
1631 iden("id"),
1632 iden("account_id"),
1633 iden("category"),
1634 iden("suppressed_at"),
1635 ])
1636 .from(iden(EMAIL_SUPPRESSED))
1637 .order_by(iden("suppressed_at"), Order::Asc)
1638 .order_by(iden("id"), Order::Asc)
1639 .limit(limit);
1640 if let Some((at, id)) = after {
1641 select.and_where(
1642 Expr::col(iden("suppressed_at"))
1643 .gt(at)
1644 .or(Expr::col(iden("suppressed_at"))
1645 .eq(at)
1646 .and(Expr::col(iden("id")).gt(id))),
1647 );
1648 }
1649 let mut rows = Vec::new();
1650 for row in db.query(&Statement::render(&select)).await?.rows {
1651 rows.push(SuppressedRow {
1652 id: row
1653 .get::<String>("id")
1654 .ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "id"))?,
1655 account_id: row
1656 .get::<String>("account_id")
1657 .ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "account_id"))?,
1658 category: row
1659 .get::<String>("category")
1660 .ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "category"))?,
1661 suppressed_at: row
1662 .get::<String>("suppressed_at")
1663 .ok_or_else(|| corrupt(EMAIL_SUPPRESSED, "?", "suppressed_at"))?,
1664 });
1665 }
1666 Ok(rows)
1667}
1668
1669#[must_use]
1674pub(crate) fn clear_email_suppression_statement(ids: &[String]) -> Statement {
1675 let mut delete = Query::delete();
1676 delete
1677 .from_table(iden(EMAIL_SUPPRESSED))
1678 .and_where(Expr::col(iden("id")).is_in(ids.to_vec()));
1679 Statement::render(&delete)
1680}
1681
1682pub(crate) async fn prune_email_suppressed(
1688 db: &dyn Database,
1689 older_than: &str,
1690) -> Result<u64, DbError> {
1691 let mut delete = Query::delete();
1692 delete
1693 .from_table(iden(EMAIL_SUPPRESSED))
1694 .and_where(Expr::col(iden("suppressed_at")).lt(older_than));
1695 db.execute(&Statement::render(&delete)).await
1696}