use serde::{Deserialize, Serialize};
use crate::jobs::{EnqueuedJob, JobError, JobModel, JobRequest, Jobs, RegisterError, Registry};
use crate::mail::{Mail, MailSendError};
use super::channel::NotificationError;
use super::notification::MailContent;
use super::notifier::AsMailable;
pub const MAIL_JOB: JobModel<QueuedMail> = JobModel::new("arcature.notifications.mail", 1, 3);
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[non_exhaustive]
pub struct QueuedMail {
to: String,
subject: String,
text: String,
#[serde(default)]
html: Option<String>,
}
impl QueuedMail {
#[must_use]
pub fn new(to: impl Into<String>, content: &MailContent) -> Self {
Self {
to: to.into(),
subject: content.subject().to_owned(),
text: content.text().to_owned(),
html: content.html_body().map(ToOwned::to_owned),
}
}
#[must_use]
pub fn to(&self) -> &str {
&self.to
}
#[must_use]
pub fn subject(&self) -> &str {
&self.subject
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn html_body(&self) -> Option<&str> {
self.html.as_deref()
}
#[must_use]
pub fn content(&self) -> MailContent {
let content = MailContent::new(&self.subject, &self.text);
match &self.html {
Some(html) => content.html(html),
None => content,
}
}
pub fn request(&self) -> Result<JobRequest<Self>, NotificationError> {
Ok(JobRequest::new(&MAIL_JOB, self)?)
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct NotificationQueue {
jobs: Jobs,
}
impl std::fmt::Debug for NotificationQueue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NotificationQueue").finish_non_exhaustive()
}
}
impl NotificationQueue {
#[must_use]
pub fn new(jobs: Jobs) -> Self {
Self { jobs }
}
#[must_use]
pub fn jobs(&self) -> &Jobs {
&self.jobs
}
pub async fn enqueue(&self, mail: &QueuedMail) -> Result<EnqueuedJob, NotificationError> {
Ok(self.jobs.enqueue(&mail.request()?).await?)
}
}
pub fn register_mail_handler(registry: &mut Registry, mail: Mail) -> Result<(), RegisterError> {
registry.add(&MAIL_JOB, move |job: QueuedMail| {
let mail = mail.clone();
async move { deliver(&mail, &job).await }
})?;
Ok(())
}
async fn deliver(mail: &Mail, job: &QueuedMail) -> Result<(), JobError> {
let content = job.content();
match mail.to(job.to()).send(&AsMailable(&content)).await {
Ok(()) => Ok(()),
Err(error @ MailSendError::Build { .. }) => Err(JobError::permanent(error)),
Err(error) => Err(JobError::retryable(error)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mail::Mailer;
fn content() -> MailContent {
MailContent::new("Welcome", "Welcome to Acme.").html("<p>Welcome to Acme.</p>")
}
fn mailer(ok: bool) -> (Mailer, Mail) {
let mailer = if ok {
Mailer::capture_ok()
} else {
Mailer::capture_error()
};
let mail = Mail::new(mailer.clone(), "noreply@example.com".parse().unwrap());
(mailer, mail)
}
#[test]
fn the_job_kind_is_one_the_queue_will_accept() {
let job = QueuedMail::new("ada@example.com", &content());
let request = job.request().expect("a small email is a valid payload");
assert_eq!(request.kind(), "arcature.notifications.mail");
assert_eq!(request.version(), 1);
assert_eq!(request.effective_max_attempts(), 3);
}
#[test]
fn the_payload_survives_the_round_trip_through_the_row() {
let job = QueuedMail::new("ada@example.com", &content());
let stored = serde_json::to_value(&job).unwrap();
let loaded: QueuedMail = serde_json::from_value(stored).unwrap();
assert_eq!(loaded, job);
assert_eq!(loaded.content(), content());
}
#[test]
fn a_row_written_without_an_html_body_still_loads() {
let loaded: QueuedMail = serde_json::from_value(serde_json::json!({
"to": "ada@example.com",
"subject": "Welcome",
"text": "Welcome to Acme.",
}))
.expect("html is optional");
assert_eq!(loaded.html_body(), None);
assert_eq!(loaded.content().html_body(), None);
}
#[tokio::test]
async fn a_delivered_job_reaches_the_transport_with_the_body_intact() {
let (mailer, mail) = mailer(true);
let job = QueuedMail::new("ada@example.com", &content());
deliver(&mail, &job).await.expect("capture_ok accepts");
let captured = mailer.captured().await.unwrap();
assert_eq!(captured.len(), 1);
}
#[tokio::test]
async fn a_transport_failure_is_retryable() {
let (_mailer, mail) = mailer(false);
let job = QueuedMail::new("ada@example.com", &content());
let error = deliver(&mail, &job).await.unwrap_err();
assert!(error.is_retryable(), "got {error:?}");
}
#[tokio::test]
async fn an_unparseable_address_is_permanent() {
let (_mailer, mail) = mailer(true);
let job = QueuedMail::new("not an address", &content());
let error = deliver(&mail, &job).await.unwrap_err();
assert!(error.is_permanent(), "got {error:?}");
}
#[test]
fn registering_the_handler_twice_is_refused() {
let (_mailer, mail) = mailer(true);
let mut registry = Registry::new();
register_mail_handler(&mut registry, mail.clone()).expect("first registration");
let error = register_mail_handler(&mut registry, mail).unwrap_err();
assert!(
matches!(error, RegisterError::AlreadyRegistered { .. }),
"got {error:?}"
);
}
#[test]
fn debug_does_not_print_the_pool() {
let rendered = format!("{:?}", DebugShape);
assert_eq!(rendered, "NotificationQueue { .. }");
}
struct DebugShape;
impl std::fmt::Debug for DebugShape {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NotificationQueue").finish_non_exhaustive()
}
}
}