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 +4 -4
- data/README.md +17 -3
- data/lib/mailmate/cli/search.rb +296 -95
- data/lib/mailmate/cli/send.rb +27 -6
- data/lib/mailmate/mcp.rb +12 -13
- data/lib/mailmate/search_syntax.rb +311 -0
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81aeb9b0f5abd70325c74329db580be4134b27eee50b03f6caef5958b653f288
|
|
4
|
+
data.tar.gz: c614a5dcc2e4fb2bd6fa797e09d1e959a1ecbf9c26ae7c6a21b0e26899c6a32e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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.
|
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 \"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
|
-
|
|
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
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
data/lib/mailmate/version.rb
CHANGED
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.
|
|
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
|