mailmate 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b395d407ecab6e32e9e9d94da136031b0edd5cfa0302809721940a0dcefa2ba7
4
- data.tar.gz: 80d7dc99046e899093e57422153e1b413f7688b5e1d080aa9ae7898778b92136
3
+ metadata.gz: 81aeb9b0f5abd70325c74329db580be4134b27eee50b03f6caef5958b653f288
4
+ data.tar.gz: c614a5dcc2e4fb2bd6fa797e09d1e959a1ecbf9c26ae7c6a21b0e26899c6a32e
5
5
  SHA512:
6
- metadata.gz: 55136934383ab2be45fec5c5787242594a33208b6d71bffedf0b0ee4a0e1176f5876b4b09611c9716afcf34e6d7132a687e6c907a27f586b9a89f5ef7c9e4da1
7
- data.tar.gz: 481da51efb264b36587908af1ab321d5f63c2174a3f2502be5722bb45600243433c210e30ad869737e531a8d4bffa97ffd371835265346c27dfef177d74ff24f
6
+ metadata.gz: 9c27e9400d40047291b3486e6895b47f8e8855985bb74bfe75de2a338c5c5d59356b92d330ad93186828e3aa579f9c6273602432a77f2dbc383b3e7afdfb02aa
7
+ data.tar.gz: c3d898edeb7fe251c9f816dc333014e915bcb8b1e4d63e503a52192b22ca6353bf5657109688c36a71af04060cda6fc816a95c3b680b526382f8afc47e63851c
data/README.md CHANGED
@@ -60,6 +60,16 @@ This repo doubles as a Claude Code plugin marketplace. Inside Claude Code:
60
60
 
61
61
  The plugin's MCP server self-provisions on first launch — Ruby (if needed) and gem dependencies go into `~/.mailmate-mcp`; nothing touches your system Ruby, Homebrew, or shell profile. It runs the plugin's bundled source, so plugin updates take effect without waiting for a gem release. Uninstall: `/plugin uninstall mailmate`, then `rm -rf ~/.mailmate-mcp`.
62
62
 
63
+ ### Claude Cowork (desktop app)
64
+
65
+ The same plugin works in Cowork on the macOS desktop app — verified end to end, including a from-scratch first run:
66
+
67
+ 1. In Cowork: **Customize → Plugins → + → Add marketplace** → `brianmd/mailmate`, then install **mailmate** from the Discover tab.
68
+ 2. Ask Claude about your mail. The first launch provisions the runtime inside Cowork's sandbox, which can take a minute or two, and Cowork will ask you to **allow the connector's commands and file access** — those approvals are the gate to your local mail store.
69
+ 3. The Claude desktop app must be running on the Mac where MailMate lives; `send`, `draft`, `modify`, and `open` additionally need the MailMate app running (as always).
70
+
71
+ Cowork runs the connector through the desktop app's sandboxed bridge, so its provisioning leaves nothing on your real filesystem — not even `~/.mailmate-mcp` (only the Claude Code path creates that directory).
72
+
63
73
  ### One-line installer (any MCP client)
64
74
 
65
75
  ```bash
@@ -163,7 +173,7 @@ mmsearch 'd 2026-05'
163
173
  mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
164
174
  ```
165
175
 
166
- **Quicksearch syntax.** The search-string is a list of specs combined with **AND** (`or`/parens not yet supported). Wrap multi-word terms in `"double quotes"`.
176
+ **Quicksearch syntax.** The search-string is a list of specs combined with **AND**; a bare `or` separates alternatives, and AND binds tighter (no parens write `(f bob or f ann) s invoice` out as `f bob s invoice or f ann s invoice`). After `or`, a bare first term inherits the modifier in force: `d 2024 or 2025 or 2y`. Wrap multi-word terms in `"double quotes"` (also how to search for the literal word "or"). `mmsearch --help` is the canonical, always-current rendering of this table.
167
177
 
168
178
  | Modifier | Scope |
169
179
  |---|---|
@@ -175,9 +185,11 @@ mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
175
185
  | `a <term>` | Any address header contains. |
176
186
  | `b <term>` | Body (plain text) contains. |
177
187
  | `m <term>` | Common headers OR body. |
