mailmate 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -71,7 +71,17 @@ module Mailmate
71
71
  parser = build_parser(opts)
72
72
  parser.parse!(argv)
73
73
 
74
+ self.date_order = opts[:european] ? :dmy : :mdy
75
+
74
76
  search_string = argv[0] || DEFAULT_SEARCH
77
+ # Rewrite Gmail/Outlook-style key:value tokens to their exact
78
+ # quicksearch equivalent — loudly, never silently: every rewrite is
79
+ # announced on stderr so the transcript shows what actually ran (and
80
+ # the caller learns the syntax). stdout stays clean CSV.
81
+ search_string, translations = Mailmate::SearchSyntax.translate(search_string, european: !!opts[:european])
82
+ if (notice = Mailmate::SearchSyntax.translation_notice(translations))
83
+ warn notice
84
+ end
75
85
  fields_arg = (opts[:fields] || argv[1] || DEFAULT_FIELDS).to_s.strip
76
86
  # `+...` means "defaults plus these"; bare list = exactly those columns.
77
87
  # Defaults already include `id` as the first column, so `+x` keeps id
@@ -103,6 +113,19 @@ module Mailmate
103
113
  end
104
114
 
105
115
  specs = order_specs(parse_search(search_string))
116
+ # Validate date specs per or-group: only when EVERY branch is
117
+ # unsatisfiable is the query itself an error. A single dead branch
118
+ # in a multi-branch query gets a warning — the other branches still
119
+ # mean something, and the dead one silently contributing nothing is
120
+ # exactly the failure mode this validation exists to surface.
121
+ date_errs = specs.filter_map { |group| date_spec_error(group) }
122
+ if date_errs.any?
123
+ if date_errs.size == specs.size
124
+ warn date_errs.first
125
+ return 2
126
+ end
127
+ date_errs.each { |e| warn "dead or-branch (matches nothing): #{e}" }
128
+ end
106
129
 
107
130
  # Compose + parse the smart-mailbox filter exactly once. The same AST
108
131
  # feeds the evaluator, the tier classifier, and the literals extractor.
@@ -142,6 +165,15 @@ module Mailmate
142
165
 
143
166
  sort_rows!(rows, opts[:sort])
144
167
  emit_output(rows, fields, opts)
168
+ # A query written in another mail system's dialect is not a syntax
169
+ # error here — it parses as a literal term and quietly matches
170
+ # nothing. Callers (people and agents alike) read that empty result
171
+ # as "no such mail" and stop. Say so on stderr, so stdout stays
172
+ # clean CSV and the exit status stays 0: the search DID run, it just
173
+ # cannot have found what the caller meant.
174
+ if rows.empty? && (hint = Mailmate::SearchSyntax.zero_result_hint(search_string))
175
+ warn hint
176
+ end
145
177
  0
146
178
  end
147
179
 
@@ -189,26 +221,18 @@ module Mailmate
189
221
  o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
190
222
  o.on("--sort MODE", %w[asc desc none],
191
223
  "Sort rows by date+time: asc (default), desc, none") { |v| opts[:sort] = v.to_sym }
224
+ o.on("--european",
225
+ "Slash dates are day-first: d 9/8/2026 = Aug 9 (default: month-first American)") { opts[:european] = true }
192
226
  o.separator ""
193
227
  o.separator "SEARCH-STRING SYNTAX"
194
- o.separator " Mirrors MailMate's toolbar quicksearch. Specs combine with AND."
195
- o.separator " Wrap multi-word terms in \"double quotes\". Prefix operand with ! to negate."
196
- o.separator ""
197
- o.separator " <term> common headers (from/to/cc/subject) OR body contains <term>"
198
- o.separator " f <term> from contains"
199
- o.separator " t <term> to/cc (recipients) contains"
200
- o.separator " c <term> cc contains"
201
- o.separator " s <term> subject contains"
202
- o.separator " a <term> any address header contains"
203
- o.separator " b <term> body contains (reads MailMate's body indexes; --all for un-indexed too)"
204
- o.separator " m <term> common headers OR body (same as bare term)"
205
- o.separator " d <date> received date: Nd|Nw|Nm|Ny (relative), or Y, Y-M, Y-M-D"
206
- o.separator " T <tag> tag / IMAP keyword contains (K is a synonym)"
228
+ o.separator " Mirrors MailMate's toolbar quicksearch, plus native state specs"
229
+ o.separator " (is:unread, has:attachment). Other familiar key:value tokens are"
230
+ o.separator " auto-translated (see FOREIGN SYNTAX below)."
231
+ o.separator Mailmate::SearchSyntax.reference(indent: " ")
232
+ o.separator " (b also takes --all to include un-indexed messages.)"
207
233
  o.separator ""
