mailmate 1.9.0 → 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 +7 -2
- data/lib/mailmate/cli/search.rb +143 -26
- data/lib/mailmate/mcp.rb +7 -3
- 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.
|
|
@@ -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.)
|
|
200
205
|
|
|
201
206
|
| Field | What it shows |
|
|
202
207
|
|---|---|
|
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
|
|
181
252
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
259
|
+
|
|
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
|
|
193
268
|
end
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
287
|
+
end
|
|
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 ""
|
|
@@ -1166,7 +1276,7 @@ module Mailmate
|
|
|
1166
1276
|
# Absolute send time for an eml_id, preferring the MailMate `#date` index
|
|
1167
1277
|
# (cheap, no .eml read). Falls back to the parsed mail's Date header.
|
|
1168
1278
|
def message_time(eml_id, mail)
|
|
1169
|
-
s = (
|
|
1279
|
+
s = (reader_for("#date")&.value_for(eml_id.to_i) rescue nil)
|
|
1170
1280
|
if s && !s.empty?
|
|
1171
1281
|
t = fast_time(s) || (Time.parse(s) rescue nil)
|
|
1172
1282
|
return t if t
|
|
@@ -1191,7 +1301,7 @@ module Mailmate
|
|
|
1191
1301
|
# safely interleave with UTF-8 strings in joined output rows.
|
|
1192
1302
|
def header_index_value(eml_id, name)
|
|
1193
1303
|
return nil if eml_id.nil?
|
|
1194
|
-
v =
|
|
1304
|
+
v = reader_for(name)&.value_for(eml_id.to_i)
|
|
1195
1305
|
v && v.dup.force_encoding("UTF-8").scrub
|
|
1196
1306
|
rescue ArgumentError
|
|
1197
1307
|
nil
|
|
@@ -1314,6 +1424,11 @@ module Mailmate
|
|
|
1314
1424
|
|
|
1315
1425
|
def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
|
|
1316
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?
|
|
1317
1432
|
rows = []
|
|
1318
1433
|
catch(:done) do
|
|
1319
1434
|
dirs.each do |dir|
|
|
@@ -1354,8 +1469,10 @@ module Mailmate
|
|
|
1354
1469
|
end
|
|
1355
1470
|
end
|
|
1356
1471
|
|
|
1357
|
-
|
|
1358
|
-
|
|
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]
|
|
1359
1476
|
end
|
|
1360
1477
|
end
|
|
1361
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"]
|
data/lib/mailmate/version.rb
CHANGED