178
- | `d <date>` | Received date: `Y`, `Y-M`, `Y-M-D`, or relative `1d`/`2w`/`3m`/`1y`. |
188
+ | `d <date>` | Received: `Nh` (rolling clock hours, `24h` = last 24 hours), `Nd`/`Nw`/`Nm`/`Ny` (N calendar units ending today — `1d` = today, `2d` = yesterday + today), or absolute `Y`, `Y-M`, `Y-M-D`. Slash dates are month-first American (`8/9/2026` = Aug 9); `--european` flips to day-first. Comparisons on absolute dates: `d >2026-08` (after), `d <2026-08` (before), also `>=`/`<=`. |
179
189
  | `T <tag>` | Tags / IMAP keywords (`K` is a synonym). |
180
- | `!<value>` | Negate, e.g. `f !smith` = From does NOT contain smith. |
190
+ | `!<value>` | Negate, e.g. `f !smith` = From does NOT contain smith; works on dates too (`d !3d` = more than 3 days ago). |
191
+
192
+ Dates match on the **display-zone day** — the same day the `date`/`time` output columns show. An impossible date term or combination (`d 2026-02-31`, `d >2026 d <2025`) is a usage error, not a silent empty result. Familiar foreign `key:value` tokens (`from:bob`, `date:today`, `after:2026-08-01`, `older_than:2w`) are auto-translated to quicksearch with each rewrite announced on stderr; unrecognized keys (`is:unread`) are searched as literal text, and an empty result says so.
181
193
 
182
194
  The `--mailbox` argument accepts an account, an `account/path`, a bare mailbox name matched across accounts, or a **smart-mailbox name** (e.g. `Newsletters`, `Receipts`, `Priority`) whose filter is ANDed into the search.
183
195
 
@@ -414,6 +426,8 @@ A few rough edges to be aware of:
414
426
 
415
427
  ## Status
416
428
 
