Skip to main content

mailrs_outbound_queue/queue/
suppression.rs

1//! Suppression list (hard-bounce blocklist).
2
3#[cfg(feature = "pg")]
4use sqlx::PgPool;
5
6/// check if a recipient address is in the suppression list (hard bounce)
7#[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/// add a recipient to the suppression list after a hard bounce
17#[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/// remove an address from the suppression list (admin override)
38#[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/// list all suppressed addresses
48#[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
62/// detect if an SMTP error is a permanent/hard bounce (5xx)
63pub 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}