1use 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
14pub mod channel {
16 pub const MAIL: &str = "mail";
18 pub const DATABASE: &str = "database";
20 pub const WEBHOOK: &str = "webhook";
22}
23
24#[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 pub fn mail(self, address: impl Into<String>) -> Recipient {
46 self.route(channel::MAIL, address)
47 }
48
49 pub fn webhook(self, url: impl Into<String>) -> Recipient {
51 self.route(channel::WEBHOOK, url)
52 }
53
54 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
73pub trait Notification: Send + Sync {
79 fn name(&self) -> &str {
81 "notification"
82 }
83
84 fn channels(&self) -> Vec<&'static str>;
86
87 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 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 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#[derive(Debug)]
114pub struct Delivery {
115 pub channel: &'static str,
116 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#[derive(Debug)]
130pub struct Notified {
131 pub deliveries: Vec<Delivery>,
132}
133
134impl Notified {
135 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 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 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#[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 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 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 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 = ¬ifier.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 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}