429
+ 1.7.0 — Search-language release, driven by a study of how LLM agents actually misuse `mmsearch`. The quicksearch syntax reference is now single-sourced (`Mailmate::SearchSyntax`) into both `mmsearch --help` and the MCP `search` description, so the two can no longer drift. Foreign `key:value` dialects (Gmail/Outlook/Spotlight — `from:bob`, `date:today`, `after:2026-08-01`, `older_than:2w`) auto-translate to their exact quicksearch equivalent, loudly: each rewrite is announced on stderr, and untranslatable keys are flagged when a search returns nothing. The language itself grew: boolean `or` (AND binds tighter, no parens; a bare term after `or` inherits the modifier in force), date comparisons (`d >2026-08`, `d <2026-08`, `>=`/`<=`), rolling hour windows (`d 24h`), and American slash dates (`d 8/9/2026`; `--european` for day-first). Two semantic fixes: `d 1d` now means *today* (N calendar units ending today, matching the MailMate app; the old today−N made it span two days), and date matching converts to the display zone — the same conversion the `date`/`time` columns use — so the day a search matches is always the day shown (sender-local index days previously leaked "tomorrow's" mail into `d 1d`). Impossible date terms and combinations (`d 0d`, `d 2026-02-31`, `d >2026 d <2025`) are usage errors instead of silent empty results.
430
+
417
431
  1.6.0 — Distribution release. The repo is now a Claude Code plugin marketplace (`/plugin marketplace add brianmd/mailmate`), and a one-line `install.sh` provisions the MCP server into an isolated `~/.mailmate-mcp` — including a private relocatable Ruby when no Ruby ≥ 3.0 is present — without touching system Ruby, Homebrew, or shell profiles. Every MCP tool now carries a `title` plus `readOnlyHint`/`destructiveHint` annotations (Claude clients use these for permission behavior; Anthropic's directory review requires them), and the README gains a formal Privacy Policy section. No changes to CLI or library behavior.
418
432
 
419
433
  1.5.0 — Reliability and batch-verification for `mm-modify`, plus search/read speedups. `mm-modify` gains a no-window retry guard (a `mid:` open that spawns no viewer would otherwise act on the wrong message) and opt-in effect verification: `--check` confirms a flag/tag/read action landed on the target eml-id by re-reading `#flags` (the only way to catch a duplicate-Message-ID misland). Because MailMate flushes `#flags` to disk ~5 s after acting, a new **`mm-verify`** command plus `mm-modify --emit-check` decouple acting from confirming — collect JSON check-tickets across a batch and verify them all in one flush-wait instead of paying the latency per message. `mmsearch` is substantially faster (compiled date ranges, cheapest-spec-first ordering, bulk-unpack index reader, inverted body search) with bit-identical output; the persistent MCP server now invalidates index caches on disk change. `mmmessage` shows user tags and lazy-loads the `mail` gem (`--raw`/`--mailmate` skip it). MCP: `message` gains `markdown`, `modify` gains a `check` mode (`none|inline|defer`), and a new `verify` tool batch-confirms deferred tickets.
@@ -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 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"
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
data/lib/mailmate/mcp.rb CHANGED
@@ -80,22 +80,19 @@ module Mailmate
80
80
  Search MailMate's .eml files using MailMate's quicksearch syntax.
81
81
  Returns column-aligned CSV. Same engine as the `mmsearch` CLI.
82
82
 
83
- Query = space-separated specs combined with AND (no or/parens yet).
84
- Quote multi-word terms ("invoice due"). Modifiers:
85
- (bare term) From/To/Cc/Subject OR body contains
86
- f <t> From t <t> To/Cc c <t> Cc s <t> Subject
87
- a <t> any address header b <t> body m <t> headers OR body
88
- d <date> received: Y, Y-M, Y-M-D, or relative 1d/2w/3m/1y
89
- T <tag> tag / IMAP keyword (K is a synonym)
90
- !<value> negate, e.g. f !smith (From does NOT contain smith)
83
+ There is NO native key:value form write quicksearch directly using
84
+ the modifiers below. Familiar foreign tokens (`from:bob`,
85
+ `date:today`, `after:2026-08-01`, `older_than:2w`) are auto-translated
86
+ to quicksearch, and the rewrite is announced in the result — that
87
+ announcement means your query was translated, not that it failed.
88
+ Unrecognized keys (`is:unread`, `has:attachment`) are searched as
89
+ literal text and match nothing, silently.
90
+
91
+ #{Mailmate::SearchSyntax.reference(indent: " ")}
92
+
91
93
  The `mailbox` arg also accepts a smart-mailbox name (e.g. Newsletters,
92
94
  Receipts, Priority) whose filter is ANDed into the search.
93
95
 
94
- Examples:
95
- query="f substack d 7d" from Substack in the last 7 days
96
- query="T urgent" tagged "urgent"
97
- query="s 'invoice due' !draft" subject has 'invoice due', not 'draft'
98
-
99
96
  Fields default to: flags date time direction party subject.
100
97
  Prefix with "+" to add to the defaults ("+tags +mailbox"); a bare
101
98
  list replaces them (id is always the first column). Meanings:
@@ -124,6 +121,7 @@ module Mailmate
124
121
  limit: { type: "integer", description: "Stop after N matches." },
125
122
  headers_only: { type: "boolean", description: "Skip body matching (much faster on text searches)." },
126
123
  sort: { type: "string", enum: %w[asc desc none], description: "Sort by date+time. Default: asc." },
124
+ european: { type: "boolean", description: "Slash dates in the query are day-first (d 9/8/2026 = Aug 9). Default: month-first American." },
127
125
  },
128
126
  additionalProperties: false,
129
127
  },
@@ -416,6 +414,7 @@ module Mailmate
416
414
  argv.push("--limit", args["limit"].to_i.to_s) if args["limit"]
417
415
  argv.push("--headers-only") if args["headers_only"]
418
416
  argv.push("--sort", args["sort"].to_s) if args["sort"]
417
+ argv.push("--european") if args["european"]
419
418
  # Positionals: search-string then fields. Only include if the caller
420
419
  # gave us either — otherwise let the CLI apply its defaults.
