1use chrono::{Datelike, Local, TimeZone};
15
16use crate::message::{self, Envelope};
17
18#[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 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 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 Recipient(Matcher),
88 Sender(Matcher),
90 Header(Matcher),
92 MessageId(Matcher),
94 References(Matcher),
96 Whole(Matcher),
98 Label(Matcher),
100 FromOrTo(Matcher),
102 Read,
104 Old,
106 Replied,
108 Subscribed,
110 Thread(Box<Pattern>),
112 Parent(Box<Pattern>),
114 Child(Box<Pattern>),
116 Collapsed,
118 Unreferenced,
120 Date {
122 min: Option<i64>,
123 max: Option<i64>,
124 },
125 Received {
127 min: Option<i64>,
128 max: Option<i64>,
129 },
130 Number {
133 min: Option<Bound>,
134 max: Option<Bound>,
135 },
136 Size {
138 min: Option<u64>,
139 max: Option<u64>,
140 },
141 Duplicate,
143 New,
144 Flagged,
145 Deleted,
146 Unread,
147 Tagged,
148 ToMe,
150 FromMe,
152 ToList,
154 Default(Matcher),
156}
157
158#[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#[derive(Default, Clone, Copy)]
183pub struct Me<'a> {
184 pub addresses: &'a [String],
186 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 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 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 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#[derive(Default, Clone, Copy)]
233pub struct Scope<'a> {
234 pub me: Me<'a>,
235 pub lists: &'a [Matcher],
237 pub subscribed: &'a [Matcher],
239 pub position: Position,
240 pub thread: Option<ThreadView<'a>>,
244}
245
246#[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
256pub 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 pub fn me(me: &'a [String]) -> Scope<'a> {
277 Scope {
278 me: Me::addresses(me),
279 ..Default::default()
280 }
281 }
282
283 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#[derive(Debug, Clone, Copy, Default)]
296pub struct Position {
297 pub number: usize,
299 pub current: usize,
301 pub last: usize,
303 pub duplicate: bool,
304}
305
306enum 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 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
380pub fn parse(input: &str) -> Result<Vec<Pattern>, String> {
382 parse_at(input, Local::now().timestamp())
383}
384
385fn 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 '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
509fn 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
564fn 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
582fn 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
621fn 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
651fn 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
697pub type BodyOracle<'a> = &'a dyn Fn(&Envelope, &Matcher) -> Option<bool>;
702
703struct 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
714pub fn matches(patterns: &[Pattern], env: &Envelope, me: &[String]) -> bool {
717 matches_in(patterns, env, Scope::me(me), None)
718}
719
720pub 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
731pub 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
751pub 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
773pub 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
790fn 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 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 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 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 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 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 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 assert!(at("~(~f bob)", 0));
1139 assert!(at("~(~f bob)", 2));
1140 assert!(!at("~(~f alice)", 1));
1141 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 assert!(at("~v", 0));
1149 assert!(!at("~v", 1));
1150 assert!(!at("~$", 0), "it has children");
1151 assert!(at("!~$ ~(~s again)", 2));
1152 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 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()); assert!(parse("~g").is_err());
1169 assert!(parse("~f").is_err());
1170 assert!(parse("~x").is_err()); 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, &[])); assert!(matches(&ok("~N"), &e, &[]));
1185 assert!(!matches(&ok("~F"), &e, &[]));
1186 assert!(matches(&[], &e, &[])); }
1188
1189 #[test]
1190 fn from_matches_the_whole_header() {
1191 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 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 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 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 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 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 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 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 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; let mut e = env("Jane", "s", false, Flags::default());
1323 e.date = now - 3 * 86400; 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 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 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}