mailrs_outbound_queue/queue/
suppression.rs1#[cfg(feature = "pg")]
4use sqlx::PgPool;
5
6#[cfg(feature = "pg")]
8pub async fn is_suppressed(pool: &PgPool, email: &str) -> bool {
9 sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM suppression_list WHERE email = $1)")
10 .bind(email)
11 .fetch_one(pool)
12 .await
13 .unwrap_or(false)
14}
15
16#[cfg(feature = "pg")]
18pub async fn add_suppression(
19 pool: &PgPool,
20 email: &str,
21 reason: &str,
22 smtp_code: Option<i32>,
23) -> Result<(), sqlx::Error> {
24 sqlx::query(
25 "INSERT INTO suppression_list (email, reason, bounce_type, smtp_code) \
26 VALUES ($1, $2, 'hard', $3) \
27 ON CONFLICT (email) DO UPDATE SET reason = $2, smtp_code = $3, created_at = NOW()",
28 )
29 .bind(email)
30 .bind(reason)
31 .bind(smtp_code)
32 .execute(pool)
33 .await?;
34 Ok(())
35}
36
37#[cfg(feature = "pg")]
39pub async fn remove_suppression(pool: &PgPool, email: &str) -> Result<bool, sqlx::Error> {
40 let result = sqlx::query("DELETE FROM suppression_list WHERE email = $1")
41 .bind(email)
42 .execute(pool)
43 .await?;
44 Ok(result.rows_affected() > 0)
45}
46
47#[cfg(feature = "pg")]
49pub async fn list_suppressions(
50 pool: &PgPool,
51 limit: i64,
52) -> Result<Vec<(String, String, Option<i32>, i64)>, sqlx::Error> {
53 sqlx::query_as(
54 "SELECT email, reason, smtp_code, EXTRACT(EPOCH FROM created_at)::BIGINT \
55 FROM suppression_list ORDER BY created_at DESC LIMIT $1",
56 )
57 .bind(limit)
58 .fetch_all(pool)
59 .await
60}
61
62pub fn is_hard_bounce(error: &str) -> bool {
64 let trimmed = error.trim();
65 trimmed.starts_with('5') || trimmed.starts_with("5.")
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn hard_bounce_detects_5xx() {
74 assert!(is_hard_bounce("550 mailbox unavailable"));
75 assert!(is_hard_bounce("552 message too large"));
76 assert!(is_hard_bounce("5.7.1 policy reject"));
77 }
78
79 #[test]
80 fn hard_bounce_rejects_4xx_and_other() {
81 assert!(!is_hard_bounce("450 try again"));
82 assert!(!is_hard_bounce("421 service unavailable"));
83 assert!(!is_hard_bounce("250 ok"));
84 assert!(!is_hard_bounce(""));
85 assert!(!is_hard_bounce("random text"));
86 }
87
88 #[test]
89 fn hard_bounce_trims_leading_whitespace() {
90 assert!(is_hard_bounce(" 550 spaces"));
91 assert!(!is_hard_bounce(" 450 spaces"));
92 }
93}