//! Mock email transport for integration tests. //! //! Records all sent emails so tests can assert on recipients, subjects, and bodies //! without hitting any external service. use super::faults::Faults; use makenotwork::email::EmailTransport; use makenotwork::error::Result; use std::sync::Mutex; /// A sent email record. #[derive(Debug, Clone)] #[allow(dead_code)] pub(crate) struct SentEmail { pub to: String, pub subject: String, pub body: String, pub unsub_url: Option, pub stream: Option, /// The fan-out this mail was attributed to (`93f23f00`). Recorded rather /// than ignored, because "the id went out with the mail" is the half of the /// attribution a test can see -- the other half is Postmark handing it /// back. pub send: Option, } /// In-memory email transport that records all sent emails. pub(crate) struct MockEmailTransport { sent: Mutex>, /// Injected send failures. Empty by default. faults: Faults, } #[allow(dead_code)] impl MockEmailTransport { pub(crate) fn new() -> Self { MockEmailTransport { sent: Mutex::new(Vec::new()), faults: Faults::new(), } } /// The failure policy. All four send methods share the operation name /// `send_email`, because a caller picks one by what it needs to include and /// a transport outage takes out all of them together. pub(crate) fn faults(&self) -> &Faults { &self.faults } /// Return all emails sent so far. pub(crate) fn sent(&self) -> Vec { self.sent.lock().unwrap().clone() } /// Return emails sent to a specific address. pub(crate) fn sent_to(&self, address: &str) -> Vec { self.sent .lock() .unwrap() .iter() .filter(|e| e.to == address) .cloned() .collect() } /// Clear the sent email log. pub(crate) fn clear(&self) { self.sent.lock().unwrap().clear(); } /// Return the number of emails sent. pub(crate) fn count(&self) -> usize { self.sent.lock().unwrap().len() } } #[async_trait::async_trait] impl EmailTransport for MockEmailTransport { async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { self.faults.check("send_email")?; self.sent.lock().unwrap().push(SentEmail { to: to.to_string(), subject: subject.to_string(), body: body.to_string(), unsub_url: None, stream: None, // Transactional mail belongs to no fan-out. send: None, }); Ok(()) } async fn send_email_with_headers_and_unsub( &self, to: &str, subject: &str, body: &str, _extra_headers: &[(&str, String)], unsub_url: Option<&str>, ) -> Result<()> { self.faults.check("send_email")?; self.sent.lock().unwrap().push(SentEmail { to: to.to_string(), subject: subject.to_string(), body: body.to_string(), unsub_url: unsub_url.map(std::string::ToString::to_string), stream: None, // Transactional mail belongs to no fan-out. send: None, }); Ok(()) } async fn send_email_broadcast_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, send: Option, ) -> Result<()> { self.faults.check("send_email")?; self.sent.lock().unwrap().push(SentEmail { to: to.to_string(), subject: subject.to_string(), body: body.to_string(), unsub_url: unsub_url.map(std::string::ToString::to_string), stream: Some("broadcast".to_string()), send: send.map(|id| id.to_string()), }); Ok(()) } }