Skip to main content

mailrs_outbound_queue/
pg_store.rs

1//! Postgres / Redis-backed implementations of [`QueueStore`] and [`Notifier`].
2//!
3//! Enabled by the `pg` feature (on by default). The SQL schema this store
4//! targets is published in the [mailrs] repo (`scripts/init-schema.sql` —
5//! tables `outbound_queue` and `suppression_list`).
6//!
7//! [mailrs]: https://github.com/goliajp/mailrs
8
9use redis::AsyncCommands;
10use sqlx::PgPool;
11
12use crate::queue;
13use crate::queue::QueuedMessage;
14use crate::store::{Notifier, QueueStore, StoreError};
15
16/// `QueueStore` backed by a `sqlx::PgPool` against the mailrs schema.
17#[derive(Debug, Clone)]
18pub struct PgQueueStore {
19    pool: PgPool,
20}
21
22impl PgQueueStore {
23    /// Build a Postgres-backed store over an existing connection pool. The
24    /// pool is cloned cheaply on every operation, so a single pool can be
25    /// shared with the rest of the application.
26    pub fn new(pool: PgPool) -> Self {
27        Self { pool }
28    }
29
30    /// Borrow the underlying pool for ad-hoc queries.
31    pub fn pool(&self) -> &PgPool {
32        &self.pool
33    }
34}
35
36#[async_trait::async_trait]
37impl QueueStore for PgQueueStore {
38    async fn enqueue(
39        &self,
40        sender: &str,
41        recipient: &str,
42        domain: &str,
43        message_data: &[u8],
44        message_id: Option<&str>,
45        now: i64,
46        is_forwarded: bool,
47    ) -> Result<i64, StoreError> {
48        Ok(queue::enqueue_ex(
49            &self.pool,
50            sender,
51            recipient,
52            domain,
53            message_data,
54            message_id,
55            now,
56            is_forwarded,
57        )
58        .await?)
59    }
60
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        Ok(queue::enqueue_scheduled(
72            &self.pool,
73            sender,
74            recipient,
75            domain,
76            message_data,
77            message_id,
78            created_at,
79            scheduled_at,
80        )
81        .await?)
82    }
83
84    async fn dequeue(&self, now: i64, limit: u32) -> Result<Vec<QueuedMessage>, StoreError> {
85        Ok(queue::dequeue(&self.pool, now, limit).await?)
86    }
87
88    async fn recover_stale_inflight(&self, now: i64) -> Result<u64, StoreError> {
89        Ok(queue::recover_stale_inflight(&self.pool, now).await?)
90    }
91
92    async fn mark_inflight(&self, id: i64, now: i64) -> Result<(), StoreError> {
93        Ok(queue::mark_inflight(&self.pool, id, now).await?)
94    }
95
96    async fn mark_delivered(&self, id: i64, now: i64) -> Result<(), StoreError> {
97        Ok(queue::mark_delivered(&self.pool, id, now).await?)
98    }
99
100    async fn mark_failed(
101        &self,
102        id: i64,
103        error: &str,
104        next_retry: i64,
105        now: i64,
106    ) -> Result<(), StoreError> {
107        Ok(queue::mark_failed(&self.pool, id, error, next_retry, now).await?)
108    }
109
110    async fn mark_bounced(&self, id: i64, error: &str, now: i64) -> Result<(), StoreError> {
111        Ok(queue::mark_bounced(&self.pool, id, error, now).await?)
112    }
113
114    async fn get_message(&self, id: i64) -> Result<Option<QueuedMessage>, StoreError> {
115        Ok(queue::get_message(&self.pool, id).await?)
116    }
117
118    async fn queue_stats(&self) -> Result<Vec<(String, i64)>, StoreError> {
119        Ok(queue::queue_stats(&self.pool).await?)
120    }
121
122    async fn list_recent(&self, limit: i32) -> Result<Vec<QueuedMessage>, StoreError> {
123        Ok(queue::list_recent(&self.pool, limit).await?)
124    }
125
126    async fn cancel_pending(&self, id: i64) -> Result<bool, StoreError> {
127        Ok(queue::cancel_pending(&self.pool, id).await?)
128    }
129
130    async fn cancel_pending_by_message_id(
131        &self,
132        message_id: &str,
133        sender: &str,
134    ) -> Result<bool, StoreError> {
135        Ok(queue::cancel_pending_by_message_id(&self.pool, message_id, sender).await?)
136    }
137
138    async fn retry_message(&self, id: i64, now: i64) -> Result<bool, StoreError> {
139        Ok(queue::retry_message(&self.pool, id, now).await?)
140    }
141
142    async fn is_suppressed(&self, email: &str) -> bool {
143        queue::is_suppressed(&self.pool, email).await
144    }
145
146    async fn add_suppression(
147        &self,
148        email: &str,
149        reason: &str,
150        smtp_code: Option<i32>,
151    ) -> Result<(), StoreError> {
152        Ok(queue::add_suppression(&self.pool, email, reason, smtp_code).await?)
153    }
154
155    async fn remove_suppression(&self, email: &str) -> Result<bool, StoreError> {
156        Ok(queue::remove_suppression(&self.pool, email).await?)
157    }
158
159    async fn list_suppressions(
160        &self,
161        limit: i64,
162    ) -> Result<Vec<(String, String, Option<i32>, i64)>, StoreError> {
163        Ok(queue::list_suppressions(&self.pool, limit).await?)
164    }
165}
166
167/// Redis pub/sub-backed [`Notifier`] using the `queue:notify` channel.
168#[derive(Debug, Clone)]
169pub struct RedisNotifier {
170    url: String,
171}
172
173impl RedisNotifier {
174    /// Build a notifier from a connection URL (e.g.
175    /// `redis://127.0.0.1:6379`). Subscribers run on a background tokio task
176    /// that this struct spawns when [`wait`](Notifier::wait) is first called.
177    pub fn new(url: impl Into<String>) -> Self {
178        Self { url: url.into() }
179    }
180}
181
182#[async_trait::async_trait]
183impl Notifier for RedisNotifier {
184    async fn notify(&self) {
185        let Ok(client) = redis::Client::open(self.url.as_str()) else {
186            return;
187        };
188        let Ok(mut conn) = client.get_connection_manager().await else {
189            return;
190        };
191        let _: Result<i32, _> = conn.publish("queue:notify", "1").await;
192    }
193
194    async fn wait(&self) {
195        // Each call opens a fresh pubsub connection. Callers typically await
196        // wait() inside a select! arm with a poll-interval sleep, so a short
197        // connection lifetime is fine — even if pubsub setup fails or returns
198        // immediately, the next poll loop will start a new wait().
199        let Ok(client) = redis::Client::open(self.url.as_str()) else {
200            std::future::pending::<()>().await;
201            return;
202        };
203        let Ok(mut pubsub) = client.get_async_pubsub().await else {
204            std::future::pending::<()>().await;
205            return;
206        };
207        if pubsub.subscribe("queue:notify").await.is_err() {
208            std::future::pending::<()>().await;
209            return;
210        }
211        use futures_util::StreamExt;
212        let mut stream = pubsub.on_message();
213        let _ = stream.next().await;
214    }
215}