208
- o.separator " Examples:"
209
- o.separator " mmsearch 'f substack d 7d' from Substack in last 7 days"
210
- o.separator " mmsearch 's \"invoice due\" !draft' subject has invoice due, no 'draft'"
211
- o.separator " mmsearch 'd 2026-05' received in May 2026"
234
+ o.separator "FOREIGN SYNTAX (Gmail/Outlook-style, auto-translated, announced on stderr)"
235
+ o.separator Mailmate::SearchSyntax.translation_reference(indent: " ")
212
236
  o.separator ""
213
237
  o.separator "FIELDS (for the fields argument / --fields)"
214
238
  o.separator " id eml-id (always included as first column)"
@@ -303,6 +327,13 @@ module Mailmate
303
327
  # ---- search-string parsing ----------------------------------------------
304
328
 
305
329
  def tokenize(str)
330
+ tokenize_q(str).map(&:first)
331
+ end
332
+
333
+ # [text, quoted] pairs — quoted-ness must survive tokenization so a
334
+ # deliberate search for the literal word "or" (`s "or"`) is not taken
335
+ # as the group separator, and a quoted "f" is never read as a modifier.
336
+ def tokenize_q(str)
306
337
  tokens = []
307
338
  i = 0
308
339
  while i < str.length
@@ -311,42 +342,82 @@ module Mailmate
311
342
  i += 1
312
343
  elsif c == "\""
313
344
  j = str.index("\"", i + 1) || str.length
314
- tokens << str[(i + 1)...j]
345
+ tokens << [str[(i + 1)...j], true]
315
346
  i = j + 1
316
347
  else
317
348
  j = i
318
349
  j += 1 while j < str.length && str[j] != " "
319
- tokens << str[i...j]
350
+ tokens << [str[i...j], false]
320
351
  i = j
321
352
  end
322
353
  end
323
354
  tokens
324
355
  end
325
356
 
357
+ # A bare `or` splits the query into groups: specs within a group AND
358
+ # together, groups OR together — `and` (juxtaposition) binds tighter
359
+ # than `or`, and there are no parens: `(f bob or f ann) s invoice` is
360
+ # written out as `f bob s invoice or f ann s invoice`. A group that
361
+ # OPENS with a bare unquoted operand inherits the modifier in force at
362
+ # the end of the previous group — the app's `d 2024 or 2025 or 2y`
363
+ # shorthand. Returns an array of spec groups; empty groups (a dangling
364
+ # `or`) are dropped.
326
365
  def parse_search(str)
327
- tokens = tokenize(str)
366
+ token_groups = [[]]
367
+ tokenize_q(str).each do |tok, quoted|
368
+ if !quoted && tok.casecmp?("or")
369
+ token_groups << []
370
+ else
371
+ token_groups.last << [tok, quoted]
372
+ end
373
+ end
374
+
375
+ carried = nil
376
+ groups = token_groups.map do |tokens|
377
+ specs, carried = parse_group(tokens, carried)
378
+ specs
379
+ end
380
+ groups.reject(&:empty?)
381
+ end
382
+
383
+ def parse_group(tokens, inherited_field)
328
384
  specs = []
385
+ in_force = inherited_field
329
386
  i = 0
330
387
  while i < tokens.size
331
- tok = tokens[i]
332
- field = MODIFIERS[tok]
388
+ tok, quoted = tokens[i]
389
+ field = quoted ? nil : MODIFIERS[tok]
333
390
  if field && i + 1 < tokens.size
334
- operand = tokens[i + 1]
391
+ operand, = tokens[i + 1]
335
392
  negate = operand.start_with?("!")
336
393
  operand = operand[1..] if negate
337
394
  specs << [field, operand.downcase, negate]
395
+ in_force = field
338
396
  i += 2
339
397
  else
340
398
  negate = tok.start_with?("!")