421
420
  if args.key?("query") || args["fields"]
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Mailmate
6
+ # THE one description of quicksearch syntax. Both surfaces that teach the
7
+ # syntax — `mmsearch --help` and the MCP `search` tool description — render
8
+ # from the tables here, so the two can no longer drift apart (they already
9
+ # had: the CLI said "Nd|Nw|Nm|Ny (relative), or Y, Y-M, Y-M-D" while the MCP
10
+ # said "Y, Y-M, Y-M-D, or relative 1d/2w/3m/1y" — same rules, two wordings,
11
+ # two things to remember to update).
12
+ #
13
+ # Downstream consumers should POINT at these surfaces rather than restate
14
+ # them. A copy of the syntax in someone else's system prompt is a copy that
15
+ # goes stale the next time a modifier is added here.
16
+ module SearchSyntax
17
+ # [spec, meaning]. Order is the teaching order, not alphabetical.
18
+ MODIFIERS = [
19
+ ["<term>", "common headers (from/to/cc/subject) OR body contains <term>"],
20
+ ["f <term>", "from contains"],
21
+ ["t <term>", "to/cc (recipients) contains"],
22
+ ["c <term>", "cc contains"],
23
+ ["s <term>", "subject contains"],
24
+ ["a <term>", "any address header contains"],
25
+ ["b <term>", "body contains"],
26
+ ["m <term>", "common headers OR body (same as a bare term)"],
27
+ ["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (N calendar units ending today; 1d = today), or Y, Y-M, Y-M-D"],
28
+ ["T <tag>", "tag / IMAP keyword contains (K is a synonym)"],
29
+ ].freeze
30
+
31
+ EXAMPLES = [
32
+ ["f substack d 7d", "from Substack in the last 7 days"],
33
+ ["s \"invoice due\" !draft", "subject has 'invoice due', not 'draft'"],
34
+ ["d 2026-05", "received in May 2026"],
35
+ ["d 2026-08-10", "received on one specific day"],
36
+ ["d 1d", "received today (the default); d 2d = yesterday + today"],
37
+ ["d 24h", "received in the last 24 hours (rolling, not calendar)"],
38
+ ["d >=2026-05 d <2026-08", "received May through July 2026"],
39
+ ["d 1h or 2026-08-09", "last hour, plus everything from Aug 9"],
40
+ ["T urgent", "tagged 'urgent'"],
41
+ ].freeze
42
+
43
+ RULES = [
44
+ "Specs combine with AND; `or` separates alternatives, and AND binds tighter",
45
+ "(no parens): (f bob or f ann) s invoice = f bob s invoice or f ann s invoice.",
46
+ "After `or`, a bare first term inherits the modifier in force: d 2024 or 2025.",
47
+ "Wrap multi-word terms in \"double quotes\" (also how to search the word \"or\").",
48
+ "Prefix an operand with ! to negate: f !smith = from does NOT contain smith.",
49
+ "Negation works on dates too: d !3d = received MORE than 3 days ago.",
50
+ "Absolute dates compare: d >2026-08 (after Aug), d <2026-08 (before), also >= <=.",
51
+ "Slash dates are month-first American: d 8/9/2026 = Aug 9 (day-first: --european).",
52
+ "An impossible date combination (d >2026 d <2025) is an error, not 0 results.",
53
+ ].freeze
54
+
55
+ # Search keys from OTHER mail systems (Gmail, Outlook, Apple Mail, IMAP
56
+ # dialects). Quicksearch has no `key:value` form at all, so a term like
57
+ # `date:today` is not a syntax error — it parses as a bare term and
58
+ # searches for the literal string "date:today" in headers and body, which
59
+ # matches nothing. That silence is the whole problem this list exists to
60
+ # break: an agent or a person gets an empty result set that is
61
+ # indistinguishable from "your mail really has nothing", and believes it.
62
+ FOREIGN_KEYS = %w[
63
+ after before older newer older_than newer_than on since until
64
+ date sent received time
65
+ from to cc bcc subject body
66
+ is has in label folder mailbox category filename
67
+ ].freeze
68
+
69
+ # Spec placeholders for the zero-result hint, for foreign keys whose value
70
+ # translate() could NOT rewrite (e.g. `after:8am`). Keys absent here still
71
+ # get flagged, just without a suggested rewrite.
72
+ EQUIVALENTS = {
73
+ "from" => "f <term>", "to" => "t <term>", "cc" => "c <term>",
74
+ "subject" => "s <term>", "body" => "b <term>", "label" => "T <tag>",
75
+ "date" => "d <date>", "sent" => "d <date>", "received" => "d <date>",
76
+ "on" => "d <date>", "after" => "d >=YYYY-MM-DD", "since" => "d >=YYYY-MM-DD",
77
+ "before" => "d <YYYY-MM-DD", "until" => "d <=YYYY-MM-DD",
78
+ "newer_than" => "d Nd", "older_than" => "d !Nd",
79
+ "newer" => "d Nd", "older" => "d !Nd",
80
+ }.freeze
81
+
82
+ # Foreign header-ish keys with a direct quicksearch spec. The value
83
+ # carries over unchanged, so these translate regardless of what it is.
84
+ HEADER_EQUIV = {
85
+ "from" => "f", "to" => "t", "cc" => "c",
86
+ "subject" => "s", "body" => "b", "label" => "T",
87
+ }.freeze
88
+
89
+ # The --help table for the translator. Symbolic, not computed — <N> is
90
+ # resolved against the current date at translation time.
91
+ TRANSLATIONS_HELP = [
92
+ ["from:bob (to: cc: subject: body: label:)", "f bob (t c s b T)"],
93
+ ["-from:bob or !from:bob", "f !bob"],
94
+ ["date:today / date:yesterday", "d 1d / d <that day>"],
95
+ ["date:2026-03-05 or date:3/5/2026 (M/D/Y)", "d 2026-03-05"],
96
+ ["newer_than:2d / older_than:2w", "d 2d / d !2w"],
97
+ ["after:2026-05 or since:2026-05", "d >=2026-05"],
98
+ ["before:2026-08 / until:2026-08", "d <2026-08 / d <=2026-08"],
99
+ ].freeze
100
+
101
+ module_function
102
+
103
+ # The shared syntax reference, indented for embedding. Used verbatim by
104
+ # `mmsearch --help` and by the MCP tool description.
105
+ def reference(indent: " ")
106
+ width = MODIFIERS.map { |spec, _| spec.length }.max
107
+ lines = []
108
+ RULES.each { |r| lines << "#{indent}#{r}" }
109
+ lines << ""
110
+ MODIFIERS.each { |spec, meaning| lines << "#{indent} #{spec.ljust(width)} #{meaning}" }
111
+ lines << ""
112
+ lines << "#{indent}Examples:"
113
+ ex_width = EXAMPLES.map { |q, _| q.length }.max
114
+ EXAMPLES.each { |q, meaning| lines << "#{indent} #{q.ljust(ex_width)} #{meaning}" }
115
+ lines.join("\n")
116
+ end
117
+
118
+ # One token: an (optionally key:-prefixed) quoted string, or a bare run
119
+ # of non-space. Quoted regions survive as single tokens so translate()
120
+ # can leave a deliberate literal search (`s "date:today"`) alone.
121
+ TOKEN_RX = /(?:[-!]?[A-Za-z_]+:)?"[^"]*"|(?:[-!]?[A-Za-z_]+:)?'[^']*'|\S+/
122
+
123
+ # Rewrite foreign `key:value` tokens to their exact quicksearch
124
+ # equivalent, leaving everything else byte-for-byte intact. Returns
125
+ # [query, notes] where notes is [[original_token, replacement], ...] —
126
+ # callers MUST surface the notes (stderr, tool result); a silent rewrite
127
+ # would show the reader a query that never ran.
128
+ #
129
+ # Only rewrites where the equivalence is exact. A foreign key whose value
130
+ # can't be translated faithfully (`after:8am`, `date:next week`) stays in
131
+ # the query as literal text, and zero_result_hint still flags it there.
132
+ def translate(query, today: Date.today, european: false)
133
+ notes = []
134
+ translated = query.to_s.gsub(TOKEN_RX) do |token|
135
+ replacement = translate_token(token, today, european)
136
+ notes << [token, replacement] if replacement
137
+ replacement || token
138
+ end
139
+ [translated, notes]
140
+ end
141
+
142
+ # The stderr/tool-result announcement for a rewritten query. nil when
143
+ # nothing was rewritten.
144
+ def translation_notice(notes)
145
+ return nil if notes.empty?
146
+
147
+ width = notes.map { |from, _| from.length }.max
148
+ lines = ["translated foreign search syntax to MailMate quicksearch (`mmsearch --help`):"]
149
+ notes.each { |from, to| lines << " #{from.ljust(width)} -> #{to}" }
150
+ lines.join("\n")
151
+ end
152
+
153
+ # The --help table, indented for embedding.
154
+ def translation_reference(indent: " ")
155
+ width = TRANSLATIONS_HELP.map { |from, _| from.length }.max
156
+ lines = TRANSLATIONS_HELP.map { |from, to| "#{indent} #{from.ljust(width)} -> #{to}" }
157
+ lines << ""
158
+ lines << "#{indent}Keys with no equivalent (is: has: in: filename: ...) are searched as"
159
+ lines << "#{indent}literal text; an empty result will call them out."
160
+ lines.join("\n")
161
+ end
162
+
163
+ # Foreign `key:value` tokens in a query, lowercased keys, in order of
164
+ # appearance and de-duplicated. Quoted regions are skipped: a deliberate
165
+ # search for the literal text `s "date:today"` is not a mistake.
166
+ def foreign_tokens(query)
167
+ unquoted = query.to_s.gsub(/"[^"]*"|'[^']*'/, " ")
168
+ unquoted.scan(/(?<![\w-])!?([A-Za-z_]+):(\S*)/).filter_map do |key, value|
169
+ k = key.downcase
170
+ next unless FOREIGN_KEYS.include?(k)
171
+ ["#{key}:#{value}", k]
172
+ end.uniq { |_token, k| k }
173
+ end
174
+
175
+ # The advisory a caller should print when a search matched NOTHING and the
176
+ # query carries foreign syntax. nil when there is nothing to say — an
177
+ # ordinary empty result stays silent, because polling for mail that has
178
+ # not arrived yet is a normal, correct thing to do.
179
+ def zero_result_hint(query)
180
+ tokens = foreign_tokens(query)
181
+ return nil if tokens.empty?
182
+
183
+ quoted = tokens.map { |token, _| "`#{token}`" }.join(", ")
184
+ lines = ["0 results, and #{quoted} #{tokens.size == 1 ? "is not" : "are not"} " \
185
+ "MailMate quicksearch syntax — it was searched for as literal text."]
186
+ tokens.each do |token, key|
187
+ eq = EQUIVALENTS[key] or next
188
+ lines << " #{token} -> #{eq}"
189
+ end
190
+ lines << "Run `mmsearch --help` for the full syntax."
191
+ lines.join("\n")
192
+ end
193
+
194
+ # ---- translation internals -------------------------------------------
195
+
196
+ # nil = not a rewritable token (not key:value, unknown key, or a value
197
+ # with no faithful equivalent).
198
+ def translate_token(token, today, european = false)
199
+ m = token.match(/\A(?<neg>[-!])?(?<key>[A-Za-z_]+):(?<value>.+)\z/m)
200
+ return nil unless m
201
+
202
+ key = m[:key].downcase
203
+ value = unquote(m[:value])
204
+ return nil if value.empty?
205
+
206
+ if (spec = HEADER_EQUIV[key])
207
+ negated = !m[:neg].nil?
208
+ # `f !"a b"` won't tokenize (the ! detaches the quotes) — leave a
209
+ # negated multi-word value alone rather than emit a broken spec.
210
+ return nil if negated && value =~ /\s/
211
+ operand = value =~ /\s/ ? "\"#{value}\"" : value
212
+ return "#{spec} #{negated ? "!" : ""}#{operand}"
213
+ end
214
+
215
+ # Date keys: Gmail has no negated date form, so a -/! prefix here means
216
+ # the caller is inventing syntax — don't guess at intent.
217
+ return nil if m[:neg]
218
+
219
+ case key
220
+ when "date", "on", "sent", "received", "time"
221
+ translate_point_date(value, today, european)
222
+ when "after", "since"
223
+ translate_after(value, today, european)
224
+ when "before"
225
+ translate_before(value, today, "<", european)
226
+ when "until"
227
+ translate_before(value, today, "<=", european)
228
+ when "newer_than", "newer"
229
+ (rel = parse_relative(value)) && "d #{rel}"
230
+ when "older_than", "older"
231
+ (rel = parse_relative(value)) && "d !#{rel}"
232
+ end
233
+ end
234
+
235
+ def unquote(value)
236
+ case value
237
+ when /\A"(.*)"\z/m, /\A'(.*)'\z/m then Regexp.last_match(1)
238
+ else value
239
+ end
240
+ end
241
+
242
+ # A day-, month-, or year-precision point in time. `d <period>` matches
243
+ # exactly that period in the engine, so these are exact.
244
+ def translate_point_date(value, today, european = false)
245
+ v = value.downcase
246
+ return "d 1d" if v == "today"
247
+ return "d #{(today - 1).strftime("%Y-%m-%d")}" if v == "yesterday"
248
+ # `date:8/10/2026-today` (seen in real transcripts): a range whose end
249
+ # is now IS an after-window.
250
+ return translate_after(v.delete_suffix("-today"), today, european) if v.end_with?("-today")
251
+ return "d #{v}" if v =~ /\A\d+[dwmy]\z/
252
+
253
+ (period = normalize_period(v, european)) && "d #{period}"
254
+ end
255
+
256
+ # Gmail's after: includes the named day; since: likewise → >=.
257
+ def translate_after(value, today, european = false)
258
+ v = value.downcase
259
+ return "d 1d" if v == "today"
260
+ return "d 2d" if v == "yesterday"
261
+
262
+ (period = normalize_period(v, european)) && "d >=#{period}"
263
+ end
264
+
265
+ # Gmail's before: excludes the named day → <. until: includes it → <=.
266
+ def translate_before(value, today, op, european = false)
267
+ v = value.downcase
268
+ v = today.strftime("%Y-%m-%d") if v == "today"
269
+ v = (today - 1).strftime("%Y-%m-%d") if v == "yesterday"
270
+
271
+ (period = normalize_period(v, european)) && "d #{op}#{period}"
272
+ end
273
+
274
+ # "2026", "2026-05", "2026-03-05", "3/5/2026", "2026/3/5" → the
275
+ # normalized absolute period string quicksearch expects, or nil.
276
+ def normalize_period(value, european = false)
277
+ if (day = parse_day(value, european))
278
+ day.strftime("%Y-%m-%d")
279
+ elsif value =~ %r{\A(\d{4})[-/.](\d{1,2})\z}
280
+ format("%04d-%02d", Regexp.last_match(1).to_i, Regexp.last_match(2).to_i)
281
+ elsif value =~ /\A\d{4}\z/
282
+ value
283
+ end
284
+ end
285
+
286
+ # Gmail relative units (d/m/y, plus w) carry over as-is: `d N<u>` uses
287
+ # the same calendar arithmetic.
288
+ def parse_relative(value)
289
+ value =~ /\A(\d+)\s*([dwmy])\z/ ? "#{Regexp.last_match(1)}#{Regexp.last_match(2)}" : nil
290
+ end
291
+
292
+ # Y-M-D (any of - / . separators), or slash-dates with a trailing
293
+ # 4-digit year — US M/D/Y by default, D/M/Y when european. Two-digit
294
+ # years are ambiguous across dialects — refused rather than guessed.
295
+ def parse_day(value, european = false)
296
+ parts = value.split(%r{[-/.]})
297
+ return nil unless parts.size == 3 && parts.all? { |p| p =~ /\A\d+\z/ }
298
+
299
+ y, m, d =
300
+ if parts[0].length == 4
301
+ [parts[0], parts[1], parts[2]]
302
+ elsif parts[2].length == 4
303
+ european ? [parts[2], parts[1], parts[0]] : [parts[2], parts[0], parts[1]]
304
+ end
305
+ return nil unless y
306
+
307
+ y, m, d = y.to_i, m.to_i, d.to_i
308
+ Date.valid_date?(y, m, d) ? Date.new(y, m, d) : nil
309
+ end
310
+ end
311
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "1.6.0"
4
+ VERSION = "1.7.0"
5
5
  end
data/lib/mailmate.rb CHANGED
@@ -50,6 +50,7 @@ require_relative "mailmate/mailbox_graph"
50
50
  require_relative "mailmate/source_resolver"
51
51
  require_relative "mailmate/var_resolver"
52
52
  require_relative "mailmate/filter_classifier"
53
+ require_relative "mailmate/search_syntax"
53
54
 
54
55
  module Mailmate
55
56
  # First-run bootstrap. If ~/.config/mailmate/config.yml is missing,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailmate
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Murphy-Dye
@@ -153,6 +153,7 @@ files:
153
153
  - lib/mailmate/parser.rb
154
154
  - lib/mailmate/part_lookup.rb
155
155
  - lib/mailmate/platform_error.rb
156
+ - lib/mailmate/search_syntax.rb
156
157
  - lib/mailmate/source_resolver.rb
157
158
  - lib/mailmate/var_resolver.rb
158
159
  - lib/mailmate/version.rb