cratefield_module_notifications/channel.rs
1//! The seam a pluggable delivery channel registers through (issue #764):
2//! one trait, the message it is handed, and the errors its delivery can
3//! answer with.
4//!
5//! Push and email are built in: their transports are ports (`Push`,
6//! `Mailer`) and their recipients live in this module's own tables. A
7//! channel like Telegram is neither — its credentials belong to the crate
8//! that implements them — so the venture hands the module a [`Channel`]
9//! instead, and the module does for it exactly what it does for mail: gate
10//! it on the account's preference, queue it in the outbox so it commits
11//! atomically with the state change that caused it, re-check the gates at
12//! send time, retry what `Transient` and dead-letter what is not.
13
14use std::time::Duration;
15
16use async_trait::async_trait;
17
18use cratefield_core::Database;
19
20/// One notification, as an extra [`Channel`] delivers it: the rendered
21/// text, the category it was sent under, and the link a tap opens.
22///
23/// What it deliberately does not carry is any recipient — no chat id, no
24/// bot token. The channel resolves those out of its own tables by the
25/// `account_id` its [`Channel::deliver`] is handed, the same way the mail
26/// job carries an account and not an address.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ChannelMessage {
29 /// The category the notification was sent under — the vocabulary the
30 /// account's preferences are written in.
31 pub category: String,
32 /// The rendered title.
33 pub title: String,
34 /// The rendered body.
35 pub body: String,
36 /// Where a tap should take the recipient, when the caller set one.
37 pub url: Option<String>,
38 /// The id shared by every copy of this notification, stable across
39 /// the outbox row's retries — a channel that cannot accept a message
40 /// twice dedupes on it, the way mail's idempotency key does.
41 pub notification_id: String,
42}
43
44/// Why a [`Channel`] could not deliver.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ChannelError {
47 /// The account cannot be reached on this channel — its linked
48 /// credential is gone. Dead-letters rather than retries: only the
49 /// account can fix it, by linking again.
50 Unreachable,
51 /// A permanent refusal. Dead-letters with the message as the reason:
52 /// nothing about this notification will ever be accepted.
53 Permanent(String),
54 /// A transient failure. Retried with backoff, never before the
55 /// `retry_after` the channel asked for, to the module's attempt bound.
56 Transient { retry_after: Option<Duration> },
57}
58
59impl std::fmt::Display for ChannelError {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 ChannelError::Unreachable => {
63 f.write_str("the account is not reachable on this channel")
64 }
65 ChannelError::Permanent(message) => f.write_str(message),
66 ChannelError::Transient { retry_after } => match retry_after {
67 Some(after) => write!(f, "transient; retry after {after:?}"),
68 None => f.write_str("transient"),
69 },
70 }
71 }
72}
73
74impl std::error::Error for ChannelError {}
75
76/// One extra delivery channel, registered next to the built-in push and
77/// email through [`crate::Notifications::channel`] (issue #764).
78///
79/// The module owns everything around the send — preference gates, the
80/// outbox, retries, dead letters — and the implementation owns exactly one
81/// thing: how a message reaches one account. Its name is written into the
82/// outbox payload and matched again in the drain, so it must be stable
83/// across deploys.
84#[async_trait]
85pub trait Channel: Send + Sync + 'static {
86 /// The channel's stable, lower-case name — `"telegram"` — as it is
87 /// written into outbox payloads and dead-letter reasons. One row per
88 /// name: registering two channels with the same name is refused at
89 /// composition time.
90 fn name(&self) -> &'static str;
91
92 /// Whether this account can be reached on this channel at all — an
93 /// account that has linked one, say. Checked when the notification is
94 /// enqueued and again immediately before sending: an answer that
95 /// changed in between must still win.
96 ///
97 /// An `Err` is the same as `Ok(false)` for the enqueue decision, and
98 /// takes its own outcome in the drain.
99 async fn reachable(&self, db: &dyn Database, account_id: &str) -> Result<bool, ChannelError>;
100
101 /// Delivers one message to this account. Called from the drain, once
102 /// per outbox row; the outcome decides what happens to the row.
103 async fn deliver(
104 &self,
105 db: &dyn Database,
106 account_id: &str,
107 message: &ChannelMessage,
108 ) -> Result<(), ChannelError>;
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn an_error_says_what_a_dead_letter_reason_cannot() {
117 assert_eq!(
118 ChannelError::Unreachable.to_string(),
119 "the account is not reachable on this channel"
120 );
121 assert_eq!(
122 ChannelError::Permanent("telegram 400: chat not found".to_owned()).to_string(),
123 "telegram 400: chat not found"
124 );
125 assert_eq!(
126 ChannelError::Transient { retry_after: None }.to_string(),
127 "transient"
128 );
129 assert!(
130 ChannelError::Transient {
131 retry_after: Some(Duration::from_mins(15))
132 }
133 .to_string()
134 .contains("900"),
135 "the delay the channel named is in the text"
136 );
137 }
138}