Skip to main content

qualia_client_core/
mail_rules.rs

1//! Mail rules engine — evaluates a mailbox's [`MailRules`](crate::domains::MailRules)
2//! against an inbound message.
3//!
4//! This is a **pure** decision layer: no filesystem, no network, no clock. Given the rules
5//! configured on an address (a rule-bearing mailbox) and an [`InboundMessage`], it returns a
6//! [`MailVerdict`] describing whether the message is delivered, quarantined, or rejected, plus the
7//! priority/notify hints and a human-readable trail of `reasons` for auditability.
8//!
9//! The rules themselves live on [`crate::domains::MailRules`]; this module only interprets them.
10
11use serde::{Deserialize, Serialize};
12
13/// An inbound message presented to the rules engine for a delivery decision.
14///
15/// This is the minimal envelope the engine needs — it does not carry the message body; delivery
16/// decisions here are made from addressing, sender verification state, subject and size alone.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct InboundMessage {
19    /// The sender's address (e.g. `alice@example.org`).
20    pub from_address: String,
21    /// The recipient mailbox address this message was delivered to.
22    pub to_address: String,
23    /// The sender's DID, if one was presented.
24    pub sender_did: Option<String>,
25    /// Whether the sender's identity was verified (DID-signed / established relationship).
26    pub sender_verified: bool,
27    /// The message subject line.
28    pub subject: String,
29    /// The message size in bytes.
30    pub size_bytes: usize,
31}
32
33/// The outcome of evaluating [`MailRules`](crate::domains::MailRules) against an [`InboundMessage`].
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct MailVerdict {
36    /// Whether the message is delivered at all. `false` when rejected.
37    pub deliver: bool,
38    /// Whether the message, though delivered, is routed to quarantine rather than the inbox.
39    pub quarantined: bool,
40    /// If the message was rejected, a short human-readable reason; `None` when delivered.
41    pub rejected: Option<String>,
42    /// Priority hint propagated from the rules (0 = normal; higher = more important).
43    pub priority: i8,
44    /// Whether the recipient should be notified on receipt.
45    pub notify: bool,
46    /// A human-readable trail of the rules that fired, for auditability.
47    pub reasons: Vec<String>,
48}
49
50/// Evaluate `rules` against `msg` and produce a [`MailVerdict`].
51///
52/// Decision order:
53/// 1. If the rules require a verified sender and the sender is not verified, the message is
54///    **rejected** (not delivered) and the function returns early.
55/// 2. Otherwise the message is **delivered**. If the rules quarantine incoming mail, it is
56///    delivered to quarantine rather than the inbox.
57/// 3. The `priority` and `notify` hints are carried through from the rules, with reasons recorded
58///    for a non-zero priority and for quarantine.
59/// 4. Optional **semantic_route** (agreement / credential / values id, or a small DSL):
60///    - `quarantine` — force quarantine
61///    - `require_verified` — same as require_verified_sender for this evaluation
62///    - `priority:N` — override priority
63///    - anything else — recorded as audit trail (hook for rights/agreement engines)
64pub fn evaluate(rules: &crate::domains::MailRules, msg: &InboundMessage) -> MailVerdict {
65    let mut reasons: Vec<String> = Vec::new();
66    let mut require_verified = rules.require_verified_sender;
67    let mut force_quarantine = rules.quarantine;
68    let mut priority = rules.priority;
69    let mut notify = rules.notify;
70
71    // (0) Semantic route DSL / agreement reference.
72    if let Some(ref route) = rules.semantic_route {
73        let r = route.trim();
74        if !r.is_empty() {
75            reasons.push(format!("semantic_route: {r}"));
76            for token in r.split(|c: char| c == ',' || c == ';' || c.is_whitespace()) {
77                let t = token.trim().to_ascii_lowercase();
78                if t.is_empty() {
79                    continue;
80                }
81                if t == "quarantine" {
82                    force_quarantine = true;
83                } else if t == "require_verified" || t == "verified_only" {
84                    require_verified = true;
85                } else if let Some(rest) = t.strip_prefix("priority:") {
86                    if let Ok(p) = rest.parse::<i8>() {
87                        priority = p;
88                    }
89                } else if t == "notify" {
90                    notify = true;
91                } else if t == "silent" {
92                    notify = false;
93                }
94                // Other tokens (agreement ids, values-credentials) stay as audit trail only.
95            }
96        }
97    }
98
99    // (1) Verified-sender gate — fail closed, no delivery.
100    if require_verified && !msg.sender_verified {
101        reasons.push("rejected: unverified sender".to_string());
102        return MailVerdict {
103            deliver: false,
104            quarantined: false,
105            rejected: Some("unverified sender".to_string()),
106            priority,
107            notify,
108            reasons,
109        };
110    }
111
112    // (2) Delivered. Quarantine still delivers, but to the quarantine store.
113    let quarantined = force_quarantine;
114    if quarantined {
115        reasons.push("quarantined by rule".to_string());
116    }
117
118    // (3) Priority / notify hints.
119    if priority > 0 {
120        reasons.push(format!("priority set to {priority}"));
121    }
122
123    MailVerdict {
124        deliver: true,
125        quarantined,
126        rejected: None,
127        priority,
128        notify,
129        reasons,
130    }
131}
132
133/// Compute the retention cutoff (unix seconds) for a message received at `received_unix`.
134///
135/// Returns `Some(received_unix + retention_days * 86400)` when a finite retention is configured,
136/// or `None` when `retention_days == 0` (keep indefinitely).
137pub fn retention_cutoff_unix(rules: &crate::domains::MailRules, received_unix: u64) -> Option<u64> {
138    if rules.retention_days > 0 {
139        Some(received_unix + rules.retention_days as u64 * 86_400)
140    } else {
141        None
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::domains::MailRules;
149
150    fn msg(sender_verified: bool) -> InboundMessage {
151        InboundMessage {
152            from_address: "alice@example.org".to_string(),
153            to_address: "junkmail@me.example".to_string(),
154            sender_did: Some("did:example:alice".to_string()),
155            sender_verified,
156            subject: "hello".to_string(),
157            size_bytes: 1024,
158        }
159    }
160
161    #[test]
162    fn unverified_sender_is_rejected() {
163        let rules = MailRules {
164            require_verified_sender: true,
165            ..Default::default()
166        };
167        let verdict = evaluate(&rules, &msg(false));
168        assert!(!verdict.deliver, "unverified sender must not be delivered");
169        assert!(!verdict.quarantined);
170        assert_eq!(verdict.rejected.as_deref(), Some("unverified sender"));
171        assert!(verdict
172            .reasons
173            .iter()
174            .any(|r| r.contains("unverified sender")));
175    }
176
177    #[test]
178    fn verified_sender_passes_the_gate() {
179        let rules = MailRules {
180            require_verified_sender: true,
181            ..Default::default()
182        };
183        let verdict = evaluate(&rules, &msg(true));
184        assert!(verdict.deliver);
185        assert_eq!(verdict.rejected, None);
186    }
187
188    #[test]
189    fn quarantine_delivers_to_quarantine() {
190        let rules = MailRules {
191            quarantine: true,
192            ..Default::default()
193        };
194        let verdict = evaluate(&rules, &msg(true));
195        assert!(verdict.deliver, "quarantined mail is still delivered");
196        assert!(verdict.quarantined);
197        assert_eq!(verdict.rejected, None);
198        assert!(verdict.reasons.iter().any(|r| r == "quarantined by rule"));
199    }
200
201    #[test]
202    fn priority_passes_through() {
203        let rules = MailRules {
204            priority: 7,
205            notify: true,
206            ..Default::default()
207        };
208        let verdict = evaluate(&rules, &msg(true));
209        assert_eq!(verdict.priority, 7);
210        assert!(verdict.notify);
211        assert!(verdict
212            .reasons
213            .iter()
214            .any(|r| r.contains("priority set to 7")));
215    }
216
217    #[test]
218    fn retention_cutoff_some_and_none() {
219        let received: u64 = 1_000_000;
220
221        let keep_forever = MailRules {
222            retention_days: 0,
223            ..Default::default()
224        };
225        assert_eq!(retention_cutoff_unix(&keep_forever, received), None);
226
227        let thirty_days = MailRules {
228            retention_days: 30,
229            ..Default::default()
230        };
231        assert_eq!(
232            retention_cutoff_unix(&thirty_days, received),
233            Some(received + 30 * 86_400)
234        );
235    }
236
237    #[test]
238    fn semantic_route_quarantine_and_priority() {
239        let rules = MailRules {
240            semantic_route: Some("quarantine priority:9 notify".into()),
241            ..Default::default()
242        };
243        let verdict = evaluate(&rules, &msg(true));
244        assert!(verdict.deliver);
245        assert!(verdict.quarantined);
246        assert_eq!(verdict.priority, 9);
247        assert!(verdict.notify);
248        assert!(verdict
249            .reasons
250            .iter()
251            .any(|r| r.starts_with("semantic_route:")));
252    }
253
254    #[test]
255    fn semantic_route_require_verified() {
256        let rules = MailRules {
257            semantic_route: Some("require_verified".into()),
258            ..Default::default()
259        };
260        let v = evaluate(&rules, &msg(false));
261        assert!(!v.deliver);
262    }
263}