Skip to main content

rustlavel_mail/
notification.rs

1//! Notifications: one definition, several channels.
2//!
3//! A notification says what it is; the channels say how it looks in each
4//! place. Adding a webhook to something that already sends mail is one more
5//! method, not a second copy of the message.
6
7use crate::message::Message;
8use crate::transport::Mailer;
9use rustlavel_client::Client;
10use rustlavel_core::events::Event;
11use rustlavel_core::{Error, Json, Result};
12use std::collections::BTreeMap;
13
14/// The channel names the framework knows.
15pub mod channel {
16    /// Email, through the application's [`crate::Mailer`].
17    pub const MAIL: &str = "mail";
18    /// A JSON payload the application stores itself.
19    pub const DATABASE: &str = "database";
20    /// A JSON POST to whatever URL the recipient is routed to.
21    pub const WEBHOOK: &str = "webhook";
22}
23
24/// Where one recipient can be reached, per channel.
25///
26/// A route per channel rather than a `User` trait: the address a notification
27/// goes to is not always a column on a model — a webhook URL belongs to a
28/// team, an email address may come from a form.
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct Recipient {
31    name: Option<String>,
32    routes: BTreeMap<String, String>,
33}
34
35impl Recipient {
36    pub fn new() -> Recipient {
37        Recipient::default()
38    }
39
40    pub fn named(name: impl Into<String>) -> Recipient {
41        Recipient { name: Some(name.into()), routes: BTreeMap::new() }
42    }
43
44    /// Where to email this recipient.
45    pub fn mail(self, address: impl Into<String>) -> Recipient {
46        self.route(channel::MAIL, address)
47    }
48
49    /// Where to POST this recipient's webhooks.
50    pub fn webhook(self, url: impl Into<String>) -> Recipient {
51        self.route(channel::WEBHOOK, url)
52    }
53
54    /// What identifies this recipient in the application's own store.
55    pub fn database(self, key: impl Into<String>) -> Recipient {
56        self.route(channel::DATABASE, key)
57    }
58
59    pub fn route(mut self, channel: &str, value: impl Into<String>) -> Recipient {
60        self.routes.insert(channel.to_string(), value.into());
61        self
62    }
63
64    pub fn route_for(&self, channel: &str) -> Option<&str> {
65        self.routes.get(channel).map(String::as_str)
66    }
67
68    pub fn name(&self) -> Option<&str> {
69        self.name.as_deref()
70    }
71}
72
73/// Something a recipient should be told about.
74///
75/// Only [`Notification::channels`] is required; a notification implements the
76/// `to_*` method for each channel it declares, and the default implementation
77/// of the others explains what is missing rather than sending something empty.
78pub trait Notification: Send + Sync {
79    /// A stable name for this notification, used in events and errors.
80    fn name(&self) -> &str {
81        "notification"
82    }
83
84    /// Which channels this notification goes out on.
85    fn channels(&self) -> Vec<&'static str>;
86
87    /// The email, with no recipient: [`Notifier`] addresses it.
88    fn to_mail(&self, _recipient: &Recipient) -> Result<Message> {
89        Err(Error::msg(format!(
90            "`{}` lists the mail channel but does not implement `to_mail`",
91            self.name()
92        )))
93    }
94
95    /// The payload for the application to store.
96    fn to_database(&self, _recipient: &Recipient) -> Result<Json> {
97        Err(Error::msg(format!(
98            "`{}` lists the database channel but does not implement `to_database`",
99            self.name()
100        )))
101    }
102
103    /// The JSON body posted to the recipient's webhook.
104    fn to_webhook(&self, _recipient: &Recipient) -> Result<Json> {
105        Err(Error::msg(format!(
106            "`{}` lists the webhook channel but does not implement `to_webhook`",
107            self.name()
108        )))
109    }
110}
111
112/// What happened on one channel.
113#[derive(Debug)]
114pub struct Delivery {
115    pub channel: &'static str,
116    /// The payload, for a channel that produces one — `database` returns the
117    /// document the application is expected to insert.
118    pub payload: Option<Json>,
119    pub error: Option<Error>,
120}
121
122impl Delivery {
123    pub fn is_ok(&self) -> bool {
124        self.error.is_none()
125    }
126}
127
128/// The result of notifying one recipient over every declared channel.
129#[derive(Debug)]
130pub struct Notified {
131    pub deliveries: Vec<Delivery>,
132}
133
134impl Notified {
135    /// True when every channel succeeded.
136    pub fn is_ok(&self) -> bool {
137        self.deliveries.iter().all(Delivery::is_ok)
138    }
139
140    pub fn failures(&self) -> Vec<&Delivery> {
141        self.deliveries.iter().filter(|delivery| !delivery.is_ok()).collect()
142    }
143
144    pub fn delivered(&self, channel: &str) -> bool {
145        self.deliveries.iter().any(|d| d.channel == channel && d.is_ok())
146    }
147
148    /// The payload a channel produced, for the caller to store.
149    pub fn payload(&self, channel: &str) -> Option<&Json> {
150        self.deliveries
151            .iter()
152            .find(|delivery| delivery.channel == channel)
153            .and_then(|delivery| delivery.payload.as_ref())
154    }
155
156    /// Collapse into a `Result`, naming every channel that failed.
157    ///
158    /// Optional on purpose: a caller usually wants the mail to have gone out
159    /// even though the webhook endpoint is down, and only then decides whether
160    /// that is worth failing the request over.
161    pub fn into_result(self) -> Result<Notified> {
162        let described: Vec<String> = self
163            .deliveries
164            .iter()
165            .filter_map(|delivery| {
166                delivery.error.as_ref().map(|error| format!("{}: {error}", delivery.channel))
167            })
168            .collect();
169
170        if described.is_empty() {
171            return Ok(self);
172        }
173        Err(Error::msg(format!("some channels failed — {}", described.join("; "))))
174    }
175}
176
177/// Sends notifications over the channels they declare.
178#[derive(Clone)]
179pub struct Notifier {
180    mailer: Mailer,
181    client: Client,
182}
183
184impl Notifier {
185    pub fn new(mailer: Mailer) -> Notifier {
186        Notifier { mailer, client: Client::new() }
187    }
188
189    /// Use a particular HTTP client for the webhook channel — a faked one in
190    /// tests, or one with retries configured in production.
191    pub fn with_client(mut self, client: Client) -> Notifier {
192        self.client = client;
193        self
194    }
195
196    pub fn mailer(&self) -> &Mailer {
197        &self.mailer
198    }
199
200    pub fn client(&self) -> &Client {
201        &self.client
202    }
203
204    /// Deliver a notification to one recipient over every channel it declares.
205    ///
206    /// One channel failing does not stop the others: a webhook endpoint being
207    /// down is no reason for the recipient not to get their email. Every
208    /// outcome comes back, and the caller decides what a failure is worth.
209    pub async fn notify<N: Notification + ?Sized>(
210        &self,
211        recipient: &Recipient,
212        notification: &N,
213    ) -> Notified {
214        let mut deliveries = Vec::new();
215
216        for channel in notification.channels() {
217            let outcome = match channel {
218                channel::MAIL => self.deliver_mail(recipient, notification).await.map(|_| None),
219                channel::DATABASE => notification.to_database(recipient).map(Some),
220                channel::WEBHOOK => self.deliver_webhook(recipient, notification).await.map(Some),
221                unknown => Err(Error::msg(format!(
222                    "`{unknown}` is not a notification channel. Use one of: mail, database, \
223                     webhook."
224                ))),
225            };
226
227            let delivery = match outcome {
228                Ok(payload) => Delivery { channel, payload, error: None },
229                Err(error) => {
230                    rustlavel_core::warn!(
231                        "notification `{}` failed on the {channel} channel: {error}",
232                        notification.name()
233                    );
234                    Delivery { channel, payload: None, error: Some(error) }
235                }
236            };
237
238            Event::new("notification.sent")
239                .with("notification", notification.name())
240                .with("channel", channel)
241                .with("ok", delivery.is_ok())
242                .dispatch();
243
244            deliveries.push(delivery);
245        }
246
247        Notified { deliveries }
248    }
249
250    async fn deliver_mail<N: Notification + ?Sized>(
251        &self,
252        recipient: &Recipient,
253        notification: &N,
254    ) -> Result<()> {
255        let address = recipient.route_for(channel::MAIL).ok_or_else(|| {
256            Error::msg(format!(
257                "`{}` goes out over mail, but this recipient has no mail route. Add one with \
258                 `Recipient::mail(...)`.",
259                notification.name()
260            ))
261        })?;
262
263        let message = match recipient.name() {
264            Some(name) => notification.to_mail(recipient)?.to((name, address)),
265            None => notification.to_mail(recipient)?.to(address),
266        };
267
268        self.mailer.send(message).await
269    }
270
271    async fn deliver_webhook<N: Notification + ?Sized>(
272        &self,
273        recipient: &Recipient,
274        notification: &N,
275    ) -> Result<Json> {
276        let url = recipient.route_for(channel::WEBHOOK).ok_or_else(|| {
277            Error::msg(format!(
278                "`{}` goes out over webhook, but this recipient has no webhook route. Add one \
279                 with `Recipient::webhook(...)`.",
280                notification.name()
281            ))
282        })?;
283
284        let payload = notification.to_webhook(recipient)?;
285        self.client
286            .post(url)
287            .json(payload.clone())
288            .send()
289            .await?
290            .error_for_status()
291            .map_err(|error| Error::msg(format!("the webhook at {url} failed: {error}")))?;
292
293        Ok(payload)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::Mail;
301    use rustlavel_client::{Fake as HttpFake, FakeResponse};
302
303    struct InvoicePaid {
304        amount: i64,
305    }
306
307    impl Notification for InvoicePaid {
308        fn name(&self) -> &str {
309            "invoice-paid"
310        }
311
312        fn channels(&self) -> Vec<&'static str> {
313            vec![channel::MAIL, channel::DATABASE, channel::WEBHOOK]
314        }
315
316        fn to_mail(&self, _recipient: &Recipient) -> Result<Message> {
317            Ok(Message::new()
318                .from("billing@example.com")
319                .subject("Invoice paid")
320                .text(format!("We received {} cents. Thank you.", self.amount)))
321        }
322
323        fn to_database(&self, _recipient: &Recipient) -> Result<Json> {
324            Ok(Json::object([
325                ("type", Json::from("invoice-paid")),
326                ("amount", Json::from(self.amount)),
327            ]))
328        }
329
330        fn to_webhook(&self, _recipient: &Recipient) -> Result<Json> {
331            Ok(Json::object([("event", Json::from("invoice.paid"))]))
332        }
333    }
334
335    fn recipient() -> Recipient {
336        Recipient::named("Ada Lovelace")
337            .mail("ada@example.com")
338            .webhook("https://hooks.example.com/ada")
339    }
340
341    fn notifier(http: HttpFake) -> Notifier {
342        Notifier::new(Mail::fake()).with_client(Client::new().faking(http))
343    }
344
345    #[tokio::test]
346    async fn one_definition_fans_out_to_every_channel_it_declares() {
347        let notifier = notifier(HttpFake::new().fallback(FakeResponse::text("ok")));
348
349        let result = notifier.notify(&recipient(), &InvoicePaid { amount: 4_200 }).await;
350
351        assert!(result.is_ok(), "{:?}", result.failures());
352        assert_eq!(result.deliveries.len(), 3);
353        assert!(result.delivered(channel::MAIL));
354
355        // The database channel hands back the document to store.
356        let stored = result.payload(channel::DATABASE).unwrap();
357        assert_eq!(stored.get("amount").unwrap().as_i64(), Some(4_200));
358
359        notifier.mailer().assert_sent_to("ada@example.com");
360        let sent = &notifier.mailer().fake().unwrap().sent()[0];
361        assert_eq!(sent.subject_text(), "Invoice paid");
362        assert_eq!(sent.to_addresses()[0].to_header(), "Ada Lovelace <ada@example.com>");
363
364        notifier.client().fake().unwrap().assert_sent("hooks.example.com/ada");
365        let posted = notifier.client().fake().unwrap().recorded()[0].json().unwrap();
366        assert_eq!(posted.get("event").unwrap().as_str(), Some("invoice.paid"));
367
368        assert!(result.into_result().is_ok());
369    }
370
371    #[tokio::test]
372    async fn one_channel_failing_does_not_stop_the_others() {
373        // The webhook endpoint is down; the email must still go out.
374        let notifier = notifier(HttpFake::new().fallback(FakeResponse::text("boom").status(500)));
375
376        let result = notifier.notify(&recipient(), &InvoicePaid { amount: 100 }).await;
377
378        assert!(!result.is_ok());
379        assert!(result.delivered(channel::MAIL), "the mail should have been sent anyway");
380        assert!(result.delivered(channel::DATABASE));
381        assert!(!result.delivered(channel::WEBHOOK));
382
383        let failures = result.failures();
384        assert_eq!(failures.len(), 1);
385        assert_eq!(failures[0].channel, channel::WEBHOOK);
386
387        notifier.mailer().assert_sent_times(1);
388
389        let error = result.into_result().unwrap_err().to_string();
390        assert!(error.contains("webhook"), "{error}");
391        assert!(error.contains("500"), "{error}");
392    }
393
394    #[tokio::test]
395    async fn a_recipient_with_no_route_for_a_channel_is_told_which_one_is_missing() {
396        let notifier = notifier(HttpFake::new().fallback(FakeResponse::text("ok")));
397        let unreachable = Recipient::named("Nobody");
398
399        let result = notifier.notify(&unreachable, &InvoicePaid { amount: 1 }).await;
400
401        let error = result.into_result().unwrap_err().to_string();
402        assert!(error.contains("Recipient::mail"), "{error}");
403        assert!(error.contains("Recipient::webhook"), "{error}");
404    }
405
406    #[tokio::test]
407    async fn a_channel_a_notification_forgot_to_implement_says_so() {
408        struct Half;
409        impl Notification for Half {
410            fn name(&self) -> &str {
411                "half-written"
412            }
413            fn channels(&self) -> Vec<&'static str> {
414                vec![channel::DATABASE]
415            }
416        }
417
418        let result = notifier(HttpFake::new()).notify(&recipient(), &Half).await;
419        let error = result.into_result().unwrap_err().to_string();
420
421        assert!(error.contains("does not implement `to_database`"), "{error}");
422    }
423
424    #[tokio::test]
425    async fn an_unknown_channel_lists_the_ones_that_exist() {
426        struct Odd;
427        impl Notification for Odd {
428            fn channels(&self) -> Vec<&'static str> {
429                vec!["carrier-pigeon"]
430            }
431        }
432
433        let result = notifier(HttpFake::new()).notify(&recipient(), &Odd).await;
434        let error = result.into_result().unwrap_err().to_string();
435
436        assert!(error.contains("mail, database, webhook"), "{error}");
437    }
438
439    #[test]
440    fn routes_are_per_channel() {
441        let recipient = recipient().database("user:7");
442
443        assert_eq!(recipient.route_for(channel::MAIL), Some("ada@example.com"));
444        assert_eq!(recipient.route_for(channel::DATABASE), Some("user:7"));
445        assert_eq!(recipient.route_for("sms"), None);
446        assert_eq!(recipient.name(), Some("Ada Lovelace"));
447    }
448}