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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 284cbe7b6bd7c22400073332c186ec005a8c7bd7f60953084d7067ebf4ec9b6a
4
- data.tar.gz: 5e5fa97e0c3eb222bc081c5d29ce611c65de9c159476075a844c91bf00cfcafa
3
+ metadata.gz: 88d6dabd89e0d5e37026235c1e97fff082e6f1e2a81c6fe372ba224dacacbe7c
4
+ data.tar.gz: 816b35b9b2fa41b41e4f08e851af0b125c7fc7240c0deafdd29349b68672c107
5
5
  SHA512:
6
- metadata.gz: 573429618b13cba013fcb7b4c07db6284335887f577fe0b8d4d2b3c91d2961815d6a32005254d7b848b5c71716a2ee7a0d011caa4a33ab7d35ac1c74c1448700
7
- data.tar.gz: 7e3ef0a93e0462a60f97028a84e9d360e0c8560552100349bd0f348a962e5b76ccf4b5d5febfdefa1b522e8c73aa4934c1d5a17b1bc3c5cc84a185e49709c022
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
  |---|---|
@@ -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
- search_string = argv[0] || DEFAULT_SEARCH
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(search_string, european: !!opts[:european])
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. 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
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 opts[:limit] && total > opts[:limit]
208
- warn "[limit] showing #{opts[:limit]} of #{total} matches (#{describe_order(opts[:limit_by])}); " \
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
- if rows.empty? && (hint = Mailmate::SearchSyntax.zero_result_hint(search_string))
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:}`), 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.
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.index <=> b.index if c.zero?
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 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
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.first(limit)
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "2.0.0"
4
+ VERSION = "2.1.0"
5
5
  end
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.0.0
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.12
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