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.
@@ -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. 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. 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 " Examples:"
209
- o.separator " mmsearch 'f medium d 7d' from Medium in last 7 days"
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
- tokens = tokenize(str)
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
- # 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]
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. 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] }
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 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.
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 ("1d") stay correct in long-lived processes
384
- # (the MCP server).
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
- y = parts[0].to_i
409
- return nil if y.zero?
520
+ return nil unless parts.any? && parts.all? { |p| p.match?(/\A\d+\z/) }
521
+
410
522
  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]
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
- # "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
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
- range = date_range_for(term)
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 ymd.nil? && mail
637
+ if t.nil? && mail
448
638
  raw = mail.date
449
- d = raw.respond_to?(:to_time) ? raw.to_time : raw
450
- ymd = d && ymd_int(d.to_date)
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, 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
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
 
@@ -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. Uses
45
- # `system` (not `exec`) so the caller — and the test suite — can
46
- # actually observe the result.
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
- warn PREAMBLE if argv.include?("--help") || argv.include?("-h")
54
- system(EMATE_PATH, "mailto", "--markup", "markdown", *argv)
55
- $?.exitstatus
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