Skip to main content

cratefield_module_sealed/
notify.rs

1//! The "blob unlocked" notification: a small port, because a download is
2//! worth telling the subject about and nothing else in the harness fires on
3//! one.
4//!
5//! The port is deliberately narrow: one method, one notice. A notification
6//! is best-effort by contract — the download route logs a failure and serves
7//! the record anyway, because refusing to hand somebody their own ciphertext
8//! because a mailer hiccuped would put the mailer in front of the data.
9
10use async_trait::async_trait;
11use thiserror::Error;
12
13/// A download happened. `email` is the subject's **verified** address from
14/// the `Auth` port; the caller sends a notice only when it has one, so
15/// implementations may rely on it being present.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Notice {
18    pub subject: String,
19    pub email: String,
20    pub blob_id: String,
21    pub purpose: String,
22}
23
24/// Why a notice did not go out. The download does not care; this exists so a
25/// caller that does (an operator dashboard) can log something actionable.
26#[derive(Debug, Clone, Error)]
27#[error("the unlock notice was not sent: {0}")]
28pub struct NotifyError(pub String);
29
30/// Fires on every download of one of the subject's blobs.
31#[async_trait]
32pub trait UnlockNotifier: Send + Sync {
33    async fn blob_unlocked(&self, notice: &Notice) -> Result<(), NotifyError>;
34}
35
36/// The default: acknowledges everything, sends nothing. A venture that has
37/// not wired a notifier still downloads.
38#[derive(Debug, Default, Clone, Copy)]
39pub struct NoopNotifier;
40
41#[async_trait]
42impl UnlockNotifier for NoopNotifier {
43    async fn blob_unlocked(&self, _notice: &Notice) -> Result<(), NotifyError> {
44        Ok(())
45    }
46}
47
48/// Sends the notice through the harness `Mailer` port. `from` is the venture's
49/// sending address; a mailer that reports
50/// [`SendOutcome::NotConfigured`](cratefield_core::SendOutcome::NotConfigured) is
51/// recorded as "nothing sent" rather than an error, which is what it is.
52pub struct MailNotifier {
53    mailer: std::sync::Arc<dyn cratefield_core::Mailer>,
54    from: String,
55}
56
57impl MailNotifier {
58    #[must_use]
59    pub fn new(
60        mailer: std::sync::Arc<dyn cratefield_core::Mailer>,
61        from: impl Into<String>,
62    ) -> Self {
63        Self {
64            mailer,
65            from: from.into(),
66        }
67    }
68}
69
70#[async_trait]
71impl UnlockNotifier for MailNotifier {
72    async fn blob_unlocked(&self, notice: &Notice) -> Result<(), NotifyError> {
73        let subject = format!("Your sealed blob \"{}\" was opened", notice.blob_id);
74        let text = format!(
75            "Your sealed blob `{}` (purpose `{}`) was downloaded. If this was you, \
76             nothing to do; if it was not, rotate that blob's unlocks now.",
77            notice.blob_id, notice.purpose
78        );
79        match self
80            .mailer
81            .send(cratefield_core::Message::new(
82                notice.email.as_str(),
83                self.from.as_str(),
84                subject,
85                text.clone(),
86                format!(
87                    "<p>{}</p>",
88                    text.replace("rotate", "<strong>rotate</strong>")
89                ),
90            ))
91            .await
92        {
93            Ok(
94                cratefield_core::SendOutcome::Sent { .. }
95                | cratefield_core::SendOutcome::NotConfigured,
96            ) => Ok(()),
97            Err(err) => Err(NotifyError(err.to_string())),
98        }
99    }
100}
101
102/// The recording fake the tests (and a venture's own) drive: every notice,
103/// in order.
104#[derive(Debug, Default, Clone)]
105pub struct RecordingNotifier {
106    // A recording fixture, not request state (ADR 0007) — the same scoped
107    // `Mutex` allow the harness's own fakes carry.
108    #[allow(clippy::disallowed_types)]
109    recorded: std::sync::Arc<std::sync::Mutex<Vec<Notice>>>,
110}
111
112impl RecordingNotifier {
113    #[must_use]
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Every notice it accepted, in order.
119    ///
120    /// # Panics
121    ///
122    /// Only if the fixture mutex was poisoned, which no caller can cause —
123    /// nothing a test does with a notice panics.
124    #[must_use]
125    pub fn recorded(&self) -> Vec<Notice> {
126        self.recorded.lock().expect("uncontended").clone()
127    }
128}
129
130#[async_trait]
131impl UnlockNotifier for RecordingNotifier {
132    async fn blob_unlocked(&self, notice: &Notice) -> Result<(), NotifyError> {
133        self.recorded
134            .lock()
135            .expect("uncontended")
136            .push(notice.clone());
137        Ok(())
138    }
139}
140
141/// The conformance every [`UnlockNotifier`] passes: it accepts a notice with
142/// every field set, one with an address that is not a deliverable form, and
143/// answers `Ok` for both — a notification is best-effort, so an
144/// implementation whose happy path is an error has the contract backwards.
145///
146/// # Panics
147///
148/// With a message naming the property that failed.
149pub async fn conformance(notifier: &dyn UnlockNotifier) {
150    let notice = Notice {
151        subject: "subject-a".into(),
152        email: "a@example.test".into(),
153        blob_id: "blob-1".into(),
154        purpose: "vault".into(),
155    };
156    notifier
157        .blob_unlocked(&notice)
158        .await
159        .expect("a notice with every field set is accepted");
160    let odd = Notice {
161        email: "not-an-address".into(),
162        ..notice.clone()
163    };
164    notifier
165        .blob_unlocked(&odd)
166        .await
167        .expect("address validation is the caller's job, not the notifier's");
168}