use std::sync::Mutex;
use crate::queue::{QueueStatus, QueuedMessage};
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("store backend: {0}")]
Backend(String),
}
#[cfg(feature = "pg")]
impl From<sqlx::Error> for StoreError {
fn from(err: sqlx::Error) -> Self {
Self::Backend(err.to_string())
}
}
#[async_trait::async_trait]
pub trait QueueStore: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn enqueue(
&self,
sender: &str,
recipient: &str,
domain: &str,
message_data: &[u8],
message_id: Option<&str>,
now: i64,
is_forwarded: bool,
) -> Result<i64, StoreError>;
#[allow(clippy::too_many_arguments)]
async fn enqueue_scheduled(
&self,
sender: &str,
recipient: &str,
domain: &str,
message_data: &[u8],
message_id: Option<&str>,
created_at: i64,
scheduled_at: i64,
) -> Result<i64, StoreError>;
async fn dequeue(&self, now: i64, limit: u32) -> Result<Vec<QueuedMessage>, StoreError>;
async fn recover_stale_inflight(&self, now: i64) -> Result<u64, StoreError>;
async fn mark_inflight(&self, id: i64, now: i64) -> Result<(), StoreError>;
async fn mark_delivered(&self, id: i64, now: i64) -> Result<(), StoreError>;
async fn mark_failed(
&self,
id: i64,
error: &str,
next_retry: i64,
now: i64,
) -> Result<(), StoreError>;
async fn mark_bounced(&self, id: i64, error: &str, now: i64) -> Result<(), StoreError>;
async fn get_message(&self, id: i64) -> Result<Option<QueuedMessage>, StoreError>;
async fn queue_stats(&self) -> Result<Vec<(String, i64)>, StoreError>;
async fn list_recent(&self, limit: i32) -> Result<Vec<QueuedMessage>, StoreError>;
async fn cancel_pending(&self, id: i64) -> Result<bool, StoreError>;
async fn cancel_pending_by_message_id(
&self,
message_id: &str,
sender: &str,
) -> Result<bool, StoreError>;
async fn retry_message(&self, id: i64, now: i64) -> Result<bool, StoreError>;
async fn is_suppressed(&self, email: &str) -> bool;
async fn add_suppression(
&self,
email: &str,
reason: &str,
smtp_code: Option<i32>,
) -> Result<(), StoreError>;
async fn remove_suppression(&self, email: &str) -> Result<bool, StoreError>;
async fn list_suppressions(
&self,
limit: i64,
) -> Result<Vec<(String, String, Option<i32>, i64)>, StoreError>;
}
#[async_trait::async_trait]
pub trait Notifier: Send + Sync {
async fn notify(&self);
async fn wait(&self);
}
#[derive(Default)]
pub struct InMemoryNotifier {
inner: tokio::sync::Notify,
}
impl InMemoryNotifier {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait::async_trait]
impl Notifier for InMemoryNotifier {
async fn notify(&self) {
self.inner.notify_one();
}
async fn wait(&self) {
self.inner.notified().await;
}
}
pub struct NoopNotifier;
#[async_trait::async_trait]
impl Notifier for NoopNotifier {
async fn notify(&self) {}
async fn wait(&self) {
std::future::pending::<()>().await;
}
}
pub struct InMemoryQueueStore {
state: Mutex<MemState>,
}
struct MemState {
next_id: i64,
messages: Vec<QueuedMessage>,
suppressions: Vec<(String, String, Option<i32>, i64)>, }
impl Default for InMemoryQueueStore {
fn default() -> Self {
Self::new()
}
}
impl InMemoryQueueStore {
pub fn new() -> Self {
Self {
state: Mutex::new(MemState {
next_id: 1,
messages: Vec::new(),
suppressions: Vec::new(),
}),
}
}
#[allow(clippy::too_many_arguments)]
fn insert_msg(
s: &mut MemState,
sender: &str,
recipient: &str,
domain: &str,
message_data: &[u8],
message_id: Option<&str>,
next_retry: i64,
created_at: i64,
is_forwarded: bool,
) -> i64 {
let id = s.next_id;
s.next_id += 1;
s.messages.push(QueuedMessage {
id,
sender: sender.into(),
recipient: recipient.into(),
domain: domain.into(),
message_data: message_data.to_vec(),
status: QueueStatus::Pending,
attempts: 0,
max_attempts: 8,
next_retry,
last_error: None,
message_id: message_id.map(str::to_owned),
created_at,
updated_at: created_at,
is_forwarded,
});
id
}
}
#[async_trait::async_trait]
impl QueueStore for InMemoryQueueStore {
async fn enqueue(
&self,
sender: &str,
recipient: &str,
domain: &str,
message_data: &[u8],
message_id: Option<&str>,
now: i64,
is_forwarded: bool,
) -> Result<i64, StoreError> {
let mut s = self.state.lock().unwrap();
Ok(Self::insert_msg(
&mut s,
sender,
recipient,
domain,
message_data,
message_id,
now,
now,
is_forwarded,
))
}
async fn enqueue_scheduled(
&self,
sender: &str,
recipient: &str,
domain: &str,
message_data: &[u8],
message_id: Option<&str>,
created_at: i64,
scheduled_at: i64,
) -> Result<i64, StoreError> {
let mut s = self.state.lock().unwrap();
Ok(Self::insert_msg(
&mut s,
sender,
recipient,
domain,
message_data,
message_id,
scheduled_at,
created_at,
false,
))
}
async fn dequeue(&self, now: i64, limit: u32) -> Result<Vec<QueuedMessage>, StoreError> {
let s = self.state.lock().unwrap();
let mut out: Vec<QueuedMessage> = s
.messages
.iter()
.filter(|m| m.status == QueueStatus::Pending && m.next_retry <= now)
.cloned()
.collect();
out.sort_by_key(|m| m.next_retry);
out.truncate(limit as usize);
Ok(out)
}
async fn recover_stale_inflight(&self, now: i64) -> Result<u64, StoreError> {
let threshold = now - 600;
let mut s = self.state.lock().unwrap();
let mut affected = 0u64;
for m in s.messages.iter_mut() {
if m.status == QueueStatus::InFlight && m.updated_at < threshold {
m.status = QueueStatus::Pending;
m.updated_at = now;
affected += 1;
}
}
Ok(affected)
}
async fn mark_inflight(&self, id: i64, now: i64) -> Result<(), StoreError> {
let mut s = self.state.lock().unwrap();
if let Some(m) = s.messages.iter_mut().find(|m| m.id == id) {
m.status = QueueStatus::InFlight;
m.updated_at = now;
}
Ok(())
}
async fn mark_delivered(&self, id: i64, now: i64) -> Result<(), StoreError> {
let mut s = self.state.lock().unwrap();
if let Some(m) = s.messages.iter_mut().find(|m| m.id == id) {
m.status = QueueStatus::Delivered;
m.updated_at = now;
}
Ok(())
}
async fn mark_failed(
&self,
id: i64,
error: &str,
next_retry: i64,
now: i64,
) -> Result<(), StoreError> {
let mut s = self.state.lock().unwrap();
if let Some(m) = s.messages.iter_mut().find(|m| m.id == id) {
m.status = QueueStatus::Pending;
m.attempts += 1;
m.last_error = Some(error.into());
m.next_retry = next_retry;
m.updated_at = now;
}
Ok(())
}
async fn mark_bounced(&self, id: i64, error: &str, now: i64) -> Result<(), StoreError> {
let mut s = self.state.lock().unwrap();
if let Some(m) = s.messages.iter_mut().find(|m| m.id == id) {
m.status = QueueStatus::Bounced;
m.last_error = Some(error.into());
m.updated_at = now;
}
Ok(())
}
async fn get_message(&self, id: i64) -> Result<Option<QueuedMessage>, StoreError> {
let s = self.state.lock().unwrap();
Ok(s.messages.iter().find(|m| m.id == id).cloned())
}
async fn queue_stats(&self) -> Result<Vec<(String, i64)>, StoreError> {
use std::collections::HashMap;
let s = self.state.lock().unwrap();
let mut counts: HashMap<&'static str, i64> = HashMap::new();
for m in &s.messages {
*counts.entry(m.status.as_str()).or_insert(0) += 1;
}
Ok(counts
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect())
}
async fn list_recent(&self, limit: i32) -> Result<Vec<QueuedMessage>, StoreError> {
let s = self.state.lock().unwrap();
let mut out: Vec<QueuedMessage> = s.messages.clone();
out.sort_by_key(|m| std::cmp::Reverse(m.created_at));
out.truncate(limit.max(0) as usize);
Ok(out)
}
async fn cancel_pending(&self, id: i64) -> Result<bool, StoreError> {
let mut s = self.state.lock().unwrap();
let before = s.messages.len();
s.messages
.retain(|m| !(m.id == id && m.status == QueueStatus::Pending));
Ok(s.messages.len() < before)
}
async fn cancel_pending_by_message_id(
&self,
message_id: &str,
sender: &str,
) -> Result<bool, StoreError> {
let mut s = self.state.lock().unwrap();
let before = s.messages.len();
s.messages.retain(|m| {
!(m.status == QueueStatus::Pending
&& m.sender == sender
&& m.message_id.as_deref() == Some(message_id))
});
Ok(s.messages.len() < before)
}
async fn retry_message(&self, id: i64, now: i64) -> Result<bool, StoreError> {
let mut s = self.state.lock().unwrap();
if let Some(m) = s.messages.iter_mut().find(|m| {
m.id == id && (m.status == QueueStatus::Bounced || m.status == QueueStatus::Failed)
}) {
m.status = QueueStatus::Pending;
m.next_retry = now;
m.updated_at = now;
return Ok(true);
}
Ok(false)
}
async fn is_suppressed(&self, email: &str) -> bool {
let s = self.state.lock().unwrap();
s.suppressions.iter().any(|(e, _, _, _)| e == email)
}
async fn add_suppression(
&self,
email: &str,
reason: &str,
smtp_code: Option<i32>,
) -> Result<(), StoreError> {
let mut s = self.state.lock().unwrap();
if let Some(entry) = s.suppressions.iter_mut().find(|(e, _, _, _)| e == email) {
entry.1 = reason.into();
entry.2 = smtp_code;
} else {
let now = chrono::Utc::now().timestamp();
s.suppressions
.push((email.into(), reason.into(), smtp_code, now));
}
Ok(())
}
async fn remove_suppression(&self, email: &str) -> Result<bool, StoreError> {
let mut s = self.state.lock().unwrap();
let before = s.suppressions.len();
s.suppressions.retain(|(e, _, _, _)| e != email);
Ok(s.suppressions.len() < before)
}
async fn list_suppressions(
&self,
limit: i64,
) -> Result<Vec<(String, String, Option<i32>, i64)>, StoreError> {
let s = self.state.lock().unwrap();
let mut out = s.suppressions.clone();
out.sort_by_key(|(_, _, _, t)| std::cmp::Reverse(*t));
out.truncate(limit.max(0) as usize);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::time::Duration;
fn store() -> Arc<dyn QueueStore> {
Arc::new(InMemoryQueueStore::new())
}
#[tokio::test]
async fn enqueue_dequeue_roundtrip() {
let s = store();
let id = s
.enqueue("a@x", "b@y", "y", b"raw", None, 0, false)
.await
.unwrap();
let msgs = s.dequeue(0, 10).await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].id, id);
assert_eq!(msgs[0].sender, "a@x");
}
#[tokio::test]
async fn lifecycle_transitions() {
let s = store();
let id = s
.enqueue("a@x", "b@y", "y", b"", None, 0, false)
.await
.unwrap();
s.mark_inflight(id, 10).await.unwrap();
s.mark_delivered(id, 20).await.unwrap();
assert_eq!(
s.get_message(id).await.unwrap().unwrap().status,
QueueStatus::Delivered
);
}
#[tokio::test]
async fn stale_inflight_recovers() {
let s = store();
let id = s
.enqueue("a@x", "b@y", "y", b"", None, 0, false)
.await
.unwrap();
s.mark_inflight(id, 0).await.unwrap();
let n = s.recover_stale_inflight(601).await.unwrap();
assert_eq!(n, 1);
assert_eq!(
s.get_message(id).await.unwrap().unwrap().status,
QueueStatus::Pending
);
}
#[tokio::test]
async fn suppression_list() {
let s = store();
assert!(!s.is_suppressed("user@x").await);
s.add_suppression("user@x", "bounced", Some(550))
.await
.unwrap();
assert!(s.is_suppressed("user@x").await);
assert!(s.remove_suppression("user@x").await.unwrap());
assert!(!s.is_suppressed("user@x").await);
}
#[tokio::test]
async fn in_memory_notifier_wakes_waiter() {
let n = Arc::new(InMemoryNotifier::new());
let n2 = n.clone();
let h = tokio::spawn(async move {
tokio::time::timeout(Duration::from_secs(2), n2.wait())
.await
.expect("waiter timed out")
});
n.notify().await;
h.await.unwrap();
}
}