cratefield_module_sealed/
notify.rs1use async_trait::async_trait;
11use thiserror::Error;
12
13#[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#[derive(Debug, Clone, Error)]
27#[error("the unlock notice was not sent: {0}")]
28pub struct NotifyError(pub String);
29
30#[async_trait]
32pub trait UnlockNotifier: Send + Sync {
33 async fn blob_unlocked(&self, notice: &Notice) -> Result<(), NotifyError>;
34}
35
36#[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
48pub 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#[derive(Debug, Default, Clone)]
105pub struct RecordingNotifier {
106 #[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 #[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
141pub 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(¬ice)
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}