Skip to main content

rmut_core/
pattern.rs

1//! Mutt-style search/limit patterns. Terms: `~f` from, `~s` subject,
2//! `~b` body, `~t` to, `~c` cc, `~C` to-or-cc, `~e` sender, `~h` any
3//! header, `~i` Message-ID, `~x` References, `~d` date, `~r` received
4//! date, `~m` index range, `~z` size range, `~=` duplicate,
5//! `~N` new, `~F` flagged, `~D` deleted, `~U` unread, `~T` tagged,
6//! `~l` addressed to a known mailing list,
7//! `~p` addressed to me, `~P` sent by me, `~A` every message;
8//! a bare word matches subject or from (mutt's
9//! $simple_search). Adjacent terms AND, `|` ORs, `!` negates, `()`
10//! groups; string arguments are case-insensitive regexes (quote them
11//! to include spaces), `~d`/`~r` take `DD/MM/YYYY` ranges or `<`/`>`/`=`
12//! offsets like `<1w`.
13
14use chrono::{Datelike, Local, TimeZone};
15
16use crate::message::{self, Envelope};
17
18/// A string argument: tried as a case-insensitive regex; an invalid
19/// regex degrades to a case-insensitive substring match.
20#[derive(Debug, Clone)]
21pub struct Matcher {
22    raw: String,
23    re: Option<regex_lite::Regex>,
24}
25
26impl Matcher {
27    pub fn new(raw: &str) -> Matcher {
28        Matcher {
29            raw: raw.to_string(),
30            re: regex_lite::Regex::new(&format!("(?i){raw}")).ok(),
31        }
32    }
33
34    /// The pattern text as written (for server-side IMAP search).
35    pub fn raw(&self) -> &str {
36        &self.raw
37    }
38
39    pub fn is_match(&self, text: &str) -> bool {
40        match &self.re {
41            Some(re) => re.is_match(text),
42            None => text.to_lowercase().contains(&self.raw.to_lowercase()),
43        }
44    }
45
46    /// Byte ranges of every match in `text`, for highlighters. The
47    /// substring fallback compares ASCII-case-insensitively (which
48    /// keeps offsets valid, unlike full lowercasing).
49    pub fn find_ranges(&self, text: &str) -> Vec<(usize, usize)> {
50        if let Some(re) = &self.re {
51            return re.find_iter(text).map(|m| (m.start(), m.end())).collect();
52        }
53        let hay = text.to_ascii_lowercase();
54        let needle = self.raw.to_ascii_lowercase();
55        if needle.is_empty() {
56            return Vec::new();
57        }
58        let mut out = Vec::new();
59        let mut from = 0;
60        while let Some(pos) = hay[from..].find(&needle) {
61            let start = from + pos;
62            out.push((start, start + needle.len()));
63            from = start + needle.len();
64        }
65        out
66    }
67}
68
69impl PartialEq for Matcher {
70    fn eq(&self, other: &Self) -> bool {
71        self.raw == other.raw
72    }
73}
74impl Eq for Matcher {}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Pattern {
78    All(Vec<Pattern>),
79    Any(Vec<Pattern>),
80    Not(Box<Pattern>),
81    From(Matcher),
82    Subject(Matcher),
83    Body(Matcher),
84    To(Matcher),
85    Cc(Matcher),
86    /// `~C`: any To or Cc address.
87    Recipient(Matcher),
88    /// `~e`: the Sender header (read from disk on demand).
89    Sender(Matcher),
90    /// `~h`: any header line, as `Name: value` text (read from disk).
91    Header(Matcher),
92    /// `~i`: the Message-ID.
93    MessageId(Matcher),
94    /// `~x`: any References / In-Reply-To id.
95    References(Matcher),
96    /// `~B`: any header or the body (the whole message text).
97    Whole(Matcher),
98    /// `~y`: the X-Label header.
99    Label(Matcher),
100    /// `~L`: an address in From, To or Cc.
101    FromOrTo(Matcher),
102    /// `~R`: read (seen).
103    Read,
104    /// `~O`: old, meaning unread but not new this session.
105    Old,
106    /// `~Q`: replied to (the Answered flag).
107    Replied,
108    /// `~u`: addressed to a subscribed mailing list.
109    Subscribed,
110    /// `~(P)`: some message in the same thread matches P.
111    Thread(Box<Pattern>),
112    /// `~<(P)`: the immediate parent matches P.
113    Parent(Box<Pattern>),
114    /// `~>(P)`: an immediate child matches P.
115    Child(Box<Pattern>),
116    /// `~v`: the message heads a collapsed thread.
117    Collapsed,
118    /// `~$`: no parent and no children, in a threaded index.
119    Unreferenced,
120    /// `~d`: epoch-second bounds, min inclusive, max exclusive.
121    Date {
122        min: Option<i64>,
123        max: Option<i64>,
124    },
125    /// `~r`: the same bounds against the file's delivery time.
126    Received {
127        min: Option<i64>,
128        max: Option<i64>,
129    },
130    /// `~m`: index-number range, inclusive, over the numbering on
131    /// screen; `.` is the selected message and `$` the last one.
132    Number {
133        min: Option<Bound>,
134        max: Option<Bound>,
135    },
136    /// `~z`: size in bytes, min inclusive, max inclusive.
137    Size {
138        min: Option<u64>,
139        max: Option<u64>,
140    },
141    /// `~=`: the Message-ID occurs more than once in the mailbox.
142    Duplicate,
143    New,
144    Flagged,
145    Deleted,
146    Unread,
147    Tagged,
148    /// `~p`: addressed to one of my addresses.
149    ToMe,
150    /// `~P`: sent by me (From is one of my addresses).
151    FromMe,
152    /// `~l`: addressed to a known mailing list.
153    ToList,
154    /// Bare word: matches subject or from.
155    Default(Matcher),
156}
157
158/// One end of a `~m` range: a literal number, or mutt's `.` (the
159/// selected message) and `$` (the last), resolved at match time.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum Bound {
162    Num(usize),
163    Current,
164    Last,
165}
166
167impl Bound {
168    fn resolve(self, pos: &Position) -> usize {
169        match self {
170            Bound::Num(n) => n,
171            Bound::Current => pos.current,
172            Bound::Last => pos.last,
173        }
174    }
175}
176
177/// Which addresses count as mine: the exact ones the identity layers
178/// name (bare, lowercase) plus mutt's `alternates`, regexes over the
179/// bare address. Everything that asks "is this me?" goes through here:
180/// `~p` and `~P`, the `+`/`T`/`C`/`F` index marks, reverse_name, the
181/// group-reply dedup, and Mail-Followup-To.
182#[derive(Default, Clone, Copy)]
183pub struct Me<'a> {
184    /// My bare lowercase addresses.
185    pub addresses: &'a [String],
186    /// mutt's `alternates`: regexes for my other addresses.
187    pub alternates: &'a [Matcher],
188}
189
190impl<'a> Me<'a> {
191    pub fn new(addresses: &'a [String], alternates: &'a [Matcher]) -> Me<'a> {
192        Me {
193            addresses,
194            alternates,
195        }
196    }
197
198    /// Only the exact addresses, no alternates (tests, and callers
199    /// that have no config in reach).
200    pub fn addresses(addresses: &'a [String]) -> Me<'a> {
201        Me {
202            addresses,
203            alternates: &[],
204        }
205    }
206
207    pub fn is_me(&self, addr: &str) -> bool {
208        let bare = addr.trim().to_lowercase();
209        self.addresses.contains(&bare) || self.alternates.iter().any(|m| m.is_match(&bare))
210    }
211
212    /// True when any of these bare addresses is mine.
213    pub fn any<'b>(&self, addresses: impl IntoIterator<Item = &'b String>) -> bool {
214        addresses.into_iter().any(|a| self.is_me(a))
215    }
216
217    /// True when the message's From names one of my addresses (`~P`).
218    pub fn wrote(&self, from_header: &str) -> bool {
219        crate::compose::addresses(from_header)
220            .iter()
221            .any(|a| self.is_me(a))
222    }
223
224    pub fn is_empty(&self) -> bool {
225        self.addresses.is_empty() && self.alternates.is_empty()
226    }
227}
228
229/// Everything matching needs besides the message: my own addresses
230/// (`~p`, `~P`), the known mailing lists (`~l`), and the message's
231/// place in the list (`~m`, `~=`).
232#[derive(Default, Clone, Copy)]
233pub struct Scope<'a> {
234    pub me: Me<'a>,
235    /// Address patterns naming mailing lists, subscribed or not.
236    pub lists: &'a [Matcher],
237    /// The subscribed subset, for `~u`.
238    pub subscribed: &'a [Matcher],
239    pub position: Position,
240    /// The message's place in its thread, for `~(`, `~<`, `~>`, `~v`
241    /// and `~$`. None outside a threaded index, where `~(P)` means P
242    /// of the message itself and the rest are false.
243    pub thread: Option<ThreadView<'a>>,
244}
245
246/// One message's neighbours in the thread, by index into `envs`.
247#[derive(Clone, Copy)]
248pub struct ThreadView<'a> {
249    pub members: &'a [usize],
250    pub parent: Option<usize>,
251    pub children: &'a [usize],
252    pub collapsed: bool,
253    pub envs: &'a dyn EnvSource,
254}
255
256/// Where a thread term finds the other messages: a session's list,
257/// or a plain slice in a test.
258pub trait EnvSource {
259    fn envelope(&self, i: usize) -> Option<&Envelope>;
260}
261
262impl EnvSource for [Envelope] {
263    fn envelope(&self, i: usize) -> Option<&Envelope> {
264        self.get(i)
265    }
266}
267
268impl EnvSource for Vec<Envelope> {
269    fn envelope(&self, i: usize) -> Option<&Envelope> {
270        self.get(i)
271    }
272}
273
274impl<'a> Scope<'a> {
275    /// The common case: my addresses, nothing else known.
276    pub fn me(me: &'a [String]) -> Scope<'a> {
277        Scope {
278            me: Me::addresses(me),
279            ..Default::default()
280        }
281    }
282
283    /// True when any of these addresses names a known list.
284    pub fn any_list(&self, addresses: &[String]) -> bool {
285        addresses
286            .iter()
287            .any(|a| self.lists.iter().any(|m| m.is_match(a)))
288    }
289}
290
291/// What a message needs from the list around it: its index number for
292/// `~m` and whether its Message-ID repeats for `~=`. Matching without
293/// a list (a single color rule, say) leaves this at its default, where
294/// both terms are false.
295#[derive(Debug, Clone, Copy, Default)]
296pub struct Position {
297    /// 1-based number as shown in the index; 0 when not on screen.
298    pub number: usize,
299    /// The selected message's number, for `.`.
300    pub current: usize,
301    /// The last message's number, for `$`.
302    pub last: usize,
303    pub duplicate: bool,
304}
305
306// ---- parsing ----
307
308enum Tok {
309    Not,
310    Or,
311    LParen,
312    RParen,
313    Op(char),
314    Word(String),
315}
316
317fn lex(input: &str) -> Vec<Tok> {
318    let mut out = Vec::new();
319    let mut chars = input.chars().peekable();
320    while let Some(&c) = chars.peek() {
321        match c {
322            c if c.is_whitespace() => {
323                chars.next();
324            }
325            '!' => {
326                chars.next();
327                out.push(Tok::Not);
328            }
329            '|' => {
330                chars.next();
331                out.push(Tok::Or);
332            }
333            '(' => {
334                chars.next();
335                out.push(Tok::LParen);
336            }
337            ')' => {
338                chars.next();
339                out.push(Tok::RParen);
340            }
341            '~' => {
342                chars.next();
343                if let Some(op) = chars.next() {
344                    out.push(Tok::Op(op));
345                    // `~(`: the paren is the operator's own; the
346                    // group it opens is parsed as a term of its own.
347                    if op == '(' {
348                        out.push(Tok::LParen);
349                    }
350                }
351            }
352            '"' | '\'' => {
353                let quote = c;
354                chars.next();
355                let mut word = String::new();
356                for c in chars.by_ref() {
357                    if c == quote {
358                        break;
359                    }
360                    word.push(c);
361                }
362                out.push(Tok::Word(word));
363            }
364            _ => {
365                let mut word = String::new();
366                while let Some(&c) = chars.peek() {
367                    if c.is_whitespace() || matches!(c, '(' | ')' | '|') {
368                        break;
369                    }
370                    word.push(c);
371                    chars.next();
372                }
373                out.push(Tok::Word(word));
374            }
375        }
376    }
377    out
378}
379
380/// Top-level terms (implicitly ANDed, like mutt).
381pub fn parse(input: &str) -> Result<Vec<Pattern>, String> {
382    parse_at(input, Local::now().timestamp())
383}
384
385/// `now` anchors relative `~d` offsets (injectable for tests).
386fn parse_at(input: &str, now: i64) -> Result<Vec<Pattern>, String> {
387    let mut toks = lex(input).into_iter().peekable();
388    let top = parse_or(&mut toks, now)?;
389    if toks.next().is_some() {
390        return Err("unbalanced )".into());
391    }
392    Ok(match top {
393        Pattern::All(terms) => terms,
394        one => vec![one],
395    })
396}
397
398type Toks = std::iter::Peekable<std::vec::IntoIter<Tok>>;
399
400fn parse_or(toks: &mut Toks, now: i64) -> Result<Pattern, String> {
401    let mut terms = vec![parse_and(toks, now)?];
402    while matches!(toks.peek(), Some(Tok::Or)) {
403        toks.next();
404        terms.push(parse_and(toks, now)?);
405    }
406    Ok(if terms.len() == 1 {
407        terms.pop().expect("one term")
408    } else {
409        Pattern::Any(terms)
410    })
411}
412
413fn parse_and(toks: &mut Toks, now: i64) -> Result<Pattern, String> {
414    let mut terms = Vec::new();
415    while !matches!(toks.peek(), None | Some(Tok::Or) | Some(Tok::RParen)) {
416        terms.push(parse_unary(toks, now)?);
417    }
418    Ok(if terms.len() == 1 {
419        terms.pop().expect("one term")
420    } else {
421        Pattern::All(terms)
422    })
423}
424
425fn parse_unary(toks: &mut Toks, now: i64) -> Result<Pattern, String> {
426    match toks.next() {
427        Some(Tok::Not) => Ok(Pattern::Not(Box::new(parse_unary(toks, now)?))),
428        Some(Tok::LParen) => {
429            let inner = parse_or(toks, now)?;
430            match toks.next() {
431                Some(Tok::RParen) => Ok(inner),
432                _ => Err("missing )".into()),
433            }
434        }
435        Some(Tok::Op(op @ ('(' | '<' | '>'))) => {
436            match toks.next() {
437                Some(Tok::LParen) => {}
438                _ => return Err(format!("~{op} needs a (pattern)")),
439            }
440            let inner = Box::new(parse_or(toks, now)?);
441            match toks.next() {
442                Some(Tok::RParen) => {}
443                _ => return Err("missing )".into()),
444            }
445            Ok(match op {
446                '(' => Pattern::Thread(inner),
447                '<' => Pattern::Parent(inner),
448                _ => Pattern::Child(inner),
449            })
450        }
451        Some(Tok::Op(op)) => {
452            let mut arg = || match toks.next() {
453                Some(Tok::Word(w)) => Ok(w),
454                _ => Err(format!("~{op} needs an argument")),
455            };
456            Ok(match op {
457                'f' => Pattern::From(Matcher::new(&arg()?)),
458                's' => Pattern::Subject(Matcher::new(&arg()?)),
459                'b' => Pattern::Body(Matcher::new(&arg()?)),
460                't' => Pattern::To(Matcher::new(&arg()?)),
461                'c' => Pattern::Cc(Matcher::new(&arg()?)),
462                'C' => Pattern::Recipient(Matcher::new(&arg()?)),
463                'e' => Pattern::Sender(Matcher::new(&arg()?)),
464                'h' => Pattern::Header(Matcher::new(&arg()?)),
465                'i' => Pattern::MessageId(Matcher::new(&arg()?)),
466                'x' => Pattern::References(Matcher::new(&arg()?)),
467                'B' => Pattern::Whole(Matcher::new(&arg()?)),
468                'y' => Pattern::Label(Matcher::new(&arg()?)),
469                'L' => Pattern::FromOrTo(Matcher::new(&arg()?)),
470                'd' => date_term(&arg()?, now)?,
471                'r' => match date_term(&arg()?, now)? {
472                    Pattern::Date { min, max } => Pattern::Received { min, max },
473                    other => other,
474                },
475                'm' => number_term(&arg()?)?,
476                'z' => size_term(&arg()?)?,
477                '=' => Pattern::Duplicate,
478                'N' => Pattern::New,
479                'F' => Pattern::Flagged,
480                'D' => Pattern::Deleted,
481                'U' => Pattern::Unread,
482                'T' => Pattern::Tagged,
483                'A' => Pattern::All(Vec::new()),
484                'p' => Pattern::ToMe,
485                'P' => Pattern::FromMe,
486                'l' => Pattern::ToList,
487                'R' => Pattern::Read,
488                'O' => Pattern::Old,
489                'Q' => Pattern::Replied,
490                'u' => Pattern::Subscribed,
491                'v' => Pattern::Collapsed,
492                '$' => Pattern::Unreferenced,
493                // Recognised but not supported, named so the message
494                // says what it is rather than "unknown". ~S/~E have
495                // no in-memory state in rmut; the crypto and
496                // attachment-count terms wait for their own round;
497                // ~n/~H are mutt's scoring, a non-goal.
498                'S' | 'E' | 'g' | 'G' | 'V' | 'k' | 'X' | 'n' | 'H' => {
499                    return Err(format!("~{op} is not supported"));
500                }
501                other => return Err(format!("unknown pattern ~{other}")),
502            })
503        }
504        Some(Tok::Word(w)) => Ok(Pattern::Default(Matcher::new(&w))),
505        _ => Err("empty pattern group".into()),
506    }
507}
508
509// ---- ~d date specs ----
510
511/// `<1w` newer than, `>2d` older than, `=3d` exactly that day, or
512/// absolute `DD/MM/YYYY` (a single day, or a `-` range with either
513/// end open).
514fn date_term(spec: &str, now: i64) -> Result<Pattern, String> {
515    if let Some(rest) = spec.strip_prefix('<') {
516        return Ok(Pattern::Date {
517            min: Some(now - offset_secs(rest)?),
518            max: None,
519        });
520    }
521    if let Some(rest) = spec.strip_prefix('>') {
522        return Ok(Pattern::Date {
523            min: None,
524            max: Some(now - offset_secs(rest)?),
525        });
526    }
527    if let Some(rest) = spec.strip_prefix('=') {
528        let then = now - offset_secs(rest)?;
529        let date = Local
530            .timestamp_opt(then, 0)
531            .earliest()
532            .ok_or("bad offset")?
533            .date_naive();
534        let (min, max) = day_bounds_of(date)?;
535        return Ok(Pattern::Date {
536            min: Some(min),
537            max: Some(max),
538        });
539    }
540    match spec.split_once('-') {
541        Some((min, max)) => {
542            let min = if min.is_empty() {
543                None
544            } else {
545                Some(day_bounds(min, now)?.0)
546            };
547            let max = if max.is_empty() {
548                None
549            } else {
550                Some(day_bounds(max, now)?.1)
551            };
552            Ok(Pattern::Date { min, max })
553        }
554        None => {
555            let (min, max) = day_bounds(spec, now)?;
556            Ok(Pattern::Date {
557                min: Some(min),
558                max: Some(max),
559            })
560        }
561    }
562}
563
564/// "2w" → seconds; units y(ears) m(onths) w(eeks) d(ays) H(ours)
565/// M(inutes), months and years approximated like mutt.
566fn offset_secs(s: &str) -> Result<i64, String> {
567    let err = || format!("bad date offset {s:?} (want e.g. 1w, 2d, 3H)");
568    let unit = s.chars().last().ok_or_else(err)?;
569    let n: i64 = s[..s.len() - unit.len_utf8()].parse().map_err(|_| err())?;
570    let secs = match unit {
571        'y' => 365 * 86400,
572        'm' => 30 * 86400,
573        'w' => 7 * 86400,
574        'd' => 86400,
575        'H' => 3600,
576        'M' => 60,
577        _ => return Err(err()),
578    };
579    Ok(n * secs)
580}
581
582/// "DD[/MM[/YYYY]]" → that local day as [start, end) epoch seconds;
583/// missing month/year come from `now`, 2-digit years mean 20xx.
584fn day_bounds(s: &str, now: i64) -> Result<(i64, i64), String> {
585    let err = || format!("bad date {s:?} (want DD/MM/YYYY)");
586    let today = Local
587        .timestamp_opt(now, 0)
588        .earliest()
589        .ok_or_else(err)?
590        .date_naive();
591    let mut parts = s.split('/');
592    let day: u32 = parts.next().unwrap_or("").parse().map_err(|_| err())?;
593    let month: u32 = match parts.next() {
594        Some(m) => m.parse().map_err(|_| err())?,
595        None => today.month(),
596    };
597    let year: i32 = match parts.next() {
598        Some(y) => {
599            let y: i32 = y.parse().map_err(|_| err())?;
600            if y < 100 { 2000 + y } else { y }
601        }
602        None => today.year(),
603    };
604    if parts.next().is_some() {
605        return Err(err());
606    }
607    let date = chrono::NaiveDate::from_ymd_opt(year, month, day).ok_or_else(err)?;
608    day_bounds_of(date)
609}
610
611fn day_bounds_of(date: chrono::NaiveDate) -> Result<(i64, i64), String> {
612    let midnight = date.and_hms_opt(0, 0, 0).ok_or("bad date")?;
613    let start = Local
614        .from_local_datetime(&midnight)
615        .earliest()
616        .ok_or("bad date")?
617        .timestamp();
618    Ok((start, start + 86400))
619}
620
621// ---- ~m index ranges and ~z sizes ----
622
623/// `~m 10-20`, `~m 5`, `~m 5-`, `~m -20`, and mutt's `.` (selected)
624/// and `$` (last) at either end.
625fn number_term(spec: &str) -> Result<Pattern, String> {
626    let end = |text: &str| -> Result<Bound, String> {
627        match text {
628            "." => Ok(Bound::Current),
629            "$" => Ok(Bound::Last),
630            n => n
631                .parse()
632                .map(Bound::Num)
633                .map_err(|_| format!("~m wants a number, . or $, got {n:?}")),
634        }
635    };
636    match spec.split_once('-') {
637        Some((min, max)) => Ok(Pattern::Number {
638            min: (!min.is_empty()).then(|| end(min)).transpose()?,
639            max: (!max.is_empty()).then(|| end(max)).transpose()?,
640        }),
641        None => {
642            let one = end(spec)?;
643            Ok(Pattern::Number {
644                min: Some(one),
645                max: Some(one),
646            })
647        }
648    }
649}
650
651/// `~z >100K`, `~z <2M`, `~z 10K-1M`, `~z 500`. K/M/G suffixes are
652/// mutt's (powers of 1024), case-insensitive.
653fn size_term(spec: &str) -> Result<Pattern, String> {
654    if let Some(rest) = spec.strip_prefix('>') {
655        return Ok(Pattern::Size {
656            min: Some(bytes(rest)?),
657            max: None,
658        });
659    }
660    if let Some(rest) = spec.strip_prefix('<') {
661        return Ok(Pattern::Size {
662            min: None,
663            max: Some(bytes(rest)?),
664        });
665    }
666    match spec.split_once('-') {
667        Some((min, max)) => Ok(Pattern::Size {
668            min: (!min.is_empty()).then(|| bytes(min)).transpose()?,
669            max: (!max.is_empty()).then(|| bytes(max)).transpose()?,
670        }),
671        None => {
672            let exact = bytes(spec)?;
673            Ok(Pattern::Size {
674                min: Some(exact),
675                max: Some(exact),
676            })
677        }
678    }
679}
680
681fn bytes(spec: &str) -> Result<u64, String> {
682    let spec = spec.trim();
683    let err = || format!("~z wants a size like 100K, got {spec:?}");
684    let (digits, scale) = match spec.chars().last().map(|c| c.to_ascii_uppercase()) {
685        Some('K') => (&spec[..spec.len() - 1], 1024),
686        Some('M') => (&spec[..spec.len() - 1], 1024 * 1024),
687        Some('G') => (&spec[..spec.len() - 1], 1024 * 1024 * 1024),
688        _ => (spec, 1),
689    };
690    digits
691        .trim()
692        .parse::<u64>()
693        .map(|n| n * scale)
694        .map_err(|_| err())
695}
696
697// ---- matching ----
698
699/// Answers `~b` for a message without the local file read: Some
700/// when a server-side search already knows, None to fall back.
701pub type BodyOracle<'a> = &'a dyn Fn(&Envelope, &Matcher) -> Option<bool>;
702
703/// Per-message context: disk reads happen at most once.
704struct Ctx<'a> {
705    env: &'a Envelope,
706    scope: Scope<'a>,
707    body: Option<String>,
708    sender: Option<String>,
709    headers: Option<String>,
710    received: Option<i64>,
711    oracle: Option<BodyOracle<'a>>,
712}
713
714/// AND of the top-level patterns. `me` are my own bare lowercase
715/// addresses, for `~p`.
716pub fn matches(patterns: &[Pattern], env: &Envelope, me: &[String]) -> bool {
717    matches_in(patterns, env, Scope::me(me), None)
718}
719
720/// Like `matches`, with `~b` optionally answered by `oracle`
721/// (server-side IMAP search) instead of reading the file.
722pub fn matches_via(
723    patterns: &[Pattern],
724    env: &Envelope,
725    me: &[String],
726    oracle: Option<BodyOracle>,
727) -> bool {
728    matches_in(patterns, env, Scope::me(me), oracle)
729}
730
731/// The full entry point: `scope` answers the terms a lone message
732/// cannot (`~p`, `~l`, `~m`, `~=`).
733pub fn matches_in<'a>(
734    patterns: &[Pattern],
735    env: &'a Envelope,
736    scope: Scope<'a>,
737    oracle: Option<BodyOracle<'a>>,
738) -> bool {
739    let mut ctx = Ctx {
740        env,
741        scope,
742        body: None,
743        sender: None,
744        headers: None,
745        received: None,
746        oracle,
747    };
748    patterns.iter().all(|p| eval(p, &mut ctx))
749}
750
751/// Every `~b` argument in the pattern, for pre-resolving on a server.
752pub fn body_terms(patterns: &[Pattern]) -> Vec<String> {
753    let mut out = Vec::new();
754    fn walk(p: &Pattern, out: &mut Vec<String>) {
755        match p {
756            Pattern::All(terms) | Pattern::Any(terms) => {
757                terms.iter().for_each(|t| walk(t, out));
758            }
759            Pattern::Not(term)
760            | Pattern::Thread(term)
761            | Pattern::Parent(term)
762            | Pattern::Child(term) => {
763                walk(term, out);
764            }
765            Pattern::Body(m) if !out.contains(&m.raw) => out.push(m.raw.clone()),
766            _ => {}
767        }
768    }
769    patterns.iter().for_each(|p| walk(p, &mut out));
770    out
771}
772
773/// Whether the pattern has a `~=` (duplicate) term anywhere: the
774/// Message-ID tally it needs costs a pass over the mailbox.
775pub fn mentions_duplicate(patterns: &[Pattern]) -> bool {
776    fn walk(p: &Pattern) -> bool {
777        match p {
778            Pattern::All(terms) | Pattern::Any(terms) => terms.iter().any(walk),
779            Pattern::Not(term)
780            | Pattern::Thread(term)
781            | Pattern::Parent(term)
782            | Pattern::Child(term) => walk(term),
783            Pattern::Duplicate => true,
784            _ => false,
785        }
786    }
787    patterns.iter().any(walk)
788}
789
790/// A thread term's inner pattern over another message of the thread:
791/// the same me and lists, but no position and no thread of its own,
792/// so `~m`, `~=` and a nested thread term read as the lone-message
793/// case.
794fn eval_peer(inner: &Pattern, view: ThreadView, i: usize, ctx: &Ctx) -> bool {
795    let Some(env) = view.envs.envelope(i) else {
796        return false;
797    };
798    let mut peer = Ctx {
799        env,
800        scope: Scope {
801            me: ctx.scope.me,
802            lists: ctx.scope.lists,
803            subscribed: ctx.scope.subscribed,
804            position: Position::default(),
805            thread: None,
806        },
807        body: None,
808        sender: None,
809        headers: None,
810        received: None,
811        oracle: ctx.oracle,
812    };
813    eval(inner, &mut peer)
814}
815
816fn eval(p: &Pattern, ctx: &mut Ctx) -> bool {
817    let env = ctx.env;
818    match p {
819        Pattern::All(terms) => terms.iter().all(|t| eval(t, ctx)),
820        Pattern::Any(terms) => terms.iter().any(|t| eval(t, ctx)),
821        Pattern::Not(term) => !eval(term, ctx),
822        Pattern::From(m) => m.is_match(&env.from) || m.is_match(&env.from_full),
823        Pattern::Subject(m) => m.is_match(&env.subject),
824        Pattern::Default(m) => {
825            m.is_match(&env.subject) || m.is_match(&env.from) || m.is_match(&env.from_full)
826        }
827        Pattern::To(m) => env.to.iter().any(|a| m.is_match(a)),
828        Pattern::Cc(m) => env.cc.iter().any(|a| m.is_match(a)),
829        Pattern::Recipient(m) => env.to.iter().chain(&env.cc).any(|a| m.is_match(a)),
830        Pattern::Sender(m) => {
831            let sender = ctx.sender.get_or_insert_with(|| {
832                message::first_header(&env.file.path, "Sender").unwrap_or_default()
833            });
834            !sender.is_empty() && m.is_match(sender)
835        }
836        Pattern::Header(m) => {
837            let headers = ctx
838                .headers
839                .get_or_insert_with(|| message::header_text(&env.file.path).unwrap_or_default());
840            m.is_match(headers)
841        }
842        Pattern::MessageId(m) => env.msg_id.as_deref().is_some_and(|id| m.is_match(id)),
843        Pattern::References(m) => env.references.iter().any(|id| m.is_match(id)),
844        Pattern::Whole(m) => {
845            if m.is_match(&env.from_full)
846                || m.is_match(&env.subject)
847                || env.to.iter().chain(&env.cc).any(|a| m.is_match(a))
848            {
849                return true;
850            }
851            let headers = ctx
852                .headers
853                .get_or_insert_with(|| message::header_text(&env.file.path).unwrap_or_default());
854            if m.is_match(headers) {
855                return true;
856            }
857            let body = ctx
858                .body
859                .get_or_insert_with(|| message::body_text(&env.file.path).unwrap_or_default());
860            m.is_match(body)
861        }
862        Pattern::Label(m) => env.label.as_deref().is_some_and(|l| m.is_match(l)),
863        Pattern::FromOrTo(m) => {
864            m.is_match(&env.from)
865                || m.is_match(&env.from_full)
866                || env.to.iter().chain(&env.cc).any(|a| m.is_match(a))
867        }
868        Pattern::Read => env.file.flags.seen,
869        Pattern::Old => !env.file.is_new && !env.file.flags.seen,
870        Pattern::Replied => env.file.flags.answered,
871        Pattern::Subscribed => env
872            .to
873            .iter()
874            .chain(&env.cc)
875            .any(|a| ctx.scope.subscribed.iter().any(|m| m.is_match(a))),
876        Pattern::Date { min, max } => {
877            min.is_none_or(|min| env.date >= min) && max.is_none_or(|max| env.date < max)
878        }
879        Pattern::Received { min, max } => {
880            // Delivery time, which for a maildir is the file's mtime;
881            // the Date header stands in when the file is unreadable.
882            let at = *ctx.received.get_or_insert_with(|| {
883                std::fs::metadata(&env.file.path)
884                    .and_then(|m| m.modified())
885                    .ok()
886                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
887                    .map(|d| d.as_secs() as i64)
888                    .unwrap_or(env.date)
889            });
890            min.is_none_or(|min| at >= min) && max.is_none_or(|max| at < max)
891        }
892        Pattern::Number { min, max } => {
893            let n = ctx.scope.position.number;
894            n > 0
895                && min.is_none_or(|b| n >= b.resolve(&ctx.scope.position))
896                && max.is_none_or(|b| n <= b.resolve(&ctx.scope.position))
897        }
898        Pattern::Size { min, max } => {
899            let size = env.file.size;
900            min.is_none_or(|min| size >= min) && max.is_none_or(|max| size <= max)
901        }
902        Pattern::Duplicate => ctx.scope.position.duplicate,
903        Pattern::Thread(inner) => match ctx.scope.thread {
904            Some(t) => t.members.iter().any(|&i| eval_peer(inner, t, i, ctx)),
905            None => eval(inner, ctx),
906        },
907        Pattern::Parent(inner) => match ctx.scope.thread {
908            Some(t) => t.parent.is_some_and(|i| eval_peer(inner, t, i, ctx)),
909            None => false,
910        },
911        Pattern::Child(inner) => match ctx.scope.thread {
912            Some(t) => t.children.iter().any(|&i| eval_peer(inner, t, i, ctx)),
913            None => false,
914        },
915        Pattern::Collapsed => ctx.scope.thread.is_some_and(|t| t.collapsed),
916        Pattern::Unreferenced => ctx
917            .scope
918            .thread
919            .is_some_and(|t| t.parent.is_none() && t.children.is_empty()),
920        Pattern::New => env.file.is_new,
921        Pattern::Flagged => env.file.flags.flagged,
922        Pattern::Deleted => env.file.flags.deleted,
923        Pattern::Unread => !env.file.flags.seen,
924        Pattern::Tagged => env.tagged,
925        Pattern::ToMe => ctx.scope.me.any(env.to.iter().chain(&env.cc)),
926        Pattern::FromMe => ctx.scope.me.wrote(&env.from_full),
927        Pattern::ToList => env
928            .to
929            .iter()
930            .chain(&env.cc)
931            .any(|a| ctx.scope.lists.iter().any(|m| m.is_match(a))),
932        Pattern::Body(m) => {
933            if let Some(answer) = ctx.oracle.and_then(|oracle| oracle(env, m)) {
934                return answer;
935            }
936            let body = ctx
937                .body
938                .get_or_insert_with(|| message::body_text(&env.file.path).unwrap_or_default());
939            m.is_match(body)
940        }
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947    use crate::maildir::{Flags, MailFile};
948
949    fn env(from: &str, subject: &str, is_new: bool, flags: Flags) -> Envelope {
950        Envelope {
951            file: MailFile {
952                path: "/nonexistent".into(),
953                is_new,
954                flags,
955                size: 0,
956            },
957            from: crate::message::short_from(from),
958            from_full: from.into(),
959            subject: subject.into(),
960            date: 0,
961            msg_id: None,
962            references: vec![],
963            tagged: false,
964            to: vec![],
965            cc: vec![],
966            lines: Some(0),
967            list: None,
968            label: None,
969            broken: false,
970        }
971    }
972
973    fn ok(input: &str) -> Vec<Pattern> {
974        parse(input).unwrap_or_else(|e| panic!("parse {input:?}: {e}"))
975    }
976
977    #[test]
978    fn parse_mixed_terms() {
979        assert_eq!(
980            ok("~f jane ~N lunch"),
981            vec![
982                Pattern::From(Matcher::new("jane")),
983                Pattern::New,
984                Pattern::Default(Matcher::new("lunch")),
985            ]
986        );
987        assert_eq!(ok(""), vec![]);
988        // Quotes keep spaces together.
989        assert_eq!(
990            ok("~s \"pizza friday\""),
991            vec![Pattern::Subject(Matcher::new("pizza friday"))]
992        );
993    }
994
995    #[test]
996    fn patterns_v3_parse() {
997        assert_eq!(
998            ok("~i msg1"),
999            vec![Pattern::MessageId(Matcher::new("msg1"))]
1000        );
1001        assert_eq!(
1002            ok("~x parent"),
1003            vec![Pattern::References(Matcher::new("parent"))]
1004        );
1005        assert_eq!(
1006            ok("~h x-spam"),
1007            vec![Pattern::Header(Matcher::new("x-spam"))]
1008        );
1009        assert_eq!(ok("~="), vec![Pattern::Duplicate]);
1010        assert!(mentions_duplicate(&ok("~F | !(~=)")));
1011        assert!(!mentions_duplicate(&ok("~F ~s x")));
1012        assert_eq!(
1013            ok("~m 10-20"),
1014            vec![Pattern::Number {
1015                min: Some(Bound::Num(10)),
1016                max: Some(Bound::Num(20)),
1017            }]
1018        );
1019        assert_eq!(
1020            ok("~m .-$"),
1021            vec![Pattern::Number {
1022                min: Some(Bound::Current),
1023                max: Some(Bound::Last),
1024            }]
1025        );
1026        assert_eq!(
1027            ok("~m 7"),
1028            vec![Pattern::Number {
1029                min: Some(Bound::Num(7)),
1030                max: Some(Bound::Num(7)),
1031            }]
1032        );
1033        assert_eq!(
1034            ok("~z >100K"),
1035            vec![Pattern::Size {
1036                min: Some(102400),
1037                max: None,
1038            }]
1039        );
1040        assert_eq!(
1041            ok("~z 1K-2M"),
1042            vec![Pattern::Size {
1043                min: Some(1024),
1044                max: Some(2 * 1024 * 1024),
1045            }]
1046        );
1047        // ~r shares the ~d spec vocabulary but keeps its own variant.
1048        assert!(matches!(
1049            ok("~r <1w").as_slice(),
1050            [Pattern::Received { .. }]
1051        ));
1052        assert!(parse("~m nonsense").is_err());
1053        assert!(parse("~z 10X").is_err());
1054    }
1055
1056    #[test]
1057    fn patterns_v3_match() {
1058        let mut e = env("jane@x", "lunch", false, Flags::default());
1059        e.msg_id = Some("msg1@example.com".into());
1060        e.references = vec!["parent@example.com".into()];
1061        e.file.size = 150 * 1024;
1062        assert!(matches(&ok("~i msg1"), &e, &[]));
1063        assert!(!matches(&ok("~i other"), &e, &[]));
1064        assert!(matches(&ok("~x parent"), &e, &[]));
1065        assert!(matches(&ok("~z >100K"), &e, &[]));
1066        assert!(!matches(&ok("~z >1M"), &e, &[]));
1067        assert!(matches(&ok("~z 100K-200K"), &e, &[]));
1068        // ~m and ~= are false without a list around the message.
1069        assert!(!matches(&ok("~m 1"), &e, &[]));
1070        assert!(!matches(&ok("~="), &e, &[]));
1071        let pos = Position {
1072            number: 3,
1073            current: 5,
1074            last: 9,
1075            duplicate: true,
1076        };
1077        let scope = Scope {
1078            position: pos,
1079            ..Default::default()
1080        };
1081        let at = |p: &str| matches_in(&ok(p), &e, scope, None);
1082        assert!(at("~m 3"));
1083        assert!(at("~m 1-3"));
1084        assert!(at("~m -3"));
1085        assert!(!at("~m 4-"));
1086        assert!(at("~m 1-."));
1087        assert!(!at("~m .-$"));
1088        assert!(at("~="));
1089        assert!(!matches_in(&ok("~m 3"), &e, Scope::default(), None));
1090        // ~l needs the configured list patterns to say anything.
1091        e.to = vec!["dev@lists.example.com".into()];
1092        assert!(!matches(&ok("~l"), &e, &[]));
1093        let lists = [Matcher::new("@lists\\.example\\.com")];
1094        let scope = Scope {
1095            lists: &lists,
1096            ..Default::default()
1097        };
1098        assert!(matches_in(&ok("~l"), &e, scope, None));
1099        assert!(scope.any_list(&["dev@lists.example.com".to_string()]));
1100    }
1101
1102    #[test]
1103    fn thread_terms_look_at_the_neighbours() {
1104        // A thread of three: root (from jane), reply (from bob),
1105        // reply to the reply (from jane again). Views are built the
1106        // way a session builds them, over a slice.
1107        let envs = vec![
1108            env("jane@example.com", "root", false, Flags::default()),
1109            env("bob@example.com", "reply", false, Flags::default()),
1110            env("jane@example.com", "again", false, Flags::default()),
1111        ];
1112        let members = [0usize, 1, 2];
1113        let children_of_root = [1usize];
1114        let children_of_reply = [2usize];
1115        let view = |i: usize| ThreadView {
1116            members: &members,
1117            parent: match i {
1118                0 => None,
1119                1 => Some(0),
1120                _ => Some(1),
1121            },
1122            children: match i {
1123                0 => &children_of_root[..],
1124                1 => &children_of_reply[..],
1125                _ => &[],
1126            },
1127            collapsed: i == 0,
1128            envs: &envs,
1129        };
1130        let at = |pat: &str, i: usize| {
1131            let scope = Scope {
1132                thread: Some(view(i)),
1133                ..Default::default()
1134            };
1135            matches_in(&ok(pat), &envs[i], scope, None)
1136        };
1137        // ~(P): anyone in the thread.
1138        assert!(at("~(~f bob)", 0));
1139        assert!(at("~(~f bob)", 2));
1140        assert!(!at("~(~f alice)", 1));
1141        // ~<(P): the parent; ~>(P): a child.
1142        assert!(at("~<(~f jane)", 1));
1143        assert!(!at("~<(~f jane)", 2));
1144        assert!(!at("~<(~A)", 0));
1145        assert!(at("~>(~f bob)", 0));
1146        assert!(!at("~>(~f jane)", 0), "a grandchild is not a child");
1147        // ~v and ~$, and the terms compose like any other.
1148        assert!(at("~v", 0));
1149        assert!(!at("~v", 1));
1150        assert!(!at("~$", 0), "it has children");
1151        assert!(at("!~$ ~(~s again)", 2));
1152        // Without a thread view, ~(P) is P of the message and the
1153        // rest are false.
1154        let alone = |pat: &str, i: usize| matches_in(&ok(pat), &envs[i], Scope::default(), None);
1155        assert!(alone("~(~f bob)", 1));
1156        assert!(!alone("~(~f bob)", 0));
1157        assert!(!alone("~<(~A)", 1));
1158        assert!(!alone("~$", 0));
1159        // Spelling: the paren belongs to the operator.
1160        assert!(parse("~(").is_err());
1161        assert!(parse("~<~f x").is_err());
1162        assert!(parse("~(~f x").is_err());
1163    }
1164
1165    #[test]
1166    fn parse_reports_errors() {
1167        assert!(parse("~S").is_err()); // recognised but unsupported
1168        assert!(parse("~g").is_err());
1169        assert!(parse("~f").is_err());
1170        assert!(parse("~x").is_err()); // still needs an argument
1171        assert!(parse("(~N").is_err());
1172        assert!(parse("~N)").is_err());
1173        assert!(parse("~d nonsense").is_err());
1174    }
1175
1176    #[test]
1177    fn matches_is_case_insensitive_and_anded() {
1178        let e = env("Jane Doe", "Lunch on Friday", true, Flags::default());
1179        assert!(matches(&ok("~f JANE"), &e, &[]));
1180        assert!(matches(&ok("~f jane ~s lunch"), &e, &[]));
1181        assert!(!matches(&ok("~f jane ~s dinner"), &e, &[]));
1182        assert!(matches(&ok("friday"), &e, &[]));
1183        assert!(matches(&ok("doe"), &e, &[])); // Default also matches from
1184        assert!(matches(&ok("~N"), &e, &[]));
1185        assert!(!matches(&ok("~F"), &e, &[]));
1186        assert!(matches(&[], &e, &[])); // empty pattern matches everything
1187    }
1188
1189    #[test]
1190    fn from_matches_the_whole_header() {
1191        // Like mutt: ~f (and a bare word) match the address too, not
1192        // just the displayed name.
1193        let e = env(
1194            "Jane Doe <jane@example.com>",
1195            "Lunch",
1196            false,
1197            Flags::default(),
1198        );
1199        assert!(matches(&ok("~f jane@example.com"), &e, &[]));
1200        assert!(matches(&ok("~f example"), &e, &[]));
1201        assert!(matches(&ok("~f \"jane doe\""), &e, &[]));
1202        assert!(matches(&ok("example.com"), &e, &[]));
1203        assert!(!matches(&ok("~f petr@example.com"), &e, &[]));
1204        // A pre-1.24 header cache entry has no full header stored;
1205        // the short form still matches.
1206        let mut old = env(
1207            "Jane Doe <jane@example.com>",
1208            "Lunch",
1209            false,
1210            Flags::default(),
1211        );
1212        old.from_full = String::new();
1213        assert!(matches(&ok("~f doe"), &old, &[]));
1214        assert!(!matches(&ok("~f jane@example.com"), &old, &[]));
1215    }
1216
1217    #[test]
1218    fn not_or_and_grouping() {
1219        let e = env("Jane", "Lunch", true, Flags::default());
1220        assert!(matches(&ok("!~F"), &e, &[]));
1221        assert!(!matches(&ok("!~N"), &e, &[]));
1222        assert!(matches(&ok("~f jane | ~f petr"), &e, &[]));
1223        assert!(matches(&ok("~f petr | ~f jane"), &e, &[]));
1224        assert!(!matches(&ok("~f petr | ~f alice"), &e, &[]));
1225        // AND binds tighter than OR.
1226        assert!(matches(&ok("~f petr ~s x | ~f jane ~s lunch"), &e, &[]));
1227        assert!(!matches(&ok("~f petr (~s x | ~s lunch)"), &e, &[]));
1228        assert!(matches(&ok("~f jane (~s x | ~s lunch)"), &e, &[]));
1229        assert!(matches(&ok("!(~f petr | ~f alice)"), &e, &[]));
1230    }
1231
1232    #[test]
1233    fn body_terms_collect_and_the_oracle_answers() {
1234        let pats = ok("~b invoice ~s x !(~b a | ~b invoice)");
1235        assert_eq!(body_terms(&pats), vec!["invoice", "a"]);
1236        // The oracle's verdict replaces the (missing) local body read.
1237        let e = env("Jane", "report", false, Flags::default());
1238        let pats = ok("~b invoice");
1239        assert!(!matches(&pats, &e, &[]));
1240        let yes = |_: &Envelope, m: &Matcher| Some(m.raw() == "invoice");
1241        assert!(matches_via(&pats, &e, &[], Some(&yes)));
1242        // None falls back to the local read (empty body: no match).
1243        let dunno = |_: &Envelope, _: &Matcher| None;
1244        assert!(!matches_via(&pats, &e, &[], Some(&dunno)));
1245    }
1246
1247    #[test]
1248    fn string_arguments_are_regexes() {
1249        let e = env("Jane", "Re: budget", false, Flags::default());
1250        assert!(matches(&ok("~s ^re:"), &e, &[]));
1251        assert!(!matches(&ok("~s ^budget"), &e, &[]));
1252        assert!(matches(&ok("~s bud.et"), &e, &[]));
1253        assert!(matches(&ok("~s \"re:.*budget\""), &e, &[]));
1254        // An invalid regex still works as a plain substring.
1255        let e2 = env("Jane", "cost [draft]", false, Flags::default());
1256        assert!(matches(&ok("~s \"[draft\""), &e2, &[]));
1257    }
1258
1259    #[test]
1260    fn recipients_and_to_me() {
1261        let mut e = env("Jane", "s", false, Flags::default());
1262        e.to = vec!["team@example.com".into(), "me@example.com".into()];
1263        e.cc = vec!["boss@example.com".into()];
1264        assert!(matches(&ok("~t team@"), &e, &[]));
1265        assert!(!matches(&ok("~t boss@"), &e, &[]));
1266        assert!(matches(&ok("~c boss@"), &e, &[]));
1267        assert!(matches(&ok("~C boss@"), &e, &[]));
1268        assert!(matches(&ok("~C team@"), &e, &[]));
1269        let me = vec!["me@example.com".to_string()];
1270        assert!(matches(&ok("~p"), &e, &me));
1271        assert!(!matches(&ok("~p"), &e, &["other@example.com".to_string()]));
1272        assert!(!matches(&ok("~p"), &e, &[]));
1273    }
1274
1275    #[test]
1276    fn alternates_widen_who_counts_as_me() {
1277        let mut e = env("Jane Doe <jane@example.com>", "s", false, Flags::default());
1278        e.to = vec!["j.doe@old.example.com".into()];
1279        // The exact list does not have it; an alternate regex does.
1280        assert!(!matches(&ok("~p"), &e, &["me@example.com".to_string()]));
1281        let alternates = vec![Matcher::new("@old\\.example\\.com$")];
1282        let scope = Scope {
1283            me: Me::new(&[], &alternates),
1284            ..Default::default()
1285        };
1286        assert!(matches_in(&ok("~p"), &e, scope, None));
1287        // ~P reads the From header, so an alternate there is me too.
1288        assert!(!matches_in(&ok("~P"), &e, scope, None));
1289        let mine = vec!["jane@example.com".to_string()];
1290        let scope = Scope {
1291            me: Me::addresses(&mine),
1292            ..Default::default()
1293        };
1294        assert!(matches_in(&ok("~P"), &e, scope, None));
1295        assert!(!matches_in(&ok("~p"), &e, scope, None));
1296    }
1297
1298    #[test]
1299    fn sender_header_is_read_from_disk() {
1300        let dir = tempfile::tempdir().unwrap();
1301        let path = dir.path().join("msg");
1302        std::fs::write(
1303            &path,
1304            "From: jane@example.com\nSender: list-bot@example.com\nSubject: s\n\nbody\n",
1305        )
1306        .unwrap();
1307        let mut e = env("jane", "s", false, Flags::default());
1308        e.file.path = path;
1309        assert!(matches(&ok("~e list-bot"), &e, &[]));
1310        assert!(!matches(&ok("~e jane"), &e, &[]));
1311        // No Sender header at all: ~e never matches.
1312        assert!(!matches(
1313            &ok("~e .*"),
1314            &env("j", "s", false, Flags::default()),
1315            &[]
1316        ));
1317    }
1318
1319    #[test]
1320    fn date_offsets_and_ranges() {
1321        let now = 1_800_000_000i64; // fixed "now" for the offsets
1322        let mut e = env("Jane", "s", false, Flags::default());
1323        e.date = now - 3 * 86400; // three days ago
1324        let m = |input: &str, e: &Envelope| matches(&parse_at(input, now).unwrap(), e, &[]);
1325        assert!(m("~d <1w", &e));
1326        assert!(!m("~d <2d", &e));
1327        assert!(m("~d >2d", &e));
1328        assert!(!m("~d >1w", &e));
1329        assert!(m("~d =3d", &e));
1330        assert!(!m("~d =2d", &e));
1331        assert!(m("!~d <2d", &e));
1332        // Absolute days, resolved in the local timezone.
1333        let day = chrono::Local.timestamp_opt(e.date, 0).unwrap();
1334        let spec = day.format("%d/%m/%Y").to_string();
1335        assert!(m(&format!("~d {spec}"), &e));
1336        assert!(m(&format!("~d {spec}-"), &e));
1337        assert!(m(&format!("~d -{spec}"), &e));
1338        let before = day - chrono::Duration::days(2);
1339        assert!(!m(&format!("~d -{}", before.format("%d/%m/%Y")), &e));
1340        assert!(m(&format!("~d {}-", before.format("%d/%m/%Y")), &e));
1341        // Two-digit years and short forms parse.
1342        assert!(parse_at("~d 1/1/26-31/12/26", now).is_ok());
1343        assert!(parse_at("~d 15", now).is_ok());
1344    }
1345
1346    #[test]
1347    fn tagged_pattern_reads_the_runtime_mark() {
1348        let mut e = env("Jane", "s", false, Flags::default());
1349        assert!(!matches(&ok("~T"), &e, &[]));
1350        e.tagged = true;
1351        assert!(matches(&ok("~T"), &e, &[]));
1352    }
1353}