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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd43afdf338a47a4616748d1e3ecb7781466ee73c836030bb9fdd763af1e65e3
4
- data.tar.gz: d0d87cfbe4166a9aa8868e237ff2fc628a3d00b57f8de8079eacdb4cb4282de1
3
+ metadata.gz: 88d6dabd89e0d5e37026235c1e97fff082e6f1e2a81c6fe372ba224dacacbe7c
4
+ data.tar.gz: 816b35b9b2fa41b41e4f08e851af0b125c7fc7240c0deafdd29349b68672c107
5
5
  SHA512:
6
- metadata.gz: 052c4619e7cf07129903242c5d8081f61ae64a46371cdf04fc5df0a6f1dbbef982a4e944e5629d12aa82ff036f14016080fb0e77fc814e23f0a9595ef2064962
7
- data.tar.gz: 1a9918739f80c5b5d46030214b58e9c8dcb87ba76e12c5dee14c9c79f2e96de34dc3bf2461f528edc9f22deb1e1f5497566e14c58d31f981e42cd8a819c76c65
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 + cap results + raw CSV (no padding)
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` is always the first column).
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
  |---|---|
@@ -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, headers_only: false, all: false,
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: :asc,
92
+ header: true, align: true, sort: DEFAULT_SORT, stats: false,
69
93
  }
70
94
 
71
95
  parser = build_parser(opts)
72
- parser.parse!(argv)
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
- search_string = argv[0] || DEFAULT_SEARCH
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(search_string, european: !!opts[:european])
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
- sort_rows!(rows, opts[:sort])
167
- emit_output(rows, fields, opts)
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
- 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))
175
261
  warn hint
176
262
  end
177
263
  0
178
264
  end
179
265
 
180
- # ---- sort ---------------------------------------------------------------
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
- # Sorts `rows` in place by the message's absolute send instant (UTC), so
183
- # senders in different timezones still order correctly. The first column
184
- # is always `id` (forced in `run`), which lets us hit the `#date` index
185
- # without re-reading any .eml.
186
- def sort_rows!(rows, mode)
187
- return rows if mode == :none || rows.size < 2
188
- reader = Mailmate::IndexReader.for("#date") rescue nil
189
- epoch = Time.at(0)
190
- rows.sort_by! do |r|
191
- s = reader && (reader.value_for(r[0].to_i) rescue nil)
192
- (s && !s.empty? && (fast_time(s) || (Time.parse(s) rescue nil))) || epoch
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
- rows.reverse! if mode == :desc
195
- rows
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, "Stop after N matches") { |n| opts[:limit] = n }
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 MODE", %w[asc desc none],
223
- "Sort rows by date+time: asc (default), desc, none") { |v| opts[:sort] = v.to_sym }
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 = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
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 = Mailmate::IndexReader.for(name).value_for(eml_id.to_i)
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
- rows << fields.map { |f| extract(f, eml_id, path, mail) }
1358
- throw :done if opts[:limit] && rows.size >= opts[:limit]
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: "Stop after N matches." },
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", enum: %w[asc desc none], description: "Sort by date+time. Default: asc." },
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", args["limit"].to_i.to_s) if args["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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "1.9.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: 1.9.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