1#[cfg(feature = "pg")]
2use redis::AsyncCommands;
3#[cfg(feature = "pg")]
4use sqlx::PgPool;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum QueueStatus {
9 Pending,
11 InFlight,
13 Delivered,
15 Failed,
17 Bounced,
19}
20
21impl QueueStatus {
22 pub fn as_str(&self) -> &'static str {
24 match self {
25 Self::Pending => "pending",
26 Self::InFlight => "inflight",
27 Self::Delivered => "delivered",
28 Self::Failed => "failed",
29 Self::Bounced => "bounced",
30 }
31 }
32
33 pub fn parse(s: &str) -> Option<Self> {
35 match s {
36 "pending" => Some(Self::Pending),
37 "inflight" => Some(Self::InFlight),
38 "delivered" => Some(Self::Delivered),
39 "failed" => Some(Self::Failed),
40 "bounced" => Some(Self::Bounced),
41 _ => None,
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct QueuedMessage {
49 pub id: i64,
51 pub sender: String,
53 pub recipient: String,
56 pub domain: String,
58 pub message_data: Vec<u8>,
60 pub status: QueueStatus,
62 pub attempts: u32,
64 pub max_attempts: u32,
66 pub next_retry: i64,
68 pub last_error: Option<String>,
70 pub message_id: Option<String>,
72 pub created_at: i64,
74 pub updated_at: i64,
76 pub is_forwarded: bool,
79}
80
81#[cfg(feature = "pg")]
83pub async fn enqueue(
84 pool: &PgPool,
85 sender: &str,
86 recipient: &str,
87 domain: &str,
88 message_data: &[u8],
89 message_id: Option<&str>,
90 now: i64,
91) -> Result<i64, sqlx::Error> {
92 enqueue_ex(
93 pool,
94 sender,
95 recipient,
96 domain,
97 message_data,
98 message_id,
99 now,
100 false,
101 )
102 .await
103}
104
105#[allow(clippy::too_many_arguments)]
107#[cfg(feature = "pg")]
108pub async fn enqueue_ex(
109 pool: &PgPool,
110 sender: &str,
111 recipient: &str,
112 domain: &str,
113 message_data: &[u8],
114 message_id: Option<&str>,
115 now: i64,
116 is_forwarded: bool,
117) -> Result<i64, sqlx::Error> {
118 let row: (i64,) = sqlx::query_as(
119 "INSERT INTO outbound_queue (sender, recipient, domain, message_data, status, next_retry, message_id, created_at, updated_at, is_forwarded)
120 VALUES ($1, $2, $3, $4, 'pending', $5, $6, $5, $5, $7)
121 RETURNING id",
122 )
123 .bind(sender)
124 .bind(recipient)
125 .bind(domain)
126 .bind(message_data)
127 .bind(now)
128 .bind(message_id)
129 .bind(is_forwarded)
130 .fetch_one(pool)
131 .await?;
132 Ok(row.0)
133}
134
135#[cfg(feature = "pg")]
137pub async fn notify(valkey: &mut redis::aio::ConnectionManager) {
138 let _: Result<i32, _> = valkey.publish("queue:notify", "1").await;
139}
140
141#[cfg(feature = "pg")]
144pub async fn recover_stale_inflight(pool: &PgPool, now: i64) -> Result<u64, sqlx::Error> {
145 let stale_threshold = now - 600; let result = sqlx::query(
147 "UPDATE outbound_queue SET status = 'pending', updated_at = $1 \
148 WHERE status = 'inflight' AND updated_at < $2",
149 )
150 .bind(now)
151 .bind(stale_threshold)
152 .execute(pool)
153 .await?;
154 Ok(result.rows_affected())
155}
156
157#[cfg(feature = "pg")]
161pub async fn count_pending(pool: &PgPool) -> Result<i64, sqlx::Error> {
162 let (n,): (i64,) =
163 sqlx::query_as("SELECT count(*) FROM outbound_queue WHERE status = 'pending'")
164 .fetch_one(pool)
165 .await?;
166 Ok(n)
167}
168
169#[cfg(feature = "pg")]
173pub async fn count_inflight(pool: &PgPool) -> Result<i64, sqlx::Error> {
174 let (n,): (i64,) =
175 sqlx::query_as("SELECT count(*) FROM outbound_queue WHERE status = 'inflight'")
176 .fetch_one(pool)
177 .await?;
178 Ok(n)
179}
180
181#[cfg(feature = "pg")]
189pub async fn dequeue(
190 pool: &PgPool,
191 now: i64,
192 limit: u32,
193) -> Result<Vec<QueuedMessage>, sqlx::Error> {
194 #[allow(clippy::type_complexity)]
195 let rows: Vec<(i64, String, String, String, Vec<u8>, String, i32, i32, i64, Option<String>, Option<String>, i64, i64, bool)> = sqlx::query_as(
196 "SELECT id, sender, recipient, domain, message_data, status, attempts, max_attempts, next_retry, last_error, message_id, created_at, updated_at, is_forwarded
197 FROM outbound_queue
198 WHERE status = 'pending' AND next_retry <= $1
199 ORDER BY next_retry ASC
200 LIMIT $2",
201 )
202 .bind(now)
203 .bind(limit as i32)
204 .fetch_all(pool)
205 .await?;
206
207 Ok(rows
208 .into_iter()
209 .map(|r| QueuedMessage {
210 id: r.0,
211 sender: r.1,
212 recipient: r.2,
213 domain: r.3,
214 message_data: r.4,
215 status: QueueStatus::parse(&r.5).unwrap_or(QueueStatus::Pending),
216 attempts: r.6 as u32,
217 max_attempts: r.7 as u32,
218 next_retry: r.8,
219 last_error: r.9,
220 message_id: r.10,
221 created_at: r.11,
222 updated_at: r.12,
223 is_forwarded: r.13,
224 })
225 .collect())
226}
227
228#[cfg(feature = "pg")]
245pub async fn claim_for_delivery(
246 pool: &PgPool,
247 now: i64,
248 limit: u32,
249) -> Result<Vec<QueuedMessage>, sqlx::Error> {
250 #[allow(clippy::type_complexity)]
251 let rows: Vec<(i64, String, String, String, Vec<u8>, String, i32, i32, i64, Option<String>, Option<String>, i64, i64, bool)> = sqlx::query_as(
252 "UPDATE outbound_queue
253 SET status = 'inflight', updated_at = $1
254 WHERE id IN (
255 SELECT id FROM outbound_queue
256 WHERE status = 'pending' AND next_retry <= $2
257 ORDER BY next_retry ASC
258 LIMIT $3
259 FOR UPDATE SKIP LOCKED
260 )
261 RETURNING id, sender, recipient, domain, message_data, status, attempts, max_attempts, next_retry, last_error, message_id, created_at, updated_at, is_forwarded",
262 )
263 .bind(now)
264 .bind(now)
265 .bind(limit as i32)
266 .fetch_all(pool)
267 .await?;
268
269 Ok(rows
270 .into_iter()
271 .map(|r| QueuedMessage {
272 id: r.0,
273 sender: r.1,
274 recipient: r.2,
275 domain: r.3,
276 message_data: r.4,
277 status: QueueStatus::parse(&r.5).unwrap_or(QueueStatus::InFlight),
278 attempts: r.6 as u32,
279 max_attempts: r.7 as u32,
280 next_retry: r.8,
281 last_error: r.9,
282 message_id: r.10,
283 created_at: r.11,
284 updated_at: r.12,
285 is_forwarded: r.13,
286 })
287 .collect())
288}
289
290#[cfg(feature = "pg")]
292pub async fn mark_inflight(pool: &PgPool, id: i64, now: i64) -> Result<(), sqlx::Error> {
293 sqlx::query("UPDATE outbound_queue SET status = 'inflight', updated_at = $1 WHERE id = $2")
294 .bind(now)
295 .bind(id)
296 .execute(pool)
297 .await?;
298 Ok(())
299}
300
301#[cfg(feature = "pg")]
303pub async fn mark_delivered(pool: &PgPool, id: i64, now: i64) -> Result<(), sqlx::Error> {
304 sqlx::query("UPDATE outbound_queue SET status = 'delivered', updated_at = $1 WHERE id = $2")
305 .bind(now)
306 .bind(id)
307 .execute(pool)
308 .await?;
309 Ok(())
310}
311
312#[cfg(feature = "pg")]
314pub async fn mark_failed(
315 pool: &PgPool,
316 id: i64,
317 error: &str,
318 next_retry: i64,
319 now: i64,
320) -> Result<(), sqlx::Error> {
321 sqlx::query(
322 "UPDATE outbound_queue SET status = 'pending', attempts = attempts + 1, last_error = $1, next_retry = $2, updated_at = $3 WHERE id = $4",
323 )
324 .bind(error)
325 .bind(next_retry)
326 .bind(now)
327 .bind(id)
328 .execute(pool)
329 .await?;
330 Ok(())
331}
332
333#[cfg(feature = "pg")]
335pub async fn mark_bounced(
336 pool: &PgPool,
337 id: i64,
338 error: &str,
339 now: i64,
340) -> Result<(), sqlx::Error> {
341 sqlx::query(
342 "UPDATE outbound_queue SET status = 'bounced', last_error = $1, updated_at = $2 WHERE id = $3",
343 )
344 .bind(error)
345 .bind(now)
346 .bind(id)
347 .execute(pool)
348 .await?;
349 Ok(())
350}
351
352#[cfg(feature = "pg")]
354pub async fn queue_stats(pool: &PgPool) -> Result<Vec<(String, i64)>, sqlx::Error> {
355 let rows: Vec<(String, i64)> =
356 sqlx::query_as("SELECT status, COUNT(*) FROM outbound_queue GROUP BY status")
357 .fetch_all(pool)
358 .await?;
359 Ok(rows)
360}
361
362#[cfg(feature = "pg")]
364pub async fn get_message(pool: &PgPool, id: i64) -> Result<Option<QueuedMessage>, sqlx::Error> {
365 #[allow(clippy::type_complexity)]
366 let row: Option<(i64, String, String, String, Vec<u8>, String, i32, i32, i64, Option<String>, Option<String>, i64, i64, bool)> = sqlx::query_as(
367 "SELECT id, sender, recipient, domain, message_data, status, attempts, max_attempts, next_retry, last_error, message_id, created_at, updated_at, is_forwarded
368 FROM outbound_queue WHERE id = $1",
369 )
370 .bind(id)
371 .fetch_optional(pool)
372 .await?;
373
374 Ok(row.map(|r| QueuedMessage {
375 id: r.0,
376 sender: r.1,
377 recipient: r.2,
378 domain: r.3,
379 message_data: r.4,
380 status: QueueStatus::parse(&r.5).unwrap_or(QueueStatus::Pending),
381 attempts: r.6 as u32,
382 max_attempts: r.7 as u32,
383 next_retry: r.8,
384 last_error: r.9,
385 message_id: r.10,
386 created_at: r.11,
387 updated_at: r.12,
388 is_forwarded: r.13,
389 }))
390}
391
392#[allow(clippy::too_many_arguments)]
394#[cfg(feature = "pg")]
395pub async fn enqueue_scheduled(
396 pool: &PgPool,
397 sender: &str,
398 recipient: &str,
399 domain: &str,
400 message_data: &[u8],
401 message_id: Option<&str>,
402 created_at: i64,
403 scheduled_at: i64,
404) -> Result<i64, sqlx::Error> {
405 let row: (i64,) = sqlx::query_as(
406 "INSERT INTO outbound_queue (sender, recipient, domain, message_data, status, next_retry, message_id, created_at, updated_at, is_forwarded)
407 VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $7, false)
408 RETURNING id",
409 )
410 .bind(sender)
411 .bind(recipient)
412 .bind(domain)
413 .bind(message_data)
414 .bind(scheduled_at)
415 .bind(message_id)
416 .bind(created_at)
417 .fetch_one(pool)
418 .await?;
419 Ok(row.0)
420}
421
422#[cfg(feature = "pg")]
424pub async fn cancel_pending(pool: &PgPool, id: i64) -> Result<bool, sqlx::Error> {
425 let result = sqlx::query("DELETE FROM outbound_queue WHERE id = $1 AND status = 'pending'")
426 .bind(id)
427 .execute(pool)
428 .await?;
429 Ok(result.rows_affected() > 0)
430}
431
432#[cfg(feature = "pg")]
434pub async fn cancel_pending_by_message_id(
435 pool: &PgPool,
436 message_id: &str,
437 sender: &str,
438) -> Result<bool, sqlx::Error> {
439 let result = sqlx::query(
440 "DELETE FROM outbound_queue WHERE message_id = $1 AND status = 'pending' AND sender = $2",
441 )
442 .bind(message_id)
443 .bind(sender)
444 .execute(pool)
445 .await?;
446 Ok(result.rows_affected() > 0)
447}
448
449#[cfg(feature = "pg")]
451pub async fn retry_message(pool: &PgPool, id: i64, now: i64) -> Result<bool, sqlx::Error> {
452 let result = sqlx::query(
453 "UPDATE outbound_queue SET status = 'pending', next_retry = $1, updated_at = $1 WHERE id = $2 AND status IN ('bounced', 'failed')",
454 )
455 .bind(now)
456 .bind(id)
457 .execute(pool)
458 .await?;
459 Ok(result.rows_affected() > 0)
460}
461
462#[cfg(feature = "pg")]
464pub async fn list_recent(pool: &PgPool, limit: i32) -> Result<Vec<QueuedMessage>, sqlx::Error> {
465 #[allow(clippy::type_complexity)]
466 let rows: Vec<(i64, String, String, String, Vec<u8>, String, i32, i32, i64, Option<String>, Option<String>, i64, i64, bool)> = sqlx::query_as(
467 "SELECT id, sender, recipient, domain, message_data, status, attempts, max_attempts, next_retry, last_error, message_id, created_at, updated_at, is_forwarded
468 FROM outbound_queue
469 ORDER BY created_at DESC
470 LIMIT $1",
471 )
472 .bind(limit)
473 .fetch_all(pool)
474 .await?;
475
476 Ok(rows
477 .into_iter()
478 .map(|r| QueuedMessage {
479 id: r.0,
480 sender: r.1,
481 recipient: r.2,
482 domain: r.3,
483 message_data: r.4,
484 status: QueueStatus::parse(&r.5).unwrap_or(QueueStatus::Pending),
485 attempts: r.6 as u32,
486 max_attempts: r.7 as u32,
487 next_retry: r.8,
488 last_error: r.9,
489 message_id: r.10,
490 created_at: r.11,
491 updated_at: r.12,
492 is_forwarded: r.13,
493 })
494 .collect())
495}
496
497mod suppression;
498
499#[cfg(feature = "pg")]
500pub use suppression::{add_suppression, is_suppressed, list_suppressions, remove_suppression};
501pub use suppression::is_hard_bounce;
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn queue_status_roundtrip() {
509 let variants = [
510 QueueStatus::Pending,
511 QueueStatus::InFlight,
512 QueueStatus::Delivered,
513 QueueStatus::Failed,
514 QueueStatus::Bounced,
515 ];
516 for v in &variants {
517 let s = v.as_str();
518 let parsed = QueueStatus::parse(s).unwrap();
519 assert_eq!(&parsed, v, "roundtrip failed for {s}");
520 }
521 }
522
523 #[test]
524 fn queue_status_parse_unknown() {
525 assert_eq!(QueueStatus::parse("unknown"), None);
526 assert_eq!(QueueStatus::parse(""), None);
527 assert_eq!(QueueStatus::parse("PENDING"), None);
528 }
529
530 #[test]
531 fn queue_status_as_str_values() {
532 assert_eq!(QueueStatus::Pending.as_str(), "pending");
533 assert_eq!(QueueStatus::InFlight.as_str(), "inflight");
534 assert_eq!(QueueStatus::Delivered.as_str(), "delivered");
535 assert_eq!(QueueStatus::Failed.as_str(), "failed");
536 assert_eq!(QueueStatus::Bounced.as_str(), "bounced");
537 }
538
539 #[test]
540 fn queue_status_parse_case_sensitive() {
541 assert_eq!(QueueStatus::parse("Pending"), None);
543 assert_eq!(QueueStatus::parse("InFlight"), None);
544 assert_eq!(QueueStatus::parse("DELIVERED"), None);
545 assert_eq!(QueueStatus::parse("Failed"), None);
546 assert_eq!(QueueStatus::parse("Bounced"), None);
547 }
548
549 #[test]
550 fn queue_status_parse_whitespace_rejected() {
551 assert_eq!(QueueStatus::parse(" pending"), None);
552 assert_eq!(QueueStatus::parse("pending "), None);
553 assert_eq!(QueueStatus::parse(" "), None);
554 }
555
556 #[test]
557 fn queue_status_eq() {
558 assert_eq!(QueueStatus::Pending, QueueStatus::Pending);
559 assert_ne!(QueueStatus::Pending, QueueStatus::Delivered);
560 assert_ne!(QueueStatus::Failed, QueueStatus::Bounced);
561 }
562
563 #[test]
564 fn queue_status_clone() {
565 let s = QueueStatus::InFlight;
566 let c = s.clone();
567 assert_eq!(s, c);
568 }
569
570 #[test]
571 fn queued_message_clone_preserves_fields() {
572 let msg = QueuedMessage {
573 id: 42,
574 sender: "s@example.com".into(),
575 recipient: "r@remote.com".into(),
576 domain: "remote.com".into(),
577 message_data: vec![1, 2, 3],
578 status: QueueStatus::Pending,
579 attempts: 3,
580 max_attempts: 8,
581 next_retry: 1_700_000_000,
582 last_error: Some("temporary failure".into()),
583 message_id: Some("msg-id-123".into()),
584 created_at: 1_699_000_000,
585 updated_at: 1_699_500_000,
586 is_forwarded: true,
587 };
588 let cloned = msg.clone();
589 assert_eq!(cloned.id, 42);
590 assert_eq!(cloned.sender, "s@example.com");
591 assert_eq!(cloned.recipient, "r@remote.com");
592 assert_eq!(cloned.domain, "remote.com");
593 assert_eq!(cloned.message_data, vec![1, 2, 3]);
594 assert_eq!(cloned.attempts, 3);
595 assert_eq!(cloned.max_attempts, 8);
596 assert_eq!(cloned.next_retry, 1_700_000_000);
597 assert_eq!(cloned.last_error, Some("temporary failure".into()));
598 assert_eq!(cloned.message_id, Some("msg-id-123".into()));
599 assert!(cloned.is_forwarded);
600 }
601
602 #[test]
603 fn queued_message_no_last_error() {
604 let msg = QueuedMessage {
605 id: 1,
606 sender: "s@example.com".into(),
607 recipient: "r@remote.com".into(),
608 domain: "remote.com".into(),
609 message_data: vec![],
610 status: QueueStatus::Pending,
611 attempts: 0,
612 max_attempts: 8,
613 next_retry: 0,
614 last_error: None,
615 message_id: None,
616 created_at: 0,
617 updated_at: 0,
618 is_forwarded: false,
619 };
620 assert!(msg.last_error.is_none());
621 assert!(msg.message_id.is_none());
622 }
623}