341
399
  operand = negate ? tok[1..] : tok
342
- # Bare terms default to MailMate's "Common" specifier — common
343
- # headers OR body matching the UI quicksearch behavior. Pass
344
- # --headers-only to skip the body scan when speed matters.
345
- specs << [:message_or_body, operand.downcase, negate]
400
+ if !quoted && operand =~ /\A-?(?:is|has):\S+\z/i
401
+ # First-class message-state specs (is:unread, has:attachment).
402
+ # The app has no state vocabulary to mirror (its A modifier
403
+ # searches attachment FILENAMES), so the familiar Gmail
404
+ # spellings are native syntax here. `-` negates too — the form
405
+ # Gmail callers actually write.
406
+ negate ||= operand.start_with?("-")
407
+ specs << [:state, operand.delete_prefix("-").downcase, negate]
408
+ else
409
+ # A bare term opening an or-group inherits the modifier in
410
+ # force (`d 2024 or 2025`). Elsewhere it is MailMate's
411
+ # "Common" specifier — common headers OR body — matching the
412
+ # UI quicksearch behavior. Pass --headers-only to skip the
413
+ # body scan when speed matters.
414
+ target = (i.zero? && !quoted && in_force) ? in_force : :message_or_body
415
+ specs << [target, operand.downcase, negate]
416
+ end
346
417
  i += 1
347
418
  end
348
419
  end
349
- specs
420
+ [specs, in_force]
350
421
  end
351
422
 
352
423
  # Static cost rank per spec field for AND evaluation order: compiled
@@ -355,17 +426,35 @@ module Mailmate
355
426
  SPEC_COST = {
356
427
  date: 0,
357
428
  from: 1, recipients: 1, cc: 1, subject: 1, address_any: 1, any: 1,
358
- tag: 1, keyword: 1,
429
+ tag: 1, keyword: 1, state: 1,
359
430
  body: 2, message_or_body: 2,
360
431
  }.freeze
361
432
 
362
- # Evaluate cheap, selective specs before expensive ones. specs combine
363
- # with AND (order-independent), and matches? short-circuits on the
364
- # first miss so `b invoice d 7d` should date-reject 47k messages
365
- # before body matching ever runs, not after. Stable within a cost rank
366
- # to keep the user's order deterministic.
367
- def order_specs(specs)
368
- specs.sort_by.with_index { |(field, _term, _negate), i| [SPEC_COST.fetch(field, 1), i] }
433
+ # Canonical state names for is:/has: specs, including the spellings
434
+ # Gmail callers actually use. Values map to a #flags IMAP flag except
435
+ # :unread (absence of \Seen) and :attachment (root MIME layout).
436
+ STATE_CANON = {
437
+ "unread" => :unread, "read" => :read,
438
+ "flagged" => :flagged, "starred" => :flagged,
439
+ "replied" => :replied, "answered" => :replied,
440
+ "draft" => :draft,
441
+ "attachment" => :attachment, "attachments" => :attachment,
442
+ }.freeze
443
+
444
+ STATE_FLAGS = {
445
+ read: "\\Seen", flagged: "\\Flagged", replied: "\\Answered", draft: "\\Draft",
446
+ }.freeze
447
+
448
+ # Evaluate cheap, selective specs before expensive ones, within each
449
+ # or-group. Specs in a group combine with AND (order-independent), and
450
+ # matches? short-circuits on the first miss — so `b invoice d 7d`
451
+ # should date-reject 47k messages before body matching ever runs, not
452
+ # after. Stable within a cost rank to keep the user's order
453
+ # deterministic.
454
+ def order_specs(groups)
455
+ groups.map do |specs|
456
+ specs.sort_by.with_index { |(field, _term, _negate), i| [SPEC_COST.fetch(field, 1), i] }
457
+ end
369
458
  end
370
459
 
371
460
  # ---- date matching ------------------------------------------------------
@@ -373,44 +462,170 @@ module Mailmate
373
462
  # The `#date` index stores fixed-format strings ("2026-03-19 18:55:19
374
463
  # -0600", sender-local time with varying UTC offsets — NOT lexically
375
464
  # comparable). date_matches? runs once per candidate message, so the
