1use regex::Regex;
16use serde::{Deserialize, Serialize};
17
18use crate::db::emails::{EmailDetail, EmailSummary};
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[cfg_attr(feature = "specta", derive(specta::Type))]
22#[serde(rename_all = "camelCase")]
23pub struct EmailPredicate {
24 pub mailbox_id: Option<String>,
26 pub from: Option<String>,
28 pub from_regex: Option<String>,
30 pub to: Option<String>,
32 pub subject: Option<String>,
34 pub subject_regex: Option<String>,
36 pub body_contains: Option<String>,
38 pub body_regex: Option<String>,
40 pub has_attachment: Option<bool>,
43 #[serde(default)]
45 pub headers: Vec<HeaderPredicate>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[cfg_attr(feature = "specta", derive(specta::Type))]
50#[serde(rename_all = "camelCase")]
51pub struct HeaderPredicate {
52 pub name: String,
53 pub contains: Option<String>,
55 pub regex: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize)]
60#[cfg_attr(feature = "specta", derive(specta::Type))]
61#[serde(rename_all = "camelCase")]
62pub struct MatchResult {
63 pub matched: bool,
64 pub mismatches: Vec<String>,
65}
66
67impl MatchResult {
68 pub fn ok() -> Self {
69 Self { matched: true, mismatches: Vec::new() }
70 }
71}
72
73impl EmailPredicate {
74 pub fn matches_summary(&self, s: &EmailSummary) -> bool {
78 if let Some(mb) = &self.mailbox_id {
79 if s.mailbox_id != *mb {
80 return false;
81 }
82 }
83 if let Some(needle) = &self.from {
84 if !s.from.to_lowercase().contains(&needle.to_lowercase()) {
85 return false;
86 }
87 }
88 if let Some(needle) = &self.to {
89 let nl = needle.to_lowercase();
90 if !s.to.iter().any(|r| r.to_lowercase().contains(&nl)) {
91 return false;
92 }
93 }
94 if let Some(needle) = &self.subject {
95 let nl = needle.to_lowercase();
96 let got = s.subject.as_deref().unwrap_or("").to_lowercase();
97 if !got.contains(&nl) {
98 return false;
99 }
100 }
101 true
103 }
104
105 pub fn check(&self, d: &EmailDetail) -> MatchResult {
109 let mut out = MatchResult::ok();
110
111 if let Some(mb) = &self.mailbox_id {
112 if d.mailbox_id != *mb {
113 out.matched = false;
114 out.mismatches
115 .push(format!("mailboxId: expected {mb:?}, got {:?}", d.mailbox_id));
116 }
117 }
118
119 if let Some(needle) = &self.from {
120 if !d.from.to_lowercase().contains(&needle.to_lowercase()) {
121 out.matched = false;
122 out.mismatches
123 .push(format!("from: expected to contain {needle:?}, got {:?}", d.from));
124 }
125 }
126 if let Some(pat) = &self.from_regex {
127 check_regex(&mut out, "from", pat, &d.from);
128 }
129
130 if let Some(needle) = &self.to {
131 let nl = needle.to_lowercase();
132 if !d.to.iter().any(|r| r.to_lowercase().contains(&nl)) {
133 out.matched = false;
134 out.mismatches
135 .push(format!("to: expected one of {:?} to contain {needle:?}", d.to));
136 }
137 }
138
139 let subject = d.subject.as_deref().unwrap_or("");
140 if let Some(needle) = &self.subject {
141 if !subject.to_lowercase().contains(&needle.to_lowercase()) {
142 out.matched = false;
143 out.mismatches
144 .push(format!("subject: expected to contain {needle:?}, got {subject:?}"));
145 }
146 }
147 if let Some(pat) = &self.subject_regex {
148 check_regex(&mut out, "subject", pat, subject);
149 }
150
151 let text_body = d.text_body.as_deref().unwrap_or("");
152 if let Some(needle) = &self.body_contains {
153 if !text_body.contains(needle) {
154 out.matched = false;
155 out.mismatches
156 .push(format!("bodyContains: {needle:?} not found in textBody"));
157 }
158 }
159 if let Some(pat) = &self.body_regex {
160 check_regex(&mut out, "bodyRegex", pat, text_body);
161 }
162
163 if let Some(want_some) = self.has_attachment {
164 let got_some = !d.attachments.is_empty();
165 if got_some != want_some {
166 out.matched = false;
167 out.mismatches.push(format!(
168 "hasAttachment: expected {want_some}, got {got_some}"
169 ));
170 }
171 }
172
173 for hp in &self.headers {
177 let value = d
178 .headers
179 .get(&hp.name)
180 .and_then(|v| v.as_str())
181 .unwrap_or("");
182 if let Some(needle) = &hp.contains {
183 if !value.to_lowercase().contains(&needle.to_lowercase()) {
184 out.matched = false;
185 out.mismatches.push(format!(
186 "header {:?}: expected to contain {:?}, got {:?}",
187 hp.name, needle, value
188 ));
189 }
190 }
191 if let Some(pat) = &hp.regex {
192 check_regex(&mut out, &format!("header {:?}", hp.name), pat, value);
193 }
194 }
195
196 out
197 }
198}
199
200fn check_regex(out: &mut MatchResult, field: &str, pat: &str, haystack: &str) {
201 match Regex::new(pat) {
202 Ok(re) => {
203 if !re.is_match(haystack) {
204 out.matched = false;
205 out.mismatches.push(format!(
206 "{field}: regex {pat:?} did not match {haystack:?}"
207 ));
208 }
209 }
210 Err(e) => {
211 out.matched = false;
212 out.mismatches
213 .push(format!("{field}: invalid regex {pat:?}: {e}"));
214 }
215 }
216}
217
218#[derive(Debug, Clone, Serialize)]
219#[cfg_attr(feature = "specta", derive(specta::Type))]
220#[serde(rename_all = "camelCase")]
221pub struct WaitOutcome {
222 pub matched: Option<EmailDetail>,
225 pub seen_during_wait: Vec<EmailSummary>,
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use serde_json::json;
235
236 fn summary(id: &str, from: &str, to: &[&str], subject: &str) -> EmailSummary {
237 EmailSummary {
238 id: id.into(),
239 mailbox_id: "mb".into(),
240 received_at: 0,
241 from: from.into(),
242 to: to.iter().map(|s| s.to_string()).collect(),
243 subject: Some(subject.into()),
244 has_html: false,
245 has_text: true,
246 size_bytes: 0,
247 read: false,
248 pinned: false,
249 starred: false,
250 tag: None,
251 }
252 }
253
254 fn detail(s: &EmailSummary, body: &str) -> EmailDetail {
255 EmailDetail {
256 id: s.id.clone(),
257 mailbox_id: s.mailbox_id.clone(),
258 received_at: s.received_at,
259 from: s.from.clone(),
260 to: s.to.clone(),
261 subject: s.subject.clone(),
262 has_html: s.has_html,
263 has_text: s.has_text,
264 size_bytes: s.size_bytes,
265 read: s.read,
266 pinned: false,
267 starred: false,
268 note: None,
269 tag: None,
270 headers: json!({"X-Mailer": "Postcrate-test"}),
271 text_body: Some(body.into()),
272 html_body: None,
273 attachments: Vec::new(),
274 message_id: None,
275 in_reply_to: None,
276 ext_smtputf8: false,
277 ext_8bitmime: false,
278 }
279 }
280
281 #[test]
282 fn summary_filter_case_insensitive() {
283 let s = summary("1", "Alice@Example.com", &["Bob@example.com"], "Welcome!");
284 let mut p = EmailPredicate::default();
285 p.from = Some("alice".into());
286 assert!(p.matches_summary(&s));
287 p.to = Some("BOB".into());
288 assert!(p.matches_summary(&s));
289 p.subject = Some("welc".into());
290 assert!(p.matches_summary(&s));
291 }
292
293 #[test]
294 fn detail_check_reports_each_mismatch() {
295 let s = summary("1", "a@b", &["c@d"], "Order shipped");
296 let d = detail(&s, "Your package is en route.");
297 let p = EmailPredicate {
298 from: Some("nobody".into()),
299 subject_regex: Some(r"^Refund".into()),
300 body_contains: Some("expired".into()),
301 has_attachment: Some(true),
302 ..Default::default()
303 };
304 let r = p.check(&d);
305 assert!(!r.matched);
306 assert_eq!(r.mismatches.len(), 4, "got {:?}", r.mismatches);
307 }
308
309 #[test]
310 fn detail_check_passes() {
311 let s = summary("1", "alerts@bank.example", &["user@example.com"], "Password Reset");
312 let d = detail(&s, "Click here to reset: https://bank.example/reset?t=abc");
313 let p = EmailPredicate {
314 from: Some("bank.example".into()),
315 subject_regex: Some(r"(?i)password\s+reset".into()),
316 body_regex: Some(r"https://\S+/reset\?t=\w+".into()),
317 ..Default::default()
318 };
319 assert!(p.check(&d).matched, "{:?}", p.check(&d).mismatches);
320 }
321
322 #[test]
323 fn header_predicate() {
324 let s = summary("1", "a@b", &["c@d"], "hi");
325 let d = detail(&s, "body");
326 let mut p = EmailPredicate::default();
327 p.headers.push(HeaderPredicate {
328 name: "X-Mailer".into(),
329 contains: Some("postcrate".into()),
330 regex: None,
331 });
332 assert!(p.check(&d).matched);
333 }
334}