rainier_notify/notification.rs
1//! What is sent, and to whom — [`Notification`], [`Notifiable`], [`Channels`].
2
3use std::any::TypeId;
4use std::sync::Arc;
5
6use rainier_mail::Mailable;
7use rainier_support::Result;
8use serde_json::Value;
9
10use crate::channel::Channel;
11
12/// Something that can be sent a notification.
13///
14/// A user, usually. Also an on-call rota, a Slack channel, a webhook endpoint —
15/// anything with an identity and an address.
16pub trait Notifiable: Send + Sync {
17 /// A stable identifier for this recipient.
18 ///
19 /// Stored by the database channel and printed in logs, so it has to mean
20 /// the same thing across restarts — a primary key, not a pointer.
21 fn notifiable_id(&self) -> String;
22
23 /// What kind of thing this is — `"User"`, `"Team"`.
24 ///
25 /// Stored alongside the id, because id `7` is only unique within a type.
26 fn notifiable_type(&self) -> &'static str;
27
28 /// Where to reach this recipient on `channel`, or `None` if you cannot.
29 ///
30 /// Laravel's `routeNotificationFor`. `"mail"` gives an email address,
31 /// `"sms"` a phone number, `"slack"` a webhook URL.
32 ///
33 /// Returning `None` **skips that channel** rather than failing the send: a
34 /// user with no phone number should still get the email.
35 ///
36 /// ```ignore
37 /// impl Notifiable for User {
38 /// fn notifiable_id(&self) -> String { self.id.to_string() }
39 /// fn notifiable_type(&self) -> &'static str { "User" }
40 ///
41 /// fn route_for(&self, channel: &str) -> Option<String> {
42 /// match channel {
43 /// "mail" => Some(self.email.clone()),
44 /// "sms" => self.phone.clone(),
45 /// _ => None,
46 /// }
47 /// }
48 /// }
49 /// ```
50 fn route_for(&self, channel: &str) -> Option<String>;
51}
52
53/// A message to a [`Notifiable`], rendered differently per channel.
54///
55/// The thing an event is not: it has a **recipient**, and it
56/// knows **how** it should reach them.
57///
58/// ```ignore
59/// pub struct InvoicePaid {
60/// pub invoice: u64,
61/// pub amount: Money,
62/// }
63///
64/// impl Notification<User> for InvoicePaid {
65/// fn notification_name(&self) -> &'static str {
66/// "billing.invoice-paid"
67/// }
68///
69/// fn via(&self, user: &User) -> Channels {
70/// let channels = Channels::new().with::<DatabaseChannel>();
71///
72/// // The recipient's own preference decides, which is the other half
73/// // of what makes this a notification rather than an event.
74/// if user.wants_email {
75/// channels.with::<MailChannel>()
76/// } else {
77/// channels
78/// }
79/// }
80///
81/// fn to_mail(&self, user: &User) -> Option<Box<dyn Mailable>> {
82/// Some(Box::new(InvoicePaidMail { invoice: self.invoice }))
83/// }
84///
85/// fn to_data(&self, _: &User) -> Option<Value> {
86/// Some(json!({ "invoice": self.invoice }))
87/// }
88/// }
89/// ```
90///
91/// # Three renderings, not one per channel
92///
93/// Laravel gives a notification a `toMail`, `toDatabase`, `toSlack`, `toVonage`
94/// — one method per channel, dispatched by `__call`. Rust has no `__call`, and
95/// a trait that named every channel would make the set of channels closed.
96///
97/// So the **representations** are closed and the channels are open. There are
98/// three shapes a notification really takes, and every channel consumes one:
99///
100/// | | Used by |
101/// |---|---|
102/// | [`to_mail`](Self::to_mail) — a full email | the mail channel |
103/// | [`to_text`](Self::to_text) — one short line | SMS, Slack, a webhook, the log |
104/// | [`to_data`](Self::to_data) — structured | the database channel, a broadcast |
105///
106/// A channel you write consumes whichever fits. Nothing here has to change for
107/// it to exist.
108pub trait Notification<N: Notifiable + ?Sized>: Send + Sync {
109 /// A stable name for this notification.
110 ///
111 /// Stored by the database channel — a row saying which notification it was
112 /// — and used in logs. Permanent once rows exist, like a job's `NAME`.
113 fn notification_name(&self) -> &'static str;
114
115 /// Which channels to send on, for **this** recipient.
116 ///
117 /// Given the recipient, so the answer can depend on them: their
118 /// preferences, their plan, whether they have a phone number.
119 fn via(&self, to: &N) -> Channels;
120
121 /// As an email.
122 ///
123 /// A [`Mailable`], so a notification gets the same view templates,
124 /// attachments and headers a mailable does. An already-assembled
125 /// [`Message`](rainier_mail::Message) is itself a `Mailable`, so the
126 /// three-lines-of-text case needs no separate type:
127 ///
128 /// ```ignore
129 /// fn to_mail(&self, user: &User) -> Option<Box<dyn Mailable>> {
130 /// Some(Box::new(InvoicePaidMail { name: user.name.clone() }))
131 /// }
132 /// ```
133 ///
134 /// Leave the envelope's `to` empty: the **recipient** supplies the
135 /// address, via [`Notifiable::route_for`]. A `to_mail` that hard-codes one
136 /// has written a mailable, not a notification.
137 fn to_mail(&self, to: &N) -> Option<Box<dyn Mailable>> {
138 let _ = to;
139 None
140 }
141
142 /// As one short line — an SMS, a Slack message, a log line.
143 fn to_text(&self, to: &N) -> Option<String> {
144 let _ = to;
145 None
146 }
147
148 /// As structured data — a database row, a broadcast payload.
149 fn to_data(&self, to: &N) -> Option<Value> {
150 let _ = to;
151 None
152 }
153}
154
155/// The channels one notification goes out on.
156///
157/// Selected **by type**, not by name, for the same reason
158/// middleware is: a misspelled `"mial"` is a notification
159/// that silently goes nowhere, and deleting a channel should break every
160/// notification that used it in the compiler.
161///
162/// ```
163/// # use rainier_notify::{Channels, LogChannel, MemoryChannel};
164/// let channels = Channels::new().with::<LogChannel>().with::<MemoryChannel>();
165///
166/// assert_eq!(channels.len(), 2);
167/// ```
168#[derive(Debug, Default, Clone, PartialEq, Eq)]
169pub struct Channels {
170 /// `(TypeId, type name)`.
171 ///
172 /// The id selects the registered channel. The type name is only a
173 /// **diagnostic** label, for the one case where the id resolves to nothing
174 /// — a channel the application never registered, which has no instance to
175 /// ask for its real name.
176 selected: Vec<(TypeId, &'static str)>,
177}
178
179impl Channels {
180 /// No channels. A notification returning this is not sent anywhere, which
181 /// is a legitimate answer — "this user has opted out of everything".
182 pub fn new() -> Self {
183 Self::default()
184 }
185
186 /// Add a channel.
187 ///
188 /// By type only — no instance and no `Default` bound. Selecting a channel
189 /// and *configuring* one are different things: a `MailChannel` needs a
190 /// mailer, and the notification has no business holding one.
191 pub fn with<C: Channel>(mut self) -> Self {
192 self.push(TypeId::of::<C>(), short_type_name(std::any::type_name::<C>()));
193 self
194 }
195
196 fn push(&mut self, id: TypeId, name: &'static str) {
197 // Selecting the same channel twice would send twice. Almost always a
198 // paste rather than an intention.
199 if !self.selected.iter().any(|(existing, _)| *existing == id) {
200 self.selected.push((id, name));
201 }
202 }
203
204 /// How many channels are selected.
205 pub fn len(&self) -> usize {
206 self.selected.len()
207 }
208
209 /// Whether none are.
210 pub fn is_empty(&self) -> bool {
211 self.selected.is_empty()
212 }
213
214 /// The selected channels, in order.
215 pub fn iter(&self) -> impl Iterator<Item = (TypeId, &'static str)> + '_ {
216 self.selected.iter().copied()
217 }
218
219 /// Their type names, for diagnostics.
220 ///
221 /// A channel's *own* [`name`](Channel::name) — the one `route_for` is asked
222 /// about — comes from the configured instance, which only the notifier
223 /// has.
224 pub fn names(&self) -> Vec<&'static str> {
225 self.selected.iter().map(|(_, name)| *name).collect()
226 }
227}
228
229/// `a::b::MailChannel` → `MailChannel`.
230fn short_type_name(full: &'static str) -> &'static str {
231 full.rsplit("::").next().unwrap_or(full)
232}
233
234/// One notification, rendered, on its way to one channel.
235///
236/// What a [`Channel`] is handed. The renderings are done once per send and
237/// cloned per channel, so a notification that implements all three does not pay
238/// for rendering them more than once.
239#[derive(Clone)]
240pub struct Delivery {
241 /// The notification's stable name.
242 pub notification: &'static str,
243 /// The recipient's id.
244 pub recipient_id: String,
245 /// The recipient's type.
246 pub recipient_type: &'static str,
247 /// Where to reach them on **this** channel, from
248 /// [`Notifiable::route_for`].
249 pub route: Option<String>,
250 /// `Arc`, not `Box`: one render is shared by every channel, and `Delivery`
251 /// is cloned per channel.
252 mail: Option<Arc<dyn Mailable>>,
253 text: Option<String>,
254 data: Option<Value>,
255}
256
257impl Delivery {
258 /// Render `notification` for `to`.
259 pub fn render<N, T>(notification: &T, to: &N) -> Self
260 where
261 N: Notifiable + ?Sized,
262 T: Notification<N> + ?Sized,
263 {
264 Self {
265 notification: notification.notification_name(),
266 recipient_id: to.notifiable_id(),
267 recipient_type: to.notifiable_type(),
268 route: None,
269 mail: notification.to_mail(to).map(Arc::from),
270 text: notification.to_text(to),
271 data: notification.to_data(to),
272 }
273 }
274
275 /// The same delivery, routed for `channel`.
276 pub fn routed_for(mut self, channel: &str, to: &(impl Notifiable + ?Sized)) -> Self {
277 self.route = to.route_for(channel);
278 self
279 }
280
281 /// A copy of this delivery addressed to `route`.
282 ///
283 /// What the notifier hands each channel: the renderings are shared, only
284 /// the address differs.
285 pub fn with_route(&self, route: Option<String>) -> Self {
286 Self { route, ..self.clone() }
287 }
288
289 /// The email form, if the notification produced one.
290 ///
291 /// Unrendered — the mail channel has the view engine, and this does not.
292 pub fn mail(&self) -> Option<&dyn Mailable> {
293 self.mail.as_deref()
294 }
295
296 /// The short-text form.
297 pub fn text(&self) -> Option<&str> {
298 self.text.as_deref()
299 }
300
301 /// The structured form.
302 pub fn data(&self) -> Option<&Value> {
303 self.data.as_ref()
304 }
305
306 /// The structured form, or the text form wrapped as `{"message": …}`.
307 ///
308 /// For a channel that wants data and will settle for a line of it, which is
309 /// most of them.
310 pub fn data_or_text(&self) -> Option<Value> {
311 self.data
312 .clone()
313 .or_else(|| self.text.as_ref().map(|text| serde_json::json!({ "message": text })))
314 }
315
316 /// The error a channel returns when the notification rendered nothing it
317 /// can use.
318 pub fn nothing_to_send(&self, channel: &str, wanted: &str) -> rainier_support::Error {
319 rainier_support::Error::internal(format!(
320 "`{}` selected the `{channel}` channel but produced no {wanted}",
321 self.notification
322 ))
323 }
324
325 /// The error a channel returns when the recipient has no address on it.
326 pub fn no_route(&self, channel: &str) -> rainier_support::Error {
327 rainier_support::Error::internal(format!(
328 "`{} {}` has no `{channel}` address",
329 self.recipient_type, self.recipient_id
330 ))
331 }
332}
333
334impl std::fmt::Debug for Delivery {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 f.debug_struct("Delivery")
337 .field("notification", &self.notification)
338 .field("to", &format_args!("{} {}", self.recipient_type, self.recipient_id))
339 .field("route", &self.route)
340 .finish()
341 }
342}
343
344/// What happened on one channel.
345#[derive(Debug)]
346pub enum Sent {
347 /// It went.
348 Delivered,
349 /// The recipient has no address on that channel, so it was skipped.
350 ///
351 /// Not a failure: a user with no phone number should still get the email.
352 NoRoute,
353 /// The channel is not registered on the notifier.
354 NotRegistered,
355 /// The channel tried and failed.
356 Failed(rainier_support::Error),
357}
358
359impl Sent {
360 /// Whether it was delivered.
361 pub fn is_delivered(&self) -> bool {
362 matches!(self, Sent::Delivered)
363 }
364
365 /// Whether it failed — as opposed to being skipped.
366 pub fn is_failure(&self) -> bool {
367 matches!(self, Sent::Failed(_) | Sent::NotRegistered)
368 }
369}
370
371impl std::fmt::Display for Sent {
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 match self {
374 Sent::Delivered => f.write_str("delivered"),
375 Sent::NoRoute => f.write_str("no address on that channel"),
376 Sent::NotRegistered => f.write_str("the channel is not registered"),
377 Sent::Failed(e) => write!(f, "failed: {}", e.message()),
378 }
379 }
380}
381
382/// What happened across every channel of one send.
383#[derive(Debug, Default)]
384pub struct Receipt {
385 /// One entry per channel the notification selected.
386 pub outcomes: Vec<(&'static str, Sent)>,
387}
388
389impl Receipt {
390 /// Whether it reached at least one channel.
391 pub fn delivered_anywhere(&self) -> bool {
392 self.outcomes.iter().any(|(_, sent)| sent.is_delivered())
393 }
394
395 /// The channels it was delivered on.
396 pub fn delivered(&self) -> Vec<&'static str> {
397 self.outcomes
398 .iter()
399 .filter(|(_, sent)| sent.is_delivered())
400 .map(|(name, _)| *name)
401 .collect()
402 }
403
404 /// The channels it failed on.
405 pub fn failures(&self) -> Vec<&'static str> {
406 self.outcomes.iter().filter(|(_, sent)| sent.is_failure()).map(|(name, _)| *name).collect()
407 }
408
409 /// An error if **every** selected channel failed, `Ok` otherwise.
410 ///
411 /// The shape a caller usually wants: one channel being down is not a reason
412 /// to fail the request that triggered the notification, but every channel
413 /// being down probably is.
414 pub fn into_result(self) -> Result<Self> {
415 if self.outcomes.is_empty() || self.delivered_anywhere() {
416 return Ok(self);
417 }
418
419 let reasons: Vec<String> =
420 self.outcomes.iter().map(|(name, sent)| format!("{name}: {sent}")).collect();
421
422 Err(rainier_support::Error::internal(format!(
423 "the notification reached nothing — {}",
424 reasons.join("; ")
425 )))
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::{LogChannel, MemoryChannel};
433
434 struct User {
435 id: u64,
436 email: Option<String>,
437 }
438
439 impl Notifiable for User {
440 fn notifiable_id(&self) -> String {
441 self.id.to_string()
442 }
443 fn notifiable_type(&self) -> &'static str {
444 "User"
445 }
446 fn route_for(&self, channel: &str) -> Option<String> {
447 match channel {
448 "mail" => self.email.clone(),
449 _ => Some(self.id.to_string()),
450 }
451 }
452 }
453
454 struct Welcome;
455
456 impl Notification<User> for Welcome {
457 fn notification_name(&self) -> &'static str {
458 "user.welcome"
459 }
460 fn via(&self, _: &User) -> Channels {
461 Channels::new().with::<LogChannel>()
462 }
463 fn to_text(&self, user: &User) -> Option<String> {
464 Some(format!("welcome, user {}", user.id))
465 }
466 }
467
468 #[test]
469 fn channels_keep_their_order() {
470 let channels = Channels::new().with::<LogChannel>().with::<MemoryChannel>();
471 assert_eq!(channels.names(), vec!["LogChannel", "MemoryChannel"]);
472 }
473
474 #[test]
475 fn selecting_a_channel_twice_only_sends_once() {
476 // Almost always a paste rather than an intention, and the symptom
477 // otherwise is a user getting two of everything.
478 let channels = Channels::new().with::<LogChannel>().with::<LogChannel>();
479 assert_eq!(channels.len(), 1);
480 }
481
482 #[test]
483 fn a_notification_can_choose_no_channels() {
484 // "This user has opted out of everything" is a real answer, not an
485 // error.
486 assert!(Channels::new().is_empty());
487 }
488
489 #[test]
490 fn rendering_happens_once_and_carries_the_recipients_identity() {
491 let user = User { id: 7, email: Some("a@b.c".into()) };
492 let delivery = Delivery::render(&Welcome, &user);
493
494 assert_eq!(delivery.notification, "user.welcome");
495 assert_eq!(delivery.recipient_id, "7");
496 assert_eq!(delivery.recipient_type, "User");
497 assert_eq!(delivery.text(), Some("welcome, user 7"));
498 assert!(delivery.mail().is_none(), "it did not implement `to_mail`");
499 }
500
501 #[test]
502 fn routing_asks_the_recipient_per_channel() {
503 let user = User { id: 7, email: Some("a@b.c".into()) };
504
505 let mail = Delivery::render(&Welcome, &user).routed_for("mail", &user);
506 assert_eq!(mail.route.as_deref(), Some("a@b.c"));
507
508 let none = User { id: 8, email: None };
509 let mail = Delivery::render(&Welcome, &none).routed_for("mail", &none);
510 assert_eq!(mail.route, None, "no address is a skip, not a failure");
511 }
512
513 #[test]
514 fn data_falls_back_to_wrapping_the_text() {
515 // So a channel that wants structured data works with a notification
516 // that only wrote a line.
517 let user = User { id: 7, email: None };
518 let delivery = Delivery::render(&Welcome, &user);
519
520 assert_eq!(
521 delivery.data_or_text(),
522 Some(serde_json::json!({ "message": "welcome, user 7" }))
523 );
524 }
525
526 #[test]
527 fn a_receipt_is_ok_when_anything_got_through() {
528 let receipt = Receipt {
529 outcomes: vec![
530 ("mail", Sent::Failed(rainier_support::Error::internal("smtp down"))),
531 ("database", Sent::Delivered),
532 ],
533 };
534
535 assert!(receipt.delivered_anywhere());
536 assert_eq!(receipt.failures(), vec!["mail"]);
537 assert!(receipt.into_result().is_ok(), "one channel down is not a failed send");
538 }
539
540 #[test]
541 fn a_receipt_fails_when_nothing_got_through() {
542 let receipt = Receipt {
543 outcomes: vec![("mail", Sent::Failed(rainier_support::Error::internal("smtp down")))],
544 };
545
546 let err = receipt.into_result().err().expect("should fail");
547 assert!(err.message().contains("smtp down"), "{}", err.message());
548 }
549
550 #[test]
551 fn sending_nowhere_at_all_is_not_a_failure() {
552 // A notification that selected no channels did what it was asked.
553 assert!(Receipt::default().into_result().is_ok());
554 }
555}