376
- # hot path avoids Time.parse (~10× slower than slicing) and per-call
377
- # cutoff arithmetic: terms compile once to an inclusive [lo, hi] range
378
- # of YYYYMMDD integers, and the indexed value slices straight to the
379
- # same integer form. Calendar-date comparison semantics are unchanged.
465
+ # hot path avoids Time.parse (~10× slower than fast_time's slicing) and
466
+ # per-call cutoff arithmetic: day terms compile once to an inclusive
467
+ # [lo, hi] range of YYYYMMDD integers; per message, fast_time slices
468
+ # the indexed value into a Time (offset preserved) which localize then
469
+ # converts to the display zone before the day compare. Hour terms
470
+ # (`24h`) compare the same Time as an epoch instant instead.
471
+
472
+ # Slash-date ordering for three-part dates with a trailing 4-digit
473
+ # year: :mdy (American month-first, the default — `8/9/2026` = Aug 9)
474
+ # or :dmy (day-first, the --european flag — `9/8/2026` = Aug 9).
475
+ # ISO Y-M-D is unaffected. Module-level because the compiled-range
476
+ # memo must reset when it flips (the MCP server outlives any one call).
477
+ def date_order
478
+ @date_order || :mdy
479
+ end
480
+
481
+ def date_order=(order)
482
+ @date_order = order
483
+ end
380
484
 
381
485
  # Compiled day-range for a date term, memoized per term. nil = term
382
486
  # can't match anything. The memo resets when the calendar day rolls
383
- # over so relative terms ("1d") stay correct in long-lived processes
384
- # (the MCP server).
487
+ # over (so relative terms like "1d" stay correct in long-lived
488
+ # processes — the MCP server) or when date_order flips.
385
489
  def date_range_for(term)
386
490
  today = Date.today
387
- if @date_ranges_day != today
491
+ if @date_ranges_day != today || @date_ranges_order != date_order
388
492
  @date_ranges_day = today
493
+ @date_ranges_order = date_order
389
494
  @date_ranges = {}
390
495
  end
391
496
  return @date_ranges[term] if @date_ranges.key?(term)
392
497
  @date_ranges[term] = compile_date_range(term, today)
393
498
  end
394
499
 
500
+ # A term is an optional comparison prefix (>, >=, <, <=) on a period.
501
+ # The prefix reshapes the period's inclusive [lo, hi] window: `>2026-08`
502
+ # is "after August" = [20260901, max], `<2026-08` is "before August" =
503
+ # [min, 20260731]. Bounds are compared as YYYYMMDD integers, so ±1 on a
504
+ # synthetic bound (a month's "day 31", a year's "Dec 31"+1) is safe —
505
+ # no real date falls in the gap. A comparison can produce an empty
506
+ # window (`>3d` — nothing is after a window that already reaches the
507
+ # future); date_spec_error reports those up front rather than letting
508
+ # them silently match nothing.
395
509
  def compile_date_range(term, today)
510
+ op = nil
511
+ if term =~ /\A(>=|<=|>|<)(.+)\z/
512
+ op, term = Regexp.last_match(1), Regexp.last_match(2)
513
+ end
514
+ base = compile_period_range(term, today)
515
+ return nil unless base
516
+ return base unless op
517
+
518
+ lo, hi = base
519
+ case op
520
+ when ">" then [hi + 1, 9999_12_31]
521
+ when ">=" then [lo, 9999_12_31]
522
+ when "<" then [0, lo - 1]
523
+ when "<=" then [0, hi]
524
+ end
525
+ end
526
+
527
+ def compile_period_range(term, today)
396
528
  if term =~ /\A(\d+)([dwmy])\z/
397
529
  n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
530
+ return nil if n.zero? # a zero-length window matches nothing
531
+ # N units ENDING today: `1d` = today only, `7d` = the last 7
532
+ # calendar days including today. (Off-by-one fixed 2026-08-11 to
533
+ # match the MailMate app, where `d 1d` is today's mail — the old
534
+ # cutoff of today-N made `d 1d` span two calendar days. For a
535
+ # rolling 24-hour clock window, that's `d 24h` now.)
398
536
  cutoff = case u
399
- when "d" then today - n
400
- when "w" then today - (n * 7)
401
- when "m" then today << n
402
- when "y" then today << (n * 12)
537
+ when "d" then today - (n - 1)
538
+ when "w" then today - (n * 7 - 1)
539
+ when "m" then (today << n) + 1
540
+ when "y" then (today << (n * 12)) + 1
403
541
  end
