Skip to main content

acme_proxy/notify/
mod.rs

1//! Operator notifications for ACME lifecycle events.
2//!
3//! The shape mirrors [`filter`](crate::filter)/[`signer`](crate::signer): a
4//! trait, an error type, and a [`from_config`] selector building the
5//! configured set at startup. Three backends exist today —
6//! [`email`], [`webhook`] (any HTTP endpoint, with the URL, method, headers
7//! and body all configured, which is what makes a chat provider configuration
8//! rather than code), and [`custom`] (an external script, for a channel that
9//! is not an HTTP request at all).
10//!
11//! ## Fire-and-forget, always — and now durable
12//!
13//! A notification can never affect the ACME response that triggered it.
14//! [`NotifyDispatcher::dispatch`] returns `()` and cannot be `?`'d: it writes
15//! the delivery to the [durable queue](crate::jobs) and returns. That is a
16//! deliberate API shape, not a convention callers must remember — there is no
17//! method on this type whose result a handler *could* propagate.
18//!
19//! What changed is what happens *after* it returns. Delivery used to be a bare
20//! `tokio::spawn`, so a refused SMTP connection or a 503 from a webhook was
21//! logged once and the notification was gone, and a restart lost everything in
22//! flight — the operator never heard that a certificate had been issued, and
23//! nothing recorded that they hadn't. Now each delivery is a `notify_deliver`
24//! job row: a transport failure is retried under `jobs.max_attempts` and the
25//! shared backoff, and a row outlives the process that queued it.
26//!
27//! Two consequences worth not rediscovering:
28//!
29//! - **One job per (occurrence × backend)**, never one per event. A retry must
30//!   not re-send to a backend that already succeeded, or one flaky webhook
31//!   produces a duplicate email on every attempt.
32//! - **[`NotifyError`] carries whether it is worth retrying.** A template that
33//!   does not render and a 400 from a webhook will fail identically for ever,
34//!   so they are permanent and refused on the first attempt; a connection
35//!   refused, a timeout and a 503 have decided nothing, so they are retried.
36//!   That is [`crate::jobs`]'s `Retry`/`Failed` split, applied at the source.
37//!
38//! ## Per-backend event filtering
39//!
40//! Unlike [`FilterConfig::rules`](crate::config::FilterConfig), where every
41//! filter must agree, notify backends are independent broadcast side-channels
42//! — an operator plausibly wants email only for issuance/revocation and a chat
43//! webhook for everything including failures. Each backend's own `events`
44//! list (defaulting to every kind) decides what reaches it, and the list
45//! lives on its [`BackendSlot`] so the check happens once, in
46//! [`NotifyDispatcher::dispatch`], rather than in each backend's own delivery
47//! code. Filtering *there* rather than in a wrapper around `send` is what stops
48//! a job being queued for a delivery that would immediately do nothing.
49//!
50//! ## Per-profile, like every other subsystem
51//!
52//! [`build_registry`] builds one [`NotifyDispatcher`] per resolved profile
53//! (not deduplicated by configuration identity like signer backends are —
54//! dispatchers are stateless side-channels, so two profiles with identical
55//! `[notify]` sections simply get two independent instances). The
56//! asynchronous `relay` signer backend, whose completion happens outside
57//! any HTTP handler, is handed the whole `profile name -> dispatcher` map so
58//! it can notify the right profile once an order settles — see
59//! `signer::relay::flow::settle`.
60
61use 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/// A pluggable notification channel.
79#[async_trait]
80pub trait NotifyBackend: Send + Sync {
81    /// The configuration name this backend runs under, used in logs.
82    fn name(&self) -> &'static str;
83
84    /// Delivers `event`. Failure is always non-fatal to the *caller* — see the
85    /// module docs — but it is no longer thrown away: [`NotifyJob`] retries it
86    /// unless the [`NotifyError`] says the attempt could never have worked.
87    async fn send(&self, event: &NotifyEvent) -> Result<(), NotifyError>;
88}
89
90/// Why a notify backend failed to deliver, and whether asking again could help.
91///
92/// The `retryable` half is what [`NotifyJob`] turns into
93/// [`JobOutcome::Retry`](crate::jobs::JobOutcome::Retry) or
94/// [`JobOutcome::Failed`](crate::jobs::JobOutcome::Failed), so the distinction
95/// has to be drawn where the failure happens rather than guessed from a string
96/// afterwards. The default — [`NotifyError::new`] — is *retryable*, because
97/// most of these are transport; a backend that knows better says so with
98/// [`NotifyError::permanent`].
99#[derive(Debug, thiserror::Error)]
100#[error("{detail}")]
101pub struct NotifyError {
102    detail: String,
103    retryable: bool,
104}
105
106impl NotifyError {
107    /// A failure that may not recur: a refused connection, a timeout, a 503.
108    pub fn new(detail: impl Into<String>) -> Self {
109        Self {
110            detail: detail.into(),
111            retryable: true,
112        }
113    }
114
115    /// A failure that will repeat identically however many times it is tried:
116    /// a template that does not render, a URL that does not parse, a webhook
117    /// that answers 400.
118    pub fn permanent(detail: impl Into<String>) -> Self {
119        Self {
120            detail: detail.into(),
121            retryable: false,
122        }
123    }
124
125    /// Whether another attempt could plausibly succeed.
126    #[must_use]
127    pub fn retryable(&self) -> bool {
128        self.retryable
129    }
130}
131
132/// Context common to every event: the profile it happened on and the client
133/// address, when a request was in scope.
134///
135/// `client_ip` is `None` on the one firing site with no request in scope at
136/// all — the `relay` signer backend's asynchronous completion, which
137/// runs in a background task long after any handler returned. Templates must
138/// treat it as optional.
139#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
140pub struct ProfileMountedData {
141    pub profile: String,
142}
143
144/// Payload of [`NotifyEvent::AccountCreated`]: a client registered a new
145/// account at this endpoint.
146///
147/// `contact` is what the client supplied, which may legitimately be empty —
148/// RFC 8555 does not require one.
149#[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/// Payload of [`NotifyEvent::AccountDeactivated`]: an account was deactivated,
158/// either by the client (§7.3.6) or by `acme-proxy account deactivate`.
159///
160/// Deactivation is permanent, so this event has no counterpart.
161#[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/// Payload of [`NotifyEvent::CertificateIssued`]: a certificate was signed.
169///
170/// `cert_serial` is the hex serial, the same value `POST /revokeCert` and the
171/// audit trail identify a certificate by. `identifiers` are the names the
172/// certificate covers.
173#[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/// Payload of [`NotifyEvent::CertificateRevoked`]: a certificate was withdrawn.
184///
185/// `reason` is the RFC 5280 §5.3.1 code the caller supplied, and is `None` when
186/// none was given — which is not the same as `Some(0)`. It reaches a `custom`
187/// script only in the JSON on stdin, since it has no environment variable.
188#[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/// Payload of [`NotifyEvent::ChallengeFailed`]: a validation attempt did not
199/// succeed.
200///
201/// This fires on the failure of one *challenge*, which is not necessarily the
202/// failure of the order: a client may have another enabled type left to try.
203/// `error` is the human-readable detail, the same text the challenge object's
204/// `error` member carries back to the client.
205#[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/// One certificate in a [`NotifyEvent::CertificatesExpiring`] digest.
219///
220/// `days_remaining` is derived here rather than left to a template: it is what
221/// the message is actually about, every backend wants it, and computing it in
222/// Jinja from two epoch seconds is arithmetic no template should carry.
223///
224/// `superseded_by` is the member that makes the digest readable — an operator
225/// scans for the entries where it is absent. It is `None` both when nothing
226/// has replaced this certificate and when this server cannot be *sure*
227/// something has: the annotation is deliberately conservative, since a wrong
228/// "already renewed" is an operator ignoring a certificate that really is
229/// about to lapse, where a missing one is only noise.
230#[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    /// The leaf's own notAfter, epoch seconds.
237    pub not_after: i64,
238    /// Whole days from the sweep to `not_after`, floored, never negative.
239    pub days_remaining: i64,
240    pub superseded_by: Option<SupersededBy>,
241}
242
243/// Re-exported so this event's payload still names its own members, and so the
244/// serde shape below is unchanged by where the type lives.
245///
246/// It moved to [`crate::admin::ops`] when the panel and the CLI gained expiry
247/// views: the annotation is computed once, there, and the digest is one of its
248/// three consumers rather than its owner. See [`crate::admin::superseded_by`].
249pub use crate::admin::SupersededBy;
250
251/// Payload of [`NotifyEvent::CertificatesExpiring`]: the periodic digest of
252/// what is about to lapse on one profile.
253///
254/// The one event carrying a **list**, and the only one with no request, no
255/// account and no single certificate behind it — it is generated by
256/// [`crate::notify::expiry`], on a schedule, about however many certificates
257/// happen to qualify.
258///
259/// `total` is the number that matched, which `certificates` may be shorter
260/// than: `notify.expiry.max_entries` bounds the list, so a template renders
261/// "and `total - certificates|length` more". The count is honest even when the
262/// list is truncated, which is the whole reason it is a separate member.
263#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
264pub struct CertificatesExpiringData {
265    pub profile: String,
266    /// When the sweep ran, epoch seconds — the point `days_remaining` counts
267    /// from, so a message read days later is still self-describing.
268    pub generated_at: i64,
269    /// The window this digest covers, `notify.expiry.lead_days`.
270    pub lead_days: u64,
271    /// How many certificates matched, before `max_entries` truncated the list.
272    pub total: i64,
273    pub certificates: Vec<ExpiringCertificate>,
274}
275
276/// One lifecycle event, carrying everything a template or `custom` script
277/// needs to describe it.
278///
279/// **Internally tagged on `hook`**, which is load-bearing twice over. It is the
280/// `custom` backend's stdin contract — a script reads `.hook` to tell one event
281/// from another — and it is what lets a queued delivery survive a restart, since
282/// a `notify_deliver` job payload is this enum and nothing else. The tag and the
283/// variant renaming reproduce exactly what [`Self::payload`] used to assemble by
284/// hand, so neither the script contract nor a row already in the queue changes
285/// shape; `payload_is_tagged_with_its_own_hook` pins that.
286#[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    /// The event kind name — matches [`ALL_NOTIFY_EVENTS`] and the template
300    /// file stem used to render it (e.g. `email/certificate_issued.body.j2`).
301    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    /// The profile this event happened on.
314    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    /// The template rendering context: this event's own data, serialized.
327    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    /// This event's own data as a JSON object, tagged with `"hook"` — the
340    /// `custom` backend's stdin payload, and the body of a queued
341    /// `notify_deliver` job. Not used by the templating backends, which render
342    /// from [`Self::context`] instead.
343    ///
344    /// This is the enum's own `Serialize`: the internal tag *is* the `"hook"`
345    /// member that used to be spliced in here after the fact.
346    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    /// This event's client address, when a request was in scope — `None` on
351    /// the `relay` async-completion path. Used by the `custom` backend
352    /// to fill `ACME_NOTIFY_CLIENT_IP`.
353    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            // Generated by a sweep, with no request anywhere in scope.
362            Self::CertificatesExpiring(_) => None,
363        }
364    }
365
366    /// This event's account id, when it has one. Used by the `custom` backend
367    /// to fill `ACME_NOTIFY_ACCOUNT_ID`.
368    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            // A digest spans however many accounts hold the expiring
377            // certificates; the per-entry `account_id` is in the payload.
378            Self::CertificatesExpiring(_) => None,
379        }
380    }
381
382    /// This event's order id, when it has one. Used by the `custom` backend
383    /// to fill `ACME_NOTIFY_ORDER_ID`.
384    fn order_id(&self) -> Option<&str> {
385        // Enumerated rather than `_ => None`, like every other accessor here:
386        // a wildcard would let a seventh variant carrying an order id compile,
387        // ship, and write an empty `ACME_NOTIFY_ORDER_ID` — the exact silent
388        // failure the accessor tests exist to make impossible.
389        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    /// This event's certificate serial, when it has one. Used by the `custom`
401    /// backend to fill `ACME_NOTIFY_CERT_SERIAL`.
402    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    /// This event's requested identifiers, comma-joined. Used by the `custom`
415    /// backend to fill `ACME_NOTIFY_IDENTIFIERS`.
416    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
429/// One configured backend, under the id a queued job addresses it by.
430///
431/// The id is **not** [`NotifyBackend::name`]: that is a `&'static str` a backend
432/// type answers, so every `custom` entry reports `"custom"` and two of them
433/// would be indistinguishable to a job row. It is built from the configuration
434/// instead — `email`, `webhook:<entry>`, `custom:<entry>` — which makes it
435/// stable across a restart, the property a durable payload needs. The same
436/// reasoning gives `filter::custom` its `ACME_FILTER_CHECK_NAME`.
437pub struct BackendSlot {
438    id: String,
439    /// The event kinds this backend's own `events` list admits. Filtering here
440    /// rather than inside a wrapper around `send` is what stops a job being
441    /// queued for a delivery that would immediately no-op.
442    events: HashSet<String>,
443    backend: Arc<dyn NotifyBackend>,
444}
445
446impl BackendSlot {
447    /// The id a job payload names this backend by.
448    #[must_use]
449    pub fn id(&self) -> &str {
450        &self.id
451    }
452
453    /// Whether this backend's `events` list admits `event`.
454    #[must_use]
455    pub fn wants(&self, event: &NotifyEvent) -> bool {
456        self.events.contains(event.kind())
457    }
458
459    /// Builds a slot directly.
460    ///
461    /// [`from_config`] is how one is normally made — it derives the ids from the
462    /// configuration, which is what keeps them stable across a restart. This is
463    /// for a caller assembling a dispatcher over a backend of its own, which in
464    /// practice means a test.
465    #[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
475/// The configured notify backends for one profile.
476///
477/// Cheap to clone behind the `Arc` it is always held in (`Profile::notify`,
478/// and the `profile name -> dispatcher` map handed to the `relay` signer
479/// backend and to [`NotifyJob`]).
480pub struct NotifyDispatcher {
481    profile: String,
482    slots: Vec<BackendSlot>,
483    jobs: JobQueue,
484}
485
486impl std::fmt::Debug for NotifyDispatcher {
487    /// `dyn NotifyBackend` is not `Debug`, so show the slot ids — the only part
488    /// worth reading anyway. Mirrors `FilterPolicy`'s own `Debug` impl.
489    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    /// Builds a dispatcher over already-constructed slots.
503    #[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    /// A dispatcher with no backends — every `dispatch` is a no-op. The shape a
513    /// profile with an empty `notify.enabled` gets, and what tests that do not
514    /// care about notifications want.
515    #[must_use]
516    pub fn disabled(jobs: JobQueue) -> Self {
517        Self::new("default", Vec::new(), jobs)
518    }
519
520    /// The profile whose `[notify]` section this dispatcher was built from.
521    #[must_use]
522    pub fn profile(&self) -> &str {
523        &self.profile
524    }
525
526    /// The backend registered under `id`, if it is still configured. `None`
527    /// after a configuration change removed a backend a queued job still names.
528    #[must_use]
529    pub fn slot(&self, id: &str) -> Option<&BackendSlot> {
530        self.slots.iter().find(|slot| slot.id == id)
531    }
532
533    /// Queues one delivery per backend that wants this event, and returns.
534    ///
535    /// Not `?`-able and with nothing useful to return: a notification can never
536    /// affect the ACME response that triggered it, which is why this goes
537    /// through [`JobQueue::enqueue_or_log`] — a database failure here is logged
538    /// and swallowed exactly like the delivery failure it stands in for.
539    ///
540    /// The `delivery_id` is minted per call, so the queue's identity index never
541    /// refuses a genuine second occurrence of the same event: two dispatches
542    /// mean two notifications, as they always did. It is shared across the
543    /// backends of one call purely so an operator can correlate them in a log.
544    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::now_v7().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    /// Runs one delivery, now, against the backend registered under `id`.
576    ///
577    /// What [`NotifyJob`] calls for each claimed row, and what a test calls when
578    /// it wants delivery without a runner in the way. `Ok(None)` means no such
579    /// backend is configured any more — a decision for the caller, since a
580    /// handler must retire that job rather than retry it for ever.
581    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
591/// An `events` list naming something outside [`ALL_NOTIFY_EVENTS`] is a
592/// startup error, the same treatment `filter.enabled`/`challenge.enabled`
593/// give an unknown name — a typo here should stop the server, not silently
594/// mean "never fires".
595fn 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
605/// Builds the configured notify dispatcher. Called once per profile at
606/// startup (via [`build_registry`]), so it may fail fast.
607///
608/// `jobs` is the enqueue side of the durable queue: every delivery is a row on
609/// it, so a dispatcher cannot be built without one.
610pub fn from_config(
611    profile: &str,
612    cfg: &NotifyConfig,
613    outbound: crate::http_client::Outbound,
614    jobs: &JobQueue,
615) -> anyhow::Result<Arc<NotifyDispatcher>> {
616    // Built once and shared: both templating backends render from the same
617    // `template_dir` override, and `Environment` is cheap to clone (it holds
618    // an `Arc`-like handle to its loader internally) but not to construct.
619    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            // Refused by name, the `signer.backend = "acme_proxy"` -> `relay`
635            // treatment: the `mattermost` backend was one provider's payload
636            // shape frozen into a copy of the webhook transport, and every
637            // other part of it now lives in `webhook`. An unmigrated
638            // configuration stops the server rather than coming up looking
639            // configured and notifying nobody.
640            "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
669/// One slot per `notify.custom` entry, addressed `custom:<entry>`.
670///
671/// The entry name is what makes two custom backends tell-apart-able: every
672/// [`custom::CustomScriptNotifier`] answers `"custom"` to
673/// [`NotifyBackend::name`], so a job payload naming that alone could not say
674/// which script it meant. `resolve_named_entries` has already refused a name
675/// that is not a valid environment-variable segment, so the id is safe to build
676/// from it.
677fn 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
698/// One slot per selected `notify.webhook` entry, addressed `webhook:<entry>`.
699///
700/// Same shape and same reasoning as [`build_custom_slots`] — every
701/// [`webhook::WebhookNotifier`] answers `"webhook"` to
702/// [`NotifyBackend::name`], so the entry name is what tells two of them apart
703/// in a durable job payload. The environment is passed by reference rather than
704/// cloned in: each notifier compiles its own `body` into a clone of it, so a
705/// template that does not parse is a startup error.
706fn 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
731/// One generation's `profile name -> dispatcher` map.
732///
733/// Named because it appears in four signatures and clippy is right that the
734/// spelled-out form is unreadable in all of them.
735pub type DispatcherMap = HashMap<String, Arc<NotifyDispatcher>>;
736
737/// The writing half of a [`Notifiers`] handle.
738pub type NotifiersSender = tokio::sync::watch::Sender<Arc<DispatcherMap>>;
739
740/// The `profile name -> dispatcher` map, as a handle that survives a
741/// configuration reload.
742///
743/// The map itself is rebuilt whole on every generation, but two of its readers
744/// outlive a generation: a signer backend captures it at construction (and
745/// signer backends are carried across reloads rather than rebuilt), and
746/// [`NotifyJob`] captures it at registration. Handing those two a plain `Arc`
747/// pinned them to generation zero, which is worse than stale — a request served
748/// by a *new* router writes a `notify_deliver` row naming a slot id from the
749/// *new* configuration, and a `NotifyJob` still holding the old map would answer
750/// [`JobOutcome::Failed`](crate::jobs::JobOutcome::Failed) for it. Permanently:
751/// an unknown backend id is retired rather than retried, by design.
752///
753/// [`tokio::sync::watch::Receiver::borrow`] takes `&self` and does not mark the
754/// value seen, so this stays `Clone + Send + Sync` and [`get`](Self::get) is
755/// callable from any task. A generation is published with a single synchronous
756/// [`tokio::sync::watch::Sender::send_replace`], so no reader can observe a
757/// half-built map.
758#[derive(Clone)]
759pub struct Notifiers(tokio::sync::watch::Receiver<Arc<DispatcherMap>>);
760
761impl Notifiers {
762    /// The dispatcher for `profile` in the current generation, if it is mounted.
763    #[must_use]
764    pub fn get(&self, profile: &str) -> Option<Arc<NotifyDispatcher>> {
765        // The `Ref` guard dies at the semicolon, so nothing holds the lock
766        // across an await — which is the whole reason this returns an owned
767        // `Arc` rather than lending one out.
768        self.0.borrow().get(profile).cloned()
769    }
770}
771
772/// A fixed map, for every caller that has no reload to serve — the tests, and
773/// any construction that predates the first generation being published.
774///
775/// Dropping the sender leaves `borrow` working for ever (only `changed()` ever
776/// errors on a closed channel), so this is a genuine constant, not a channel
777/// that will go quiet. It is also what lets every existing call site pass an
778/// `Arc<HashMap<..>>` unchanged.
779impl 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/// One cell the reload path replaces, and every reader sees the new map at the
792/// next `get`.
793#[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
799/// Builds one [`NotifyDispatcher`] per resolved profile, keyed by profile
800/// name — the map the `relay` signer backend needs to notify the right
801/// profile from its background completion task, where there is no
802/// `AppState`/`Profile` to reach through.
803pub 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
822/// One embedded template: the name it is known by *is* the path it lives at.
823///
824/// Each entry used to spell the filename twice — once as the key and once
825/// inside `include_str!` — across 21 entries here and 35 in
826/// `webadmin::pages::templates`. Two spellings of one name is a mismatch
827/// waiting to happen, and the failure would be a template that renders as
828/// missing at delivery time rather than at build time.
829macro_rules! embed {
830    ($name:literal) => {
831        ($name, include_str!(concat!("templates/", $name)))
832    };
833}
834
835/// Every default template, embedded so the server needs no external
836/// `templates/` directory to run. Keyed the same way [`build_environment`]'s
837/// loader looks them up: `"<backend>/<event>.<subject|body>.j2"` for email,
838/// `"<backend>/<event>.j2"` for the webhook message.
839static 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
865/// Builds a template environment: `template_dir` (if set) is checked for each
866/// named template before falling back to the compiled-in default, so an
867/// operator can override a single message and leave every other one at its
868/// default.
869pub(crate) fn build_environment(template_dir: &str) -> minijinja::Environment<'static> {
870    crate::templating::loader_env(template_dir, &EMBEDDED_TEMPLATES)
871}
872
873/// The names of every embedded notify template.
874///
875/// Exists for `webadmin::pages::templates`'s naming guard, which asserts that
876/// every name here ends `.j2` — minijinja reads auto-escaping off the
877/// extension, so an `.html` in this table would HTML-escape every message body
878/// the server sends.
879#[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
886/// Renders one named template against `event`'s own data.
887///
888/// Both failures are **permanent**: a template that is absent or does not
889/// compile will be just as absent on the fifth attempt, so retrying only delays
890/// the log line that tells the operator to fix it.
891pub(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    /// The shared resolver `Profile::build_all` supplies at startup.
914    fn test_resolver() -> Arc<dyn crate::dns::Resolver> {
915        Arc::new(crate::dns::HickoryResolver::from_system_uncached().unwrap())
916    }
917
918    /// A queue over an in-memory database, for the assertions that read back
919    /// the rows `dispatch` wrote.
920    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    /// How a backend failed, when it is configured to.
926    #[derive(Default, Clone, Copy)]
927    enum Failure {
928        #[default]
929        None,
930        Retryable,
931        Permanent,
932    }
933
934    /// A backend recording every event it received, for asserting dispatch
935    /// behavior without a real SMTP/HTTP/script target.
936    #[derive(Default)]
937    pub(crate) struct RecordingNotifyBackend {
938        pub(crate) events: Mutex<Vec<NotifyEvent>>,
939        fail: Failure,
940    }
941
942    impl RecordingNotifyBackend {
943        /// Fails the way a refused connection does: worth another attempt.
944        pub(crate) fn failing() -> Self {
945            Self {
946                events: Mutex::new(Vec::new()),
947                fail: Failure::Retryable,
948            }
949        }
950
951        /// Fails the way a missing template does: another attempt is pointless.
952        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    /// One of every variant, all on the same profile — the input to the
985    /// accessor sweep below, which is what the `custom` backend's environment
986    /// and both templating backends' contexts are built from.
987    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    /// Every variant answers every accessor. These are wide `match`es over an
1047    /// enum that grows, so a new variant added to only some of them would
1048    /// otherwise surface as a silently empty template field.
1049    #[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            // The rendering context and the `custom` backend's stdin payload
1067            // are built from the same data by two different routes.
1068            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        // Two events name no account, order, certificate or client at all, and
1079        // they bracket the list: `profile_mounted` happens at startup, outside
1080        // any request, and `certificates_expiring` is a periodic digest about
1081        // however many certificates across however many accounts — so its
1082        // per-entry ids live in the payload, not on these accessors. Asserted
1083        // as a pair rather than by index, because the ranged loops below
1084        // otherwise silently start covering whichever one moved.
1085        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        // A revocation reached through the admin CLI has no client address.
1099        assert_eq!(events[4].client_ip(), None);
1100        assert_eq!(events[1].client_ip(), Some("203.0.113.5"));
1101
1102        // Order and serial only exist once there is a certificate to name.
1103        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        // Only issuance carries the names the certificate is for.
1113        assert_eq!(
1114            events[3].identifiers_joined(),
1115            "a.example.com,b.example.com"
1116        );
1117        assert_eq!(events[5].identifiers_joined(), "");
1118
1119        // The digest is the one variant whose subject is a *list*, and the
1120        // accessors above can only say it has none of the scalars. This is
1121        // what it does carry, and it reaches a `custom` script through the
1122        // stdin payload rather than through an environment variable.
1123        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    /// `dyn NotifyBackend` is not `Debug`, so the dispatcher renders the slot
1130    /// ids instead — the part a startup log is read for, and now also the part a
1131    /// queued job addresses.
1132    #[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    /// The reload property: a reader that took its handle before the swap sees
1153    /// the map that came *after* it.
1154    ///
1155    /// This is what a signer backend and [`NotifyJob`] rely on — both are built
1156    /// once and outlive a configuration generation, so a captured `Arc` would
1157    /// pin them to whatever was configured when the process started.
1158    #[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        // A profile the new generation does not mount is absent, not stale.
1173        assert!(notifiers.get("staging").is_none());
1174
1175        // And a swap back is seen too: this is a cell, not a latch.
1176        sender.send_replace(Arc::new(HashMap::new()));
1177        assert!(notifiers.get("le").is_none());
1178    }
1179
1180    /// A fixed map keeps answering after its sender is gone.
1181    ///
1182    /// The `From` impl drops the sender on the spot, which is what lets every
1183    /// caller with no reload to serve — the tests, and anything built before the
1184    /// first generation is published — pass a plain map. If a closed channel
1185    /// made `borrow` fail, that conversion would be a trap rather than a
1186    /// convenience.
1187    #[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        // Cloned handles are the same cell, and equally durable.
1199        assert!(notifiers.clone().get("le").is_some());
1200    }
1201
1202    /// Every event kind, as a backend's own `events` list would spell them.
1203    fn every_kind() -> Vec<String> {
1204        ALL_NOTIFY_EVENTS.iter().map(|k| (*k).to_string()).collect()
1205    }
1206
1207    /// A dispatcher over one recording backend, plus the queue its rows land in.
1208    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    /// The property the whole change turns on: `dispatch` delivers nothing
1222    /// itself, it writes a row. Nothing has reached the backend when it returns.
1223    #[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    /// The `events` list is applied at *enqueue*, so a backend that does not
1240    /// want an event costs no row at all — not a row that runs and no-ops.
1241    #[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    /// One row **per backend**, so a retry against a failing one never re-sends
1257    /// through a healthy one that already delivered.
1258    #[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    /// Two dispatches of the same event are two notifications, as they always
1295    /// were: the per-call `delivery_id` keeps the identity index from mistaking
1296    /// the second for a duplicate of the first.
1297    #[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    /// A dispatcher with nothing configured writes nothing at all — the queue is
1313    /// not a place to park work no backend will ever ask for.
1314    #[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    /// A database that cannot take the row must not become a failed ACME
1330    /// request: `dispatch` returns `()` and there is nowhere to put the error.
1331    #[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    /// `deliver` is the seam the job handler runs through, and the answer for an
1340    /// id nobody has is `None` rather than an error — the handler has to tell
1341    /// "this backend refused" from "this backend is gone".
1342    #[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    /// The selector's own arms: each name reaches its constructor and lands in
1366    /// the dispatcher. Neither backend touches the network at build time —
1367    /// `lettre` only assembles a transport and `webhook` only parses a URL and
1368    /// compiles a template — so this is a pure configuration test.
1369    #[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                // Every `smtp_security` value builds a different transport.
1378                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    /// Two webhook entries are two slots with distinct ids — the `custom`
1408    /// property this backend inherits and needs for the same reason: every
1409    /// entry answers `"webhook"` to `NotifyBackend::name`, so a job row naming
1410    /// that alone could not say which endpoint it meant, and a retry would
1411    /// re-send through the one that already succeeded.
1412    #[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    /// The `mattermost` backend is gone and is refused **by name**, so an
1438    /// unmigrated configuration stops the server rather than coming up looking
1439    /// configured and notifying nobody.
1440    #[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    /// The two other `smtp_security` values, which each pick a different
1459    /// `lettre` builder, plus the one that is not a value at all.
1460    #[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    /// An event name nobody recognises is caught per backend, before the
1499    /// backend itself is built — otherwise a typo would silently mean "never
1500    /// notify" for that channel.
1501    #[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    /// `notify.custom_enabled` names entries in `notify.custom`; a name with no
1539    /// entry behind it is a startup error rather than a silently missing hook.
1540    #[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    /// A `notify.custom` key that is not a valid environment-variable segment
1562    /// could name a different entry through `ACME_PROXY_…` than in the file.
1563    #[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    /// The `events` list decides membership of the *queue*, not of the delivery:
1641    /// a backend outside the list never gets a row, so `wants` is asserted on
1642    /// both sides rather than on what arrived at a backend afterwards.
1643    #[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    /// One backend's failure is another's business, and the queue is what keeps
1673    /// them apart now: two rows, settled independently, so the healthy one is
1674    /// `done` while the failing one is still being retried.
1675    #[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    /// Two `custom` entries are two backends, and a job row has to be able to
1730    /// say which one it means. `NotifyBackend::name` answers `"custom"` for
1731    /// both, so the slot id is built from the configuration key instead.
1732    #[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    /// The durable payload has to survive a restart, so every variant must come
1765    /// back out of JSON as the variant that went in. A wide `match` over an enum
1766    /// that grows is exactly where a new variant gets forgotten.
1767    #[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    /// The `custom` backend's stdin contract: the tag is a `"hook"` member
1780    /// carrying the event kind, sitting flat beside the event's own fields. That
1781    /// used to be spliced in by hand and is now serde's internal tag — a script
1782    /// in the field must not be able to tell the difference.
1783    #[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        // A template not present in `template_dir` still falls back to the
1828        // compiled-in default rather than failing outright.
1829        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    /// A template that is not there will not be there next time either, so the
1839    /// failure must not spend a retry budget getting to the same answer.
1840    #[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    /// The digest's three templates, which are the first to loop over a list
1882    /// and the only ones a missing file would break *permanently* — `render`
1883    /// treats an absent template as a permanent failure, so an event with no
1884    /// template is a notification that can never be delivered rather than one
1885    /// that is retried.
1886    ///
1887    /// The two entries are the point: one already replaced and one not, so the
1888    /// annotation is asserted in both directions. A digest that renders every
1889    /// row the same way would be useless in exactly the way this feature
1890    /// exists to avoid.
1891    #[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}