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.
- checksums.yaml +4 -4
- data/README.md +43 -16
- data/docs/Composing and threading.md +69 -0
- data/exe/mailmate-mcp +4 -0
- data/exe/mm-draft +4 -0
- data/exe/mm-mailboxes +4 -0
- data/exe/mm-modify +4 -0
- data/exe/mm-send +4 -0
- data/exe/mm-verify +4 -0
- data/exe/mmdiscover +4 -0
- data/exe/mmmessage +4 -0
- data/exe/mmopen +4 -0
- data/exe/mmsearch +4 -0
- data/exe/mmtags +4 -0
- data/lib/mailmate/cli/search.rb +361 -96
- data/lib/mailmate/cli/send.rb +156 -18
- data/lib/mailmate/cli/version_flag.rb +37 -0
- data/lib/mailmate/header_value.rb +42 -0
- data/lib/mailmate/mcp.rb +68 -29
- data/lib/mailmate/reply_prefill.rb +193 -0
- data/lib/mailmate/search_syntax.rb +316 -0
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +5 -0
- metadata +11 -2
data/lib/mailmate/cli/search.rb
CHANGED
|
@@ -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
|
|
195
|
-
o.separator "
|
|
196
|
-
o.separator ""
|
|
197
|
-
o.separator
|
|
198
|
-
o.separator "
|
|
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 "
|
|
209
|
-
o.separator
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
#
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
377
|
-
# cutoff arithmetic: terms compile once to an inclusive
|
|
378
|
-
# of YYYYMMDD integers
|
|
379
|
-
#
|
|
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
|
|
384
|
-
#
|
|
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
|
-
|
|
409
|
-
|
|
546
|
+
return nil unless parts.any? && parts.all? { |p| p.match?(/\A\d+\z/) }
|
|
547
|
+
|
|
410
548
|
case parts.size
|
|
411
|
-
when 1
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
#
|
|
422
|
-
#
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
|
670
|
+
if t.nil? && mail
|
|
448
671
|
raw = mail.date
|
|
449
|
-
|
|
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
|
|
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,
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|