Skip to main content

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}