Skip to main content

postcrate_core/db/
bounce_rules.rs

1//! Bounce rule storage. Glob-matched at RCPT TO time by `smtp::bounce`.
2
3use chrono::Utc;
4use serde::{Deserialize, Serialize};
5use sqlx::{Row, SqlitePool};
6use uuid::Uuid;
7
8use crate::error::{Error, Result};
9use crate::events::BounceKind;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "specta", derive(specta::Type))]
13#[serde(rename_all = "camelCase")]
14pub struct BounceRule {
15    #[serde(default)]
16    pub id: String,
17    pub mailbox_id: String,
18    pub address_pattern: String,
19    pub bounce_kind: BounceKind,
20    pub smtp_code: u16,
21    pub smtp_message: String,
22    #[serde(default = "default_enabled")]
23    pub enabled: bool,
24    #[serde(default)]
25    pub created_at: i64,
26}
27
28fn default_enabled() -> bool {
29    true
30}
31
32pub(crate) async fn list(pool: &SqlitePool, mailbox_id: &str) -> Result<Vec<BounceRule>> {
33    let rows = sqlx::query(
34        r"SELECT id, mailbox_id, address_pattern, bounce_kind, smtp_code,
35                 smtp_message, enabled, created_at
36          FROM bounce_rules
37          WHERE mailbox_id = ?
38          ORDER BY created_at ASC",
39    )
40    .bind(mailbox_id)
41    .fetch_all(pool)
42    .await?;
43    Ok(rows.iter().map(row_to_rule).collect())
44}
45
46pub(crate) async fn list_enabled(pool: &SqlitePool, mailbox_id: &str) -> Result<Vec<BounceRule>> {
47    let rows = sqlx::query(
48        r"SELECT id, mailbox_id, address_pattern, bounce_kind, smtp_code,
49                 smtp_message, enabled, created_at
50          FROM bounce_rules
51          WHERE mailbox_id = ? AND enabled = 1",
52    )
53    .bind(mailbox_id)
54    .fetch_all(pool)
55    .await?;
56    Ok(rows.iter().map(row_to_rule).collect())
57}
58
59pub(crate) async fn upsert(pool: &SqlitePool, mut rule: BounceRule) -> Result<BounceRule> {
60    if rule.id.is_empty() {
61        rule.id = Uuid::new_v4().to_string();
62    }
63    if rule.created_at == 0 {
64        rule.created_at = Utc::now().timestamp_millis();
65    }
66    if !(400..600).contains(&rule.smtp_code) {
67        return Err(Error::Invalid(format!(
68            "smtp_code {} must be 4xx or 5xx",
69            rule.smtp_code
70        )));
71    }
72
73    sqlx::query(
74        r"INSERT INTO bounce_rules
75            (id, mailbox_id, address_pattern, bounce_kind, smtp_code,
76             smtp_message, enabled, created_at)
77          VALUES (?, ?, ?, ?, ?, ?, ?, ?)
78          ON CONFLICT(id) DO UPDATE SET
79            address_pattern = excluded.address_pattern,
80            bounce_kind     = excluded.bounce_kind,
81            smtp_code       = excluded.smtp_code,
82            smtp_message    = excluded.smtp_message,
83            enabled         = excluded.enabled",
84    )
85    .bind(&rule.id)
86    .bind(&rule.mailbox_id)
87    .bind(&rule.address_pattern)
88    .bind(rule.bounce_kind.as_str())
89    .bind(i64::from(rule.smtp_code))
90    .bind(&rule.smtp_message)
91    .bind(i64::from(rule.enabled))
92    .bind(rule.created_at)
93    .execute(pool)
94    .await?;
95
96    Ok(rule)
97}
98
99pub(crate) async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
100    let res = sqlx::query("DELETE FROM bounce_rules WHERE id = ?")
101        .bind(id)
102        .execute(pool)
103        .await?;
104    if res.rows_affected() == 0 {
105        return Err(Error::BounceRuleNotFound(id.to_string()));
106    }
107    Ok(())
108}
109
110fn row_to_rule(row: &sqlx::sqlite::SqliteRow) -> BounceRule {
111    let kind_str: String = row.try_get("bounce_kind").unwrap_or_else(|_| "hard".into());
112    BounceRule {
113        id: row.try_get("id").unwrap_or_default(),
114        mailbox_id: row.try_get("mailbox_id").unwrap_or_default(),
115        address_pattern: row.try_get("address_pattern").unwrap_or_default(),
116        bounce_kind: BounceKind::from_str(&kind_str),
117        smtp_code: (row.try_get::<i64, _>("smtp_code").unwrap_or(550)) as u16,
118        smtp_message: row.try_get("smtp_message").unwrap_or_default(),
119        enabled: row.try_get::<i64, _>("enabled").unwrap_or(1) != 0,
120        created_at: row.try_get("created_at").unwrap_or(0),
121    }
122}