Skip to main content

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}