use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notice {
pub subject: String,
pub email: String,
pub blob_id: String,
pub purpose: String,
}
#[derive(Debug, Clone, Error)]
#[error("the unlock notice was not sent: {0}")]
pub struct NotifyError(pub String);
#[async_trait]
pub trait UnlockNotifier: Send + Sync {
async fn blob_unlocked(&self, notice: &Notice) -> Result<(), NotifyError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopNotifier;
#[async_trait]
impl UnlockNotifier for NoopNotifier {
async fn blob_unlocked(&self, _notice: &Notice) -> Result<(), NotifyError> {
Ok(())
}
}
pub struct MailNotifier {
mailer: std::sync::Arc<dyn cratefield_core::Mailer>,
from: String,
}
impl MailNotifier {
#[must_use]
pub fn new(
mailer: std::sync::Arc<dyn cratefield_core::Mailer>,
from: impl Into<String>,
) -> Self {
Self {
mailer,
from: from.into(),
}
}
}
#[async_trait]
impl UnlockNotifier for MailNotifier {
async fn blob_unlocked(&self, notice: &Notice) -> Result<(), NotifyError> {
let subject = format!("Your sealed blob \"{}\" was opened", notice.blob_id);
let text = format!(
"Your sealed blob `{}` (purpose `{}`) was downloaded. If this was you, \
nothing to do; if it was not, rotate that blob's unlocks now.",
notice.blob_id, notice.purpose
);
match self
.mailer
.send(cratefield_core::Message::new(
notice.email.as_str(),
self.from.as_str(),
subject,
text.clone(),
format!(
"<p>{}</p>",
text.replace("rotate", "<strong>rotate</strong>")
),
))
.await
{
Ok(
cratefield_core::SendOutcome::Sent { .. }
| cratefield_core::SendOutcome::NotConfigured,
) => Ok(()),
Err(err) => Err(NotifyError(err.to_string())),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct RecordingNotifier {
#[allow(clippy::disallowed_types)]
recorded: std::sync::Arc<std::sync::Mutex<Vec<Notice>>>,
}
impl RecordingNotifier {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn recorded(&self) -> Vec<Notice> {
self.recorded.lock().expect("uncontended").clone()
}
}
#[async_trait]
impl UnlockNotifier for RecordingNotifier {
async fn blob_unlocked(&self, notice: &Notice) -> Result<(), NotifyError> {
self.recorded
.lock()
.expect("uncontended")
.push(notice.clone());
Ok(())
}
}
pub async fn conformance(notifier: &dyn UnlockNotifier) {
let notice = Notice {
subject: "subject-a".into(),
email: "a@example.test".into(),
blob_id: "blob-1".into(),
purpose: "vault".into(),
};
notifier
.blob_unlocked(¬ice)
.await
.expect("a notice with every field set is accepted");
let odd = Notice {
email: "not-an-address".into(),
..notice.clone()
};
notifier
.blob_unlocked(&odd)
.await
.expect("address validation is the caller's job, not the notifier's");
}