mailmate 1.5.0 → 1.7.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 +163 -95
- data/lib/mailmate/cli/search.rb +296 -95
- data/lib/mailmate/cli/send.rb +27 -6
- data/lib/mailmate/mcp.rb +34 -15
- data/lib/mailmate/search_syntax.rb +311 -0
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +1 -0
- metadata +2 -1
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,17 @@ 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 " 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. There is no native key:value"
|
|
229
|
+
o.separator " form — but familiar foreign tokens are auto-translated (see below)."
|
|
230
|
+
o.separator Mailmate::SearchSyntax.reference(indent: " ")
|
|
231
|
+
o.separator " (b also takes --all to include un-indexed messages.)"
|
|
207
232
|
o.separator ""
|
|
208
|
-
o.separator "
|
|
209
|
-
o.separator
|
|
210
|
-
o.separator " mmsearch 's \"rent due\" !draft' subject has rent due, no 'draft'"
|
|
211
|
-
o.separator " mmsearch 'd 2026-05' received in May 2026"
|
|
233
|
+
o.separator "FOREIGN SYNTAX (Gmail/Outlook-style, auto-translated, announced on stderr)"
|
|
234
|
+
o.separator Mailmate::SearchSyntax.translation_reference(indent: " ")
|
|
212
235
|
o.separator ""
|
|
213
236
|
o.separator "FIELDS (for the fields argument / --fields)"
|
|
214
237
|
o.separator " id eml-id (always included as first column)"
|
|
@@ -303,6 +326,13 @@ module Mailmate
|
|
|
303
326
|
# ---- search-string parsing ----------------------------------------------
|
|
304
327
|
|
|
305
328
|
def tokenize(str)
|
|
329
|
+
tokenize_q(str).map(&:first)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# [text, quoted] pairs — quoted-ness must survive tokenization so a
|
|
333
|
+
# deliberate search for the literal word "or" (`s "or"`) is not taken
|
|
334
|
+
# as the group separator, and a quoted "f" is never read as a modifier.
|
|
335
|
+
def tokenize_q(str)
|
|
306
336
|
tokens = []
|
|
307
337
|
i = 0
|
|
308
338
|
while i < str.length
|
|
@@ -311,42 +341,72 @@ module Mailmate
|
|
|
311
341
|
i += 1
|
|
312
342
|
elsif c == "\""
|
|
313
343
|
j = str.index("\"", i + 1) || str.length
|
|
314
|
-
tokens << str[(i + 1)...j]
|
|
344
|
+
tokens << [str[(i + 1)...j], true]
|
|
315
345
|
i = j + 1
|
|
316
346
|
else
|
|
317
347
|
j = i
|
|
318
348
|
j += 1 while j < str.length && str[j] != " "
|
|
319
|
-
tokens << str[i...j]
|
|
349
|
+
tokens << [str[i...j], false]
|
|
320
350
|
i = j
|
|
321
351
|
end
|
|
322
352
|
end
|
|
323
353
|
tokens
|
|
324
354
|
end
|
|
325
355
|
|
|
356
|
+
# A bare `or` splits the query into groups: specs within a group AND
|
|
357
|
+
# together, groups OR together — `and` (juxtaposition) binds tighter
|
|
358
|
+
# than `or`, and there are no parens: `(f bob or f ann) s invoice` is
|
|
359
|
+
# written out as `f bob s invoice or f ann s invoice`. A group that
|
|
360
|
+
# OPENS with a bare unquoted operand inherits the modifier in force at
|
|
361
|
+
# the end of the previous group — the app's `d 2024 or 2025 or 2y`
|
|
362
|
+
# shorthand. Returns an array of spec groups; empty groups (a dangling
|
|
363
|
+
# `or`) are dropped.
|
|
326
364
|
def parse_search(str)
|
|
327
|
-
|
|
365
|
+
token_groups = [[]]
|
|
366
|
+
tokenize_q(str).each do |tok, quoted|
|
|
367
|
+
if !quoted && tok.casecmp?("or")
|
|
368
|
+
token_groups << []
|
|
369
|
+
else
|
|
370
|
+
token_groups.last << [tok, quoted]
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
carried = nil
|
|
375
|
+
groups = token_groups.map do |tokens|
|
|
376
|
+
specs, carried = parse_group(tokens, carried)
|
|
377
|
+
specs
|
|
378
|
+
end
|
|
379
|
+
groups.reject(&:empty?)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def parse_group(tokens, inherited_field)
|
|
328
383
|
specs = []
|
|
384
|
+
in_force = inherited_field
|
|
329
385
|
i = 0
|
|
330
386
|
while i < tokens.size
|
|
331
|
-
tok = tokens[i]
|
|
332
|
-
field = MODIFIERS[tok]
|
|
387
|
+
tok, quoted = tokens[i]
|
|
388
|
+
field = quoted ? nil : MODIFIERS[tok]
|
|
333
389
|
if field && i + 1 < tokens.size
|
|
334
|
-
operand = tokens[i + 1]
|
|
390
|
+
operand, = tokens[i + 1]
|
|
335
391
|
negate = operand.start_with?("!")
|
|
336
392
|
operand = operand[1..] if negate
|
|
337
393
|
specs << [field, operand.downcase, negate]
|
|
394
|
+
in_force = field
|
|
338
395
|
i += 2
|
|
339
396
|
else
|
|
340
397
|
negate = tok.start_with?("!")
|
|
341
398
|
operand = negate ? tok[1..] : tok
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
|
|
399
|
+
# A bare term opening an or-group inherits the modifier in force
|
|
400
|
+
# (`d 2024 or 2025`). Elsewhere it is MailMate's "Common"
|
|
401
|
+
# specifier — common headers OR body — matching the UI
|
|
402
|
+
# quicksearch behavior. Pass --headers-only to skip the body scan
|
|
403
|
+
# when speed matters.
|
|
404
|
+
target = (i.zero? && !quoted && in_force) ? in_force : :message_or_body
|
|
405
|
+
specs << [target, operand.downcase, negate]
|
|
346
406
|
i += 1
|
|
347
407
|
end
|
|
348
408
|
end
|
|
349
|
-
specs
|
|
409
|
+
[specs, in_force]
|
|
350
410
|
end
|
|
351
411
|
|
|
352
412
|
# Static cost rank per spec field for AND evaluation order: compiled
|
|
@@ -359,13 +419,16 @@ module Mailmate
|
|
|
359
419
|
body: 2, message_or_body: 2,
|
|
360
420
|
}.freeze
|
|
361
421
|
|
|
362
|
-
# Evaluate cheap, selective specs before expensive ones
|
|
363
|
-
# with AND (order-independent), and
|
|
364
|
-
# first miss — so `b invoice d 7d`
|
|
365
|
-
# before body matching ever runs, not
|
|
366
|
-
# to keep the user's order
|
|
367
|
-
|
|
368
|
-
|
|
422
|
+
# Evaluate cheap, selective specs before expensive ones, within each
|
|
423
|
+
# or-group. Specs in a group combine with AND (order-independent), and
|
|
424
|
+
# matches? short-circuits on the first miss — so `b invoice d 7d`
|
|
425
|
+
# should date-reject 47k messages before body matching ever runs, not
|
|
426
|
+
# after. Stable within a cost rank to keep the user's order
|
|
427
|
+
# deterministic.
|
|
428
|
+
def order_specs(groups)
|
|
429
|
+
groups.map do |specs|
|
|
430
|
+
specs.sort_by.with_index { |(field, _term, _negate), i| [SPEC_COST.fetch(field, 1), i] }
|
|
431
|
+
end
|
|
369
432
|
end
|
|
370
433
|
|
|
371
434
|
# ---- date matching ------------------------------------------------------
|
|
@@ -373,44 +436,163 @@ module Mailmate
|
|
|
373
436
|
# The `#date` index stores fixed-format strings ("2026-03-19 18:55:19
|
|
374
437
|
# -0600", sender-local time with varying UTC offsets — NOT lexically
|
|
375
438
|
# 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
|
-
#
|
|
439
|
+
# hot path avoids Time.parse (~10× slower than fast_time's slicing) and
|
|
440
|
+
# per-call cutoff arithmetic: day terms compile once to an inclusive
|
|
441
|
+
# [lo, hi] range of YYYYMMDD integers; per message, fast_time slices
|
|
442
|
+
# the indexed value into a Time (offset preserved) which localize then
|
|
443
|
+
# converts to the display zone before the day compare. Hour terms
|
|
444
|
+
# (`24h`) compare the same Time as an epoch instant instead.
|
|
445
|
+
|
|
446
|
+
# Slash-date ordering for three-part dates with a trailing 4-digit
|
|
447
|
+
# year: :mdy (American month-first, the default — `8/9/2026` = Aug 9)
|
|
448
|
+
# or :dmy (day-first, the --european flag — `9/8/2026` = Aug 9).
|
|
449
|
+
# ISO Y-M-D is unaffected. Module-level because the compiled-range
|
|
450
|
+
# memo must reset when it flips (the MCP server outlives any one call).
|
|
451
|
+
def date_order
|
|
452
|
+
@date_order || :mdy
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def date_order=(order)
|
|
456
|
+
@date_order = order
|
|
457
|
+
end
|
|
380
458
|
|
|
381
459
|
# Compiled day-range for a date term, memoized per term. nil = term
|
|
382
460
|
# can't match anything. The memo resets when the calendar day rolls
|
|
383
|
-
# over so relative terms
|
|
384
|
-
#
|
|
461
|
+
# over (so relative terms like "1d" stay correct in long-lived
|
|
462
|
+
# processes — the MCP server) or when date_order flips.
|
|
385
463
|
def date_range_for(term)
|
|
386
464
|
today = Date.today
|
|
387
|
-
if @date_ranges_day != today
|
|
465
|
+
if @date_ranges_day != today || @date_ranges_order != date_order
|
|
388
466
|
@date_ranges_day = today
|
|
467
|
+
@date_ranges_order = date_order
|
|
389
468
|
@date_ranges = {}
|
|
390
469
|
end
|
|
391
470
|
return @date_ranges[term] if @date_ranges.key?(term)
|
|
392
471
|
@date_ranges[term] = compile_date_range(term, today)
|
|
393
472
|
end
|
|
394
473
|
|
|
474
|
+
# A term is an optional comparison prefix (>, >=, <, <=) on a period.
|
|
475
|
+
# The prefix reshapes the period's inclusive [lo, hi] window: `>2026-08`
|
|
476
|
+
# is "after August" = [20260901, max], `<2026-08` is "before August" =
|
|
477
|
+
# [min, 20260731]. Bounds are compared as YYYYMMDD integers, so ±1 on a
|
|
478
|
+
# synthetic bound (a month's "day 31", a year's "Dec 31"+1) is safe —
|
|
479
|
+
# no real date falls in the gap. A comparison can produce an empty
|
|
480
|
+
# window (`>3d` — nothing is after a window that already reaches the
|
|
481
|
+
# future); date_spec_error reports those up front rather than letting
|
|
482
|
+
# them silently match nothing.
|
|
395
483
|
def compile_date_range(term, today)
|
|
484
|
+
op = nil
|
|
485
|
+
if term =~ /\A(>=|<=|>|<)(.+)\z/
|
|
486
|
+
op, term = Regexp.last_match(1), Regexp.last_match(2)
|
|
487
|
+
end
|
|
488
|
+
base = compile_period_range(term, today)
|
|
489
|
+
return nil unless base
|
|
490
|
+
return base unless op
|
|
491
|
+
|
|
492
|
+
lo, hi = base
|
|
493
|
+
case op
|
|
494
|
+
when ">" then [hi + 1, 9999_12_31]
|
|
495
|
+
when ">=" then [lo, 9999_12_31]
|
|
496
|
+
when "<" then [0, lo - 1]
|
|
497
|
+
when "<=" then [0, hi]
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def compile_period_range(term, today)
|
|
396
502
|
if term =~ /\A(\d+)([dwmy])\z/
|
|
397
503
|
n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
|
|
504
|
+
return nil if n.zero? # a zero-length window matches nothing
|
|
505
|
+
# N units ENDING today: `1d` = today only, `7d` = the last 7
|
|
506
|
+
# calendar days including today. (Off-by-one fixed 2026-08-11 to
|
|
507
|
+
# match the MailMate app, where `d 1d` is today's mail — the old
|
|
508
|
+
# cutoff of today-N made `d 1d` span two calendar days. For a
|
|
509
|
+
# rolling 24-hour clock window, that's `d 24h` now.)
|
|
398
510
|
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)
|
|
511
|
+
when "d" then today - (n - 1)
|
|
512
|
+
when "w" then today - (n * 7 - 1)
|
|
513
|
+
when "m" then (today << n) + 1
|
|
514
|
+
when "y" then (today << (n * 12)) + 1
|
|
403
515
|
end
|
|
404
516
|
return [ymd_int(cutoff), 9999_12_31]
|
|
405
517
|
end
|
|
406
518
|
|
|
407
519
|
parts = term.tr("/.", "-").split("-")
|
|
408
|
-
|
|
409
|
-
|
|
520
|
+
return nil unless parts.any? && parts.all? { |p| p.match?(/\A\d+\z/) }
|
|
521
|
+
|
|
410
522
|
case parts.size
|
|
411
|
-
when 1
|
|
412
|
-
|
|
413
|
-
|
|
523
|
+
when 1
|
|
524
|
+
y = parts[0].to_i
|
|
525
|
+
return nil if y.zero?
|
|
526
|
+
[y * 10_000 + 101, y * 10_000 + 1231]
|
|
527
|
+
when 2
|
|
528
|
+
# Year-first (2026-08) or month-first with a 4-digit year (8/2026).
|
|
529
|
+
y, m = parts[1].length == 4 ? [parts[1], parts[0]] : [parts[0], parts[1]]
|
|
530
|
+
y, m = y.to_i, m.to_i
|
|
531
|
+
return nil if y.zero? || !(1..12).cover?(m)
|
|
532
|
+
[y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
|
|
533
|
+
when 3
|
|
534
|
+
# ISO year-first, or slash-date with trailing 4-digit year ordered
|
|
535
|
+
# per date_order. Impossible calendar dates (2026-02-31, month 13)
|
|
536
|
+
# compile to nil so date_spec_error names them instead of the
|
|
537
|
+
# search silently matching nothing.
|
|
538
|
+
y, m, d =
|
|
539
|
+
if parts[0].length == 4
|
|
540
|
+
parts.map(&:to_i)
|
|
541
|
+
elsif parts[2].length == 4
|
|
542
|
+
a, b, yr = parts.map(&:to_i)
|
|
543
|
+
date_order == :dmy ? [yr, b, a] : [yr, a, b]
|
|
544
|
+
end
|
|
545
|
+
return nil unless y && Date.valid_date?(y, m, d)
|
|
546
|
+
[ymd = y * 10_000 + m * 100 + d, ymd]
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Usage-error string for the date specs in ONE or-group (specs within a
|
|
551
|
+
# group AND together; the caller decides how errors across groups
|
|
552
|
+
# combine), nil when they're fine. Two failure classes, both of which
|
|
553
|
+
# would otherwise surface as a clean, successful, empty result — the
|
|
554
|
+
# silent-nothing this gem keeps having to fight: a single term that
|
|
555
|
+
# cannot match anything (`d >3d`, `d garbage`), and positive terms
|
|
556
|
+
# whose windows don't intersect (`d >2026 d <2025`). Negated terms
|
|
557
|
+
# subtract rather than intersect, so they're validated individually
|
|
558
|
+
# but excluded from the intersection.
|
|
559
|
+
def date_spec_error(specs)
|
|
560
|
+
day_terms, hour_terms = [], []
|
|
561
|
+
specs.each do |field, term, negate|
|
|
562
|
+
next unless field == :date
|
|
563
|
+
range = hour_range_for(term) || date_range_for(term)
|
|
564
|
+
if range.nil? || range[0] > range[1]
|
|
565
|
+
return "date term cannot match anything: d #{term}#{date_term_hint(term)}"
|
|
566
|
+
end
|
|
567
|
+
next if negate
|
|
568
|
+
(term.end_with?("h") ? hour_terms : day_terms) << [term, range]
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Day windows intersect with day windows and hour windows with hour
|
|
572
|
+
# windows; the two families use different scales (YYYYMMDD ints vs
|
|
573
|
+
# epoch seconds), and a cross-family contradiction is not worth the
|
|
574
|
+
# unit conversion to detect.
|
|
575
|
+
[day_terms, hour_terms].each do |family|
|
|
576
|
+
next if family.size < 2
|
|
577
|
+
lo = family.map { |_, r| r[0] }.max
|
|
578
|
+
hi = family.map { |_, r| r[1] }.min
|
|
579
|
+
next if lo <= hi
|
|
580
|
+
return "impossible date range (empty intersection): #{family.map { |t, _| "d #{t}" }.join(" ")}"
|
|
581
|
+
end
|
|
582
|
+
nil
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# `13/8/2026` under month-first ordering is month 13 — almost certainly
|
|
586
|
+
# a day-first date (and vice versa). Name the likely fix instead of
|
|
587
|
+
# leaving the generic cannot-match.
|
|
588
|
+
def date_term_hint(term)
|
|
589
|
+
parts = term.sub(/\A(>=|<=|>|<)/, "").tr("/.", "-").split("-")
|
|
590
|
+
return nil unless parts.size == 3 && parts[2].length == 4
|
|
591
|
+
a, b = parts[0].to_i, parts[1].to_i
|
|
592
|
+
if date_order == :mdy && a > 12 && (1..12).cover?(b)
|
|
593
|
+
" (day-first date? pass --european)"
|
|
594
|
+
elsif date_order == :dmy && b > 12 && (1..12).cover?(a)
|
|
595
|
+
" (month-first date? drop --european)"
|
|
414
596
|
end
|
|
415
597
|
end
|
|
416
598
|
|
|
@@ -418,39 +600,56 @@ module Mailmate
|
|
|
418
600
|
d.year * 10_000 + d.month * 100 + d.day
|
|
419
601
|
end
|
|
420
602
|
|
|
421
|
-
#
|
|
422
|
-
#
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
603
|
+
# Rolling clock windows: `24h` = the last 24 hours as an instant range,
|
|
604
|
+
# unlike d/w/m/y which are calendar windows. Returns [lo, hi] epoch
|
|
605
|
+
# floats (lo > hi means the term cannot match — date_spec_error reports
|
|
606
|
+
# it), or nil when the term isn't an hour form. Deliberately NOT
|
|
607
|
+
# memoized: the cutoff moves with the clock, and the MCP server process
|
|
608
|
+
# lives long enough for a cached one to go stale.
|
|
609
|
+
def hour_range_for(term)
|
|
610
|
+
m = /\A(>=|<=|>|<)?(\d+)h\z/.match(term)
|
|
611
|
+
return nil unless m
|
|
612
|
+
op, n = m[1], m[2].to_i
|
|
613
|
+
return [1.0, 0.0] if n.zero?
|
|
614
|
+
|
|
615
|
+
cutoff = Time.now.to_f - (n * 3600)
|
|
616
|
+
case op
|
|
617
|
+
when nil, ">=" then [cutoff, Float::INFINITY]
|
|
618
|
+
when ">" then [1.0, 0.0] # the window already reaches the future
|
|
619
|
+
when "<" then [-Float::INFINITY, cutoff]
|
|
620
|
+
when "<=" then [-Float::INFINITY, Float::INFINITY]
|
|
621
|
+
end
|
|
430
622
|
end
|
|
431
623
|
|
|
624
|
+
# Match on the message's absolute send instant, converted to the display
|
|
625
|
+
# zone via Mailmate.localize — the SAME conversion the date/time output
|
|
626
|
+
# columns use, so the day a term matches is always the day the caller
|
|
627
|
+
# sees in the output. (The raw `#date` index value is sender-local time;
|
|
628
|
+
# matching on its sliced day — the old fast path — made `d 1d` return
|
|
629
|
+
# mail displayed under yesterday's date whenever the sender's calendar
|
|
630
|
+
# ran ahead of the display zone, e.g. a UTC sender after 6pm MDT.)
|
|
432
631
|
def date_matches?(mail, eml_id, term)
|
|
433
|
-
|
|
434
|
-
return false unless range
|
|
435
|
-
|
|
436
|
-
ymd = nil
|
|
632
|
+
t = nil
|
|
437
633
|
if eml_id
|
|
438
634
|
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
|
|
635
|
+
t = fast_time(s) || (Time.parse(s) rescue nil) if s && !s.empty?
|
|
446
636
|
end
|
|
447
|
-
if
|
|
637
|
+
if t.nil? && mail
|
|
448
638
|
raw = mail.date
|
|
449
|
-
|
|
450
|
-
|
|
639
|
+
t = raw.respond_to?(:to_time) ? raw.to_time : raw
|
|
640
|
+
end
|
|
641
|
+
return false unless t
|
|
642
|
+
|
|
643
|
+
if (hours = hour_range_for(term))
|
|
644
|
+
f = t.to_f
|
|
645
|
+
return f >= hours[0] && f <= hours[1]
|
|
451
646
|
end
|
|
452
|
-
return false unless ymd
|
|
453
647
|
|
|
648
|
+
range = date_range_for(term)
|
|
649
|
+
return false unless range
|
|
650
|
+
|
|
651
|
+
local = Mailmate.localize(t)
|
|
652
|
+
ymd = local.year * 10_000 + local.month * 100 + local.day
|
|
454
653
|
ymd >= range[0] && ymd <= range[1]
|
|
455
654
|
rescue StandardError
|
|
456
655
|
false
|
|
@@ -627,26 +826,28 @@ module Mailmate
|
|
|
627
826
|
texts
|
|
628
827
|
end
|
|
629
828
|
|
|
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
|
-
|
|
829
|
+
def matches?(mail, eml_id, groups, headers_only, path = nil, index_only: false, exclude_quoted: false)
|
|
830
|
+
groups.any? do |specs|
|
|
831
|
+
specs.all? do |field, term, negate|
|
|
832
|
+
term_b = term.b
|
|
833
|
+
hit =
|
|
834
|
+
case field
|
|
835
|
+
when :from, :recipients, :cc, :subject, :address_any
|
|
836
|
+
field_value(eml_id, mail, field).include?(term_b)
|
|
837
|
+
when :tag, :keyword
|
|
838
|
+
tag_value(eml_id).include?(term_b)
|
|
839
|
+
when :body
|
|
840
|
+
headers_only ? false : body_matches?(eml_id, mail, path, term, term_b, index_only: index_only, exclude_quoted: exclude_quoted)
|
|
841
|
+
when :message_or_body
|
|
842
|
+
common = %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
|
|
843
|
+
common || (!headers_only && body_matches?(eml_id, mail, path, term, term_b, index_only: index_only, exclude_quoted: exclude_quoted))
|
|
844
|
+
when :date
|
|
845
|
+
date_matches?(mail, eml_id, term)
|
|
846
|
+
when :any
|
|
847
|
+
%i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
|
|
848
|
+
end
|
|
849
|
+
negate ? !hit : hit
|
|
850
|
+
end
|
|
650
851
|
end
|
|
651
852
|
end
|
|
652
853
|
|
data/lib/mailmate/cli/send.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
3
5
|
module Mailmate
|
|
4
6
|
module CLI
|
|
5
7
|
# `mm-send` — send mail through MailMate's `emate` CLI with a markdown body.
|
|
@@ -41,18 +43,37 @@ module Mailmate
|
|
|
41
43
|
|
|
42
44
|
PREAMBLE
|
|
43
45
|
|
|
44
|
-
# Returns the exit status of the spawned `emate` invocation.
|
|
45
|
-
#
|
|
46
|
-
#
|
|
46
|
+
# Returns the exit status of the spawned `emate` invocation.
|
|
47
|
+
#
|
|
48
|
+
# emate must NEVER inherit the caller's real stdin/stdout. Inside the
|
|
49
|
+
# MCP server, fd 0/1 are the JSON-RPC transport, and the previous
|
|
50
|
+
# `system(...)` handed both to emate: it blocked reading the protocol
|
|
51
|
+
# pipe for a body and consumed the next frame as one (a cancelled turn
|
|
52
|
+
# produced a MailMate draft whose entire body was a
|
|
53
|
+
# `notifications/cancelled` frame — the composed body, swapped in via
|
|
54
|
+
# the Ruby-level `$stdin` global, was silently discarded). So: read the
|
|
55
|
+
# body through `$stdin` (honors the MCP's StringIO swap AND a shell
|
|
56
|
+
# pipe), hand it to emate on a private pipe that capture3 EOFs (no
|
|
57
|
+
# more hanging until the server dies), and re-emit emate's output
|
|
58
|
+
# through the `$stdout`/`$stderr` globals so the MCP's capture sees it
|
|
59
|
+
# instead of the protocol stream getting corrupted.
|
|
47
60
|
def run(argv)
|
|
48
61
|
Mailmate::PlatformError.check_darwin!(component: "mm-send")
|
|
49
62
|
unless File.executable?(EMATE_PATH)
|
|
50
63
|
warn "mm-send: emate not found at #{EMATE_PATH}. Is MailMate installed?"
|
|
51
64
|
return 1
|
|
52
65
|
end
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
help = argv.include?("--help") || argv.include?("-h")
|
|
67
|
+
warn PREAMBLE if help
|
|
68
|
+
# --help never reads a body; consuming stdin here would hang an
|
|
69
|
+
# interactive `mm-send --help` waiting for Ctrl-D.
|
|
70
|
+
body = help ? "" : $stdin.read.to_s
|
|
71
|
+
out, err, status = Open3.capture3(EMATE_PATH, "mailto", "--markup", "markdown", *argv, stdin_data: body)
|
|
72
|
+
$stdout.write(out)
|
|
73
|
+
$stderr.write(err)
|
|
74
|
+
# exitstatus is nil for a signal-killed child; the exe shims do
|
|
75
|
+
# `exit run(ARGV)`, which needs an Integer.
|
|
76
|
+
status.exitstatus || 1
|
|
56
77
|
end
|
|
57
78
|
end
|
|
58
79
|
end
|