mailmate 1.8.1 → 2.0.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 +13 -5
- data/lib/mailmate/cli/search.rb +283 -52
- data/lib/mailmate/mcp.rb +7 -3
- data/lib/mailmate/search_syntax.rb +14 -4
- data/lib/mailmate/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 284cbe7b6bd7c22400073332c186ec005a8c7bd7f60953084d7067ebf4ec9b6a
|
|
4
|
+
data.tar.gz: 5e5fa97e0c3eb222bc081c5d29ce611c65de9c159476075a844c91bf00cfcafa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 573429618b13cba013fcb7b4c07db6284335887f577fe0b8d4d2b3c91d2961815d6a32005254d7b848b5c71716a2ee7a0d011caa4a33ab7d35ac1c74c1448700
|
|
7
|
+
data.tar.gz: 7e3ef0a93e0462a60f97028a84e9d360e0c8560552100349bd0f348a962e5b76ccf4b5d5febfdefa1b522e8c73aa4934c1d5a17b1bc3c5cc84a185e49709c022
|
data/README.md
CHANGED
|
@@ -169,8 +169,11 @@ mmsearch 's "invoice due" !draft'
|
|
|
169
169
|
# Received in May 2026
|
|
170
170
|
mmsearch 'd 2026-05'
|
|
171
171
|
|
|
172
|
-
# Custom columns +
|
|
172
|
+
# Custom columns + the 20 newest + raw CSV (no padding)
|
|
173
173
|
mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
|
|
174
|
+
|
|
175
|
+
# The 10 newest from acme, listed by sender (not the 10 alphabetically-first senders)
|
|
176
|
+
mmsearch 'f acme' --limit 10 --sort from
|
|
174
177
|
```
|
|
175
178
|
|
|
176
179
|
**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.
|
|
@@ -185,17 +188,20 @@ mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
|
|
|
185
188
|
| `a <term>` | Any address header contains. |
|
|
186
189
|
| `b <term>` | Body (plain text) contains. |
|
|
187
190
|
| `m <term>` | Common headers OR body. |
|
|
188
|
-
| `d <date>` | Received: `Nh` (rolling clock hours, `24h` = last 24 hours), `Nd`/`Nw`/`Nm`/`Ny` (
|
|
191
|
+
| `d <date>` | Received: `Nh` (rolling clock hours, `24h` = last 24 hours), `Nd`/`Nw`/`Nm`/`Ny` (calendar units floored to the unit start, app parity — `1d` = today, `1w` = this week, `1y` = this year), absolute `Y`, `Y-M`, `Y-M-D`, or day-of-month `D` / `M-D` (`d 7` = the most recent 7th). 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 `>=`/`<=`. |
|
|
189
192
|
| `T <tag>` | Tags / IMAP keywords (`K` is a synonym). |
|
|
190
|
-
| `is:<state>` | Message state: `is:unread`, `is:read`, `is:flagged`, `is:replied`, `is:draft` (Gmail synonyms `starred`/`answered` work; `-is:unread` negates). |
|
|
191
|
-
| `has:attachment` | Root MIME type is `multipart/mixed`
|
|
193
|
+
| `is:<state>` | Message state: `is:unread`, `is:read`, `is:flagged`, `is:replied`, `is:draft`, `is:archived` (Gmail synonyms `starred`/`answered` work; `-is:unread` negates; `in:inbox`/`in:archive` translate onto `is:archived`). |
|
|
194
|
+
| `has:attachment` | Root MIME type is `multipart/mixed`; wrapper types (`signed`/`encrypted`/`related`) are opened and checked for real attachments. |
|
|
195
|
+
| `<header>:<term>` | Any indexed header contains (`delivered-to:joe`). A header this store has never seen is a usage error, not an empty result. |
|
|
192
196
|
| `!<value>` | Negate, e.g. `f !smith` = From does NOT contain smith; works on dates too (`d !3d` = more than 3 days ago). |
|
|
193
197
|
|
|
194
198
|
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.
|
|
195
199
|
|
|
196
200
|
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.
|
|
197
201
|
|
|
198
|
-
**Output fields.** Default columns are `flags date time direction party subject`. Prefix a field list with `+` to add to the defaults; a bare list replaces them (`id`
|
|
202
|
+
**Output fields.** Default columns are `flags date time direction party subject`. Prefix a field list with `+` to add to the defaults; a bare list replaces them (include `id` yourself if you want it).
|
|
203
|
+
|
|
204
|
+
**Limiting and ordering.** `--limit N` returns the N *newest* matches, chosen after the full scan; `--limit-by KEY[:asc|desc]` changes which rows survive (`date:asc` = the N oldest). `--sort KEY[:asc|desc]` orders the emitted rows independently, so `--limit 10 --sort from` is the ten newest shown by sender. Bare `--sort asc|desc|none` still means date; a bare key reads the way the key does (date/time newest-first, text A→Z); ties break newest-first. A truncated result says so on stderr (`[limit] showing 200 of 319 matches …`) so stdout stays clean CSV. `--scan-limit N` is a different thing: it stops the *scan* after N matches in undefined order — a speed bound for slow `--all` body scans, never a way to pick rows. (Before 2.0, `--limit` had those scan-cap semantics: N arbitrary matches, however sorted.)
|
|
199
205
|
|
|
200
206
|
| Field | What it shows |
|
|
201
207
|
|---|---|
|
|
@@ -433,6 +439,8 @@ A few rough edges to be aware of:
|
|
|
433
439
|
|
|
434
440
|
## Status
|
|
435
441
|
|
|
442
|
+
1.9.0 — App-parity search release. Arbitrary header specs are native (`delivered-to:joe`, `x-mailer.name:mailmate` — subpaths search the whole header value); index-only by design: a header the store has never seen is a usage error suggesting quotes, never a `Mail.read` per candidate. Relative calendar units now floor to the unit start, matching the app's documented semantics (`1w` = this week, `1y` = this year; `Nh` stays a rolling clock window on purpose). Day-of-month terms (`d 7` = the most recent 7th, `d 12-25` = last Christmas) close the last known silent-empty. `is:archived` joins the state specs (path-derived, same source as the flags column), with `in:inbox`/`in:archive` translating onto it; `has:attachment` now opens `multipart/signed`/`encrypted`/`related` wrappers and checks for real attachments. New `rake release` runs the whole mechanical release — clean-tree + green-tests + unreleased-version guards, then build, tag, sync, `gem push`.
|
|
443
|
+
|
|
436
444
|
1.8.0 — Reply derivation, and message-state specs.
|
|
437
445
|
|
|
438
446
|
**Composing:** replies and forwards are now derived from the parent instead of hand-assembled. `mm-send` / `mm-draft` gain `--reply-to <id>`, `--reply-all-to <id>`, `--forward <id>` (eml-id or Message-ID), which compute `In-Reply-To`, the full `References` chain, recipients and the `Re:`/`Fwd:` subject, plus `--no-quote` and `--print-prefill` (derived fields as JSON, sends nothing). The MCP `send`/`draft` tools take the same `reply_to` / `reply_all_to` / `forward` arguments. Explicitly-passed fields win over derived ones, and overriding a visible field never drops the threading headers. A forward deliberately does *not* thread into the original conversation. The chain is built in exactly one place (`Mailmate::ReplyPrefill`), and `--header` values now route through one sanitizer (`Mailmate::HeaderValue`) — previously the MCP's own argv builder lacked the CR/LF defense that stops a parent `Message-ID` smuggling extra headers. All of it is documented once in [docs/Composing and threading.md](docs/Composing%20and%20threading.md), which the README, `--help` preambles and MCP instructions now point at rather than restate. Every `exe/` command answers `--version`, so consumers can check the installed version instead of probing for a flag (the CLIs pass unknown flags through to `emate`, which makes flag-probing unsafe). The gemspec finally carries `homepage` and `metadata`, so the rubygems page links back to the source.
|
data/lib/mailmate/cli/search.rb
CHANGED
|
@@ -38,6 +38,27 @@ module Mailmate
|
|
|
38
38
|
"archive" => "a",
|
|
39
39
|
}.freeze
|
|
40
40
|
|
|
41
|
+
# One matched message on its way to the output. `cells` are the
|
|
42
|
+
# extracted output columns in `fields` order; `keys` holds the values
|
|
43
|
+
# of any sort/limit key that is NOT an output column (extracted while
|
|
44
|
+
# the message was live, so ordering never re-reads a .eml); `instant`
|
|
45
|
+
# is the absolute send time (nil when unknown); `index` is scan order,
|
|
46
|
+
# the last-resort tie-break. Ordering keys off these, never off the
|
|
47
|
+
# cells — a bare fields list may omit `id`, and the old
|
|
48
|
+
# `rows[0].to_i` shortcut silently sorted such runs by readdir order.
|
|
49
|
+
Row = Struct.new(:eml_id, :index, :instant, :cells, :keys) do
|
|
50
|
+
def instant_or_epoch = instant || Time.at(0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `--sort` / `--limit-by` values: `{ key: <field>, dir: :asc|:desc }`,
|
|
54
|
+
# or :none (sort only). Omitted `--sort` keeps the historical
|
|
55
|
+
# date-ascending output; omitted `--limit-by` keeps the N newest.
|
|
56
|
+
DEFAULT_SORT = { key: "date", dir: :asc }.freeze
|
|
57
|
+
DEFAULT_LIMIT_BY = { key: "date", dir: :desc }.freeze
|
|
58
|
+
# A bare KEY (no :DIR) reads the way the key does: dates newest-first,
|
|
59
|
+
# text A→Z.
|
|
60
|
+
DESC_BY_DEFAULT = %w[date time].freeze
|
|
61
|
+
|
|
41
62
|
# All output fields are now index-tier: MailMate maintains a per-header
|
|
42
63
|
# binary index under Database.noindex/Headers/, so extracting from/to/
|
|
43
64
|
# subject/etc. doesn't require opening the .eml. Spec/filter matching
|
|
@@ -63,13 +84,19 @@ module Mailmate
|
|
|
63
84
|
|
|
64
85
|
def run(argv)
|
|
65
86
|
opts = {
|
|
66
|
-
mailbox: "all", limit: nil,
|
|
87
|
+
mailbox: "all", limit: nil, scan_limit: nil, limit_by: DEFAULT_LIMIT_BY,
|
|
88
|
+
headers_only: false, all: false,
|
|
67
89
|
exclude_quoted: false,
|
|
68
|
-
header: true, align: true, sort:
|
|
90
|
+
header: true, align: true, sort: DEFAULT_SORT,
|
|
69
91
|
}
|
|
70
92
|
|
|
71
93
|
parser = build_parser(opts)
|
|
72
|
-
|
|
94
|
+
begin
|
|
95
|
+
parser.parse!(argv)
|
|
96
|
+
rescue OptionParser::ParseError => e
|
|
97
|
+
warn e.message
|
|
98
|
+
return 2
|
|
99
|
+
end
|
|
73
100
|
|
|
74
101
|
self.date_order = opts[:european] ? :dmy : :mdy
|
|
75
102
|
|
|
@@ -163,8 +190,24 @@ module Mailmate
|
|
|
163
190
|
opts: opts,
|
|
164
191
|
)
|
|
165
192
|
|
|
166
|
-
|
|
167
|
-
|
|
193
|
+
# The scan stops exactly at --scan-limit, so hitting it is inferable.
|
|
194
|
+
scan_capped = opts[:scan_limit] && rows.size >= opts[:scan_limit]
|
|
195
|
+
total = rows.size
|
|
196
|
+
rows = apply_limit(rows, opts[:limit], opts[:limit_by], fields)
|
|
197
|
+
sort_rows!(rows, opts[:sort], fields)
|
|
198
|
+
emit_output(rows.map(&:cells), fields, opts)
|
|
199
|
+
# Truncation announces itself on stderr — same idiom as the
|
|
200
|
+
# zero-result hint below: stdout stays clean CSV, exit status stays
|
|
201
|
+
# 0. When the scan cap fired, the match total is itself a sample, so
|
|
202
|
+
# the [limit] notice's "of M" would be false precision; the scan
|
|
203
|
+
# notice speaks alone.
|
|
204
|
+
if scan_capped
|
|
205
|
+
warn "[scan-limit] stopped scanning after #{total} matches in undefined order — " \
|
|
206
|
+
"this is a sample, not the newest #{total}"
|
|
207
|
+
elsif opts[:limit] && total > opts[:limit]
|
|
208
|
+
warn "[limit] showing #{opts[:limit]} of #{total} matches (#{describe_order(opts[:limit_by])}); " \
|
|
209
|
+
"add a date term such as `d 3d` to narrow, or raise --limit"
|
|
210
|
+
end
|
|
168
211
|
# A query written in another mail system's dialect is not a syntax
|
|
169
212
|
# error here — it parses as a literal term and quietly matches
|
|
170
213
|
# nothing. Callers (people and agents alike) read that empty result
|
|
@@ -177,22 +220,84 @@ module Mailmate
|
|
|
177
220
|
0
|
|
178
221
|
end
|
|
179
222
|
|
|
180
|
-
# ----
|
|
223
|
+
# ---- ordering -----------------------------------------------------------
|
|
224
|
+
#
|
|
225
|
+
# Two orderings, two questions. `--limit-by` answers WHICH rows survive
|
|
226
|
+
# the cap (the N newest, by default); `--sort` answers the ORDER they
|
|
227
|
+
# are emitted in. They coincide when both are date — the default — and
|
|
228
|
+
# diverge exactly when MailMate's UI would: "sort by sender, limit 10"
|
|
229
|
+
# is the 10 newest shown by sender, not the 10 alphabetically-first
|
|
230
|
+
# senders. Both are applied AFTER the full scan; the only pre-scan
|
|
231
|
+
# bound is --scan-limit, which picks nothing (see build_parser).
|
|
232
|
+
|
|
233
|
+
# Parses a `--sort`/`--limit-by` value: bare `asc`/`desc` (date, kept
|
|
234
|
+
# for existing invocations), `none` (sort only), `KEY`, or `KEY:DIR`.
|
|
235
|
+
def parse_order(value, allow_none: false)
|
|
236
|
+
v = value.to_s.strip
|
|
237
|
+
return :none if v == "none" && allow_none
|
|
238
|
+
return { key: "date", dir: v.to_sym } if %w[asc desc].include?(v)
|
|
239
|
+
key, dir = v.split(":", 2)
|
|
240
|
+
key = "date" if key.nil? || key.empty?
|
|
241
|
+
unless VALID_FIELDS.include?(key)
|
|
242
|
+
raise OptionParser::InvalidArgument,
|
|
243
|
+
"#{value}: unknown field '#{key}' (valid: #{VALID_FIELDS.join(' ')})"
|
|
244
|
+
end
|
|
245
|
+
dir = (DESC_BY_DEFAULT.include?(key) ? "desc" : "asc") if dir.nil? || dir.empty?
|
|
246
|
+
unless %w[asc desc].include?(dir)
|
|
247
|
+
raise OptionParser::InvalidArgument,
|
|
248
|
+
"#{value}: direction must be asc or desc#{allow_none ? ' (or bare none)' : ''}"
|
|
249
|
+
end
|
|
250
|
+
{ key: key, dir: dir.to_sym }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def describe_order(order)
|
|
254
|
+
return "scan order" if order == :none
|
|
255
|
+
what = order[:key] == "date" ? (order[:dir] == :desc ? "newest first" : "oldest first")
|
|
256
|
+
: "#{order[:key]} #{order[:dir] == :desc ? 'descending' : 'ascending'}"
|
|
257
|
+
"#{what} by #{order[:key]}"
|
|
258
|
+
end
|
|
181
259
|
|
|
182
|
-
#
|
|
183
|
-
# senders in different timezones still order correctly
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
260
|
+
# The sort key value for one row. `date` is the absolute send instant
|
|
261
|
+
# (so senders in different timezones still order correctly), `id` is
|
|
262
|
+
# numeric, everything else is the column text, case-folded.
|
|
263
|
+
def order_value(row, key, column)
|
|
264
|
+
case key
|
|
265
|
+
when "date" then row.instant_or_epoch
|
|
266
|
+
when "id" then row.eml_id.to_i
|
|
267
|
+
else (column ? row.cells[column] : row.keys[key]).to_s.downcase
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Orders `rows` in place by `order` (`{key:, dir:}`), breaking ties
|
|
272
|
+
# newest-first and then by scan order — so "sorted by sender" lists each
|
|
273
|
+
# sender's mail newest-first, deterministically. Ruby's sort is not
|
|
274
|
+
# stable, and stable-over-readdir would only preserve the undefined
|
|
275
|
+
# order this exists to get rid of.
|
|
276
|
+
def order_rows!(rows, order, fields)
|
|
277
|
+
return rows.sort_by!(&:index) if order == :none
|
|
278
|
+
return rows if rows.size < 2
|
|
279
|
+
key, sign = order[:key], (order[:dir] == :desc ? -1 : 1)
|
|
280
|
+
column = fields.index(key)
|
|
281
|
+
rows.sort! do |a, b|
|
|
282
|
+
c = (order_value(a, key, column) <=> order_value(b, key, column)) || 0
|
|
283
|
+
c *= sign
|
|
284
|
+
c = b.instant_or_epoch <=> a.instant_or_epoch if c.zero? && key != "date"
|
|
285
|
+
c = a.index <=> b.index if c.zero?
|
|
286
|
+
c
|
|
193
287
|
end
|
|
194
|
-
|
|
195
|
-
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Keeps the top `limit` rows by `limit_by`, taken over the FULL match
|
|
291
|
+
# set. Returns the same array when no cap applies. Rows come back in
|
|
292
|
+
# limit-by order; `sort_rows!` re-orders them for output.
|
|
293
|
+
def apply_limit(rows, limit, limit_by, fields)
|
|
294
|
+
return rows if limit.nil? || rows.size <= limit
|
|
295
|
+
order_rows!(rows, limit_by, fields)
|
|
296
|
+
rows.first(limit)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def sort_rows!(rows, order, fields)
|
|
300
|
+
order_rows!(rows, order, fields)
|
|
196
301
|
end
|
|
197
302
|
|
|
198
303
|
# ---- option parsing -----------------------------------------------------
|
|
@@ -213,14 +318,19 @@ module Mailmate
|
|
|
213
318
|
o.separator "OPTIONS"
|
|
214
319
|
o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v }
|
|
215
320
|
o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v }
|
|
216
|
-
o.on("--limit N", Integer,
|
|
321
|
+
o.on("--limit N", Integer,
|
|
322
|
+
"Return at most N rows: the top N by --limit-by (default: the N newest), taken after the full scan. Announces truncation on stderr.") { |n| opts[:limit] = n }
|
|
323
|
+
o.on("--limit-by KEY[:DIR]",
|
|
324
|
+
"Which rows --limit keeps. KEY is any output field; DIR is asc|desc (bare date/time = desc, text keys = asc). Default: date:desc") { |v| opts[:limit_by] = parse_order(v) }
|
|
325
|
+
o.on("--scan-limit N", Integer,
|
|
326
|
+
"Stop SCANNING after N matches, in undefined order — a speed bound for slow --all body scans, never a way to pick rows. Announces on stderr.") { |n| opts[:scan_limit] = n }
|
|
217
327
|
o.on("--headers-only", "Skip body matching entirely") { opts[:headers_only] = true }
|
|
218
328
|
o.on("--all", "Include un-indexed messages in body matching by lazily reading and parsing each .eml. Slow (tens of seconds to minutes on large archives). Default behavior matches MailMate's UI: only check messages MailMate has body-indexed — fast, but bounded.") { opts[:all] = true }
|
|
219
329
|
o.on("--exclude-quoted", "Match body only against #unquoted text — skip MailMate's #quoted index (forwarded/replied-to text). Tightens search to fresh content; gets you closer to MailMate UI's body-search result set, at the cost of missing hits in quoted sections.") { opts[:exclude_quoted] = true }
|
|
220
330
|
o.on("--no-header", "Suppress column header row") { opts[:header] = false }
|
|
221
331
|
o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
|
|
222
|
-
o.on("--sort
|
|
223
|
-
"
|
|
332
|
+
o.on("--sort KEY[:DIR]",
|
|
333
|
+
"Order of the emitted rows. asc|desc|none alone mean date; or any output field, e.g. from, subject:desc (bare date/time = desc, text keys = asc). Ties break newest-first. Default: date:asc") { |v| opts[:sort] = parse_order(v, allow_none: true) }
|
|
224
334
|
o.on("--european",
|
|
225
335
|
"Slash dates are day-first: d 9/8/2026 = Aug 9 (default: month-first American)") { opts[:european] = true }
|
|
226
336
|
o.separator ""
|
|
@@ -405,6 +515,14 @@ module Mailmate
|
|
|
405
515
|
# Gmail callers actually write.
|
|
406
516
|
negate ||= operand.start_with?("-")
|
|
407
517
|
specs << [:state, operand.delete_prefix("-").downcase, negate]
|
|
518
|
+
elsif !quoted && (hdr = header_spec_token(operand))
|
|
519
|
+
# Arbitrary header specs (delivered-to:joe) — native app
|
|
520
|
+
# syntax per the manual. Foreign keys (date:, from:, ...) never
|
|
521
|
+
# reach here: translate() rewrote the translatable ones before
|
|
522
|
+
# parsing, and the rest stay literal so zero_result_hint can
|
|
523
|
+
# suggest their quicksearch equivalent.
|
|
524
|
+
negate ||= operand.start_with?("-")
|
|
525
|
+
specs << [:header, hdr, negate]
|
|
408
526
|
else
|
|
409
527
|
# A bare term opening an or-group inherits the modifier in
|
|
410
528
|
# force (`d 2024 or 2025`). Elsewhere it is MailMate's
|
|
@@ -420,13 +538,29 @@ module Mailmate
|
|
|
420
538
|
[specs, in_force]
|
|
421
539
|
end
|
|
422
540
|
|
|
541
|
+
# The downcased "name:value" for a bare token that should parse as an
|
|
542
|
+
# arbitrary-header spec, nil otherwise. Excluded: keys the translator
|
|
543
|
+
# owns (FOREIGN_KEYS — their untranslatable forms stay literal for the
|
|
544
|
+
# zero-result hint), state keys (is/has, handled first), and
|
|
545
|
+
# URL-shaped tokens (http://... is a term, not a search of the
|
|
546
|
+
# nonexistent "http" header).
|
|
547
|
+
def header_spec_token(operand)
|
|
548
|
+
m = operand.match(/\A-?([A-Za-z][\w-]*(?:\.[\w.-]+)?):(\S+)\z/)
|
|
549
|
+
return nil unless m
|
|
550
|
+
return nil if m[2].start_with?("/")
|
|
551
|
+
key = Mailmate::SearchSyntax.normalize_key(m[1].sub(/\..*/, ""))
|
|
552
|
+
return nil if Mailmate::SearchSyntax::FOREIGN_KEYS.include?(key)
|
|
553
|
+
return nil if %w[is has].include?(key)
|
|
554
|
+
"#{m[1]}:#{m[2]}".downcase
|
|
555
|
+
end
|
|
556
|
+
|
|
423
557
|
# Static cost rank per spec field for AND evaluation order: compiled
|
|
424
558
|
# date compare < header/tag index lookup < body matching (resolves
|
|
425
559
|
# part-ids and walks every body segment). Used by order_specs.
|
|
426
560
|
SPEC_COST = {
|
|
427
561
|
date: 0,
|
|
428
562
|
from: 1, recipients: 1, cc: 1, subject: 1, address_any: 1, any: 1,
|
|
429
|
-
tag: 1, keyword: 1, state: 1,
|
|
563
|
+
tag: 1, keyword: 1, state: 1, header: 1,
|
|
430
564
|
body: 2, message_or_body: 2,
|
|
431
565
|
}.freeze
|
|
432
566
|
|
|
@@ -438,6 +572,7 @@ module Mailmate
|
|
|
438
572
|
"flagged" => :flagged, "starred" => :flagged,
|
|
439
573
|
"replied" => :replied, "answered" => :replied,
|
|
440
574
|
"draft" => :draft,
|
|
575
|
+
"archived" => :archived, "archive" => :archived,
|
|
441
576
|
"attachment" => :attachment, "attachments" => :attachment,
|
|
442
577
|
}.freeze
|
|
443
578
|
|
|
@@ -528,16 +663,23 @@ module Mailmate
|
|
|
528
663
|
if term =~ /\A(\d+)([dwmy])\z/
|
|
529
664
|
n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
|
|
530
665
|
return nil if n.zero? # a zero-length window matches nothing
|
|
531
|
-
#
|
|
532
|
-
#
|
|
533
|
-
#
|
|
534
|
-
#
|
|
535
|
-
# rolling
|
|
666
|
+
# Calendar units floored to the unit start, matching the app's
|
|
667
|
+
# documented semantics ("1y means this year and not 365 days"):
|
|
668
|
+
# 1d = today, 1w = this ISO week (from Monday), 1m = this month,
|
|
669
|
+
# 1y = this year; N reaches back N-1 further units. Only `Nh` is a
|
|
670
|
+
# rolling clock window — that split is deliberate (2026-08-18):
|
|
671
|
+
# calendar words mean calendar spans, and "the last 24 hours" is
|
|
672
|
+
# spelled d 24h.
|
|
536
673
|
cutoff = case u
|
|
537
674
|
when "d" then today - (n - 1)
|
|
538
|
-
when "w"
|
|
539
|
-
|
|
540
|
-
|
|
675
|
+
when "w"
|
|
676
|
+
start = today - (7 * (n - 1))
|
|
677
|
+
start - (start.cwday - 1)
|
|
678
|
+
when "m"
|
|
679
|
+
start = today << (n - 1)
|
|
680
|
+
Date.new(start.year, start.month, 1)
|
|
681
|
+
when "y"
|
|
682
|
+
Date.new(today.year - (n - 1), 1, 1)
|
|
541
683
|
end
|
|
542
684
|
return [ymd_int(cutoff), 9999_12_31]
|
|
543
685
|
end
|
|
@@ -547,15 +689,34 @@ module Mailmate
|
|
|
547
689
|
|
|
548
690
|
case parts.size
|
|
549
691
|
when 1
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
692
|
+
if parts[0].length == 4
|
|
693
|
+
y = parts[0].to_i
|
|
694
|
+
return nil if y.zero?
|
|
695
|
+
[y * 10_000 + 101, y * 10_000 + 1231]
|
|
696
|
+
else
|
|
697
|
+
# App semantics: a bare small number is a day of the current
|
|
698
|
+
# month — or the most recent month containing that day when it
|
|
699
|
+
# hasn't happened yet (`d 7` on the 5th = last month's 7th).
|
|
700
|
+
most_recent_day_range(parts[0].to_i, today)
|
|
701
|
+
end
|
|
553
702
|
when 2
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
703
|
+
if parts[1].length == 4
|
|
704
|
+
# Month-first with a 4-digit year (8/2026).
|
|
705
|
+
y, m = parts[1].to_i, parts[0].to_i
|
|
706
|
+
return nil if y.zero? || !(1..12).cover?(m)
|
|
707
|
+
[y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
|
|
708
|
+
elsif parts[0].length == 4
|
|
709
|
+
# Year-first (2026-08).
|
|
710
|
+
y, m = parts[0].to_i, parts[1].to_i
|
|
711
|
+
return nil if y.zero? || !(1..12).cover?(m)
|
|
712
|
+
[y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
|
|
713
|
+
else
|
|
714
|
+
# No year: month + day, ordered per date_order, most recent
|
|
715
|
+
# occurrence (`d 12-25` in August = last year's Dec 25).
|
|
716
|
+
a, b = parts.map(&:to_i)
|
|
717
|
+
m, d = date_order == :dmy ? [b, a] : [a, b]
|
|
718
|
+
most_recent_month_day_range(m, d, today)
|
|
719
|
+
end
|
|
559
720
|
when 3
|
|
560
721
|
# ISO year-first, or slash-date with trailing 4-digit year ordered
|
|
561
722
|
# per date_order. Impossible calendar dates (2026-02-31, month 13)
|
|
@@ -590,7 +751,14 @@ module Mailmate
|
|
|
590
751
|
# would otherwise quietly match no message ever.
|
|
591
752
|
if field == :state && !STATE_CANON.key?(term.split(":", 2).last)
|
|
592
753
|
return "state term cannot match anything: #{term} " \
|
|
593
|
-
"(known: is:unread is:read is:flagged is:replied is:draft has:attachment)"
|
|
754
|
+
"(known: is:unread is:read is:flagged is:replied is:draft is:archived has:attachment)"
|
|
755
|
+
end
|
|
756
|
+
if field == :header
|
|
757
|
+
name = term.split(":", 2).first.sub(/\..*/, "")
|
|
758
|
+
if reader_for(name).nil?
|
|
759
|
+
return "no '#{name}' header index — this MailMate store has never seen that " \
|
|
760
|
+
"header. Quote the token (\"#{term}\") to search it as literal text."
|
|
761
|
+
end
|
|
594
762
|
end
|
|
595
763
|
next unless field == :date
|
|
596
764
|
range = hour_range_for(term) || date_range_for(term)
|
|
@@ -615,6 +783,33 @@ module Mailmate
|
|
|
615
783
|
nil
|
|
616
784
|
end
|
|
617
785
|
|
|
786
|
+
# Single day for the most recent occurrence of day-of-month `day`,
|
|
787
|
+
# stepping back past months that lack it (`d 31` in early March =
|
|
788
|
+
# Jan 31). nil when no month within a year works (day > 31).
|
|
789
|
+
def most_recent_day_range(day, today)
|
|
790
|
+
return nil unless (1..31).cover?(day)
|
|
791
|
+
0.upto(12) do |back|
|
|
792
|
+
m = today << back
|
|
793
|
+
next unless Date.valid_date?(m.year, m.month, day)
|
|
794
|
+
candidate = Date.new(m.year, m.month, day)
|
|
795
|
+
return [ymd_int(candidate), ymd_int(candidate)] if candidate <= today
|
|
796
|
+
end
|
|
797
|
+
nil
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Single day for the most recent occurrence of month+day: this year if
|
|
801
|
+
# it has happened, else last year. nil for impossible dates.
|
|
802
|
+
def most_recent_month_day_range(month, day, today)
|
|
803
|
+
return nil unless (1..12).cover?(month) && (1..31).cover?(day)
|
|
804
|
+
[0, 1].each do |back|
|
|
805
|
+
y = today.year - back
|
|
806
|
+
next unless Date.valid_date?(y, month, day)
|
|
807
|
+
candidate = Date.new(y, month, day)
|
|
808
|
+
return [ymd_int(candidate), ymd_int(candidate)] if candidate <= today
|
|
809
|
+
end
|
|
810
|
+
nil
|
|
811
|
+
end
|
|
812
|
+
|
|
618
813
|
# `13/8/2026` under month-first ordering is month 13 — almost certainly
|
|
619
814
|
# a day-first date (and vice versa). Name the likely fix instead of
|
|
620
815
|
# leaving the generic cannot-match.
|
|
@@ -785,27 +980,54 @@ module Mailmate
|
|
|
785
980
|
end
|
|
786
981
|
|
|
787
982
|
# term is the full lowercased token ("is:unread", "has:attachment").
|
|
788
|
-
# Flag states read the #flags index;
|
|
789
|
-
#
|
|
790
|
-
#
|
|
791
|
-
#
|
|
983
|
+
# Flag states read the #flags index; archive state reads the path
|
|
984
|
+
# (same source as the flags output column); attachment presence reads
|
|
985
|
+
# the indexed root content-type — multipart/mixed is the standard
|
|
986
|
+
# attachment layout. Wrapper types that can HIDE attachments
|
|
987
|
+
# (signed/encrypted/related) fall back to reading the message and
|
|
988
|
+
# asking Mail for real attachments; plain and alternative roots are
|
|
989
|
+
# trusted as attachment-free. Unknown state values never reach here:
|
|
792
990
|
# date_spec_error rejects them up front.
|
|
793
|
-
def state_matches?(eml_id, mail, term)
|
|
991
|
+
def state_matches?(eml_id, mail, path, term)
|
|
794
992
|
state = STATE_CANON[term.split(":", 2).last]
|
|
795
993
|
return false unless state
|
|
796
994
|
|
|
797
995
|
case state
|
|
798
996
|
when :unread
|
|
799
997
|
eml_id ? !message_flags(eml_id).include?("\\Seen") : false
|
|
998
|
+
when :archived
|
|
999
|
+
path.to_s.include?("/Archive.mailbox/")
|
|
800
1000
|
when :attachment
|
|
801
1001
|
ct = eml_id ? (reader_for("content-type")&.value_for(eml_id.to_i) rescue nil).to_s : ""
|
|
802
|
-
|
|
803
|
-
|
|
1002
|
+
if ct.empty?
|
|
1003
|
+
m = mail || (path && (Mail.read(path) rescue nil))
|
|
1004
|
+
return m ? m.attachments.any? : false
|
|
1005
|
+
end
|
|
1006
|
+
ctl = ct.downcase
|
|
1007
|
+
return true if ctl.include?("multipart/mixed")
|
|
1008
|
+
if ctl.match?(%r{multipart/(signed|encrypted|related)})
|
|
1009
|
+
m = mail || (path && (Mail.read(path) rescue nil))
|
|
1010
|
+
return m ? m.attachments.any? : false
|
|
1011
|
+
end
|
|
1012
|
+
false
|
|
804
1013
|
else
|
|
805
1014
|
message_flags(eml_id).include?(STATE_FLAGS[state])
|
|
806
1015
|
end
|
|
807
1016
|
end
|
|
808
1017
|
|
|
1018
|
+
# term is the downcased "name:value" (subpath allowed on the name and
|
|
1019
|
+
# ignored: `x-mailer.name:mailmate` searches the whole x-mailer value,
|
|
1020
|
+
# which substring matching covers anyway). Index-only by design — a
|
|
1021
|
+
# header this store has never seen has no index, and date_spec_error
|
|
1022
|
+
# reports that up front instead of this method quietly missing.
|
|
1023
|
+
def header_matches?(eml_id, mail, term)
|
|
1024
|
+
name, value = term.split(":", 2)
|
|
1025
|
+
name = name.sub(/\..*/, "")
|
|
1026
|
+
v = eml_id ? (reader_for(name)&.value_for(eml_id.to_i) rescue nil) : nil
|
|
1027
|
+
v = (mail[name]&.to_s rescue nil) if v.nil? && mail
|
|
1028
|
+
v.to_s.b.downcase.include?(value.b)
|
|
1029
|
+
end
|
|
1030
|
+
|
|
809
1031
|
def message_flags(eml_id)
|
|
810
1032
|
return [] unless eml_id
|
|
811
1033
|
reader_for("#flags")&.flags_for(eml_id.to_i) || []
|
|
@@ -906,7 +1128,9 @@ module Mailmate
|
|
|
906
1128
|
when :date
|
|
907
1129
|
date_matches?(mail, eml_id, term)
|
|
908
1130
|
when :state
|
|
909
|
-
state_matches?(eml_id, mail, term)
|
|
1131
|
+
state_matches?(eml_id, mail, path, term)
|
|
1132
|
+
when :header
|
|
1133
|
+
header_matches?(eml_id, mail, term)
|
|
910
1134
|
when :any
|
|
911
1135
|
%i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
|
|
912
1136
|
end
|
|
@@ -1052,7 +1276,7 @@ module Mailmate
|
|
|
1052
1276
|
# Absolute send time for an eml_id, preferring the MailMate `#date` index
|
|
1053
1277
|
# (cheap, no .eml read). Falls back to the parsed mail's Date header.
|
|
1054
1278
|
def message_time(eml_id, mail)
|
|
1055
|
-
s = (
|
|
1279
|
+
s = (reader_for("#date")&.value_for(eml_id.to_i) rescue nil)
|
|
1056
1280
|
if s && !s.empty?
|
|
1057
1281
|
t = fast_time(s) || (Time.parse(s) rescue nil)
|
|
1058
1282
|
return t if t
|
|
@@ -1077,7 +1301,7 @@ module Mailmate
|
|
|
1077
1301
|
# safely interleave with UTF-8 strings in joined output rows.
|
|
1078
1302
|
def header_index_value(eml_id, name)
|
|
1079
1303
|
return nil if eml_id.nil?
|
|
1080
|
-
v =
|
|
1304
|
+
v = reader_for(name)&.value_for(eml_id.to_i)
|
|
1081
1305
|
v && v.dup.force_encoding("UTF-8").scrub
|
|
1082
1306
|
rescue ArgumentError
|
|
1083
1307
|
nil
|
|
@@ -1200,6 +1424,11 @@ module Mailmate
|
|
|
1200
1424
|
|
|
1201
1425
|
def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
|
|
1202
1426
|
reset_run_caches!
|
|
1427
|
+
# Sort/limit keys that are not output columns are extracted here,
|
|
1428
|
+
# while the message is live, and ride along in Row#keys. `date` and
|
|
1429
|
+
# `id` never need extracting: every row carries instant + eml_id.
|
|
1430
|
+
extra_keys = [opts[:sort], opts[:limit_by]].grep(Hash).map { |o| o[:key] } - fields - %w[date id]
|
|
1431
|
+
need_instant = opts[:sort] != :none || !opts[:limit].nil?
|
|
1203
1432
|
rows = []
|
|
1204
1433
|
catch(:done) do
|
|
1205
1434
|
dirs.each do |dir|
|
|
@@ -1240,8 +1469,10 @@ module Mailmate
|
|
|
1240
1469
|
end
|
|
1241
1470
|
end
|
|
1242
1471
|
|
|
1243
|
-
|
|
1244
|
-
|
|
1472
|
+
cells = fields.map { |f| extract(f, eml_id, path, mail) }
|
|
1473
|
+
keys = extra_keys.to_h { |k| [k, extract(k, eml_id, path, mail)] }
|
|
1474
|
+
rows << Row.new(eml_id, rows.size, need_instant ? message_time(eml_id, mail) : nil, cells, keys)
|
|
1475
|
+
throw :done if opts[:scan_limit] && rows.size >= opts[:scan_limit]
|
|
1245
1476
|
end
|
|
1246
1477
|
end
|
|
1247
1478
|
end
|
data/lib/mailmate/mcp.rb
CHANGED
|
@@ -127,9 +127,11 @@ module Mailmate
|
|
|
127
127
|
type: "string",
|
|
128
128
|
description: "Account, mailbox path, or smart-mailbox name. Default: all.",
|
|
129
129
|
},
|
|
130
|
-
limit: { type: "integer", description: "
|
|
130
|
+
limit: { type: "integer", description: "Return at most N rows — the top N by limit_by (default: the N newest), taken after the full scan. Truncation is announced on stderr, which is included in the result." },
|
|
131
|
+
limit_by: { type: "string", description: "Which rows limit keeps: KEY[:asc|desc], KEY any output field (bare date/time = desc, text keys = asc). Default: date:desc (the N newest)." },
|
|
132
|
+
scan_limit: { type: "integer", description: "Stop SCANNING after N matches, in undefined order — a speed bound for slow body scans, never a way to pick rows. Prefer limit." },
|
|
131
133
|
headers_only: { type: "boolean", description: "Skip body matching (much faster on text searches)." },
|
|
132
|
-
sort: { type: "string",
|
|
134
|
+
sort: { type: "string", description: "Order of the emitted rows: asc|desc|none (by date), or KEY[:asc|desc] for any output field, e.g. from, subject:desc. Ties break newest-first. Default: asc (date)." },
|
|
133
135
|
european: { type: "boolean", description: "Slash dates in the query are day-first (d 9/8/2026 = Aug 9). Default: month-first American." },
|
|
134
136
|
},
|
|
135
137
|
additionalProperties: false,
|
|
@@ -428,7 +430,9 @@ module Mailmate
|
|
|
428
430
|
def call_search(args)
|
|
429
431
|
argv = []
|
|
430
432
|
argv.push("--mailbox", args["mailbox"].to_s) if args["mailbox"]
|
|
431
|
-
argv.push("--limit",
|
|
433
|
+
argv.push("--limit", args["limit"].to_i.to_s) if args["limit"]
|
|
434
|
+
argv.push("--limit-by", args["limit_by"].to_s) if args["limit_by"]
|
|
435
|
+
argv.push("--scan-limit", args["scan_limit"].to_i.to_s) if args["scan_limit"]
|
|
432
436
|
argv.push("--headers-only") if args["headers_only"]
|
|
433
437
|
argv.push("--sort", args["sort"].to_s) if args["sort"]
|
|
434
438
|
argv.push("--european") if args["european"]
|
|
@@ -24,10 +24,11 @@ module Mailmate
|
|
|
24
24
|
["a <term>", "any address header contains"],
|
|
25
25
|
["b <term>", "body contains"],
|
|
26
26
|
["m <term>", "common headers OR body (same as a bare term)"],
|
|
27
|
-
["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (
|
|
27
|
+
["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (calendar, floored: 1d = today, 1w = this week, 1y = this year), Y | Y-M | Y-M-D, or D | M-D (day of month, most recent occurrence)"],
|
|
28
28
|
["T <tag>", "tag / IMAP keyword contains (K is a synonym)"],
|
|
29
|
-
["is:<state>", "message state: unread, read, flagged, replied, draft"],
|
|
30
|
-
["has:attachment", "root MIME is multipart/mixed (
|
|
29
|
+
["is:<state>", "message state: unread, read, flagged, replied, draft, archived"],
|
|
30
|
+
["has:attachment", "root MIME is multipart/mixed (wrappers like signed/related are opened and checked)"],
|
|
31
|
+
["<header>:<term>", "any indexed header contains (delivered-to:joe); unknown header = error"],
|
|
31
32
|
].freeze
|
|
32
33
|
|
|
33
34
|
EXAMPLES = [
|
|
@@ -39,7 +40,8 @@ module Mailmate
|
|
|
39
40
|
["d 24h", "received in the last 24 hours (rolling, not calendar)"],
|
|
40
41
|
["d >=2026-05 d <2026-08", "received May through July 2026"],
|
|
41
42
|
["d 1h or 2026-08-09", "last hour, plus everything from Aug 9"],
|
|
42
|
-
["is:unread d
|
|
43
|
+
["is:unread d 1w", "unread, received this week"],
|
|
44
|
+
["d 7", "day 7 of this month (last month's if the 7th is still ahead)"],
|
|
43
45
|
["T urgent", "tagged 'urgent'"],
|
|
44
46
|
].freeze
|
|
45
47
|
|
|
@@ -260,6 +262,14 @@ module Mailmate
|
|
|
260
262
|
(rel = parse_relative(value)) && "d #{rel}"
|
|
261
263
|
when "older_than", "older"
|
|
262
264
|
(rel = parse_relative(value)) && "d !#{rel}"
|
|
265
|
+
when "in"
|
|
266
|
+
# Archive state is path-derived, so Gmail's location idiom maps onto
|
|
267
|
+
# the is:archived state spec. Other locations (in:trash, in:spam)
|
|
268
|
+
# have no equivalent and stay flagged.
|
|
269
|
+
case value.downcase
|
|
270
|
+
when "archive", "archived" then "is:archived"
|
|
271
|
+
when "inbox" then "!is:archived"
|
|
272
|
+
end
|
|
263
273
|
end
|
|
264
274
|
end
|
|
265
275
|
|
data/lib/mailmate/version.rb
CHANGED