Skip to main content

postcrate_core/
matcher.rs

1//! Email predicates + structured matching.
2//!
3//! Powers the [`Service::wait_for_email`] and
4//! [`Service::assert_email_matches`] entry points, and the HTTP
5//! `/messages/wait` and `/messages/:id/assert` routes built on them.
6//!
7//! One type, two uses: cheap `matches_summary` for live-stream
8//! filtering against the lightweight `EmailSummary`, and full `check`
9//! returning a structured mismatch report against a parsed
10//! `EmailDetail`.
11//!
12//! [`Service::wait_for_email`]: crate::Service::wait_for_email
13//! [`Service::assert_email_matches`]: crate::Service::assert_email_matches
14
15use 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    /// Restrict to a single mailbox.
25    pub mailbox_id: Option<String>,
26    /// Sender substring (case-insensitive).
27    pub from: Option<String>,
28    /// Sender regex.
29    pub from_regex: Option<String>,
30    /// Any recipient contains this substring (case-insensitive).
31    pub to: Option<String>,
32    /// Subject substring (case-insensitive).
33    pub subject: Option<String>,
34    /// Subject regex.
35    pub subject_regex: Option<String>,
36    /// Plain-text body substring.
37    pub body_contains: Option<String>,
38    /// Plain-text body regex.
39    pub body_regex: Option<String>,
40    /// `Some(true)` requires at least one attachment; `Some(false)`
41    /// requires zero. `None` means "don't care".
42    pub has_attachment: Option<bool>,
43    /// Per-header predicates (any combination of substring/regex).
44    #[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    /// Match the header value via case-insensitive substring.
54    pub contains: Option<String>,
55    /// Match the header value via regex.
56    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    /// True if the predicate could possibly match — used for cheap
75    /// filtering against an `EmailSummary` before paying the cost of
76    /// fetching the full detail.
77    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        // Regex/body/header checks defer to `check()` against the detail.
102        true
103    }
104
105    /// Full check against a parsed detail. Populates `mismatches`
106    /// with one human-readable line per failed clause; an empty
107    /// `mismatches` means the predicate matched.
108    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        // Headers. Each header predicate looks up the value in
174        // `d.headers` (whose shape is `{ "Subject": "...", ... }` —
175        // see mail::headers::headers_to_json) and runs its checks.
176        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    /// The first email that satisfied the predicate, or `None` if we
223    /// timed out without a match.
224    pub matched: Option<EmailDetail>,
225    /// Every captured email observed during the wait window (whether
226    /// or not it matched). Lets agents diagnose "code didn't try" vs
227    /// "code addressed it wrong".
228    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}