Skip to main content

mailrs_outbound_queue/
store.rs

1//! Storage abstractions for the outbound queue.
2//!
3//! The bundled [`DeliveryWorker`](crate::DeliveryWorker) is currently
4//! Postgres-only (enabled with the `pg` feature). This module defines the
5//! traits a v1.x-compatible custom worker would target. External users who
6//! don't want sqlx can disable the `pg` feature, implement [`QueueStore`]
7//! and [`Notifier`] against their own backend (sqlite, sled, custom REST,
8//! in-memory for tests), and reuse the pure delivery primitives in this
9//! crate (`dkim_sign`, `dsn`, `mta_sts`, `retry`) plus
10//! [`mailrs-smtp-client`](https://crates.io/crates/mailrs-smtp-client).
11//!
12//! Wider trait coverage and a generic worker are planned for a future minor
13//! release.
14
15use std::sync::Mutex;
16
17use crate::queue::{QueueStatus, QueuedMessage};
18
19/// Error returned by store operations. Backend-specific details are stringified
20/// to keep the trait dyn-compatible across implementations.
21#[derive(Debug, thiserror::Error)]
22pub enum StoreError {
23    /// Backend-specific error (PG, SQLite, network — stringified).
24    #[error("store backend: {0}")]
25    Backend(String),
26}
27
28#[cfg(feature = "pg")]
29impl From<sqlx::Error> for StoreError {
30    fn from(err: sqlx::Error) -> Self {
31        Self::Backend(err.to_string())
32    }
33}
34
35/// The persistent store the outbound queue lives in.
36///
37/// `enqueue*` is called by the SMTP submission path. `dequeue` + `mark_*` is
38/// called by a delivery worker. `list_recent` / `queue_stats` /
39/// `cancel_*` / `retry_message` is called by admin / management code.
40///
41/// Implementations should be ACID-safe for the lifecycle transitions
42/// `pending → inflight → {delivered, failed, bounced}` so a crashed worker
43/// can't lose or duplicate-deliver a message.
44#[async_trait::async_trait]
45pub trait QueueStore: Send + Sync {
46    /// Insert a new pending message. Returns the assigned id.
47    #[allow(clippy::too_many_arguments)]
48    async fn enqueue(
49        &self,
50        sender: &str,
51        recipient: &str,
52        domain: &str,
53        message_data: &[u8],
54        message_id: Option<&str>,
55        now: i64,
56        is_forwarded: bool,
57    ) -> Result<i64, StoreError>;
58
59    /// Insert a message scheduled to deliver at `scheduled_at`.
60    #[allow(clippy::too_many_arguments)]
61    async fn enqueue_scheduled(
62        &self,
63        sender: &str,
64        recipient: &str,
65        domain: &str,
66        message_data: &[u8],
67        message_id: Option<&str>,
68        created_at: i64,
69        scheduled_at: i64,
70    ) -> Result<i64, StoreError>;
71
72    /// Fetch up to `limit` pending messages whose `next_retry` is `<= now`.
73    async fn dequeue(&self, now: i64, limit: u32) -> Result<Vec<QueuedMessage>, StoreError>;
74
75    /// Reset `inflight` messages older than ~10 minutes back to `pending`
76    /// (crash-recovery). Returns rows affected.
77    async fn recover_stale_inflight(&self, now: i64) -> Result<u64, StoreError>;
78
79    /// Mark a message as currently being delivered.
80    async fn mark_inflight(&self, id: i64, now: i64) -> Result<(), StoreError>;
81
82    /// Mark a message as delivered.
83    async fn mark_delivered(&self, id: i64, now: i64) -> Result<(), StoreError>;
84
85    /// Mark a delivery attempt as failed; the message goes back to `pending`
86    /// with an incremented `attempts` and a `next_retry` of `next_retry`.
87    async fn mark_failed(
88        &self,
89        id: i64,
90        error: &str,
91        next_retry: i64,
92        now: i64,
93    ) -> Result<(), StoreError>;
94
95    /// Mark a message as permanently bounced (no more retries).
96    async fn mark_bounced(&self, id: i64, error: &str, now: i64) -> Result<(), StoreError>;
97
98    /// Fetch a single message by id, or `None` if it has been purged.
99    async fn get_message(&self, id: i64) -> Result<Option<QueuedMessage>, StoreError>;
100
101    /// Group counts by status — e.g. `[("pending", 12), ("inflight", 2)]`.
102    async fn queue_stats(&self) -> Result<Vec<(String, i64)>, StoreError>;
103
104    /// Recently-created entries, newest first, for admin UIs.
105    async fn list_recent(&self, limit: i32) -> Result<Vec<QueuedMessage>, StoreError>;
106
107    /// Delete a single `pending` message. Returns `true` if a row was removed.
108    async fn cancel_pending(&self, id: i64) -> Result<bool, StoreError>;
109
110    /// Delete a single `pending` message identified by RFC 5322 Message-ID and
111    /// sender (so a user can only cancel their own undelivered messages).
112    async fn cancel_pending_by_message_id(
113        &self,
114        message_id: &str,
115        sender: &str,
116    ) -> Result<bool, StoreError>;
117
118    /// Reset a `bounced` / `failed` message to `pending` for another try.
119    async fn retry_message(&self, id: i64, now: i64) -> Result<bool, StoreError>;
120
121    /// `true` if the recipient is in the hard-bounce suppression list.
122    async fn is_suppressed(&self, email: &str) -> bool;
123
124    /// Add (or update) a recipient on the suppression list.
125    async fn add_suppression(
126        &self,
127        email: &str,
128        reason: &str,
129        smtp_code: Option<i32>,
130    ) -> Result<(), StoreError>;
131
132    /// Remove a recipient from the suppression list. Returns `true` if a row
133    /// was removed.
134    async fn remove_suppression(&self, email: &str) -> Result<bool, StoreError>;
135
136    /// `(email, reason, smtp_code, created_at_epoch)` tuples, newest first.
137    async fn list_suppressions(
138        &self,
139        limit: i64,
140    ) -> Result<Vec<(String, String, Option<i32>, i64)>, StoreError>;
141}
142
143/// Cross-process wake-up channel. The submitter calls [`notify`](Self::notify)
144/// after `enqueue`; the worker awaits [`wait`](Self::wait) to short-circuit
145/// its poll interval and pick up new work immediately.
146///
147/// Implementations may coalesce notifications: many `notify()` calls may
148/// release a single `wait()`. The worker still re-polls the store on every
149/// wake, so missing a notification is at worst a latency cost, never lost
150/// work.
151#[async_trait::async_trait]
152pub trait Notifier: Send + Sync {
153    /// Signal that new work was added.
154    async fn notify(&self);
155
156    /// Block until a notification arrives. Spurious wake-ups are allowed.
157    async fn wait(&self);
158}
159
160/// In-process notifier backed by [`tokio::sync::Notify`].
161///
162/// Useful for tests, single-process deployments where the worker and
163/// submitter share a runtime, or as a fallback when the cross-process
164/// notifier is unavailable.
165#[derive(Default)]
166pub struct InMemoryNotifier {
167    inner: tokio::sync::Notify,
168}
169
170impl InMemoryNotifier {
171    /// Create a new notifier with no waiters.
172    pub fn new() -> Self {
173        Self::default()
174    }
175}
176
177#[async_trait::async_trait]
178impl Notifier for InMemoryNotifier {
179    async fn notify(&self) {
180        self.inner.notify_one();
181    }
182    async fn wait(&self) {
183        self.inner.notified().await;
184    }
185}
186
187/// Notifier that drops every signal. The worker still polls on its configured
188/// interval, just without fast wake-ups. Useful when a real notifier hasn't
189/// been wired up yet.
190pub struct NoopNotifier;
191
192#[async_trait::async_trait]
193impl Notifier for NoopNotifier {
194    async fn notify(&self) {}
195    async fn wait(&self) {
196        std::future::pending::<()>().await;
197    }
198}
199
200/// Pure in-memory [`QueueStore`] for tests and single-process pilot
201/// deployments. Not durable across restarts.
202pub struct InMemoryQueueStore {
203    state: Mutex<MemState>,
204}
205
206struct MemState {
207    next_id: i64,
208    messages: Vec<QueuedMessage>,
209    suppressions: Vec<(String, String, Option<i32>, i64)>, // (email, reason, code, created_at)
210}
211
212impl Default for InMemoryQueueStore {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl InMemoryQueueStore {
219    /// Create an empty in-memory store.
220    pub fn new() -> Self {
221        Self {
222            state: Mutex::new(MemState {
223                next_id: 1,
224                messages: Vec::new(),
225                suppressions: Vec::new(),
226            }),
227        }
228    }
229
230    #[allow(clippy::too_many_arguments)]
231    fn insert_msg(
232        s: &mut MemState,
233        sender: &str,
234        recipient: &str,
235        domain: &str,
236        message_data: &[u8],
237        message_id: Option<&str>,
238        next_retry: i64,
239        created_at: i64,
240        is_forwarded: bool,
241    ) -> i64 {
242        let id = s.next_id;
243        s.next_id += 1;
244        s.messages.push(QueuedMessage {
245            id,
246            sender: sender.into(),
247            recipient: recipient.into(),
248            domain: domain.into(),
249            message_data: message_data.to_vec(),
250            status: QueueStatus::Pending,
251            attempts: 0,
252            max_attempts: 8,
253            next_retry,
254            last_error: None,
255            message_id: message_id.map(str::to_owned),
256            created_at,
257            updated_at: created_at,
258            is_forwarded,
259        });
260        id
261    }
262}
263
264#[async_trait::async_trait]
265impl QueueStore for InMemoryQueueStore {
266    async fn enqueue(
267        &self,
268        sender: &str,
269        recipient: &str,
270        domain: &str,
271        message_data: &[u8],
272        message_id: Option<&str>,
273        now: i64,
274        is_forwarded: bool,
275    ) -> Result<i64, StoreError> {
276        let mut s = self.state.lock().unwrap();
277        Ok(Self::insert_msg(
278            &mut s,
279            sender,
280            recipient,
281            domain,
282            message_data,
283            message_id,
284            now,
285            now,
286            is_forwarded,
287        ))
288    }
289
290    async fn enqueue_scheduled(
291        &self,
292        sender: &str,
293        recipient: &str,
294        domain: &str,
295        message_data: &[u8],
296        message_id: Option<&str>,
297        created_at: i64,
298        scheduled_at: i64,
299    ) -> Result<i64, StoreError> {
300        let mut s = self.state.lock().unwrap();
301        Ok(Self::insert_msg(
302            &mut s,
303            sender,
304            recipient,
305            domain,
306            message_data,
307            message_id,
308            scheduled_at,
309            created_at,
310            false,
311        ))
312    }
313
314    async fn dequeue(&self, now: i64, limit: u32) -> Result<Vec<QueuedMessage>, StoreError> {
315        let s = self.state.lock().unwrap();
316        let mut out: Vec<QueuedMessage> = s
317            .messages
318            .iter()
319            .filter(|m| m.status == QueueStatus::Pending && m.next_retry <= now)
320            .cloned()
321            .collect();
322        out.sort_by_key(|m| m.next_retry);
323        out.truncate(limit as usize);
324        Ok(out)
325    }
326
327    async fn recover_stale_inflight(&self, now: i64) -> Result<u64, StoreError> {
328        let threshold = now - 600;
329        let mut s = self.state.lock().unwrap();
330        let mut affected = 0u64;
331        for m in s.messages.iter_mut() {
332            if m.status == QueueStatus::InFlight && m.updated_at < threshold {
333                m.status = QueueStatus::Pending;
334                m.updated_at = now;
335                affected += 1;
336            }
337        }
338        Ok(affected)
339    }
340
341    async fn mark_inflight(&self, id: i64, now: i64) -> Result<(), StoreError> {
342        let mut s = self.state.lock().unwrap();
343        if let Some(m) = s.messages.iter_mut().find(|m| m.id == id) {
344            m.status = QueueStatus::InFlight;
345            m.updated_at = now;
346        }
347        Ok(())
348    }
349
350    async fn mark_delivered(&self, id: i64, now: i64) -> Result<(), StoreError> {
351        let mut s = self.state.lock().unwrap();
352        if let Some(m) = s.messages.iter_mut().find(|m| m.id == id) {
353            m.status = QueueStatus::Delivered;
354            m.updated_at = now;
355        }
356        Ok(())
357    }
358
359    async fn mark_failed(
360        &self,
361        id: i64,
362        error: &str,
363        next_retry: i64,
364        now: i64,
365    ) -> Result<(), StoreError> {
366        let mut s = self.state.lock().unwrap();
367        if let Some(m) = s.messages.iter_mut().find(|m| m.id == id) {
368            m.status = QueueStatus::Pending;
369            m.attempts += 1;
370            m.last_error = Some(error.into());
371            m.next_retry = next_retry;
372            m.updated_at = now;
373        }
374        Ok(())
375    }
376
377    async fn mark_bounced(&self, id: i64, error: &str, now: i64) -> Result<(), StoreError> {
378        let mut s = self.state.lock().unwrap();
379        if let Some(m) = s.messages.iter_mut().find(|m| m.id == id) {
380            m.status = QueueStatus::Bounced;
381            m.last_error = Some(error.into());
382            m.updated_at = now;
383        }
384        Ok(())
385    }
386
387    async fn get_message(&self, id: i64) -> Result<Option<QueuedMessage>, StoreError> {
388        let s = self.state.lock().unwrap();
389        Ok(s.messages.iter().find(|m| m.id == id).cloned())
390    }
391
392    async fn queue_stats(&self) -> Result<Vec<(String, i64)>, StoreError> {
393        use std::collections::HashMap;
394        let s = self.state.lock().unwrap();
395        let mut counts: HashMap<&'static str, i64> = HashMap::new();
396        for m in &s.messages {
397            *counts.entry(m.status.as_str()).or_insert(0) += 1;
398        }
399        Ok(counts
400            .into_iter()
401            .map(|(k, v)| (k.to_string(), v))
402            .collect())
403    }
404
405    async fn list_recent(&self, limit: i32) -> Result<Vec<QueuedMessage>, StoreError> {
406        let s = self.state.lock().unwrap();
407        let mut out: Vec<QueuedMessage> = s.messages.clone();
408        out.sort_by_key(|m| std::cmp::Reverse(m.created_at));
409        out.truncate(limit.max(0) as usize);
410        Ok(out)
411    }
412
413    async fn cancel_pending(&self, id: i64) -> Result<bool, StoreError> {
414        let mut s = self.state.lock().unwrap();
415        let before = s.messages.len();
416        s.messages
417            .retain(|m| !(m.id == id && m.status == QueueStatus::Pending));
418        Ok(s.messages.len() < before)
419    }
420
421    async fn cancel_pending_by_message_id(
422        &self,
423        message_id: &str,
424        sender: &str,
425    ) -> Result<bool, StoreError> {
426        let mut s = self.state.lock().unwrap();
427        let before = s.messages.len();
428        s.messages.retain(|m| {
429            !(m.status == QueueStatus::Pending
430                && m.sender == sender
431                && m.message_id.as_deref() == Some(message_id))
432        });
433        Ok(s.messages.len() < before)
434    }
435
436    async fn retry_message(&self, id: i64, now: i64) -> Result<bool, StoreError> {
437        let mut s = self.state.lock().unwrap();
438        if let Some(m) = s.messages.iter_mut().find(|m| {
439            m.id == id && (m.status == QueueStatus::Bounced || m.status == QueueStatus::Failed)
440        }) {
441            m.status = QueueStatus::Pending;
442            m.next_retry = now;
443            m.updated_at = now;
444            return Ok(true);
445        }
446        Ok(false)
447    }
448
449    async fn is_suppressed(&self, email: &str) -> bool {
450        let s = self.state.lock().unwrap();
451        s.suppressions.iter().any(|(e, _, _, _)| e == email)
452    }
453
454    async fn add_suppression(
455        &self,
456        email: &str,
457        reason: &str,
458        smtp_code: Option<i32>,
459    ) -> Result<(), StoreError> {
460        let mut s = self.state.lock().unwrap();
461        if let Some(entry) = s.suppressions.iter_mut().find(|(e, _, _, _)| e == email) {
462            entry.1 = reason.into();
463            entry.2 = smtp_code;
464        } else {
465            let now = chrono::Utc::now().timestamp();
466            s.suppressions
467                .push((email.into(), reason.into(), smtp_code, now));
468        }
469        Ok(())
470    }
471
472    async fn remove_suppression(&self, email: &str) -> Result<bool, StoreError> {
473        let mut s = self.state.lock().unwrap();
474        let before = s.suppressions.len();
475        s.suppressions.retain(|(e, _, _, _)| e != email);
476        Ok(s.suppressions.len() < before)
477    }
478
479    async fn list_suppressions(
480        &self,
481        limit: i64,
482    ) -> Result<Vec<(String, String, Option<i32>, i64)>, StoreError> {
483        let s = self.state.lock().unwrap();
484        let mut out = s.suppressions.clone();
485        out.sort_by_key(|(_, _, _, t)| std::cmp::Reverse(*t));
486        out.truncate(limit.max(0) as usize);
487        Ok(out)
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use std::sync::Arc;
495    use std::time::Duration;
496
497    fn store() -> Arc<dyn QueueStore> {
498        Arc::new(InMemoryQueueStore::new())
499    }
500
501    #[tokio::test]
502    async fn enqueue_dequeue_roundtrip() {
503        let s = store();
504        let id = s
505            .enqueue("a@x", "b@y", "y", b"raw", None, 0, false)
506            .await
507            .unwrap();
508        let msgs = s.dequeue(0, 10).await.unwrap();
509        assert_eq!(msgs.len(), 1);
510        assert_eq!(msgs[0].id, id);
511        assert_eq!(msgs[0].sender, "a@x");
512    }
513
514    #[tokio::test]
515    async fn lifecycle_transitions() {
516        let s = store();
517        let id = s
518            .enqueue("a@x", "b@y", "y", b"", None, 0, false)
519            .await
520            .unwrap();
521        s.mark_inflight(id, 10).await.unwrap();
522        s.mark_delivered(id, 20).await.unwrap();
523        assert_eq!(
524            s.get_message(id).await.unwrap().unwrap().status,
525            QueueStatus::Delivered
526        );
527    }
528
529    #[tokio::test]
530    async fn stale_inflight_recovers() {
531        let s = store();
532        let id = s
533            .enqueue("a@x", "b@y", "y", b"", None, 0, false)
534            .await
535            .unwrap();
536        s.mark_inflight(id, 0).await.unwrap();
537        // 10 minutes + 1 second later, recover_stale should kick it back
538        let n = s.recover_stale_inflight(601).await.unwrap();
539        assert_eq!(n, 1);
540        assert_eq!(
541            s.get_message(id).await.unwrap().unwrap().status,
542            QueueStatus::Pending
543        );
544    }
545
546    #[tokio::test]
547    async fn suppression_list() {
548        let s = store();
549        assert!(!s.is_suppressed("user@x").await);
550        s.add_suppression("user@x", "bounced", Some(550))
551            .await
552            .unwrap();
553        assert!(s.is_suppressed("user@x").await);
554        assert!(s.remove_suppression("user@x").await.unwrap());
555        assert!(!s.is_suppressed("user@x").await);
556    }
557
558    #[tokio::test]
559    async fn in_memory_notifier_wakes_waiter() {
560        let n = Arc::new(InMemoryNotifier::new());
561        let n2 = n.clone();
562        let h = tokio::spawn(async move {
563            tokio::time::timeout(Duration::from_secs(2), n2.wait())
564                .await
565                .expect("waiter timed out")
566        });
567        n.notify().await;
568        h.await.unwrap();
569    }
570}