mailmate 1.9.0 → 2.1.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 +7 -2
- data/lib/mailmate/cli/search.rb +206 -30
- data/lib/mailmate/mcp.rb +20 -4
- data/lib/mailmate/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 88d6dabd89e0d5e37026235c1e97fff082e6f1e2a81c6fe372ba224dacacbe7c
|
|
4
|
+
data.tar.gz: 816b35b9b2fa41b41e4f08e851af0b125c7fc7240c0deafdd29349b68672c107
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 046523126145151e5c82cfbced95b6a00edba71825c2fa87d411054e26363f4a893c3afad22a0bb013ac08779da0ebf8c25e4787adfb84cf7670dfb4c5aada50
|
|
7
|
+
data.tar.gz: 9eb1bb09367e35cb763789857152258a4d104172fe93655f6966e8f473cc4b127df87cfd2e534cbce42415733560cffcf18009034ea77e72d43066dc3bf0d620
|
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.
|
|
@@ -196,7 +199,9 @@ Dates match on the **display-zone day** — the same day the `date`/`time` outpu
|
|
|
196
199
|
|
|
197
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.
|
|
198
201
|
|
|
199
|
-
**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.) `--offset N` skips the first N rows of that ordering before taking `--limit` — rows 1001–1200 newest-first is `--offset 1000 --limit 200`; offsets drift as mail arrives, so a date term (`d <2026-08-25`) is the stable way to page a live mailbox. `--stats` replaces the prose notices with one machine-readable line, first on stderr — `stats: {"schema":1,"matches":321,"returned":3,"scan_capped":false,…}` — whose keys are additive-only; scripts and the MCP server read that instead of parsing prose.
|
|
200
205
|
|
|
201
206
|
| Field | What it shows |
|
|
202
207
|
|---|---|
|
data/lib/mailmate/cli/search.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "mail"
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
require "date"
|
|
6
6
|
require "csv"
|
|
7
|
+
require "json"
|
|
8
|
+
require "stringio"
|
|
7
9
|
|
|
8
10
|
module Mailmate
|
|
9
11
|
module CLI
|
|
@@ -38,6 +40,27 @@ module Mailmate
|
|
|
38
40
|
"archive" => "a",
|
|
39
41
|
}.freeze
|
|
40
42
|
|
|
43
|
+
# One matched message on its way to the output. `cells` are the
|
|
44
|
+
# extracted output columns in `fields` order; `keys` holds the values
|
|
45
|
+
# of any sort/limit key that is NOT an output column (extracted while
|
|
46
|
+
# the message was live, so ordering never re-reads a .eml); `instant`
|
|
47
|
+
# is the absolute send time (nil when unknown); `index` is scan order,
|
|
48
|
+
# the last-resort tie-break. Ordering keys off these, never off the
|
|
49
|
+
# cells — a bare fields list may omit `id`, and the old
|
|
50
|
+
# `rows[0].to_i` shortcut silently sorted such runs by readdir order.
|
|
51
|
+
Row = Struct.new(:eml_id, :index, :instant, :cells, :keys) do
|
|
52
|
+
def instant_or_epoch = instant || Time.at(0)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `--sort` / `--limit-by` values: `{ key: <field>, dir: :asc|:desc }`,
|
|
56
|
+
# or :none (sort only). Omitted `--sort` keeps the historical
|
|
57
|
+
# date-ascending output; omitted `--limit-by` keeps the N newest.
|
|
58
|
+
DEFAULT_SORT = { key: "date", dir: :asc }.freeze
|
|
59
|
+
DEFAULT_LIMIT_BY = { key: "date", dir: :desc }.freeze
|
|
60
|
+
# A bare KEY (no :DIR) reads the way the key does: dates newest-first,
|
|
61
|
+
# text A→Z.
|
|
62
|
+
DESC_BY_DEFAULT = %w[date time].freeze
|
|
63
|
+
|
|
41
64
|
# All output fields are now index-tier: MailMate maintains a per-header
|
|
42
65
|
# binary index under Database.noindex/Headers/, so extracting from/to/
|
|
43
66
|
# subject/etc. doesn't require opening the .eml. Spec/filter matching
|
|
@@ -63,22 +86,57 @@ module Mailmate
|
|
|
63
86
|
|
|
64
87
|
def run(argv)
|
|
65
88
|
opts = {
|
|
66
|
-
mailbox: "all", limit: nil,
|
|
89
|
+
mailbox: "all", limit: nil, offset: 0, scan_limit: nil, limit_by: DEFAULT_LIMIT_BY,
|
|
90
|
+
headers_only: false, all: false,
|
|
67
91
|
exclude_quoted: false,
|
|
68
|
-
header: true, align: true, sort: :
|
|
92
|
+
header: true, align: true, sort: DEFAULT_SORT, stats: false,
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
parser = build_parser(opts)
|
|
72
|
-
|
|
96
|
+
begin
|
|
97
|
+
parser.parse!(argv)
|
|
98
|
+
rescue OptionParser::ParseError => e
|
|
99
|
+
warn e.message
|
|
100
|
+
return 2
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
return search(argv, opts) unless opts[:stats]
|
|
104
|
+
|
|
105
|
+
# --stats promises one machine-readable line FIRST on stderr, but
|
|
106
|
+
# the run says other things there before the total is known (the
|
|
107
|
+
# translation notice, dead-branch warnings, [skip]s). Hold all of it
|
|
108
|
+
# back and release it, in order, after the stats line. A run that
|
|
109
|
+
# never reached the scan (usage error) produces no stats line — the
|
|
110
|
+
# held output is still released, so nothing is swallowed.
|
|
111
|
+
held = StringIO.new
|
|
112
|
+
real_err = $stderr
|
|
113
|
+
$stderr = held
|
|
114
|
+
begin
|
|
115
|
+
search(argv, opts)
|
|
116
|
+
ensure
|
|
117
|
+
$stderr = real_err
|
|
118
|
+
$stderr.puts "#{STATS_PREFIX}#{JSON.generate(opts[:stats_result])}" if opts[:stats_result]
|
|
119
|
+
$stderr.print held.string
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The `--stats` contract: this prefix, then one JSON object, on the
|
|
124
|
+
# first stderr line. `matches`, `returned` and `scan_capped` are always
|
|
125
|
+
# present; keys are additive-only, and `schema` increments only on a
|
|
126
|
+
# change that breaks that promise. Consumers (markdownr's search_mail,
|
|
127
|
+
# mailmate-mcp) parse this instead of the prose notices.
|
|
128
|
+
STATS_PREFIX = "stats: "
|
|
129
|
+
STATS_SCHEMA = 1
|
|
73
130
|
|
|
131
|
+
def search(argv, opts)
|
|
74
132
|
self.date_order = opts[:european] ? :dmy : :mdy
|
|
75
133
|
|
|
76
|
-
|
|
134
|
+
query = argv[0] || DEFAULT_SEARCH
|
|
77
135
|
# Rewrite Gmail/Outlook-style key:value tokens to their exact
|
|
78
136
|
# quicksearch equivalent — loudly, never silently: every rewrite is
|
|
79
137
|
# announced on stderr so the transcript shows what actually ran (and
|
|
80
138
|
# the caller learns the syntax). stdout stays clean CSV.
|
|
81
|
-
search_string, translations = Mailmate::SearchSyntax.translate(
|
|
139
|
+
search_string, translations = Mailmate::SearchSyntax.translate(query, european: !!opts[:european])
|
|
82
140
|
if (notice = Mailmate::SearchSyntax.translation_notice(translations))
|
|
83
141
|
warn notice
|
|
84
142
|
end
|
|
@@ -163,36 +221,135 @@ module Mailmate
|
|
|
163
221
|
opts: opts,
|
|
164
222
|
)
|
|
165
223
|
|
|
166
|
-
|
|
167
|
-
|
|
224
|
+
# The scan stops exactly at --scan-limit, so hitting it is inferable.
|
|
225
|
+
scan_capped = opts[:scan_limit] && rows.size >= opts[:scan_limit]
|
|
226
|
+
total = rows.size
|
|
227
|
+
rows = apply_limit(rows, opts[:limit], opts[:offset], opts[:limit_by], fields)
|
|
228
|
+
sort_rows!(rows, opts[:sort], fields)
|
|
229
|
+
emit_output(rows.map(&:cells), fields, opts)
|
|
230
|
+
# Truncation announces itself on stderr — same idiom as the
|
|
231
|
+
# zero-result hint below: stdout stays clean CSV, exit status stays
|
|
232
|
+
# 0. With --stats the JSON line carries it and the prose is
|
|
233
|
+
# suppressed. When the scan cap fired, the match total is itself a
|
|
234
|
+
# sample, so the [limit] notice's "of M" would be false precision;
|
|
235
|
+
# the scan notice speaks alone.
|
|
236
|
+
if opts[:stats]
|
|
237
|
+
opts[:stats_result] = {
|
|
238
|
+
schema: STATS_SCHEMA, matches: total, returned: rows.size,
|
|
239
|
+
limit: opts[:limit], offset: opts[:offset],
|
|
240
|
+
limit_by: order_to_s(opts[:limit_by]), sort: order_to_s(opts[:sort]),
|
|
241
|
+
scan_limit: opts[:scan_limit], scan_capped: !!scan_capped,
|
|
242
|
+
query: query, effective_query: search_string,
|
|
243
|
+
}
|
|
244
|
+
elsif scan_capped
|
|
245
|
+
warn "[scan-limit] stopped scanning after #{total} matches in undefined order — " \
|
|
246
|
+
"this is a sample, not the newest #{total}"
|
|
247
|
+
elsif rows.size < total
|
|
248
|
+
from = opts[:offset].positive? ? ", from #{opts[:offset] + 1}" : ""
|
|
249
|
+
warn "[limit] showing #{rows.size} of #{total} matches#{from} (#{describe_order(opts[:limit_by])}); " \
|
|
250
|
+
"add a date term such as `d 3d` to narrow, or raise --limit"
|
|
251
|
+
end
|
|
168
252
|
# A query written in another mail system's dialect is not a syntax
|
|
169
253
|
# error here — it parses as a literal term and quietly matches
|
|
170
254
|
# nothing. Callers (people and agents alike) read that empty result
|
|
171
255
|
# as "no such mail" and stop. Say so on stderr, so stdout stays
|
|
172
256
|
# clean CSV and the exit status stays 0: the search DID run, it just
|
|
173
|
-
# cannot have found what the caller meant.
|
|
174
|
-
|
|
257
|
+
# cannot have found what the caller meant. (Keyed on the match
|
|
258
|
+
# total, not the emitted rows: an --offset past the end is not a
|
|
259
|
+
# miss.)
|
|
260
|
+
if total.zero? && (hint = Mailmate::SearchSyntax.zero_result_hint(search_string))
|
|
175
261
|
warn hint
|
|
176
262
|
end
|
|
177
263
|
0
|
|
178
264
|
end
|
|
179
265
|
|
|
180
|
-
# ----
|
|
266
|
+
# ---- ordering -----------------------------------------------------------
|
|
267
|
+
#
|
|
268
|
+
# Two orderings, two questions. `--limit-by` answers WHICH rows survive
|
|
269
|
+
# the cap (the N newest, by default); `--sort` answers the ORDER they
|
|
270
|
+
# are emitted in. They coincide when both are date — the default — and
|
|
271
|
+
# diverge exactly when MailMate's UI would: "sort by sender, limit 10"
|
|
272
|
+
# is the 10 newest shown by sender, not the 10 alphabetically-first
|
|
273
|
+
# senders. Both are applied AFTER the full scan; the only pre-scan
|
|
274
|
+
# bound is --scan-limit, which picks nothing (see build_parser).
|
|
275
|
+
|
|
276
|
+
# Parses a `--sort`/`--limit-by` value: bare `asc`/`desc` (date, kept
|
|
277
|
+
# for existing invocations), `none` (sort only), `KEY`, or `KEY:DIR`.
|
|
278
|
+
def parse_order(value, allow_none: false)
|
|
279
|
+
v = value.to_s.strip
|
|
280
|
+
return :none if v == "none" && allow_none
|
|
281
|
+
return { key: "date", dir: v.to_sym } if %w[asc desc].include?(v)
|
|
282
|
+
key, dir = v.split(":", 2)
|
|
283
|
+
key = "date" if key.nil? || key.empty?
|
|
284
|
+
unless VALID_FIELDS.include?(key)
|
|
285
|
+
raise OptionParser::InvalidArgument,
|
|
286
|
+
"#{value}: unknown field '#{key}' (valid: #{VALID_FIELDS.join(' ')})"
|
|
287
|
+
end
|
|
288
|
+
dir = (DESC_BY_DEFAULT.include?(key) ? "desc" : "asc") if dir.nil? || dir.empty?
|
|
289
|
+
unless %w[asc desc].include?(dir)
|
|
290
|
+
raise OptionParser::InvalidArgument,
|
|
291
|
+
"#{value}: direction must be asc or desc#{allow_none ? ' (or bare none)' : ''}"
|
|
292
|
+
end
|
|
293
|
+
{ key: key, dir: dir.to_sym }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def order_to_s(order)
|
|
297
|
+
order == :none ? "none" : "#{order[:key]}:#{order[:dir]}"
|
|
298
|
+
end
|
|
181
299
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
300
|
+
def describe_order(order)
|
|
301
|
+
return "scan order" if order == :none
|
|
302
|
+
what = order[:key] == "date" ? (order[:dir] == :desc ? "newest first" : "oldest first")
|
|
303
|
+
: "#{order[:key]} #{order[:dir] == :desc ? 'descending' : 'ascending'}"
|
|
304
|
+
"#{what} by #{order[:key]}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The sort key value for one row. `date` is the absolute send instant
|
|
308
|
+
# (so senders in different timezones still order correctly), `id` is
|
|
309
|
+
# numeric, everything else is the column text, case-folded.
|
|
310
|
+
def order_value(row, key, column)
|
|
311
|
+
case key
|
|
312
|
+
when "date" then row.instant_or_epoch
|
|
313
|
+
when "id" then row.eml_id.to_i
|
|
314
|
+
else (column ? row.cells[column] : row.keys[key]).to_s.downcase
|
|
193
315
|
end
|
|
194
|
-
|
|
195
|
-
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Orders `rows` in place by `order` (`{key:, dir:}`). The order is
|
|
319
|
+
# TOTAL: ties break newest-first, then by eml-id (in the date's
|
|
320
|
+
# direction when date is the key, else newest-first) — so "sorted by
|
|
321
|
+
# sender" lists each sender's mail newest-first, and two runs of the
|
|
322
|
+
# same query page identically (`--offset` depends on that). Ruby's
|
|
323
|
+
# sort is not stable, and stable-over-readdir would only preserve the
|
|
324
|
+
# undefined order this exists to get rid of.
|
|
325
|
+
def order_rows!(rows, order, fields)
|
|
326
|
+
return rows.sort_by!(&:index) if order == :none
|
|
327
|
+
return rows if rows.size < 2
|
|
328
|
+
key, sign = order[:key], (order[:dir] == :desc ? -1 : 1)
|
|
329
|
+
id_sign = key == "date" ? sign : -1
|
|
330
|
+
column = fields.index(key)
|
|
331
|
+
rows.sort! do |a, b|
|
|
332
|
+
c = (order_value(a, key, column) <=> order_value(b, key, column)) || 0
|
|
333
|
+
c *= sign
|
|
334
|
+
c = b.instant_or_epoch <=> a.instant_or_epoch if c.zero? && key != "date"
|
|
335
|
+
c = (a.eml_id.to_i <=> b.eml_id.to_i) * id_sign if c.zero?
|
|
336
|
+
c
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Keeps rows `offset` through `offset + limit` of the `limit_by`
|
|
341
|
+
# ordering, taken over the FULL match set. Returns the same array when
|
|
342
|
+
# no cap applies. Rows come back in limit-by order; `sort_rows!`
|
|
343
|
+
# re-orders them for output.
|
|
344
|
+
def apply_limit(rows, limit, offset, limit_by, fields)
|
|
345
|
+
return rows if offset.zero? && (limit.nil? || rows.size <= limit)
|
|
346
|
+
order_rows!(rows, limit_by, fields)
|
|
347
|
+
window = rows.drop(offset)
|
|
348
|
+
limit ? window.first(limit) : window
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def sort_rows!(rows, order, fields)
|
|
352
|
+
order_rows!(rows, order, fields)
|
|
196
353
|
end
|
|
197
354
|
|
|
198
355
|
# ---- option parsing -----------------------------------------------------
|
|
@@ -213,14 +370,26 @@ module Mailmate
|
|
|
213
370
|
o.separator "OPTIONS"
|
|
214
371
|
o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v }
|
|
215
372
|
o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v }
|
|
216
|
-
o.on("--limit N", Integer,
|
|
373
|
+
o.on("--limit N", Integer,
|
|
374
|
+
"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 }
|
|
375
|
+
o.on("--limit-by KEY[:DIR]",
|
|
376
|
+
"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) }
|
|
377
|
+
o.on("--offset N", Integer,
|
|
378
|
+
"Skip the first N rows of the --limit-by ordering before taking --limit: rows 1001-1200 newest-first is --offset 1000 --limit 200. Pages of a live mailbox drift as mail arrives; a date term (d <2026-08-25) is the stable way to page.") { |n|
|
|
379
|
+
raise OptionParser::InvalidArgument, "#{n}: offset cannot be negative" if n.negative?
|
|
380
|
+
opts[:offset] = n
|
|
381
|
+
}
|
|
382
|
+
o.on("--scan-limit N", Integer,
|
|
383
|
+
"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
384
|
o.on("--headers-only", "Skip body matching entirely") { opts[:headers_only] = true }
|
|
218
385
|
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
386
|
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
387
|
o.on("--no-header", "Suppress column header row") { opts[:header] = false }
|
|
221
388
|
o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
|
|
222
|
-
o.on("--sort
|
|
223
|
-
"
|
|
389
|
+
o.on("--sort KEY[:DIR]",
|
|
390
|
+
"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) }
|
|
391
|
+
o.on("--stats",
|
|
392
|
+
"Write one machine-readable line FIRST on stderr — stats: {\"schema\":1,\"matches\":M,\"returned\":N,\"scan_capped\":false,...} — in place of the [limit]/[scan-limit] prose. Keys are additive-only; other stderr follows it.") { opts[:stats] = true }
|
|
224
393
|
o.on("--european",
|
|
225
394
|
"Slash dates are day-first: d 9/8/2026 = Aug 9 (default: month-first American)") { opts[:european] = true }
|
|
226
395
|
o.separator ""
|
|
@@ -1166,7 +1335,7 @@ module Mailmate
|
|
|
1166
1335
|
# Absolute send time for an eml_id, preferring the MailMate `#date` index
|
|
1167
1336
|
# (cheap, no .eml read). Falls back to the parsed mail's Date header.
|
|
1168
1337
|
def message_time(eml_id, mail)
|
|
1169
|
-
s = (
|
|
1338
|
+
s = (reader_for("#date")&.value_for(eml_id.to_i) rescue nil)
|
|
1170
1339
|
if s && !s.empty?
|
|
1171
1340
|
t = fast_time(s) || (Time.parse(s) rescue nil)
|
|
1172
1341
|
return t if t
|
|
@@ -1191,7 +1360,7 @@ module Mailmate
|
|
|
1191
1360
|
# safely interleave with UTF-8 strings in joined output rows.
|
|
1192
1361
|
def header_index_value(eml_id, name)
|
|
1193
1362
|
return nil if eml_id.nil?
|
|
1194
|
-
v =
|
|
1363
|
+
v = reader_for(name)&.value_for(eml_id.to_i)
|
|
1195
1364
|
v && v.dup.force_encoding("UTF-8").scrub
|
|
1196
1365
|
rescue ArgumentError
|
|
1197
1366
|
nil
|
|
@@ -1314,6 +1483,11 @@ module Mailmate
|
|
|
1314
1483
|
|
|
1315
1484
|
def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
|
|
1316
1485
|
reset_run_caches!
|
|
1486
|
+
# Sort/limit keys that are not output columns are extracted here,
|
|
1487
|
+
# while the message is live, and ride along in Row#keys. `date` and
|
|
1488
|
+
# `id` never need extracting: every row carries instant + eml_id.
|
|
1489
|
+
extra_keys = [opts[:sort], opts[:limit_by]].grep(Hash).map { |o| o[:key] } - fields - %w[date id]
|
|
1490
|
+
need_instant = opts[:sort] != :none || !opts[:limit].nil? || opts[:offset].positive?
|
|
1317
1491
|
rows = []
|
|
1318
1492
|
catch(:done) do
|
|
1319
1493
|
dirs.each do |dir|
|
|
@@ -1354,8 +1528,10 @@ module Mailmate
|
|
|
1354
1528
|
end
|
|
1355
1529
|
end
|
|
1356
1530
|
|
|
1357
|
-
|
|
1358
|
-
|
|
1531
|
+
cells = fields.map { |f| extract(f, eml_id, path, mail) }
|
|
1532
|
+
keys = extra_keys.to_h { |k| [k, extract(k, eml_id, path, mail)] }
|
|
1533
|
+
rows << Row.new(eml_id, rows.size, need_instant ? message_time(eml_id, mail) : nil, cells, keys)
|
|
1534
|
+
throw :done if opts[:scan_limit] && rows.size >= opts[:scan_limit]
|
|
1359
1535
|
end
|
|
1360
1536
|
end
|
|
1361
1537
|
end
|
data/lib/mailmate/mcp.rb
CHANGED
|
@@ -127,9 +127,12 @@ 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
|
+
offset: { type: "integer", description: "Skip the first N rows of the limit_by ordering before taking limit (rows 1001-1200 = offset 1000, limit 200). Offsets drift as mail arrives; a date term in the query (d <2026-08-25) is the stable way to page." },
|
|
133
|
+
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
134
|
headers_only: { type: "boolean", description: "Skip body matching (much faster on text searches)." },
|
|
132
|
-
sort: { type: "string",
|
|
135
|
+
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
136
|
european: { type: "boolean", description: "Slash dates in the query are day-first (d 9/8/2026 = Aug 9). Default: month-first American." },
|
|
134
137
|
},
|
|
135
138
|
additionalProperties: false,
|
|
@@ -428,17 +431,30 @@ module Mailmate
|
|
|
428
431
|
def call_search(args)
|
|
429
432
|
argv = []
|
|
430
433
|
argv.push("--mailbox", args["mailbox"].to_s) if args["mailbox"]
|
|
431
|
-
argv.push("--limit",
|
|
434
|
+
argv.push("--limit", args["limit"].to_i.to_s) if args["limit"]
|
|
435
|
+
argv.push("--limit-by", args["limit_by"].to_s) if args["limit_by"]
|
|
436
|
+
argv.push("--scan-limit", args["scan_limit"].to_i.to_s) if args["scan_limit"]
|
|
437
|
+
argv.push("--offset", args["offset"].to_i.to_s) if args["offset"]
|
|
432
438
|
argv.push("--headers-only") if args["headers_only"]
|
|
433
439
|
argv.push("--sort", args["sort"].to_s) if args["sort"]
|
|
434
440
|
argv.push("--european") if args["european"]
|
|
441
|
+
# Always ask for the stats line: it carries the true match total and
|
|
442
|
+
# the truncation facts as one JSON object, first on stderr, in place
|
|
443
|
+
# of the prose notices. It stays in the text (readable as-is) and is
|
|
444
|
+
# ALSO surfaced as structuredContent for clients that render it.
|
|
445
|
+
argv.push("--stats")
|
|
435
446
|
# Positionals: search-string then fields. Only include if the caller
|
|
436
447
|
# gave us either — otherwise let the CLI apply its defaults.
|
|
437
448
|
if args.key?("query") || args["fields"]
|
|
438
449
|
argv << (args["query"] || "")
|
|
439
450
|
argv << args["fields"].to_s if args["fields"]
|
|
440
451
|
end
|
|
441
|
-
run_cli(Mailmate::CLI::Search, argv)
|
|
452
|
+
res = run_cli(Mailmate::CLI::Search, argv)
|
|
453
|
+
if (m = res[:content].first[:text].match(/^#{Regexp.escape(Mailmate::CLI::Search::STATS_PREFIX)}(\{.*\})$/))
|
|
454
|
+
stats = (JSON.parse(m[1]) rescue nil)
|
|
455
|
+
res[:structuredContent] = { stats: stats } if stats
|
|
456
|
+
end
|
|
457
|
+
res
|
|
442
458
|
end
|
|
443
459
|
|
|
444
460
|
def call_message(args)
|
data/lib/mailmate/version.rb
CHANGED
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: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Murphy-Dye
|
|
@@ -182,7 +182,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
182
182
|
- !ruby/object:Gem::Version
|
|
183
183
|
version: '0'
|
|
184
184
|
requirements: []
|
|
185
|
-
rubygems_version: 4.0.
|
|
185
|
+
rubygems_version: 4.0.19
|
|
186
186
|
specification_version: 4
|
|
187
187
|
summary: Ruby toolkit for MailMate on macOS — search, read, modify, send, and smart-mailbox
|
|
188
188
|
evaluation
|