404
542
  return [ymd_int(cutoff), 9999_12_31]
405
543
  end
406
544
 
407
545
  parts = term.tr("/.", "-").split("-")
408
- y = parts[0].to_i
409
- return nil if y.zero?
546
+ return nil unless parts.any? && parts.all? { |p| p.match?(/\A\d+\z/) }
547
+
410
548
  case parts.size
411
- when 1 then [y * 10_000 + 101, y * 10_000 + 1231]
412
- when 2 then [y * 10_000 + parts[1].to_i * 100 + 1, y * 10_000 + parts[1].to_i * 100 + 31]
413
- when 3 then [ymd = y * 10_000 + parts[1].to_i * 100 + parts[2].to_i, ymd]
549
+ when 1
550
+ y = parts[0].to_i
551
+ return nil if y.zero?
552
+ [y * 10_000 + 101, y * 10_000 + 1231]
553
+ when 2
554
+ # Year-first (2026-08) or month-first with a 4-digit year (8/2026).
555
+ y, m = parts[1].length == 4 ? [parts[1], parts[0]] : [parts[0], parts[1]]
556
+ y, m = y.to_i, m.to_i
557
+ return nil if y.zero? || !(1..12).cover?(m)
558
+ [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
559
+ when 3
560
+ # ISO year-first, or slash-date with trailing 4-digit year ordered
561
+ # per date_order. Impossible calendar dates (2026-02-31, month 13)
562
+ # compile to nil so date_spec_error names them instead of the
563
+ # search silently matching nothing.
564
+ y, m, d =
565
+ if parts[0].length == 4
566
+ parts.map(&:to_i)
567
+ elsif parts[2].length == 4
568
+ a, b, yr = parts.map(&:to_i)
569
+ date_order == :dmy ? [yr, b, a] : [yr, a, b]
570
+ end
571
+ return nil unless y && Date.valid_date?(y, m, d)
572
+ [ymd = y * 10_000 + m * 100 + d, ymd]
573
+ end
574
+ end
575
+
576
+ # Usage-error string for the date specs in ONE or-group (specs within a
577
+ # group AND together; the caller decides how errors across groups
578
+ # combine), nil when they're fine. Two failure classes, both of which
579
+ # would otherwise surface as a clean, successful, empty result — the
580
+ # silent-nothing this gem keeps having to fight: a single term that
581
+ # cannot match anything (`d >3d`, `d garbage`), and positive terms
582
+ # whose windows don't intersect (`d >2026 d <2025`). Negated terms
583
+ # subtract rather than intersect, so they're validated individually
584
+ # but excluded from the intersection.
585
+ def date_spec_error(specs)
586
+ day_terms, hour_terms = [], []
587
+ specs.each do |field, term, negate|
588
+ # State specs validate here too (same pre-pass, same
589
+ # silent-nothing failure being prevented): an unknown state value
590
+ # would otherwise quietly match no message ever.
591
+ if field == :state && !STATE_CANON.key?(term.split(":", 2).last)
592
+ return "state term cannot match anything: #{term} " \
593
+ "(known: is:unread is:read is:flagged is:replied is:draft has:attachment)"
594
+ end
595
+ next unless field == :date
596
+ range = hour_range_for(term) || date_range_for(term)
597
+ if range.nil? || range[0] > range[1]
598
+ return "date term cannot match anything: d #{term}#{date_term_hint(term)}"
599
+ end
600
+ next if negate
601
+ (term.end_with?("h") ? hour_terms : day_terms) << [term, range]
602
+ end
603
+
604
+ # Day windows intersect with day windows and hour windows with hour
605
+ # windows; the two families use different scales (YYYYMMDD ints vs
606
+ # epoch seconds), and a cross-family contradiction is not worth the
607
+ # unit conversion to detect.
608
+ [day_terms, hour_terms].each do |family|
609
+ next if family.size < 2
610
+ lo = family.map { |_, r| r[0] }.max
611
+ hi = family.map { |_, r| r[1] }.min
612
+ next if lo <= hi
613
+ return "impossible date range (empty intersection): #{family.map { |t, _| "d #{t}" }.join(" ")}"
614
+ end
615
+ nil
616
+ end
617
+
618
+ # `13/8/2026` under month-first ordering is month 13 — almost certainly
619
+ # a day-first date (and vice versa). Name the likely fix instead of
620
+ # leaving the generic cannot-match.
621
+ def date_term_hint(term)
622
+ parts = term.sub(/\A(>=|<=|>|<)/, "").tr("/.", "-").split("-")
623
+ return nil unless parts.size == 3 && parts[2].length == 4
624
+ a, b = parts[0].to_i, parts[1].to_i
625
+ if date_order == :mdy && a > 12 && (1..12).cover?(b)
626
+ " (day-first date? pass --european)"
627
+ elsif date_order == :dmy && b > 12 && (1..12).cover?(a)
628
+ " (month-first date? drop --european)"
414
629
  end
415
630
  end
416
631
 
@@ -418,39 +633,56 @@ module Mailmate
418
633
  d.year * 10_000 + d.month * 100 + d.day
419
634
  end
420
635
 
421
- # "2026-03-19 …" 20260319 without Time.parse. nil when the value
422
- # isn't in the indexed shape (caller falls back to the slow path).
423
- def fast_ymd(s)
424
- return nil unless s && s.length >= 10 && s.getbyte(4) == 0x2D && s.getbyte(7) == 0x2D
425
- y = s[0, 4].to_i
426
- m = s[5, 2].to_i
427
- d = s[8, 2].to_i
428
- return nil if y.zero? || m.zero? || d.zero?
429
- y * 10_000 + m * 100 + d
636
+ # Rolling clock windows: `24h` = the last 24 hours as an instant range,
637
+ # unlike d/w/m/y which are calendar windows. Returns [lo, hi] epoch
638
+ # floats (lo > hi means the term cannot match — date_spec_error reports
639
+ # it), or nil when the term isn't an hour form. Deliberately NOT
640
+ # memoized: the cutoff moves with the clock, and the MCP server process
641
+ # lives long enough for a cached one to go stale.
642
+ def hour_range_for(term)
643
+ m = /\A(>=|<=|>|<)?(\d+)h\z/.match(term)
644
+ return nil unless m
645
+ op, n = m[1], m[2].to_i
646
+ return [1.0, 0.0] if n.zero?
647
+
648
+ cutoff = Time.now.to_f - (n * 3600)
649
+ case op
650
+ when nil, ">=" then [cutoff, Float::INFINITY]
651
+ when ">" then [1.0, 0.0] # the window already reaches the future
652
+ when "<" then [-Float::INFINITY, cutoff]
653
+ when "<=" then [-Float::INFINITY, Float::INFINITY]
654
+ end
430
655
  end
431
656
 
657
+ # Match on the message's absolute send instant, converted to the display
658
+ # zone via Mailmate.localize — the SAME conversion the date/time output
659
+ # columns use, so the day a term matches is always the day the caller
660
+ # sees in the output. (The raw `#date` index value is sender-local time;
661
+ # matching on its sliced day — the old fast path — made `d 1d` return
662
+ # mail displayed under yesterday's date whenever the sender's calendar
663
+ # ran ahead of the display zone, e.g. a UTC sender after 6pm MDT.)
432
664
  def date_matches?(mail, eml_id, term)
433
- range = date_range_for(term)
434
- return false unless range
435
-
436
- ymd = nil
665
+ t = nil
437
666
  if eml_id
438
667
  s = (reader_for("#date")&.value_for(eml_id.to_i) rescue nil)
439
- if s && !s.empty?
440
- ymd = fast_ymd(s)
441
- if ymd.nil?
442
- t = (Time.parse(s) rescue nil)
443
- ymd = t && ymd_int(t.to_date)
444
- end
445
- end
668
+ t = fast_time(s) || (Time.parse(s) rescue nil) if s && !s.empty?
446
669
  end
447
- if ymd.nil? && mail
670
+ if t.nil? && mail
448
671
  raw = mail.date
449
- d = raw.respond_to?(:to_time) ? raw.to_time : raw
450
- ymd = d && ymd_int(d.to_date)
672
+ t = raw.respond_to?(:to_time) ? raw.to_time : raw
451
673
  end
452
- return false unless ymd
674
+ return false unless t
453
675
 
676
+ if (hours = hour_range_for(term))
677
+ f = t.to_f
678
+ return f >= hours[0] && f <= hours[1]
679
+ end
680
+
681
+ range = date_range_for(term)
682
+ return false unless range
683
+
684
+ local = Mailmate.localize(t)
685
+ ymd = local.year * 10_000 + local.month * 100 + local.day
454
686
  ymd >= range[0] && ymd <= range[1]
455
687
  rescue StandardError
456
688
  false
@@ -552,6 +784,35 @@ module Mailmate
552
784
  flags.reject { |f| f.start_with?("\\", "$") }.join(" ").downcase
553
785
  end
554
786
 
787
+ # term is the full lowercased token ("is:unread", "has:attachment").
788
+ # Flag states read the #flags index; attachment presence reads the
789
+ # indexed root content-type — multipart/mixed is the standard
790
+ # attachment layout (a Mail fallback checks real attachments when the
791
+ # message is already loaded). Unknown state values never reach here:
792
+ # date_spec_error rejects them up front.
793
+ def state_matches?(eml_id, mail, term)
794
+ state = STATE_CANON[term.split(":", 2).last]
795
+ return false unless state
796
+
797
+ case state
798
+ when :unread
799
+ eml_id ? !message_flags(eml_id).include?("\\Seen") : false
800
+ when :attachment
801
+ ct = eml_id ? (reader_for("content-type")&.value_for(eml_id.to_i) rescue nil).to_s : ""
802
+ return ct.downcase.include?("multipart/mixed") unless ct.empty?
803
+ mail ? mail.attachments.any? : false
804
+ else
805
+ message_flags(eml_id).include?(STATE_FLAGS[state])
806
+ end
807
+ end
808
+
809
+ def message_flags(eml_id)
810
+ return [] unless eml_id
811
+ reader_for("#flags")&.flags_for(eml_id.to_i) || []
812
+ rescue StandardError
813
+ []
814
+ end
815
+
555
816
  def text_body(mail)
556
817
  (mail.text_part&.decoded || mail.body.decoded).to_s.force_encoding("UTF-8").scrub.downcase
557
818
  rescue StandardError
@@ -627,26 +888,30 @@ module Mailmate
627
888
  texts
628
889
  end
629
890
 
630
- def matches?(mail, eml_id, specs, headers_only, path = nil, index_only: false, exclude_quoted: false)
631
- specs.all? do |field, term, negate|
632
- term_b = term.b
633
- hit =
634
- case field
635
- when :from, :recipients, :cc, :subject, :address_any
636
- field_value(eml_id, mail, field).include?(term_b)
637
- when :tag, :keyword
638
- tag_value(eml_id).include?(term_b)
639
- when :body
640
- headers_only ? false : body_matches?(eml_id, mail, path, term, term_b, index_only: index_only, exclude_quoted: exclude_quoted)
641
- when :message_or_body
642
- common = %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
643
- common || (!headers_only && body_matches?(eml_id, mail, path, term, term_b, index_only: index_only, exclude_quoted: exclude_quoted))
644
- when :date
645
- date_matches?(mail, eml_id, term)
646
- when :any
647
- %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
648
- end
649
- negate ? !hit : hit
891
+ def matches?(mail, eml_id, groups, headers_only, path = nil, index_only: false, exclude_quoted: false)
892
+ groups.any? do |specs|
893
+ specs.all? do |field, term, negate|
894
+ term_b = term.b
895
+ hit =
896
+ case field
897
+ when :from, :recipients, :cc, :subject, :address_any
898
+ field_value(eml_id, mail, field).include?(term_b)
899
+ when :tag, :keyword
900
+ tag_value(eml_id).include?(term_b)
901
+ when :body
902
+ headers_only ? false : body_matches?(eml_id, mail, path, term, term_b, index_only: index_only, exclude_quoted: exclude_quoted)
903
+ when :message_or_body
904
+ common = %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
905
+ common || (!headers_only && body_matches?(eml_id, mail, path, term, term_b, index_only: index_only, exclude_quoted: exclude_quoted))
906
+ when :date
907
+ date_matches?(mail, eml_id, term)
908
+ when :state
909
+ state_matches?(eml_id, mail, term)
910
+ when :any
911
+ %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
912
+ end
913
+ negate ? !hit : hit
914
+ end
650
915
  end
651
916
  end
652
917