1#![deny(missing_docs)]
30#![deny(rustdoc::broken_intra_doc_links)]
31
32use std::collections::HashMap;
33use std::sync::Arc;
34
35use async_trait::async_trait;
36use serde::{Deserialize, Serialize};
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub enum Block {
42 Heading(String),
44 Text(String),
46 Fields(Vec<(String, String)>),
48 Divider,
50 Action {
52 label: String,
54 url: String,
56 },
57}
58
59impl Block {
60 pub fn heading(s: impl Into<String>) -> Self {
62 Block::Heading(s.into())
63 }
64 pub fn text(s: impl Into<String>) -> Self {
66 Block::Text(s.into())
67 }
68 pub fn fields(kv: Vec<(String, String)>) -> Self {
70 Block::Fields(kv)
71 }
72 pub fn action(label: impl Into<String>, url: impl Into<String>) -> Self {
74 Block::Action {
75 label: label.into(),
76 url: url.into(),
77 }
78 }
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
83pub struct Notification {
84 pub title: Option<String>,
86 pub blocks: Vec<Block>,
88}
89
90impl Notification {
91 pub fn new() -> Self {
93 Self::default()
94 }
95
96 pub fn title(mut self, t: impl Into<String>) -> Self {
98 self.title = Some(t.into());
99 self
100 }
101
102 pub fn block(mut self, b: Block) -> Self {
104 self.blocks.push(b);
105 self
106 }
107
108 pub fn to_plain_text(&self) -> String {
110 let mut out = String::new();
111 if let Some(t) = &self.title {
112 out.push_str(t);
113 out.push_str("\n\n");
114 }
115 for block in &self.blocks {
116 match block {
117 Block::Heading(h) => {
118 out.push_str(h);
119 out.push('\n');
120 }
121 Block::Text(t) => {
122 out.push_str(t);
123 out.push('\n');
124 }
125 Block::Fields(kv) => {
126 for (k, v) in kv {
127 out.push_str(&format!("{k}: {v}\n"));
128 }
129 }
130 Block::Divider => out.push_str("---\n"),
131 Block::Action { label, url } => out.push_str(&format!("{label}: {url}\n")),
132 }
133 }
134 out.trim_end().to_string()
135 }
136}
137
138#[derive(Debug, thiserror::Error)]
140pub enum NotifyError {
141 #[error("no sender registered for channel '{0}'")]
143 UnknownChannel(String),
144 #[error("transport '{transport}' failed: {reason}")]
146 Transport {
147 transport: String,
149 reason: String,
151 },
152}
153
154#[async_trait]
157pub trait Sender: Send + Sync {
158 fn name(&self) -> &str;
160 async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError>;
162}
163
164#[derive(Debug, Clone, PartialEq)]
166pub struct OutboxItem {
167 pub id: String,
169 pub tenant_id: String,
171 pub subject_id: String,
173 pub channel: String,
175 pub recipient: String,
177 pub notification: Notification,
179 pub attempts: u32,
181 pub lease_version: i64,
183}
184
185#[derive(Debug, Clone, PartialEq)]
187pub struct NewOutboxItem {
188 pub tenant_id: String,
190 pub subject_id: String,
192 pub idempotency_key: String,
194 pub channel: String,
196 pub recipient: String,
198 pub notification: Notification,
200}
201
202#[async_trait]
204pub trait DurableOutbox: Send + Sync {
205 async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String>;
207 async fn claim(
209 &self,
210 worker_id: &str,
211 lease_secs: i64,
212 batch: usize,
213 ) -> Result<Vec<OutboxItem>, String>;
214 async fn mark_sent(&self, id: &str, lease_version: i64) -> Result<(), String>;
216 async fn retry(
218 &self,
219 id: &str,
220 lease_version: i64,
221 error: &str,
222 delay_secs: i64,
223 ) -> Result<(), String>;
224}
225
226#[derive(Default, Clone)]
228pub struct Dispatcher {
229 senders: HashMap<String, Arc<dyn Sender>>,
230}
231
232impl Dispatcher {
233 pub fn new() -> Self {
235 Self::default()
236 }
237
238 pub fn register(&mut self, sender: Arc<dyn Sender>) -> &mut Self {
240 self.senders.insert(sender.name().to_string(), sender);
241 self
242 }
243
244 pub fn channels(&self) -> impl Iterator<Item = &str> {
246 self.senders.keys().map(|s| s.as_str())
247 }
248
249 pub async fn dispatch(
251 &self,
252 channel: &str,
253 recipient: &str,
254 notif: &Notification,
255 ) -> Result<(), NotifyError> {
256 let sender = self
257 .senders
258 .get(channel)
259 .ok_or_else(|| NotifyError::UnknownChannel(channel.to_string()))?;
260 sender.send(recipient, notif).await
261 }
262
263 pub async fn drain(
265 &self,
266 outbox: &dyn DurableOutbox,
267 worker_id: &str,
268 lease_secs: i64,
269 batch: usize,
270 retry_delay_secs: i64,
271 ) -> Result<usize, String> {
272 let items = outbox
273 .claim(worker_id, lease_secs, batch.clamp(1, 100))
274 .await?;
275 for item in &items {
276 match self
277 .dispatch(&item.channel, &item.recipient, &item.notification)
278 .await
279 {
280 Ok(()) => outbox.mark_sent(&item.id, item.lease_version).await?,
281 Err(error) => {
282 outbox
283 .retry(
284 &item.id,
285 item.lease_version,
286 &error.to_string(),
287 retry_delay_secs,
288 )
289 .await?
290 }
291 }
292 }
293 Ok(items.len())
294 }
295}
296
297pub struct LogSender;
300
301#[async_trait]
302impl Sender for LogSender {
303 fn name(&self) -> &str {
304 "log"
305 }
306 async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
307 tracing::info!(target: "notify", recipient, body = %notif.to_plain_text(), "notification");
308 Ok(())
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use std::sync::Mutex;
316
317 #[derive(Default)]
318 struct MemoryOutbox {
319 items: Mutex<Vec<OutboxItem>>,
320 sent: Mutex<Vec<String>>,
321 }
322
323 #[async_trait]
324 impl DurableOutbox for MemoryOutbox {
325 async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String> {
326 let id = item.idempotency_key.clone();
327 self.items.lock().unwrap().push(OutboxItem {
328 id: id.clone(),
329 tenant_id: item.tenant_id,
330 subject_id: item.subject_id,
331 channel: item.channel,
332 recipient: item.recipient,
333 notification: item.notification,
334 attempts: 0,
335 lease_version: 1,
336 });
337 Ok(id)
338 }
339 async fn claim(&self, _: &str, _: i64, _: usize) -> Result<Vec<OutboxItem>, String> {
340 Ok(self.items.lock().unwrap().clone())
341 }
342 async fn mark_sent(&self, id: &str, _: i64) -> Result<(), String> {
343 self.sent.lock().unwrap().push(id.into());
344 Ok(())
345 }
346 async fn retry(&self, _: &str, _: i64, _: &str, _: i64) -> Result<(), String> {
347 Ok(())
348 }
349 }
350
351 #[test]
352 fn renders_plain_text() {
353 let n = Notification::new()
354 .title("Run finished")
355 .block(Block::heading("Summary"))
356 .block(Block::text("All good."))
357 .block(Block::fields(vec![("duration".into(), "1.2s".into())]))
358 .block(Block::action("View", "https://x/y"));
359 let txt = n.to_plain_text();
360 assert!(txt.starts_with("Run finished"));
361 assert!(txt.contains("duration: 1.2s"));
362 assert!(txt.contains("View: https://x/y"));
363 }
364
365 struct CapturingSender(Mutex<Vec<String>>);
366 #[async_trait]
367 impl Sender for CapturingSender {
368 fn name(&self) -> &str {
369 "capture"
370 }
371 async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
372 self.0
373 .lock()
374 .unwrap()
375 .push(format!("{recipient}|{}", notif.to_plain_text()));
376 Ok(())
377 }
378 }
379
380 #[tokio::test]
381 async fn dispatch_routes_to_named_sender() {
382 let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
383 let mut d = Dispatcher::new();
384 d.register(sender.clone());
385
386 let n = Notification::new().block(Block::text("hi"));
387 d.dispatch("capture", "user1", &n).await.unwrap();
388
389 assert_eq!(sender.0.lock().unwrap().len(), 1);
390 assert!(sender.0.lock().unwrap()[0].starts_with("user1|hi"));
391 }
392
393 #[tokio::test]
394 async fn unknown_channel_errors() {
395 let d = Dispatcher::new();
396 let n = Notification::new();
397 assert!(matches!(
398 d.dispatch("nope", "x", &n).await,
399 Err(NotifyError::UnknownChannel(_))
400 ));
401 }
402
403 #[tokio::test]
404 async fn durable_dispatch_marks_claimed_messages_sent() {
405 let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
406 let mut dispatcher = Dispatcher::new();
407 dispatcher.register(sender);
408 let outbox = MemoryOutbox::default();
409 outbox
410 .enqueue(NewOutboxItem {
411 tenant_id: "tenant".into(),
412 subject_id: "subject".into(),
413 idempotency_key: "one".into(),
414 channel: "capture".into(),
415 recipient: "recipient".into(),
416 notification: Notification::new().block(Block::text("hello")),
417 })
418 .await
419 .unwrap();
420 assert_eq!(
421 dispatcher
422 .drain(&outbox, "worker", 30, 10, 5)
423 .await
424 .unwrap(),
425 1
426 );
427 assert_eq!(&*outbox.sent.lock().unwrap(), &["one"]);
428 }
429}