qualia_client_core/
mail_rules.rs1use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct InboundMessage {
19 pub from_address: String,
21 pub to_address: String,
23 pub sender_did: Option<String>,
25 pub sender_verified: bool,
27 pub subject: String,
29 pub size_bytes: usize,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct MailVerdict {
36 pub deliver: bool,
38 pub quarantined: bool,
40 pub rejected: Option<String>,
42 pub priority: i8,
44 pub notify: bool,
46 pub reasons: Vec<String>,
48}
49
50pub 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 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 }
96 }
97 }
98
99 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 let quarantined = force_quarantine;
114 if quarantined {
115 reasons.push("quarantined by rule".to_string());
116 }
117
118 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
133pub 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}