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    /// `"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}