mailmate 2.0.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 +1 -1
- data/lib/mailmate/cli/search.rb +85 -26
- data/lib/mailmate/mcp.rb +13 -1
- 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
|
@@ -201,7 +201,7 @@ The `--mailbox` argument accepts an account, an `account/path`, a bare mailbox n
|
|
|
201
201
|
|
|
202
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
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.)
|
|
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.
|
|
205
205
|
|
|
206
206
|
| Field | What it shows |
|
|
207
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
|
|
@@ -84,10 +86,10 @@ module Mailmate
|
|
|
84
86
|
|
|
85
87
|
def run(argv)
|
|
86
88
|
opts = {
|
|
87
|
-
mailbox: "all", limit: nil, scan_limit: nil, limit_by: DEFAULT_LIMIT_BY,
|
|
89
|
+
mailbox: "all", limit: nil, offset: 0, scan_limit: nil, limit_by: DEFAULT_LIMIT_BY,
|
|
88
90
|
headers_only: false, all: false,
|
|
89
91
|
exclude_quoted: false,
|
|
90
|
-
header: true, align: true, sort: DEFAULT_SORT,
|
|
92
|
+
header: true, align: true, sort: DEFAULT_SORT, stats: false,
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
parser = build_parser(opts)
|
|
@@ -98,14 +100,43 @@ module Mailmate
|
|
|
98
100
|
return 2
|
|
99
101
|
end
|
|
100
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
|
|
130
|
+
|
|
131
|
+
def search(argv, opts)
|
|
101
132
|
self.date_order = opts[:european] ? :dmy : :mdy
|
|
102
133
|
|
|
103
|
-
|
|
134
|
+
query = argv[0] || DEFAULT_SEARCH
|
|
104
135
|
# Rewrite Gmail/Outlook-style key:value tokens to their exact
|
|
105
136
|
# quicksearch equivalent — loudly, never silently: every rewrite is
|
|
106
137
|
# announced on stderr so the transcript shows what actually ran (and
|
|
107
138
|
# the caller learns the syntax). stdout stays clean CSV.
|
|
108
|
-
search_string, translations = Mailmate::SearchSyntax.translate(
|
|
139
|
+
search_string, translations = Mailmate::SearchSyntax.translate(query, european: !!opts[:european])
|
|
109
140
|
if (notice = Mailmate::SearchSyntax.translation_notice(translations))
|
|
110
141
|
warn notice
|
|
111
142
|
end
|
|
@@ -193,19 +224,29 @@ module Mailmate
|
|
|
193
224
|
# The scan stops exactly at --scan-limit, so hitting it is inferable.
|
|
194
225
|
scan_capped = opts[:scan_limit] && rows.size >= opts[:scan_limit]
|
|
195
226
|
total = rows.size
|
|
196
|
-
rows = apply_limit(rows, opts[:limit], opts[:limit_by], fields)
|
|
227
|
+
rows = apply_limit(rows, opts[:limit], opts[:offset], opts[:limit_by], fields)
|
|
197
228
|
sort_rows!(rows, opts[:sort], fields)
|
|
198
229
|
emit_output(rows.map(&:cells), fields, opts)
|
|
199
230
|
# Truncation announces itself on stderr — same idiom as the
|
|
200
231
|
# zero-result hint below: stdout stays clean CSV, exit status stays
|
|
201
|
-
# 0.
|
|
202
|
-
# the
|
|
203
|
-
# notice
|
|
204
|
-
|
|
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
|
|
205
245
|
warn "[scan-limit] stopped scanning after #{total} matches in undefined order — " \
|
|
206
246
|
"this is a sample, not the newest #{total}"
|
|
207
|
-
elsif
|
|
208
|
-
|
|
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])}); " \
|
|
209
250
|
"add a date term such as `d 3d` to narrow, or raise --limit"
|
|
210
251
|
end
|
|
211
252
|
# A query written in another mail system's dialect is not a syntax
|
|
@@ -213,8 +254,10 @@ module Mailmate
|
|
|
213
254
|
# nothing. Callers (people and agents alike) read that empty result
|
|
214
255
|
# as "no such mail" and stop. Say so on stderr, so stdout stays
|
|
215
256
|
# clean CSV and the exit status stays 0: the search DID run, it just
|
|
216
|
-
# cannot have found what the caller meant.
|
|
217
|
-
|
|
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))
|
|
218
261
|
warn hint
|
|
219
262
|
end
|
|
220
263
|
0
|
|
@@ -250,6 +293,10 @@ module Mailmate
|
|
|
250
293
|
{ key: key, dir: dir.to_sym }
|
|
251
294
|
end
|
|
252
295
|
|
|
296
|
+
def order_to_s(order)
|
|
297
|
+
order == :none ? "none" : "#{order[:key]}:#{order[:dir]}"
|
|
298
|
+
end
|
|
299
|
+
|
|
253
300
|
def describe_order(order)
|
|
254
301
|
return "scan order" if order == :none
|
|
255
302
|
what = order[:key] == "date" ? (order[:dir] == :desc ? "newest first" : "oldest first")
|
|
@@ -268,32 +315,37 @@ module Mailmate
|
|
|
268
315
|
end
|
|
269
316
|
end
|
|
270
317
|
|
|
271
|
-
# Orders `rows` in place by `order` (`{key:, dir:}`)
|
|
272
|
-
# newest-first
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
#
|
|
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.
|
|
276
325
|
def order_rows!(rows, order, fields)
|
|
277
326
|
return rows.sort_by!(&:index) if order == :none
|
|
278
327
|
return rows if rows.size < 2
|
|
279
328
|
key, sign = order[:key], (order[:dir] == :desc ? -1 : 1)
|
|
329
|
+
id_sign = key == "date" ? sign : -1
|
|
280
330
|
column = fields.index(key)
|
|
281
331
|
rows.sort! do |a, b|
|
|
282
332
|
c = (order_value(a, key, column) <=> order_value(b, key, column)) || 0
|
|
283
333
|
c *= sign
|
|
284
334
|
c = b.instant_or_epoch <=> a.instant_or_epoch if c.zero? && key != "date"
|
|
285
|
-
c = a.
|
|
335
|
+
c = (a.eml_id.to_i <=> b.eml_id.to_i) * id_sign if c.zero?
|
|
286
336
|
c
|
|
287
337
|
end
|
|
288
338
|
end
|
|
289
339
|
|
|
290
|
-
# Keeps
|
|
291
|
-
# set. Returns the same array when
|
|
292
|
-
# limit-by order; `sort_rows!`
|
|
293
|
-
|
|
294
|
-
|
|
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)
|
|
295
346
|
order_rows!(rows, limit_by, fields)
|
|
296
|
-
rows.
|
|
347
|
+
window = rows.drop(offset)
|
|
348
|
+
limit ? window.first(limit) : window
|
|
297
349
|
end
|
|
298
350
|
|
|
299
351
|
def sort_rows!(rows, order, fields)
|
|
@@ -322,6 +374,11 @@ module Mailmate
|
|
|
322
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 }
|
|
323
375
|
o.on("--limit-by KEY[:DIR]",
|
|
324
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
|
+
}
|
|
325
382
|
o.on("--scan-limit N", Integer,
|
|
326
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 }
|
|
327
384
|
o.on("--headers-only", "Skip body matching entirely") { opts[:headers_only] = true }
|
|
@@ -331,6 +388,8 @@ module Mailmate
|
|
|
331
388
|
o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
|
|
332
389
|
o.on("--sort KEY[:DIR]",
|
|
333
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 }
|
|
334
393
|
o.on("--european",
|
|
335
394
|
"Slash dates are day-first: d 9/8/2026 = Aug 9 (default: month-first American)") { opts[:european] = true }
|
|
336
395
|
o.separator ""
|
|
@@ -1428,7 +1487,7 @@ module Mailmate
|
|
|
1428
1487
|
# while the message is live, and ride along in Row#keys. `date` and
|
|
1429
1488
|
# `id` never need extracting: every row carries instant + eml_id.
|
|
1430
1489
|
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?
|
|
1490
|
+
need_instant = opts[:sort] != :none || !opts[:limit].nil? || opts[:offset].positive?
|
|
1432
1491
|
rows = []
|
|
1433
1492
|
catch(:done) do
|
|
1434
1493
|
dirs.each do |dir|
|
data/lib/mailmate/mcp.rb
CHANGED
|
@@ -129,6 +129,7 @@ module Mailmate
|
|
|
129
129
|
},
|
|
130
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
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." },
|
|
132
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." },
|
|
133
134
|
headers_only: { type: "boolean", description: "Skip body matching (much faster on text searches)." },
|
|
134
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)." },
|
|
@@ -433,16 +434,27 @@ module Mailmate
|
|
|
433
434
|
argv.push("--limit", args["limit"].to_i.to_s) if args["limit"]
|
|
434
435
|
argv.push("--limit-by", args["limit_by"].to_s) if args["limit_by"]
|
|
435
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"]
|
|
436
438
|
argv.push("--headers-only") if args["headers_only"]
|
|
437
439
|
argv.push("--sort", args["sort"].to_s) if args["sort"]
|
|
438
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")
|
|
439
446
|
# Positionals: search-string then fields. Only include if the caller
|
|
440
447
|
# gave us either — otherwise let the CLI apply its defaults.
|
|
441
448
|
if args.key?("query") || args["fields"]
|
|
442
449
|
argv << (args["query"] || "")
|
|
443
450
|
argv << args["fields"].to_s if args["fields"]
|
|
444
451
|
end
|
|
445
|
-
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
|
|
446
458
|
end
|
|
447
459
|
|
|
448
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: 2.
|
|
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
|