rustlavel-mail 0.7.2

Rustlavel mail: MIME messages, an SMTP client written on Tokio, and multi-channel notifications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! Notifications: one definition, several channels.
//!
//! A notification says what it is; the channels say how it looks in each
//! place. Adding a webhook to something that already sends mail is one more
//! method, not a second copy of the message.

use crate::message::Message;
use crate::transport::Mailer;
use rustlavel_client::Client;
use rustlavel_core::events::Event;
use rustlavel_core::{Error, Json, Result};
use std::collections::BTreeMap;

/// The channel names the framework knows.
pub mod channel {
    /// Email, through the application's [`crate::Mailer`].
    pub const MAIL: &str = "mail";
    /// A JSON payload the application stores itself.
    pub const DATABASE: &str = "database";
    /// A JSON POST to whatever URL the recipient is routed to.
    pub const WEBHOOK: &str = "webhook";
}

/// Where one recipient can be reached, per channel.
///
/// A route per channel rather than a `User` trait: the address a notification
/// goes to is not always a column on a model — a webhook URL belongs to a
/// team, an email address may come from a form.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Recipient {
    name: Option<String>,
    routes: BTreeMap<String, String>,
}

impl Recipient {
    pub fn new() -> Recipient {
        Recipient::default()
    }

    pub fn named(name: impl Into<String>) -> Recipient {
        Recipient { name: Some(name.into()), routes: BTreeMap::new() }
    }

    /// Where to email this recipient.
    pub fn mail(self, address: impl Into<String>) -> Recipient {
        self.route(channel::MAIL, address)
    }

    /// Where to POST this recipient's webhooks.
    pub fn webhook(self, url: impl Into<String>) -> Recipient {
        self.route(channel::WEBHOOK, url)
    }

    /// What identifies this recipient in the application's own store.
    pub fn database(self, key: impl Into<String>) -> Recipient {
        self.route(channel::DATABASE, key)
    }

    pub fn route(mut self, channel: &str, value: impl Into<String>) -> Recipient {
        self.routes.insert(channel.to_string(), value.into());
        self
    }

    pub fn route_for(&self, channel: &str) -> Option<&str> {
        self.routes.get(channel).map(String::as_str)
    }

    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }
}

/// Something a recipient should be told about.
///
/// Only [`Notification::channels`] is required; a notification implements the
/// `to_*` method for each channel it declares, and the default implementation
/// of the others explains what is missing rather than sending something empty.
pub trait Notification: Send + Sync {
    /// A stable name for this notification, used in events and errors.
    fn name(&self) -> &str {
        "notification"
    }

    /// Which channels this notification goes out on.
    fn channels(&self) -> Vec<&'static str>;

    /// The email, with no recipient: [`Notifier`] addresses it.
    fn to_mail(&self, _recipient: &Recipient) -> Result<Message> {
        Err(Error::msg(format!(
            "`{}` lists the mail channel but does not implement `to_mail`",
            self.name()
        )))
    }

    /// The payload for the application to store.
    fn to_database(&self, _recipient: &Recipient) -> Result<Json> {
        Err(Error::msg(format!(
            "`{}` lists the database channel but does not implement `to_database`",
            self.name()
        )))
    }

    /// The JSON body posted to the recipient's webhook.
    fn to_webhook(&self, _recipient: &Recipient) -> Result<Json> {
        Err(Error::msg(format!(
            "`{}` lists the webhook channel but does not implement `to_webhook`",
            self.name()
        )))
    }
}

/// What happened on one channel.
#[derive(Debug)]
pub struct Delivery {
    pub channel: &'static str,
    /// The payload, for a channel that produces one — `database` returns the
    /// document the application is expected to insert.
    pub payload: Option<Json>,
    pub error: Option<Error>,
}

impl Delivery {
    pub fn is_ok(&self) -> bool {
        self.error.is_none()
    }
}

/// The result of notifying one recipient over every declared channel.
#[derive(Debug)]
pub struct Notified {
    pub deliveries: Vec<Delivery>,
}

impl Notified {
    /// True when every channel succeeded.
    pub fn is_ok(&self) -> bool {
        self.deliveries.iter().all(Delivery::is_ok)
    }

    pub fn failures(&self) -> Vec<&Delivery> {
        self.deliveries.iter().filter(|delivery| !delivery.is_ok()).collect()
    }

    pub fn delivered(&self, channel: &str) -> bool {
        self.deliveries.iter().any(|d| d.channel == channel && d.is_ok())
    }

    /// The payload a channel produced, for the caller to store.
    pub fn payload(&self, channel: &str) -> Option<&Json> {
        self.deliveries
            .iter()
            .find(|delivery| delivery.channel == channel)
            .and_then(|delivery| delivery.payload.as_ref())
    }

