1use std::collections::{HashMap, HashSet};
62use std::sync::{Arc, LazyLock};
63
64use async_trait::async_trait;
65use tracing::info;
66
67use crate::config::{ALL_NOTIFY_EVENTS, NotifyConfig, ProfileConfig};
68use crate::jobs::{JobQueue, JobSpec};
69
70pub mod custom;
71pub mod email;
72pub mod expiry;
73pub mod job;
74pub mod webhook;
75
76pub use job::{NOTIFY_JOB_KIND, NotifyJob};
77
78#[async_trait]
80pub trait NotifyBackend: Send + Sync {
81 fn name(&self) -> &'static str;
83
84 async fn send(&self, event: &NotifyEvent) -> Result<(), NotifyError>;
88}
89
90#[derive(Debug, thiserror::Error)]
100#[error("{detail}")]
101pub struct NotifyError {
102 detail: String,
103 retryable: bool,
104}
105
106impl NotifyError {
107 pub fn new(detail: impl Into<String>) -> Self {
109 Self {
110 detail: detail.into(),
111 retryable: true,
112 }
113 }
114
115 pub fn permanent(detail: impl Into<String>) -> Self {
119 Self {
120 detail: detail.into(),
121 retryable: false,
122 }
123 }
124
125 #[must_use]
127 pub fn retryable(&self) -> bool {
128 self.retryable
129 }
130}
131
132#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
140pub struct ProfileMountedData {
141 pub profile: String,
142}
143
144#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
150pub struct AccountCreatedData {
151 pub profile: String,
152 pub account_id: String,
153 pub contact: Vec<String>,
154 pub client_ip: Option<String>,
155}
156
157#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
162pub struct AccountDeactivatedData {
163 pub profile: String,
164 pub account_id: String,
165 pub client_ip: Option<String>,
166}
167
168#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
174pub struct CertificateIssuedData {
175 pub profile: String,
176 pub order_id: String,
177 pub account_id: String,
178 pub cert_serial: String,
179 pub identifiers: Vec<String>,
180 pub client_ip: Option<String>,
181}
182
183#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
189pub struct CertificateRevokedData {
190 pub profile: String,
191 pub order_id: String,
192 pub account_id: String,
193 pub cert_serial: String,
194 pub reason: Option<u32>,
195 pub client_ip: Option<String>,
196}
197
198#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
206pub struct ChallengeFailedData {
207 pub profile: String,
208 pub order_id: String,
209 pub account_id: String,
210 pub authz_id: String,
211 pub challenge_id: String,
212 pub challenge_type: String,
213 pub identifier: String,
214 pub error: String,
215 pub client_ip: Option<String>,
216}
217
218#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
231pub struct ExpiringCertificate {
232 pub order_id: String,
233 pub account_id: String,
234 pub cert_serial: String,
235 pub identifiers: Vec<String>,
236 pub not_after: i64,
238 pub days_remaining: i64,
240 pub superseded_by: Option<SupersededBy>,
241}
242
243pub use crate::admin::SupersededBy;
250
251#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
264pub struct CertificatesExpiringData {
265 pub profile: String,
266 pub generated_at: i64,
269 pub lead_days: u64,
271 pub total: i64,
273 pub certificates: Vec<ExpiringCertificate>,
274}
275
276#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
287#[serde(tag = "hook", rename_all = "snake_case")]
288pub enum NotifyEvent {
289 ProfileMounted(ProfileMountedData),
290 AccountCreated(AccountCreatedData),
291 AccountDeactivated(AccountDeactivatedData),
292 CertificateIssued(CertificateIssuedData),
293 CertificateRevoked(CertificateRevokedData),
294 ChallengeFailed(ChallengeFailedData),
295 CertificatesExpiring(CertificatesExpiringData),
296}
297
298impl NotifyEvent {
299 pub fn kind(&self) -> &'static str {
302 match self {
303 Self::ProfileMounted(_) => "profile_mounted",
304 Self::AccountCreated(_) => "account_created",
305 Self::AccountDeactivated(_) => "account_deactivated",
306 Self::CertificateIssued(_) => "certificate_issued",
307 Self::CertificateRevoked(_) => "certificate_revoked",
308 Self::ChallengeFailed(_) => "challenge_failed",
309 Self::CertificatesExpiring(_) => "certificates_expiring",
310 }
311 }
312
313 pub fn profile(&self) -> &str {
315 match self {
316 Self::ProfileMounted(data) => &data.profile,
317 Self::AccountCreated(data) => &data.profile,
318 Self::AccountDeactivated(data) => &data.profile,
319 Self::CertificateIssued(data) => &data.profile,
320 Self::CertificateRevoked(data) => &data.profile,
321 Self::ChallengeFailed(data) => &data.profile,
322 Self::CertificatesExpiring(data) => &data.profile,
323 }
324 }
325
326 pub(crate) fn context(&self) -> minijinja::Value {
328 match self {
329 Self::ProfileMounted(data) => minijinja::Value::from_serialize(data),
330 Self::AccountCreated(data) => minijinja::Value::from_serialize(data),
331 Self::AccountDeactivated(data) => minijinja::Value::from_serialize(data),
332 Self::CertificateIssued(data) => minijinja::Value::from_serialize(data),
333 Self::CertificateRevoked(data) => minijinja::Value::from_serialize(data),
334 Self::ChallengeFailed(data) => minijinja::Value::from_serialize(data),
335 Self::CertificatesExpiring(data) => minijinja::Value::from_serialize(data),
336 }
337 }
338
339 pub(crate) fn payload(&self) -> serde_json::Value {
347 serde_json::to_value(self).expect("notify event data always serializes to a JSON object")
348 }
349
350 fn client_ip(&self) -> Option<&str> {
354 match self {
355 Self::ProfileMounted(_) => None,
356 Self::AccountCreated(data) => data.client_ip.as_deref(),
357 Self::AccountDeactivated(data) => data.client_ip.as_deref(),
358 Self::CertificateIssued(data) => data.client_ip.as_deref(),
359 Self::CertificateRevoked(data) => data.client_ip.as_deref(),
360 Self::ChallengeFailed(data) => data.client_ip.as_deref(),
361 Self::CertificatesExpiring(_) => None,
363 }
364 }
365
366 fn account_id(&self) -> Option<&str> {
369 match self {
370 Self::ProfileMounted(_) => None,
371 Self::AccountCreated(data) => Some(&data.account_id),
372 Self::AccountDeactivated(data) => Some(&data.account_id),
373 Self::CertificateIssued(data) => Some(&data.account_id),
374 Self::CertificateRevoked(data) => Some(&data.account_id),
375 Self::ChallengeFailed(data) => Some(&data.account_id),
376 Self::CertificatesExpiring(_) => None,
379 }
380 }
381
382 fn order_id(&self) -> Option<&str> {
385 match self {
390 Self::ProfileMounted(_) => None,
391 Self::AccountCreated(_) => None,
392 Self::AccountDeactivated(_) => None,
393 Self::CertificateIssued(data) => Some(&data.order_id),
394 Self::CertificateRevoked(data) => Some(&data.order_id),
395 Self::ChallengeFailed(data) => Some(&data.order_id),
396 Self::CertificatesExpiring(_) => None,
397 }
398 }
399
400 fn cert_serial(&self) -> Option<&str> {
403 match self {
404 Self::ProfileMounted(_) => None,
405 Self::AccountCreated(_) => None,
406 Self::AccountDeactivated(_) => None,
407 Self::CertificateIssued(data) => Some(&data.cert_serial),
408 Self::CertificateRevoked(data) => Some(&data.cert_serial),
409 Self::ChallengeFailed(_) => None,
410 Self::CertificatesExpiring(_) => None,
411 }
412 }
413
414 fn identifiers_joined(&self) -> String {
417 match self {
418 Self::ProfileMounted(_) => String::new(),
419 Self::AccountCreated(_) => String::new(),
420 Self::AccountDeactivated(_) => String::new(),
421 Self::CertificateIssued(data) => data.identifiers.join(","),
422 Self::CertificateRevoked(_) => String::new(),
423 Self::ChallengeFailed(_) => String::new(),
424 Self::CertificatesExpiring(_) => String::new(),
425 }
426 }
427}
428
429pub struct BackendSlot {
438 id: String,
439 events: HashSet<String>,
443 backend: Arc<dyn NotifyBackend>,
444}
445
446impl BackendSlot {
447 #[must_use]
449 pub fn id(&self) -> &str {
450 &self.id
451 }
452
453 #[must_use]
455 pub fn wants(&self, event: &NotifyEvent) -> bool {
456 self.events.contains(event.kind())
457 }
458
459 #[must_use]
466 pub fn new(id: impl Into<String>, backend: Arc<dyn NotifyBackend>, events: &[String]) -> Self {
467 Self {
468 id: id.into(),
469 events: events.iter().cloned().collect(),
470 backend,
471 }
472 }
473}
474
475pub struct NotifyDispatcher {
481 profile: String,
482 slots: Vec<BackendSlot>,
483 jobs: JobQueue,
484}
485
486impl std::fmt::Debug for NotifyDispatcher {
487 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490 formatter
491 .debug_struct("NotifyDispatcher")
492 .field("profile", &self.profile)
493 .field(
494 "backends",
495 &self.slots.iter().map(BackendSlot::id).collect::<Vec<_>>(),
496 )
497 .finish()
498 }
499}
500
501impl NotifyDispatcher {
502 #[must_use]
504 pub fn new(profile: impl Into<String>, slots: Vec<BackendSlot>, jobs: JobQueue) -> Self {
505 Self {
506 profile: profile.into(),
507 slots,
508 jobs,
509 }
510 }
511
512 #[must_use]
516 pub fn disabled(jobs: JobQueue) -> Self {
517 Self::new("default", Vec::new(), jobs)
518 }
519
520 #[must_use]
522 pub fn profile(&self) -> &str {
523 &self.profile
524 }
525
526 #[must_use]
529 pub fn slot(&self, id: &str) -> Option<&BackendSlot> {
530 self.slots.iter().find(|slot| slot.id == id)
531 }
532
533 pub async fn dispatch(&self, event: NotifyEvent) {
545 if self.slots.is_empty() {
546 return;
547 }
548
549 let kind = event.kind();
550 let payload = event.payload();
551 let delivery_id = uuid::Uuid::new_v4().to_string();
552 for slot in &self.slots {
553 if !slot.wants(&event) {
554 continue;
555 }
556 let spec = JobSpec::now(NOTIFY_JOB_KIND, format!("{delivery_id}:{}", slot.id))
557 .with_payload(serde_json::json!({
558 "profile": self.profile,
559 "backend": slot.id,
560 "event": payload,
561 }));
562 if self.jobs.enqueue_or_log(spec).await {
563 info!(
564 event = "notify_delivery_queued",
565 outcome = "progress",
566 profile = %self.profile,
567 backend = %slot.id,
568 kind,
569 delivery_id = %delivery_id,
570 );
571 }
572 }
573 }
574
575 pub(crate) async fn deliver(
582 &self,
583 id: &str,
584 event: &NotifyEvent,
585 ) -> Option<Result<(), NotifyError>> {
586 let slot = self.slot(id)?;
587 Some(slot.backend.send(event).await)
588 }
589}
590
591fn validate_events(field: &str, events: &[String]) -> anyhow::Result<()> {
596 for event in events {
597 anyhow::ensure!(
598 ALL_NOTIFY_EVENTS.contains(&event.as_str()),
599 "{field}: unknown event `{event}` (expected one of {ALL_NOTIFY_EVENTS:?})"
600 );
601 }
602 Ok(())
603}
604
605pub fn from_config(
611 profile: &str,
612 cfg: &NotifyConfig,
613 outbound: crate::http_client::Outbound,
614 jobs: &JobQueue,
615) -> anyhow::Result<Arc<NotifyDispatcher>> {
616 let env = Arc::new(build_environment(&cfg.template_dir));
620
621 let mut slots: Vec<BackendSlot> = Vec::with_capacity(cfg.enabled.len());
622 for name in &cfg.enabled {
623 let built: Vec<BackendSlot> = match name.as_str() {
624 "email" => {
625 validate_events("notify.email.events", &cfg.email.events)?;
626 vec![BackendSlot::new(
627 "email",
628 Arc::new(email::EmailNotifier::from_config(&cfg.email, env.clone())?),
629 &cfg.email.events,
630 )]
631 }
632 "webhook" => build_webhook_slots(cfg, &env, outbound.clone())?,
633 "custom" => build_custom_slots(cfg)?,
634 "mattermost" => anyhow::bail!(
641 "notify.enabled: `mattermost` was replaced by `webhook`. Use \
642 notify.enabled = [\"webhook\"] with a [notify.webhook.<name>] entry \
643 whose `url` is the incoming webhook URL; the default `body` is \
644 already the payload Mattermost accepts. `channel` and `username` \
645 move into that `body`"
646 ),
647 other => anyhow::bail!("unknown notify backend: {other}"),
648 };
649 slots.extend(built);
650 }
651
652 if slots.is_empty() {
653 info!(
654 event = "notify_disabled",
655 outcome = "success",
656 "no notification backends configured"
657 );
658 } else {
659 info!(event = "notify_enabled", outcome = "success", backends = ?cfg.enabled);
660 }
661
662 Ok(Arc::new(NotifyDispatcher::new(
663 profile,
664 slots,
665 jobs.clone(),
666 )))
667}
668
669fn build_custom_slots(cfg: &NotifyConfig) -> anyhow::Result<Vec<BackendSlot>> {
678 crate::config::resolve_named_entries(
679 "notify.custom",
680 "notify.custom_enabled",
681 "custom",
682 &cfg.custom,
683 &cfg.custom_enabled,
684 )?
685 .into_iter()
686 .map(|(name, script)| -> anyhow::Result<BackendSlot> {
687 validate_events(&format!("notify.custom.{name}.events"), &script.events)?;
688 let backend = custom::CustomScriptNotifier::from_config(script)?;
689 Ok(BackendSlot::new(
690 format!("custom:{name}"),
691 Arc::new(backend),
692 &script.events,
693 ))
694 })
695 .collect()
696}
697
698fn build_webhook_slots(
707 cfg: &NotifyConfig,
708 env: &minijinja::Environment<'static>,
709 outbound: crate::http_client::Outbound,
710) -> anyhow::Result<Vec<BackendSlot>> {
711 crate::config::resolve_named_entries(
712 "notify.webhook",
713 "notify.webhook_enabled",
714 "webhook",
715 &cfg.webhook,
716 &cfg.webhook_enabled,
717 )?
718 .into_iter()
719 .map(|(name, entry)| -> anyhow::Result<BackendSlot> {
720 validate_events(&format!("notify.webhook.{name}.events"), &entry.events)?;
721 let backend = webhook::WebhookNotifier::from_config(name, entry, env, outbound.clone())?;
722 Ok(BackendSlot::new(
723 format!("webhook:{name}"),
724 Arc::new(backend),
725 &entry.events,
726 ))
727 })
728 .collect()
729}
730
731pub type DispatcherMap = HashMap<String, Arc<NotifyDispatcher>>;
736
737pub type NotifiersSender = tokio::sync::watch::Sender<Arc<DispatcherMap>>;
739
740#[derive(Clone)]
759pub struct Notifiers(tokio::sync::watch::Receiver<Arc<DispatcherMap>>);
760
761impl Notifiers {
762 #[must_use]
764 pub fn get(&self, profile: &str) -> Option<Arc<NotifyDispatcher>> {
765 self.0.borrow().get(profile).cloned()
769 }
770}
771
772impl From<Arc<DispatcherMap>> for Notifiers {
780 fn from(map: Arc<DispatcherMap>) -> Self {
781 Self(tokio::sync::watch::channel(map).1)
782 }
783}
784
785impl From<DispatcherMap> for Notifiers {
786 fn from(map: DispatcherMap) -> Self {
787 Arc::new(map).into()
788 }
789}
790
791#[must_use]
794pub fn notifiers_channel(initial: DispatcherMap) -> (NotifiersSender, Notifiers) {
795 let (sender, receiver) = tokio::sync::watch::channel(Arc::new(initial));
796 (sender, Notifiers(receiver))
797}
798
799pub fn build_registry(
804 profiles: &[ProfileConfig],
805 outbound: crate::http_client::Outbound,
806 jobs: &JobQueue,
807) -> anyhow::Result<HashMap<String, Arc<NotifyDispatcher>>> {
808 let mut registry = HashMap::with_capacity(profiles.len());
809 for profile in profiles {
810 let dispatcher = from_config(
811 &profile.name,
812 &profile.sections.notify,
813 outbound.clone(),
814 jobs,
815 )
816 .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
817 registry.insert(profile.name.clone(), dispatcher);
818 }
819 Ok(registry)
820}
821
822macro_rules! embed {
830 ($name:literal) => {
831 ($name, include_str!(concat!("templates/", $name)))
832 };
833}
834
835static EMBEDDED_TEMPLATES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
840 HashMap::from([
841 embed!("email/profile_mounted.subject.j2"),
842 embed!("email/profile_mounted.body.j2"),
843 embed!("email/account_created.subject.j2"),
844 embed!("email/account_created.body.j2"),
845 embed!("email/account_deactivated.subject.j2"),
846 embed!("email/account_deactivated.body.j2"),
847 embed!("email/certificate_issued.subject.j2"),
848 embed!("email/certificate_issued.body.j2"),
849 embed!("email/certificate_revoked.subject.j2"),
850 embed!("email/certificate_revoked.body.j2"),
851 embed!("email/challenge_failed.subject.j2"),
852 embed!("email/challenge_failed.body.j2"),
853 embed!("email/certificates_expiring.subject.j2"),
854 embed!("email/certificates_expiring.body.j2"),
855 embed!("webhook/profile_mounted.j2"),
856 embed!("webhook/account_created.j2"),
857 embed!("webhook/account_deactivated.j2"),
858 embed!("webhook/certificate_issued.j2"),
859 embed!("webhook/certificate_revoked.j2"),
860 embed!("webhook/challenge_failed.j2"),
861 embed!("webhook/certificates_expiring.j2"),
862 ])
863});
864
865pub(crate) fn build_environment(template_dir: &str) -> minijinja::Environment<'static> {
870 crate::templating::loader_env(template_dir, &EMBEDDED_TEMPLATES)
871}
872
873#[must_use]
880pub fn template_names() -> Vec<&'static str> {
881 let mut names: Vec<&'static str> = EMBEDDED_TEMPLATES.keys().copied().collect();
882 names.sort_unstable();
883 names
884}
885
886pub(crate) fn render(
892 env: &minijinja::Environment<'static>,
893 template_name: &str,
894 event: &NotifyEvent,
895) -> Result<String, NotifyError> {
896 let template = env.get_template(template_name).map_err(|error| {
897 NotifyError::permanent(format!("template `{template_name}` not found: {error}"))
898 })?;
899 template.render(event.context()).map_err(|error| {
900 NotifyError::permanent(format!("template `{template_name}` failed: {error}"))
901 })
902}
903
904#[cfg(test)]
905pub(crate) mod tests {
906 use super::*;
907 use crate::config::CustomNotifyConfig;
908 use crate::sqlite::db::Database;
909 use crate::sqlite::job::Job;
910 use std::collections::BTreeMap;
911 use std::sync::Mutex;
912
913 fn test_resolver() -> Arc<dyn crate::dns::Resolver> {
915 Arc::new(crate::dns::HickoryResolver::from_system_uncached().unwrap())
916 }
917
918 pub(crate) async fn test_queue() -> JobQueue {
921 let database = Arc::new(Database::connect_in_memory().await.unwrap());
922 JobQueue::new(database, &crate::config::JobsConfig::default())
923 }
924
925 #[derive(Default, Clone, Copy)]
927 enum Failure {
928 #[default]
929 None,
930 Retryable,
931 Permanent,
932 }
933
934 #[derive(Default)]
937 pub(crate) struct RecordingNotifyBackend {
938 pub(crate) events: Mutex<Vec<NotifyEvent>>,
939 fail: Failure,
940 }
941
942 impl RecordingNotifyBackend {
943 pub(crate) fn failing() -> Self {
945 Self {
946 events: Mutex::new(Vec::new()),
947 fail: Failure::Retryable,
948 }
949 }
950
951 pub(crate) fn failing_permanently() -> Self {
953 Self {
954 events: Mutex::new(Vec::new()),
955 fail: Failure::Permanent,
956 }
957 }
958 }
959
960 #[async_trait]
961 impl NotifyBackend for RecordingNotifyBackend {
962 fn name(&self) -> &'static str {
963 "recording"
964 }
965
966 async fn send(&self, event: &NotifyEvent) -> Result<(), NotifyError> {
967 self.events.lock().unwrap().push(event.clone());
968 match self.fail {
969 Failure::None => Ok(()),
970 Failure::Retryable => Err(NotifyError::new("recording backend configured to fail")),
971 Failure::Permanent => Err(NotifyError::permanent(
972 "recording backend configured to fail permanently",
973 )),
974 }
975 }
976 }
977
978 fn profile_mounted(profile: &str) -> NotifyEvent {
979 NotifyEvent::ProfileMounted(ProfileMountedData {
980 profile: profile.to_string(),
981 })
982 }
983
984 fn every_event() -> Vec<NotifyEvent> {
988 vec![
989 profile_mounted("p"),
990 NotifyEvent::AccountCreated(AccountCreatedData {
991 profile: "p".to_string(),
992 account_id: "acct-1".to_string(),
993 contact: vec!["mailto:a@example.com".to_string()],
994 client_ip: Some("203.0.113.5".to_string()),
995 }),
996 NotifyEvent::AccountDeactivated(AccountDeactivatedData {
997 profile: "p".to_string(),
998 account_id: "acct-1".to_string(),
999 client_ip: Some("203.0.113.5".to_string()),
1000 }),
1001 NotifyEvent::CertificateIssued(CertificateIssuedData {
1002 profile: "p".to_string(),
1003 order_id: "ord-1".to_string(),
1004 account_id: "acct-1".to_string(),
1005 cert_serial: "0a0b".to_string(),
1006 identifiers: vec!["a.example.com".to_string(), "b.example.com".to_string()],
1007 client_ip: Some("203.0.113.5".to_string()),
1008 }),
1009 NotifyEvent::CertificateRevoked(CertificateRevokedData {
1010 profile: "p".to_string(),
1011 order_id: "ord-1".to_string(),
1012 account_id: "acct-1".to_string(),
1013 cert_serial: "0a0b".to_string(),
1014 reason: Some(1),
1015 client_ip: None,
1016 }),
1017 NotifyEvent::ChallengeFailed(ChallengeFailedData {
1018 profile: "p".to_string(),
1019 order_id: "ord-1".to_string(),
1020 account_id: "acct-1".to_string(),
1021 authz_id: "authz-1".to_string(),
1022 challenge_id: "chall-1".to_string(),
1023 challenge_type: "http-01".to_string(),
1024 identifier: "a.example.com".to_string(),
1025 error: "connection refused".to_string(),
1026 client_ip: Some("203.0.113.5".to_string()),
1027 }),
1028 NotifyEvent::CertificatesExpiring(CertificatesExpiringData {
1029 profile: "p".to_string(),
1030 generated_at: 1_700_000_000,
1031 lead_days: 14,
1032 total: 3,
1033 certificates: vec![ExpiringCertificate {
1034 order_id: "ord-1".to_string(),
1035 account_id: "acct-1".to_string(),
1036 cert_serial: "0a0b".to_string(),
1037 identifiers: vec!["a.example.com".to_string()],
1038 not_after: 1_700_600_000,
1039 days_remaining: 6,
1040 superseded_by: None,
1041 }],
1042 }),
1043 ]
1044 }
1045
1046 #[test]
1050 fn every_event_answers_every_accessor() {
1051 let events = every_event();
1052 assert_eq!(
1053 events.len(),
1054 ALL_NOTIFY_EVENTS.len(),
1055 "every declared event kind needs a sample here"
1056 );
1057
1058 for event in &events {
1059 assert_eq!(event.profile(), "p");
1060 assert!(
1061 ALL_NOTIFY_EVENTS.contains(&event.kind()),
1062 "{}",
1063 event.kind()
1064 );
1065
1066 assert!(!event.context().is_undefined());
1069 let payload = event.payload();
1070 assert_eq!(
1071 payload.get("hook").and_then(|v| v.as_str()),
1072 Some(event.kind()),
1073 "the payload must name its own hook"
1074 );
1075 assert_eq!(payload.get("profile").and_then(|v| v.as_str()), Some("p"));
1076 }
1077
1078 let subjectless = [&events[0], events.last().unwrap()];
1086 for event in subjectless {
1087 assert_eq!(event.client_ip(), None, "{}", event.kind());
1088 assert_eq!(event.account_id(), None, "{}", event.kind());
1089 assert_eq!(event.order_id(), None, "{}", event.kind());
1090 assert_eq!(event.cert_serial(), None, "{}", event.kind());
1091 assert_eq!(event.identifiers_joined(), "", "{}", event.kind());
1092 }
1093
1094 let per_subject = &events[1..events.len() - 1];
1095 for event in per_subject {
1096 assert_eq!(event.account_id(), Some("acct-1"));
1097 }
1098 assert_eq!(events[4].client_ip(), None);
1100 assert_eq!(events[1].client_ip(), Some("203.0.113.5"));
1101
1102 assert_eq!(events[1].order_id(), None);
1104 assert_eq!(events[2].cert_serial(), None);
1105 for event in &per_subject[2..] {
1106 assert_eq!(event.order_id(), Some("ord-1"));
1107 }
1108 assert_eq!(events[3].cert_serial(), Some("0a0b"));
1109 assert_eq!(events[4].cert_serial(), Some("0a0b"));
1110 assert_eq!(events[5].cert_serial(), None);
1111
1112 assert_eq!(
1114 events[3].identifiers_joined(),
1115 "a.example.com,b.example.com"
1116 );
1117 assert_eq!(events[5].identifiers_joined(), "");
1118
1119 let digest = events.last().unwrap().payload();
1124 assert_eq!(digest["total"], 3);
1125 assert_eq!(digest["certificates"][0]["order_id"], "ord-1");
1126 assert_eq!(digest["certificates"][0]["days_remaining"], 6);
1127 }
1128
1129 #[tokio::test]
1133 async fn the_dispatcher_debug_names_its_backends() {
1134 let queue = test_queue().await;
1135 let dispatcher = NotifyDispatcher::new(
1136 "le",
1137 vec![BackendSlot::new(
1138 "recording",
1139 Arc::new(RecordingNotifyBackend::default()),
1140 &every_kind(),
1141 )],
1142 queue.clone(),
1143 );
1144 let rendered = format!("{dispatcher:?}");
1145 assert!(rendered.contains("NotifyDispatcher"), "{rendered}");
1146 assert!(rendered.contains("recording"), "{rendered}");
1147 assert!(rendered.contains("le"), "{rendered}");
1148
1149 assert!(format!("{:?}", NotifyDispatcher::disabled(queue)).contains("[]"));
1150 }
1151
1152 #[tokio::test]
1159 async fn a_handle_taken_before_a_swap_reads_the_map_after_it() {
1160 let queue = test_queue().await;
1161 let (sender, notifiers) = notifiers_channel(HashMap::new());
1162 assert!(notifiers.get("le").is_none());
1163
1164 let mut next = HashMap::new();
1165 next.insert(
1166 "le".to_string(),
1167 Arc::new(NotifyDispatcher::disabled(queue.clone())),
1168 );
1169 sender.send_replace(Arc::new(next));
1170
1171 assert!(notifiers.get("le").is_some());
1172 assert!(notifiers.get("staging").is_none());
1174
1175 sender.send_replace(Arc::new(HashMap::new()));
1177 assert!(notifiers.get("le").is_none());
1178 }
1179
1180 #[tokio::test]
1188 async fn a_fixed_map_survives_its_sender_being_dropped() {
1189 let queue = test_queue().await;
1190 let mut map = HashMap::new();
1191 map.insert(
1192 "le".to_string(),
1193 Arc::new(NotifyDispatcher::disabled(queue)),
1194 );
1195
1196 let notifiers: Notifiers = map.into();
1197 assert!(notifiers.get("le").is_some());
1198 assert!(notifiers.clone().get("le").is_some());
1200 }
1201
1202 fn every_kind() -> Vec<String> {
1204 ALL_NOTIFY_EVENTS.iter().map(|k| (*k).to_string()).collect()
1205 }
1206
1207 async fn recording_dispatcher(
1209 events: &[String],
1210 ) -> (Arc<NotifyDispatcher>, Arc<RecordingNotifyBackend>, JobQueue) {
1211 let queue = test_queue().await;
1212 let recorder = Arc::new(RecordingNotifyBackend::default());
1213 let dispatcher = Arc::new(NotifyDispatcher::new(
1214 "le",
1215 vec![BackendSlot::new("recording", recorder.clone(), events)],
1216 queue.clone(),
1217 ));
1218 (dispatcher, recorder, queue)
1219 }
1220
1221 #[tokio::test]
1224 async fn dispatch_queues_a_row_rather_than_delivering() {
1225 let (dispatcher, recorder, queue) = recording_dispatcher(&every_kind()).await;
1226
1227 dispatcher.dispatch(profile_mounted("le")).await;
1228
1229 assert!(
1230 recorder.events.lock().unwrap().is_empty(),
1231 "dispatch must not deliver inline"
1232 );
1233 let queued = Job::count_live(NOTIFY_JOB_KIND, queue.database())
1234 .await
1235 .unwrap();
1236 assert_eq!(queued, 1, "one backend, one row");
1237 }
1238
1239 #[tokio::test]
1242 async fn a_backend_that_does_not_want_the_event_gets_no_row() {
1243 let (dispatcher, _recorder, queue) =
1244 recording_dispatcher(&["certificate_issued".to_string()]).await;
1245
1246 dispatcher.dispatch(profile_mounted("le")).await;
1247
1248 assert_eq!(
1249 Job::count_live(NOTIFY_JOB_KIND, queue.database())
1250 .await
1251 .unwrap(),
1252 0
1253 );
1254 }
1255
1256 #[tokio::test]
1259 async fn one_dispatch_queues_one_row_per_wanting_backend() {
1260 let queue = test_queue().await;
1261 let dispatcher = NotifyDispatcher::new(
1262 "le",
1263 vec![
1264 BackendSlot::new(
1265 "email",
1266 Arc::new(RecordingNotifyBackend::default()),
1267 &every_kind(),
1268 ),
1269 BackendSlot::new(
1270 "custom:webhook",
1271 Arc::new(RecordingNotifyBackend::default()),
1272 &every_kind(),
1273 ),
1274 BackendSlot::new(
1275 "custom:pager",
1276 Arc::new(RecordingNotifyBackend::default()),
1277 &["certificate_revoked".to_string()],
1278 ),
1279 ],
1280 queue.clone(),
1281 );
1282
1283 dispatcher.dispatch(profile_mounted("le")).await;
1284
1285 assert_eq!(
1286 Job::count_live(NOTIFY_JOB_KIND, queue.database())
1287 .await
1288 .unwrap(),
1289 2,
1290 "the third backend does not want this kind"
1291 );
1292 }
1293
1294 #[tokio::test]
1298 async fn the_same_event_dispatched_twice_queues_twice() {
1299 let (dispatcher, _recorder, queue) = recording_dispatcher(&every_kind()).await;
1300
1301 dispatcher.dispatch(profile_mounted("le")).await;
1302 dispatcher.dispatch(profile_mounted("le")).await;
1303
1304 assert_eq!(
1305 Job::count_live(NOTIFY_JOB_KIND, queue.database())
1306 .await
1307 .unwrap(),
1308 2
1309 );
1310 }
1311
1312 #[tokio::test]
1315 async fn a_disabled_dispatcher_queues_nothing() {
1316 let queue = test_queue().await;
1317 let dispatcher = NotifyDispatcher::disabled(queue.clone());
1318
1319 dispatcher.dispatch(profile_mounted("le")).await;
1320
1321 assert_eq!(
1322 Job::count_live(NOTIFY_JOB_KIND, queue.database())
1323 .await
1324 .unwrap(),
1325 0
1326 );
1327 }
1328
1329 #[tokio::test]
1332 async fn a_database_failure_is_swallowed_by_dispatch() {
1333 let (dispatcher, _recorder, queue) = recording_dispatcher(&every_kind()).await;
1334 queue.database().pool.close().await;
1335
1336 dispatcher.dispatch(profile_mounted("le")).await;
1337 }
1338
1339 #[tokio::test]
1343 async fn deliver_reaches_one_backend_and_reports_an_unknown_id() {
1344 let (dispatcher, recorder, _queue) = recording_dispatcher(&every_kind()).await;
1345
1346 let outcome = dispatcher
1347 .deliver("recording", &profile_mounted("le"))
1348 .await;
1349 assert!(matches!(outcome, Some(Ok(()))));
1350 assert_eq!(recorder.events.lock().unwrap().len(), 1);
1351
1352 assert!(
1353 dispatcher
1354 .deliver("carrier-pigeon", &profile_mounted("le"))
1355 .await
1356 .is_none()
1357 );
1358 assert_eq!(
1359 recorder.events.lock().unwrap().len(),
1360 1,
1361 "an unknown id must reach no backend at all"
1362 );
1363 }
1364
1365 #[tokio::test]
1370 async fn each_backend_name_builds_its_own_backend() {
1371 let cfg = NotifyConfig {
1372 enabled: vec!["email".to_string(), "webhook".to_string()],
1373 email: crate::config::EmailNotifyConfig {
1374 smtp_host: "smtp.example.com".to_string(),
1375 from: "acme@example.com".to_string(),
1376 to: vec!["ops@example.com".to_string()],
1377 smtp_security: "none".to_string(),
1379 smtp_username: "user".to_string(),
1380 smtp_password: "pass".to_string(),
1381 ..crate::config::EmailNotifyConfig::default()
1382 },
1383 webhook_enabled: vec!["chat".to_string()],
1384 webhook: BTreeMap::from([("chat".to_string(), webhook_entry())]),
1385 ..NotifyConfig::default()
1386 };
1387
1388 let dispatcher = from_config(
1389 "le",
1390 &cfg,
1391 crate::testutil::outbound_with(test_resolver()),
1392 &test_queue().await,
1393 )
1394 .expect("both backends must build");
1395 let rendered = format!("{dispatcher:?}");
1396 assert!(rendered.contains("email"), "{rendered}");
1397 assert!(rendered.contains("webhook:chat"), "{rendered}");
1398 }
1399
1400 fn webhook_entry() -> crate::config::WebhookNotifyConfig {
1401 crate::config::WebhookNotifyConfig {
1402 url: "https://chat.example.com/hooks/abc".to_string(),
1403 ..crate::config::WebhookNotifyConfig::default()
1404 }
1405 }
1406
1407 #[tokio::test]
1413 async fn two_webhook_entries_get_distinct_slot_ids() {
1414 let cfg = NotifyConfig {
1415 enabled: vec!["webhook".to_string()],
1416 webhook_enabled: vec!["slack".to_string(), "teams".to_string()],
1417 webhook: BTreeMap::from([
1418 ("slack".to_string(), webhook_entry()),
1419 ("teams".to_string(), webhook_entry()),
1420 ]),
1421 ..NotifyConfig::default()
1422 };
1423
1424 let dispatcher = from_config(
1425 "le",
1426 &cfg,
1427 crate::testutil::outbound_with(test_resolver()),
1428 &test_queue().await,
1429 )
1430 .expect("both entries must build");
1431
1432 assert!(dispatcher.slot("webhook:slack").is_some());
1433 assert!(dispatcher.slot("webhook:teams").is_some());
1434 assert!(dispatcher.slot("webhook").is_none());
1435 }
1436
1437 #[tokio::test]
1441 async fn the_removed_mattermost_backend_is_refused_by_name() {
1442 let cfg = NotifyConfig {
1443 enabled: vec!["mattermost".to_string()],
1444 ..NotifyConfig::default()
1445 };
1446 let error = from_config(
1447 "le",
1448 &cfg,
1449 crate::testutil::outbound_with(test_resolver()),
1450 &test_queue().await,
1451 )
1452 .unwrap_err()
1453 .to_string();
1454 assert!(error.contains("mattermost"), "{error}");
1455 assert!(error.contains("webhook"), "{error}");
1456 }
1457
1458 #[tokio::test]
1461 async fn every_smtp_security_mode_is_recognised() {
1462 for mode in ["starttls", "tls", "none"] {
1463 let cfg = email_config(mode);
1464 from_config(
1465 "le",
1466 &cfg,
1467 crate::testutil::outbound_with(test_resolver()),
1468 &test_queue().await,
1469 )
1470 .unwrap_or_else(|error| panic!("`{mode}` must build: {error}"));
1471 }
1472
1473 let error = from_config(
1474 "le",
1475 &email_config("carrier-pigeon"),
1476 crate::testutil::outbound_with(test_resolver()),
1477 &test_queue().await,
1478 )
1479 .unwrap_err()
1480 .to_string();
1481 assert!(error.contains("smtp_security"), "{error}");
1482 }
1483
1484 fn email_config(smtp_security: &str) -> NotifyConfig {
1485 NotifyConfig {
1486 enabled: vec!["email".to_string()],
1487 email: crate::config::EmailNotifyConfig {
1488 smtp_host: "smtp.example.com".to_string(),
1489 from: "acme@example.com".to_string(),
1490 to: vec!["ops@example.com".to_string()],
1491 smtp_security: smtp_security.to_string(),
1492 ..crate::config::EmailNotifyConfig::default()
1493 },
1494 ..NotifyConfig::default()
1495 }
1496 }
1497
1498 #[tokio::test]
1502 async fn an_unknown_event_name_is_caught_on_each_backend() {
1503 let mut email = email_config("none");
1504 email.email.events = vec!["certificate_exploded".to_string()];
1505 let error = from_config(
1506 "le",
1507 &email,
1508 crate::testutil::outbound_with(test_resolver()),
1509 &test_queue().await,
1510 )
1511 .unwrap_err()
1512 .to_string();
1513 assert!(error.contains("notify.email.events"), "{error}");
1514
1515 let webhook = NotifyConfig {
1516 enabled: vec!["webhook".to_string()],
1517 webhook_enabled: vec!["chat".to_string()],
1518 webhook: BTreeMap::from([(
1519 "chat".to_string(),
1520 crate::config::WebhookNotifyConfig {
1521 events: vec!["certificate_exploded".to_string()],
1522 ..webhook_entry()
1523 },
1524 )]),
1525 ..NotifyConfig::default()
1526 };
1527 let error = from_config(
1528 "le",
1529 &webhook,
1530 crate::testutil::outbound_with(test_resolver()),
1531 &test_queue().await,
1532 )
1533 .unwrap_err()
1534 .to_string();
1535 assert!(error.contains("notify.webhook.chat.events"), "{error}");
1536 }
1537
1538 #[tokio::test]
1541 async fn a_custom_name_with_no_entry_is_a_startup_error() {
1542 let cfg = NotifyConfig {
1543 enabled: vec!["custom".to_string()],
1544 custom_enabled: vec!["webhook".to_string()],
1545 ..NotifyConfig::default()
1546 };
1547 let error = from_config(
1548 "le",
1549 &cfg,
1550 crate::testutil::outbound_with(test_resolver()),
1551 &test_queue().await,
1552 )
1553 .unwrap_err()
1554 .to_string();
1555 assert!(
1556 error.contains("notify.custom_enabled names `webhook`"),
1557 "{error}"
1558 );
1559 }
1560
1561 #[tokio::test]
1564 async fn an_invalid_custom_key_name_is_a_startup_error() {
1565 let mut custom = std::collections::BTreeMap::new();
1566 custom.insert("Web Hook".to_string(), CustomNotifyConfig::default());
1567 let cfg = NotifyConfig {
1568 enabled: vec!["custom".to_string()],
1569 custom_enabled: vec!["Web Hook".to_string()],
1570 custom,
1571 ..NotifyConfig::default()
1572 };
1573 let error = from_config(
1574 "le",
1575 &cfg,
1576 crate::testutil::outbound_with(test_resolver()),
1577 &test_queue().await,
1578 )
1579 .unwrap_err()
1580 .to_string();
1581 assert!(error.contains("invalid name"), "{error}");
1582 }
1583
1584 #[tokio::test]
1585 async fn unknown_backend_name_is_a_startup_error() {
1586 let cfg = NotifyConfig {
1587 enabled: vec!["carrier-pigeon".to_string()],
1588 ..NotifyConfig::default()
1589 };
1590 let error = from_config(
1591 "le",
1592 &cfg,
1593 crate::testutil::outbound_with(test_resolver()),
1594 &test_queue().await,
1595 )
1596 .unwrap_err()
1597 .to_string();
1598 assert!(error.contains("unknown notify backend"), "{error}");
1599 }
1600
1601 #[tokio::test]
1602 async fn custom_enabled_empty_is_a_startup_error() {
1603 let cfg = NotifyConfig {
1604 enabled: vec!["custom".to_string()],
1605 ..NotifyConfig::default()
1606 };
1607 let error = from_config(
1608 "le",
1609 &cfg,
1610 crate::testutil::outbound_with(test_resolver()),
1611 &test_queue().await,
1612 )
1613 .unwrap_err()
1614 .to_string();
1615 assert!(error.contains("notify.custom_enabled is empty"), "{error}");
1616 }
1617
1618 #[tokio::test]
1619 async fn an_unknown_event_name_is_a_startup_error() {
1620 let cfg = NotifyConfig {
1621 enabled: vec!["email".to_string()],
1622 email: crate::config::EmailNotifyConfig {
1623 smtp_host: "localhost".to_string(),
1624 events: vec!["orders_shipped".to_string()],
1625 ..crate::config::EmailNotifyConfig::default()
1626 },
1627 ..NotifyConfig::default()
1628 };
1629 let error = from_config(
1630 "le",
1631 &cfg,
1632 crate::testutil::outbound_with(test_resolver()),
1633 &test_queue().await,
1634 )
1635 .unwrap_err()
1636 .to_string();
1637 assert!(error.contains("unknown event"), "{error}");
1638 }
1639
1640 #[tokio::test]
1644 async fn a_backend_only_accepts_events_it_is_configured_for() {
1645 let wide = BackendSlot::new(
1646 "wide",
1647 Arc::new(RecordingNotifyBackend::default()),
1648 &every_kind(),
1649 );
1650 let narrow = BackendSlot::new(
1651 "narrow",
1652 Arc::new(RecordingNotifyBackend::default()),
1653 &["certificate_issued".to_string()],
1654 );
1655
1656 assert!(wide.wants(&profile_mounted("default")));
1657 assert!(!narrow.wants(&profile_mounted("default")));
1658
1659 let queue = test_queue().await;
1660 let dispatcher = NotifyDispatcher::new("le", vec![wide, narrow], queue.clone());
1661 dispatcher.dispatch(profile_mounted("default")).await;
1662
1663 assert_eq!(
1664 Job::count_live(NOTIFY_JOB_KIND, queue.database())
1665 .await
1666 .unwrap(),
1667 1,
1668 "only the wide backend is queued for"
1669 );
1670 }
1671
1672 #[tokio::test]
1676 async fn a_failing_backend_does_not_stop_another_from_receiving_the_event() {
1677 let failing = Arc::new(RecordingNotifyBackend::failing());
1678 let healthy = Arc::new(RecordingNotifyBackend::default());
1679 let queue = test_queue().await;
1680 let dispatcher = NotifyDispatcher::new(
1681 "le",
1682 vec![
1683 BackendSlot::new("failing", failing.clone(), &every_kind()),
1684 BackendSlot::new("healthy", healthy.clone(), &every_kind()),
1685 ],
1686 queue,
1687 );
1688
1689 assert!(matches!(
1690 dispatcher
1691 .deliver("failing", &profile_mounted("default"))
1692 .await,
1693 Some(Err(_))
1694 ));
1695 assert!(matches!(
1696 dispatcher
1697 .deliver("healthy", &profile_mounted("default"))
1698 .await,
1699 Some(Ok(()))
1700 ));
1701
1702 assert_eq!(failing.events.lock().unwrap().len(), 1);
1703 assert_eq!(healthy.events.lock().unwrap().len(), 1);
1704 }
1705
1706 #[tokio::test]
1707 async fn build_registry_builds_one_dispatcher_per_profile() {
1708 let profiles = vec![
1709 ProfileConfig {
1710 name: "a".to_string(),
1711 sections: crate::config::ProfileSections::default(),
1712 },
1713 ProfileConfig {
1714 name: "b".to_string(),
1715 sections: crate::config::ProfileSections::default(),
1716 },
1717 ];
1718 let registry = build_registry(
1719 &profiles,
1720 crate::testutil::outbound_with(test_resolver()),
1721 &test_queue().await,
1722 )
1723 .unwrap();
1724 assert_eq!(registry.len(), 2);
1725 assert_eq!(registry["a"].profile(), "a");
1726 assert_eq!(registry["b"].profile(), "b");
1727 }
1728
1729 #[tokio::test]
1733 async fn two_custom_entries_get_distinct_slot_ids() {
1734 let dir = crate::testutil::TempDir::new("notify-slot");
1735 let script = crate::testutil::write_script(&dir, "notify.sh", "#!/bin/sh\nexit 0\n");
1736 let entry = || CustomNotifyConfig {
1737 script_path: script.display().to_string(),
1738 ..CustomNotifyConfig::default()
1739 };
1740 let mut custom = std::collections::BTreeMap::new();
1741 custom.insert("webhook".to_string(), entry());
1742 custom.insert("pager".to_string(), entry());
1743
1744 let cfg = NotifyConfig {
1745 enabled: vec!["custom".to_string()],
1746 custom_enabled: vec!["webhook".to_string(), "pager".to_string()],
1747 custom,
1748 ..NotifyConfig::default()
1749 };
1750
1751 let dispatcher = from_config(
1752 "le",
1753 &cfg,
1754 crate::testutil::outbound_with(test_resolver()),
1755 &test_queue().await,
1756 )
1757 .expect("both custom entries must build");
1758
1759 assert!(dispatcher.slot("custom:webhook").is_some());
1760 assert!(dispatcher.slot("custom:pager").is_some());
1761 assert!(dispatcher.slot("custom").is_none());
1762 }
1763
1764 #[test]
1768 fn every_event_round_trips_through_its_payload() {
1769 for event in every_event() {
1770 let encoded = event.payload();
1771 let decoded: NotifyEvent = serde_json::from_value(encoded.clone())
1772 .unwrap_or_else(|error| panic!("{} must decode: {error}", event.kind()));
1773 assert_eq!(decoded.kind(), event.kind());
1774 assert_eq!(decoded.profile(), event.profile());
1775 assert_eq!(decoded.payload(), encoded, "re-encoding must be stable");
1776 }
1777 }
1778
1779 #[test]
1784 fn payload_is_tagged_with_its_own_hook() {
1785 let event = NotifyEvent::CertificateIssued(CertificateIssuedData {
1786 profile: "le".to_string(),
1787 order_id: "ord-1".to_string(),
1788 account_id: "acct-1".to_string(),
1789 cert_serial: "0a0b".to_string(),
1790 identifiers: vec!["a.example.com".to_string()],
1791 client_ip: Some("203.0.113.5".to_string()),
1792 });
1793
1794 assert_eq!(
1795 event.payload(),
1796 serde_json::json!({
1797 "hook": "certificate_issued",
1798 "profile": "le",
1799 "order_id": "ord-1",
1800 "account_id": "acct-1",
1801 "cert_serial": "0a0b",
1802 "identifiers": ["a.example.com"],
1803 "client_ip": "203.0.113.5",
1804 })
1805 );
1806 }
1807
1808 #[test]
1809 fn template_dir_override_wins_over_the_embedded_default() {
1810 let dir = crate::testutil::TempDir::new("notify");
1811 std::fs::create_dir_all(dir.join("email")).unwrap();
1812 std::fs::write(
1813 dir.join("email/profile_mounted.subject.j2"),
1814 "override: {{ profile }}",
1815 )
1816 .unwrap();
1817
1818 let env = build_environment(dir.path().to_str().unwrap());
1819 let rendered = render(
1820 &env,
1821 "email/profile_mounted.subject.j2",
1822 &profile_mounted("default"),
1823 )
1824 .unwrap();
1825 assert_eq!(rendered, "override: default");
1826
1827 let rendered = render(
1830 &env,
1831 "email/profile_mounted.body.j2",
1832 &profile_mounted("default"),
1833 )
1834 .unwrap();
1835 assert!(rendered.contains("default"));
1836 }
1837
1838 #[test]
1841 fn a_template_failure_is_permanent() {
1842 let env = build_environment("");
1843
1844 let missing = render(&env, "email/no_such_event.body.j2", &profile_mounted("le"))
1845 .expect_err("there is no such template");
1846 assert!(!missing.retryable(), "{missing}");
1847
1848 let dir = crate::testutil::TempDir::new("notify-broken");
1849 std::fs::create_dir_all(dir.join("email")).unwrap();
1850 std::fs::write(dir.join("email/profile_mounted.body.j2"), "{{ unclosed").unwrap();
1851 let env = build_environment(dir.path().to_str().unwrap());
1852 let broken = render(
1853 &env,
1854 "email/profile_mounted.body.j2",
1855 &profile_mounted("le"),
1856 )
1857 .expect_err("the template does not compile");
1858 assert!(!broken.retryable(), "{broken}");
1859 }
1860
1861 #[test]
1862 fn embedded_defaults_render_with_no_template_dir() {
1863 let env = build_environment("");
1864 let event = NotifyEvent::CertificateIssued(CertificateIssuedData {
1865 profile: "le".to_string(),
1866 order_id: "ord-1".to_string(),
1867 account_id: "acc-1".to_string(),
1868 cert_serial: "AA:BB".to_string(),
1869 identifiers: vec!["example.com".to_string()],
1870 client_ip: Some("203.0.113.1".to_string()),
1871 });
1872
1873 let subject = render(&env, "email/certificate_issued.subject.j2", &event).unwrap();
1874 assert!(subject.contains("le"), "{subject}");
1875
1876 let body = render(&env, "email/certificate_issued.body.j2", &event).unwrap();
1877 assert!(body.contains("example.com"), "{body}");
1878 assert!(body.contains("203.0.113.1"), "{body}");
1879 }
1880
1881 #[test]
1892 fn the_digest_templates_render_both_kinds_of_entry() {
1893 let env = build_environment("");
1894 let event = NotifyEvent::CertificatesExpiring(CertificatesExpiringData {
1895 profile: "le".to_string(),
1896 generated_at: 1_700_000_000,
1897 lead_days: 14,
1898 total: 7,
1899 certificates: vec![
1900 ExpiringCertificate {
1901 order_id: "ord-1".to_string(),
1902 account_id: "acc-1".to_string(),
1903 cert_serial: "0a0b".to_string(),
1904 identifiers: vec!["renew-me.example.com".to_string()],
1905 not_after: 1_700_600_000,
1906 days_remaining: 6,
1907 superseded_by: None,
1908 },
1909 ExpiringCertificate {
1910 order_id: "ord-2".to_string(),
1911 account_id: "acc-1".to_string(),
1912 cert_serial: "0c0d".to_string(),
1913 identifiers: vec!["already-done.example.com".to_string()],
1914 not_after: 1_700_600_000,
1915 days_remaining: 6,
1916 superseded_by: Some(SupersededBy {
1917 order_id: "ord-3".to_string(),
1918 cert_serial: "0e0f".to_string(),
1919 not_after: 1_800_000_000,
1920 via: "replaces".to_string(),
1921 }),
1922 },
1923 ],
1924 });
1925
1926 let subject = render(&env, "email/certificates_expiring.subject.j2", &event).unwrap();
1927 assert!(subject.contains('7'), "the count, not the page: {subject}");
1928 assert!(subject.contains("le"), "{subject}");
1929
1930 let body = render(&env, "email/certificates_expiring.body.j2", &event).unwrap();
1931 assert!(body.contains("renew-me.example.com"), "{body}");
1932 assert!(body.contains("already-done.example.com"), "{body}");
1933 assert!(body.contains("ord-3"), "the successor is named: {body}");
1934 assert!(
1935 body.contains("and 5 more"),
1936 "a truncated digest says how many it did not name: {body}"
1937 );
1938
1939 let hook = render(&env, "webhook/certificates_expiring.j2", &event).unwrap();
1940 assert!(
1941 !hook.contains('\n'),
1942 "a webhook message is one line, since an entry's `body` wraps it: {hook}"
1943 );
1944 assert!(hook.contains("already replaced"), "{hook}");
1945 }
1946}