1#![deny(missing_docs)]
4#![deny(rustdoc::broken_intra_doc_links)]
5
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::Duration;
9
10use af_context::{NotificationAttemptId, NotificationId, RequestContext, SubjectId, TenantId};
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use tokio::time::Instant;
15use tokio_util::sync::CancellationToken;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum Block {
20 Heading(String),
22 Text(String),
24 Fields(Vec<(String, String)>),
26 Divider,
28 Action {
30 label: String,
32 url: String,
34 },
35}
36
37impl Block {
38 pub fn heading(value: impl Into<String>) -> Self {
40 Self::Heading(value.into())
41 }
42 pub fn text(value: impl Into<String>) -> Self {
44 Self::Text(value.into())
45 }
46 pub fn fields(value: Vec<(String, String)>) -> Self {
48 Self::Fields(value)
49 }
50 pub fn action(label: impl Into<String>, url: impl Into<String>) -> Self {
52 Self::Action {
53 label: label.into(),
54 url: url.into(),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
61pub struct Notification {
62 pub title: Option<String>,
64 pub blocks: Vec<Block>,
66}
67
68impl Notification {
69 pub fn new() -> Self {
71 Self::default()
72 }
73 pub fn from_template(
75 catalog: &af_i18n::I18n,
76 locale: &str,
77 title_key: &str,
78 body_key: &str,
79 args: &[(&str, &str)],
80 ) -> Self {
81 Self::new()
82 .title(catalog.t(locale, title_key, args))
83 .block(Block::text(catalog.t(locale, body_key, args)))
84 }
85 pub fn title(mut self, value: impl Into<String>) -> Self {
87 self.title = Some(value.into());
88 self
89 }
90 pub fn block(mut self, value: Block) -> Self {
92 self.blocks.push(value);
93 self
94 }
95 pub fn to_plain_text(&self) -> String {
97 let mut out = String::new();
98 if let Some(title) = &self.title {
99 out.push_str(title);
100 out.push_str("\n\n");
101 }
102 for block in &self.blocks {
103 match block {
104 Block::Heading(value) | Block::Text(value) => {
105 out.push_str(value);
106 out.push('\n');
107 }
108 Block::Fields(fields) => {
109 for (key, value) in fields {
110 out.push_str(&format!("{key}: {value}\n"));
111 }
112 }
113 Block::Divider => out.push_str("---\n"),
114 Block::Action { label, url } => out.push_str(&format!("{label}: {url}\n")),
115 }
116 }
117 out.trim_end().to_owned()
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum NotificationStatus {
125 Pending,
127 Sending,
129 RetryScheduled,
131 Sent,
133 DeadLetter,
135}
136
137impl NotificationStatus {
138 pub fn as_str(self) -> &'static str {
140 match self {
141 Self::Pending => "pending",
142 Self::Sending => "sending",
143 Self::RetryScheduled => "retry_scheduled",
144 Self::Sent => "sent",
145 Self::DeadLetter => "dead_letter",
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum DeliveryClass {
154 Retryable,
156 Permanent,
158 Unknown,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
164pub enum NotifyError {
165 #[error("no sender registered for channel '{0}'")]
167 UnknownChannel(String),
168 #[error("transport temporarily unavailable")]
170 Retryable,
171 #[error("transport rejected the notification")]
173 Permanent,
174 #[error("transport outcome is unknown")]
176 Unknown,
177 #[error("notification delivery cancelled")]
179 Cancelled,
180 #[error("notification delivery deadline exceeded")]
182 DeadlineExceeded,
183}
184
185impl NotifyError {
186 pub fn class(&self) -> DeliveryClass {
188 match self {
189 Self::Retryable | Self::DeadlineExceeded => DeliveryClass::Retryable,
190 Self::Permanent | Self::UnknownChannel(_) => DeliveryClass::Permanent,
191 Self::Unknown | Self::Cancelled => DeliveryClass::Unknown,
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
198pub enum NotifyStoreError {
199 #[error("invalid notification request: {0}")]
201 Invalid(String),
202 #[error("notification not found")]
204 NotFound,
205 #[error("idempotency key was reused with different notification content")]
207 IdempotencyConflict,
208 #[error("notification lease was lost")]
210 LeaseLost,
211 #[error("dead-letter retry is not authorized")]
213 Unauthorized,
214 #[error("notification store unavailable: {0}")]
216 Unavailable(String),
217}
218
219#[derive(Clone)]
221pub struct DeliveryContext {
222 pub cancellation: CancellationToken,
224 pub deadline: Instant,
226}
227
228#[derive(Debug, Clone, PartialEq)]
230pub struct NewOutboxItem {
231 pub tenant_id: TenantId,
233 pub subject_id: SubjectId,
235 pub idempotency_key: String,
237 pub channel: String,
239 pub recipient: String,
241 pub notification: Notification,
243 pub max_attempts: u32,
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub struct OutboxItem {
250 pub id: NotificationId,
252 pub tenant_id: TenantId,
254 pub subject_id: SubjectId,
256 pub channel: String,
258 pub recipient: String,
260 pub notification: Notification,
262 pub status: NotificationStatus,
264 pub attempts: u32,
266 pub max_attempts: u32,
268 pub lease_version: i64,
270 pub last_error: Option<String>,
272 pub created_at: DateTime<Utc>,
274 pub updated_at: DateTime<Utc>,
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280pub struct AttemptEvent {
281 pub id: NotificationAttemptId,
283 pub notification_id: NotificationId,
285 pub attempt: u32,
287 pub lease_version: i64,
289 pub kind: String,
291 pub class: Option<DeliveryClass>,
293 pub created_at: DateTime<Utc>,
295}
296
297#[async_trait]
299pub trait DurableOutbox: Send + Sync {
300 fn subscribe(&self) -> tokio::sync::broadcast::Receiver<NotificationId>;
303 async fn enqueue(&self, item: NewOutboxItem) -> Result<OutboxItem, NotifyStoreError>;
305 async fn get(
307 &self,
308 context: &RequestContext,
309 id: &NotificationId,
310 ) -> Result<OutboxItem, NotifyStoreError>;
311 async fn list(
313 &self,
314 context: &RequestContext,
315 status: Option<NotificationStatus>,
316 limit: usize,
317 after: Option<&NotificationId>,
318 ) -> Result<Vec<OutboxItem>, NotifyStoreError>;
319 async fn attempts(
321 &self,
322 context: &RequestContext,
323 id: &NotificationId,
324 ) -> Result<Vec<AttemptEvent>, NotifyStoreError>;
325 async fn claim(
327 &self,
328 worker_id: &str,
329 lease_secs: i64,
330 batch: usize,
331 ) -> Result<Vec<OutboxItem>, NotifyStoreError>;
332 async fn mark_sent(
334 &self,
335 id: &NotificationId,
336 lease_version: i64,
337 ) -> Result<(), NotifyStoreError>;
338 async fn record_failure(
340 &self,
341 id: &NotificationId,
342 lease_version: i64,
343 class: DeliveryClass,
344 delay: Duration,
345 ) -> Result<NotificationStatus, NotifyStoreError>;
346 async fn retry_dead_letter(
348 &self,
349 context: &RequestContext,
350 id: &NotificationId,
351 ) -> Result<OutboxItem, NotifyStoreError>;
352}
353
354#[async_trait]
356pub trait Sender: Send + Sync {
357 fn name(&self) -> &str;
359 async fn send(
361 &self,
362 context: &DeliveryContext,
363 recipient: &str,
364 notification: &Notification,
365 ) -> Result<(), NotifyError>;
366}
367
368#[derive(Debug, Clone, Copy)]
370pub struct RetryPolicy {
371 pub base: Duration,
373 pub cap: Duration,
375}
376
377#[derive(Debug, Clone)]
379pub struct WorkerConfig {
380 pub worker_id: String,
382 pub lease_secs: i64,
384 pub batch: usize,
386 pub request_timeout: Duration,
388 pub retry: RetryPolicy,
390}
391
392impl RetryPolicy {
393 pub fn delay(self, id: &NotificationId, attempt: u32) -> Duration {
395 let exponent = attempt.saturating_sub(1).min(20);
396 let raw = self
397 .base
398 .as_millis()
399 .saturating_mul(1u128 << exponent)
400 .min(self.cap.as_millis());
401 let hash = id.as_bytes().iter().fold(0u64, |value, byte| {
402 value.wrapping_mul(31).wrapping_add(u64::from(*byte))
403 });
404 let jitter = 90 + hash % 21;
405 Duration::from_millis(
406 u64::try_from(raw.saturating_mul(u128::from(jitter)) / 100).unwrap_or(u64::MAX),
407 )
408 }
409}
410
411#[derive(Default, Clone)]
413pub struct Dispatcher {
414 senders: HashMap<String, Arc<dyn Sender>>,
415}
416
417impl Dispatcher {
418 pub fn new() -> Self {
420 Self::default()
421 }
422 pub fn register(&mut self, sender: Arc<dyn Sender>) -> &mut Self {
424 self.senders.insert(sender.name().to_owned(), sender);
425 self
426 }
427 pub fn channels(&self) -> impl Iterator<Item = &str> {
429 self.senders.keys().map(String::as_str)
430 }
431 pub async fn dispatch(
433 &self,
434 context: &DeliveryContext,
435 channel: &str,
436 recipient: &str,
437 notification: &Notification,
438 ) -> Result<(), NotifyError> {
439 let sender = self
440 .senders
441 .get(channel)
442 .ok_or_else(|| NotifyError::UnknownChannel(channel.to_owned()))?;
443 sender.send(context, recipient, notification).await
444 }
445 pub async fn drain(
447 &self,
448 outbox: &dyn DurableOutbox,
449 config: &WorkerConfig,
450 cancellation: &CancellationToken,
451 ) -> Result<usize, NotifyStoreError> {
452 let items = outbox
453 .claim(
454 &config.worker_id,
455 config.lease_secs.max(
456 i64::try_from(
457 config
458 .request_timeout
459 .as_secs()
460 .saturating_mul(config.batch.clamp(1, 100) as u64)
461 .saturating_add(1),
462 )
463 .unwrap_or(i64::MAX),
464 ),
465 config.batch.clamp(1, 100),
466 )
467 .await?;
468 for item in &items {
469 if cancellation.is_cancelled() {
470 break;
471 }
472 let context = DeliveryContext {
473 cancellation: cancellation.child_token(),
474 deadline: Instant::now() + config.request_timeout,
475 };
476 match self
477 .dispatch(&context, &item.channel, &item.recipient, &item.notification)
478 .await
479 {
480 Ok(()) => outbox.mark_sent(&item.id, item.lease_version).await?,
481 Err(error) => {
482 outbox
483 .record_failure(
484 &item.id,
485 item.lease_version,
486 error.class(),
487 config.retry.delay(&item.id, item.attempts),
488 )
489 .await?;
490 }
491 }
492 }
493 Ok(items.len())
494 }
495}
496
497pub struct LogSender;
499
500#[async_trait]
501impl Sender for LogSender {
502 fn name(&self) -> &str {
503 "log"
504 }
505 async fn send(
506 &self,
507 _context: &DeliveryContext,
508 recipient: &str,
509 notification: &Notification,
510 ) -> Result<(), NotifyError> {
511 tracing::info!(target: "notify", recipient, body = %notification.to_plain_text(), "notification");
512 Ok(())
513 }
514}
515
516pub mod testing;
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn rendering_and_backoff_are_stable() {
525 let notification = Notification::new()
526 .title("Completed")
527 .block(Block::fields(vec![("duration".into(), "1s".into())]));
528 assert_eq!(notification.to_plain_text(), "Completed\n\nduration: 1s");
529 let id = NotificationId::parse("n-1").unwrap();
530 let policy = RetryPolicy {
531 base: Duration::from_secs(1),
532 cap: Duration::from_secs(60),
533 };
534 assert_eq!(policy.delay(&id, 2), policy.delay(&id, 2));
535 assert!(policy.delay(&id, 3) > policy.delay(&id, 2));
536 let mut catalog = af_i18n::I18n::new("en").unwrap();
537 catalog
538 .load_locale(
539 "en",
540 serde_json::json!({"title":"Done", "body":"Hello {name}"}),
541 )
542 .unwrap();
543 assert_eq!(
544 Notification::from_template(&catalog, "en", "title", "body", &[("name", "Ada")])
545 .to_plain_text(),
546 "Done\n\nHello Ada"
547 );
548 }
549}