    /// Collapse into a `Result`, naming every channel that failed.
    ///
    /// Optional on purpose: a caller usually wants the mail to have gone out
    /// even though the webhook endpoint is down, and only then decides whether
    /// that is worth failing the request over.
    pub fn into_result(self) -> Result<Notified> {
        let described: Vec<String> = self
            .deliveries
            .iter()
            .filter_map(|delivery| {
                delivery.error.as_ref().map(|error| format!("{}: {error}", delivery.channel))
            })
            .collect();

        if described.is_empty() {
            return Ok(self);
        }
        Err(Error::msg(format!("some channels failed — {}", described.join("; "))))
    }
}

/// Sends notifications over the channels they declare.
#[derive(Clone)]
pub struct Notifier {
    mailer: Mailer,
    client: Client,
}

impl Notifier {
    pub fn new(mailer: Mailer) -> Notifier {
        Notifier { mailer, client: Client::new() }
    }

    /// Use a particular HTTP client for the webhook channel — a faked one in
    /// tests, or one with retries configured in production.
    pub fn with_client(mut self, client: Client) -> Notifier {
        self.client = client;
        self
    }

    pub fn mailer(&self) -> &Mailer {
        &self.mailer
    }

    pub fn client(&self) -> &Client {
        &self.client
    }

    /// Deliver a notification to one recipient over every channel it declares.
    ///
    /// One channel failing does not stop the others: a webhook endpoint being
    /// down is no reason for the recipient not to get their email. Every
    /// outcome comes back, and the caller decides what a failure is worth.
    pub async fn notify<N: Notification + ?Sized>(
        &self,
        recipient: &Recipient,
        notification: &N,
    ) -> Notified {
        let mut deliveries = Vec::new();

        for channel in notification.channels() {
            let outcome = match channel {
                channel::MAIL => self.deliver_mail(recipient, notification).await.map(|_| None),
                channel::DATABASE => notification.to_database(recipient).map(Some),
                channel::WEBHOOK => self.deliver_webhook(recipient, notification).await.map(Some),
                unknown => Err(Error::msg(format!(
                    "`{unknown}` is not a notification channel. Use one of: mail, database, \
                     webhook."
                ))),
            };

            let delivery = match outcome {
                Ok(payload) => Delivery { channel, payload, error: None },
                Err(error) => {
                    rustlavel_core::warn!(
                        "notification `{}` failed on the {channel} channel: {error}",
                        notification.name()
                    );
                    Delivery { channel, payload: None, error: Some(error) }
                }
            };

            Event::new("notification.sent")
                .with("notification", notification.name())
                .with("channel", channel)
                .with("ok", delivery.is_ok())
                .dispatch();

            deliveries.push(delivery);
        }

        Notified { deliveries }
    }

    async fn deliver_mail<N: Notification + ?Sized>(
        &self,
        recipient: &Recipient,
        notification: &N,
    ) -> Result<()> {
        let address = recipient.route_for(channel::MAIL).ok_or_else(|| {
            Error::msg(format!(
                "`{}` goes out over mail, but this recipient has no mail route. Add one with \
                 `Recipient::mail(...)`.",
                notification.name()
            ))
        })?;

        let message = match recipient.name() {
            Some(name) => notification.to_mail(recipient)?.to((name, address)),
            None => notification.to_mail(recipient)?.to(address),
        };

        self.mailer.send(message).await
    }

    async fn deliver_webhook<N: Notification + ?Sized>(
        &self,
        recipient: &Recipient,
        notification: &N,
    ) -> Result<Json> {
        let url = recipient.route_for(channel::WEBHOOK).ok_or_else(|| {
            Error::msg(format!(
                "`{}` goes out over webhook, but this recipient has no webhook route. Add one \
                 with `Recipient::webhook(...)`.",
                notification.name()
            ))
        })?;

        let payload = notification.to_webhook(recipient)?;
        self.client
            .post(url)
            .json(payload.clone())
            .send()
            .await?
            .error_for_status()
            .map_err(|error| Error::msg(format!("the webhook at {url} failed: {error}")))?;

        Ok(payload)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Mail;
    use rustlavel_client::{Fake as HttpFake, FakeResponse};

    struct InvoicePaid {
        amount: i64,
    }

    impl Notification for InvoicePaid {
        fn name(&self) -> &str {
            "invoice-paid"
        }

        fn channels(&self) -> Vec<&'static str> {
            vec![channel::MAIL, channel::DATABASE, channel::WEBHOOK]
        }

        fn to_mail(&self, _recipient: &Recipient) -> Result<Message> {
            Ok(Message::new()
                .from("billing@example.com")
                .subject("Invoice paid")
                .text(format!("We received {} cents. Thank you.", self.amount)))
        }

        fn to_database(&self, _recipient: &Recipient) -> Result<Json> {
            Ok(Json::object([
                ("type", Json::from("invoice-paid")),
                ("amount", Json::from(self.amount)),
            ]))
        }

        fn to_webhook(&self, _recipient: &Recipient) -> Result<Json> {
            Ok(Json::object([("event", Json::from("invoice.paid"))]))
        }
    }

