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