    fn recipient() -> Recipient {
        Recipient::named("Ada Lovelace")
            .mail("ada@example.com")
            .webhook("https://hooks.example.com/ada")
    }

    fn notifier(http: HttpFake) -> Notifier {
        Notifier::new(Mail::fake()).with_client(Client::new().faking(http))
    }

    #[tokio::test]
    async fn one_definition_fans_out_to_every_channel_it_declares() {
        let notifier = notifier(HttpFake::new().fallback(FakeResponse::text("ok")));

        let result = notifier.notify(&recipient(), &InvoicePaid { amount: 4_200 }).await;

        assert!(result.is_ok(), "{:?}", result.failures());
        assert_eq!(result.deliveries.len(), 3);
        assert!(result.delivered(channel::MAIL));

        // The database channel hands back the document to store.
        let stored = result.payload(channel::DATABASE).unwrap();
        assert_eq!(stored.get("amount").unwrap().as_i64(), Some(4_200));

        notifier.mailer().assert_sent_to("ada@example.com");
        let sent = &notifier.mailer().fake().unwrap().sent()[0];
        assert_eq!(sent.subject_text(), "Invoice paid");
        assert_eq!(sent.to_addresses()[0].to_header(), "Ada Lovelace <ada@example.com>");

        notifier.client().fake().unwrap().assert_sent("hooks.example.com/ada");
        let posted = notifier.client().fake().unwrap().recorded()[0].json().unwrap();
        assert_eq!(posted.get("event").unwrap().as_str(), Some("invoice.paid"));

        assert!(result.into_result().is_ok());
    }

    #[tokio::test]
    async fn one_channel_failing_does_not_stop_the_others() {
        // The webhook endpoint is down; the email must still go out.
        let notifier = notifier(HttpFake::new().fallback(FakeResponse::text("boom").status(500)));

        let result = notifier.notify(&recipient(), &InvoicePaid { amount: 100 }).await;

        assert!(!result.is_ok());
        assert!(result.delivered(channel::MAIL), "the mail should have been sent anyway");
        assert!(result.delivered(channel::DATABASE));
        assert!(!result.delivered(channel::WEBHOOK));

        let failures = result.failures();
        assert_eq!(failures.len(), 1);
        assert_eq!(failures[0].channel, channel::WEBHOOK);

        notifier.mailer().assert_sent_times(1);

        let error = result.into_result().unwrap_err().to_string();
        assert!(error.contains("webhook"), "{error}");
        assert!(error.contains("500"), "{error}");
    }

    #[tokio::test]
    async fn a_recipient_with_no_route_for_a_channel_is_told_which_one_is_missing() {
        let notifier = notifier(HttpFake::new().fallback(FakeResponse::text("ok")));
        let unreachable = Recipient::named("Nobody");

        let result = notifier.notify(&unreachable, &InvoicePaid { amount: 1 }).await;

        let error = result.into_result().unwrap_err().to_string();
        assert!(error.contains("Recipient::mail"), "{error}");
        assert!(error.contains("Recipient::webhook"), "{error}");
    }

    #[tokio::test]
    async fn a_channel_a_notification_forgot_to_implement_says_so() {
        struct Half;
        impl Notification for Half {
            fn name(&self) -> &str {
                "half-written"
            }
            fn channels(&self) -> Vec<&'static str> {
                vec![channel::DATABASE]
            }
        }

        let result = notifier(HttpFake::new()).notify(&recipient(), &Half).await;
        let error = result.into_result().unwrap_err().to_string();

        assert!(error.contains("does not implement `to_database`"), "{error}");
    }

    #[tokio::test]
    async fn an_unknown_channel_lists_the_ones_that_exist() {
        struct Odd;
        impl Notification for Odd {
            fn channels(&self) -> Vec<&'static str> {
                vec!["carrier-pigeon"]
            }
        }

        let result = notifier(HttpFake::new()).notify(&recipient(), &Odd).await;
        let error = result.into_result().unwrap_err().to_string();

        assert!(error.contains("mail, database, webhook"), "{error}");
    }

    #[test]
    fn routes_are_per_channel() {
        let recipient = recipient().database("user:7");

        assert_eq!(recipient.route_for(channel::MAIL), Some("ada@example.com"));
        assert_eq!(recipient.route_for(channel::DATABASE), Some("user:7"));
        assert_eq!(recipient.route_for("sms"), None);
        assert_eq!(recipient.name(), Some("Ada Lovelace"));
    }
}