mailmate 1.8.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2563290d07b7e90281a78c4161337f8fe94a21d4cd1f42501946a6c8107be5da
4
- data.tar.gz: 04600cc5e50d4a4518ddcd27cc210d357decfeefb32e8adc1e3bb1624af17781
3
+ metadata.gz: 284cbe7b6bd7c22400073332c186ec005a8c7bd7f60953084d7067ebf4ec9b6a
4
+ data.tar.gz: 5e5fa97e0c3eb222bc081c5d29ce611c65de9c159476075a844c91bf00cfcafa
5
5
  SHA512:
6
- metadata.gz: d4619d78ed6224df84378ad42cff61865653e32e463b8c8b3ad1349549f32c8bf74c6635e64f377653231d3a93afc087fba18cf6da9ad2897fe97c68e096d58a
7
- data.tar.gz: a1437ae600957dedc21446c151cbc6061d1def590693f83359fcd79f5d1dd36e57bcbad4ab9ecb85a7621910119a14d30b21f97eb4b90aebc826b3dc9a0f873e
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 + 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.
@@ -185,17 +188,20 @@ mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
185
188
  | `a <term>` | Any address header contains. |
186
189
  | `b <term>` | Body (plain text) contains. |
187
190
  | `m <term>` | Common headers OR body. |
188
- | `d <date>` | Received: `Nh` (rolling clock hours, `24h` = last 24 hours), `Nd`/`Nw`/`Nm`/`Ny` (N calendar units ending today — `1d` = today, `2d` = yesterday + today), or absolute `Y`, `Y-M`, `Y-M-D`. Slash dates are month-first American (`8/9/2026` = Aug 9); `--european` flips to day-first. Comparisons on absolute dates: `d >2026-08` (after), `d <2026-08` (before), also `>=`/`<=`. |
191
+ | `d <date>` | Received: `Nh` (rolling clock hours, `24h` = last 24 hours), `Nd`/`Nw`/`Nm`/`Ny` (calendar units floored to the unit start, app parity — `1d` = today, `1w` = this week, `1y` = this year), absolute `Y`, `Y-M`, `Y-M-D`, or day-of-month `D` / `M-D` (`d 7` = the most recent 7th). Slash dates are month-first American (`8/9/2026` = Aug 9); `--european` flips to day-first. Comparisons on absolute dates: `d >2026-08` (after), `d <2026-08` (before), also `>=`/`<=`. |
189
192
  | `T <tag>` | Tags / IMAP keywords (`K` is a synonym). |
190
- | `is:<state>` | Message state: `is:unread`, `is:read`, `is:flagged`, `is:replied`, `is:draft` (Gmail synonyms `starred`/`answered` work; `-is:unread` negates). |
191
- | `has:attachment` | Root MIME type is `multipart/mixed` the standard attachment layout. |
193
+ | `is:<state>` | Message state: `is:unread`, `is:read`, `is:flagged`, `is:replied`, `is:draft`, `is:archived` (Gmail synonyms `starred`/`answered` work; `-is:unread` negates; `in:inbox`/`in:archive` translate onto `is:archived`). |
194
+ | `has:attachment` | Root MIME type is `multipart/mixed`; wrapper types (`signed`/`encrypted`/`related`) are opened and checked for real attachments. |
195
+ | `<header>:<term>` | Any indexed header contains (`delivered-to:joe`). A header this store has never seen is a usage error, not an empty result. |
192
196
  | `!<value>` | Negate, e.g. `f !smith` = From does NOT contain smith; works on dates too (`d !3d` = more than 3 days ago). |
193
197
 
194
198
  Dates match on the **display-zone day** — the same day the `date`/`time` output columns show. An impossible date term or combination (`d 2026-02-31`, `d >2026 d <2025`) is a usage error, not a silent empty result. Familiar foreign `key:value` tokens (`from:bob`, `date:today`, `after:2026-08-01`, `older_than:2w`) are auto-translated to quicksearch with each rewrite announced on stderr; unrecognized keys (`is:unread`) are searched as literal text, and an empty result says so.
195
199
 
196
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.
197
201
 
198
- **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.)
199
205
 
200
206
  | Field | What it shows |
201
207
  |---|---|
@@ -433,6 +439,8 @@ A few rough edges to be aware of:
433
439
 
434
440
  ## Status
435
441
 
442
+ 1.9.0 — App-parity search release. Arbitrary header specs are native (`delivered-to:joe`, `x-mailer.name:mailmate` — subpaths search the whole header value); index-only by design: a header the store has never seen is a usage error suggesting quotes, never a `Mail.read` per candidate. Relative calendar units now floor to the unit start, matching the app's documented semantics (`1w` = this week, `1y` = this year; `Nh` stays a rolling clock window on purpose). Day-of-month terms (`d 7` = the most recent 7th, `d 12-25` = last Christmas) close the last known silent-empty. `is:archived` joins the state specs (path-derived, same source as the flags column), with `in:inbox`/`in:archive` translating onto it; `has:attachment` now opens `multipart/signed`/`encrypted`/`related` wrappers and checks for real attachments. New `rake release` runs the whole mechanical release — clean-tree + green-tests + unreleased-version guards, then build, tag, sync, `gem push`.
443
+
436
444
  1.8.0 — Reply derivation, and message-state specs.
437
445
 
438
446
  **Composing:** replies and forwards are now derived from the parent instead of hand-assembled. `mm-send` / `mm-draft` gain `--reply-to <id>`, `--reply-all-to <id>`, `--forward <id>` (eml-id or Message-ID), which compute `In-Reply-To`, the full `References` chain, recipients and the `Re:`/`Fwd:` subject, plus `--no-quote` and `--print-prefill` (derived fields as JSON, sends nothing). The MCP `send`/`draft` tools take the same `reply_to` / `reply_all_to` / `forward` arguments. Explicitly-passed fields win over derived ones, and overriding a visible field never drops the threading headers. A forward deliberately does *not* thread into the original conversation. The chain is built in exactly one place (`Mailmate::ReplyPrefill`), and `--header` values now route through one sanitizer (`Mailmate::HeaderValue`) — previously the MCP's own argv builder lacked the CR/LF defense that stops a parent `Message-ID` smuggling extra headers. All of it is documented once in [docs/Composing and threading.md](docs/Composing%20and%20threading.md), which the README, `--help` preambles and MCP instructions now point at rather than restate. Every `exe/` command answers `--version`, so consumers can check the installed version instead of probing for a flag (the CLIs pass unknown flags through to `emate`, which makes flag-probing unsafe). The gemspec finally carries `homepage` and `metadata`, so the rubygems page links back to the source.
@@ -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, headers_only: false, all: false,
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: :asc,
90
+ header: true, align: true, sort: DEFAULT_SORT,
69
91
  }
70
92
 
71
93
  parser = build_parser(opts)
72
- parser.parse!(argv)
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
- sort_rows!(rows, opts[:sort])
167
- emit_output(rows, fields, opts)
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
- # ---- sort ---------------------------------------------------------------
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
252
+
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
181
259
 
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
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
268
+ end
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
193
287
  end
194
- rows.reverse! if mode == :desc
195
- rows
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, "Stop after N matches") { |n| opts[:limit] = n }
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 MODE", %w[asc desc none],
223
- "Sort rows by date+time: asc (default), desc, none") { |v| opts[:sort] = v.to_sym }
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 ""
@@ -405,6 +515,14 @@ module Mailmate
405
515
  # Gmail callers actually write.
406
516
  negate ||= operand.start_with?("-")
407
517
  specs << [:state, operand.delete_prefix("-").downcase, negate]
518
+ elsif !quoted && (hdr = header_spec_token(operand))
519
+ # Arbitrary header specs (delivered-to:joe) — native app
520
+ # syntax per the manual. Foreign keys (date:, from:, ...) never
521
+ # reach here: translate() rewrote the translatable ones before
522
+ # parsing, and the rest stay literal so zero_result_hint can
523
+ # suggest their quicksearch equivalent.
524
+ negate ||= operand.start_with?("-")
525
+ specs << [:header, hdr, negate]
408
526
  else
409
527
  # A bare term opening an or-group inherits the modifier in
410
528
  # force (`d 2024 or 2025`). Elsewhere it is MailMate's
@@ -420,13 +538,29 @@ module Mailmate
420
538
  [specs, in_force]
421
539
  end
422
540
 
541
+ # The downcased "name:value" for a bare token that should parse as an
542
+ # arbitrary-header spec, nil otherwise. Excluded: keys the translator
543
+ # owns (FOREIGN_KEYS — their untranslatable forms stay literal for the
544
+ # zero-result hint), state keys (is/has, handled first), and
545
+ # URL-shaped tokens (http://... is a term, not a search of the
546
+ # nonexistent "http" header).
547
+ def header_spec_token(operand)
548
+ m = operand.match(/\A-?([A-Za-z][\w-]*(?:\.[\w.-]+)?):(\S+)\z/)
549
+ return nil unless m
550
+ return nil if m[2].start_with?("/")
551
+ key = Mailmate::SearchSyntax.normalize_key(m[1].sub(/\..*/, ""))
552
+ return nil if Mailmate::SearchSyntax::FOREIGN_KEYS.include?(key)
553
+ return nil if %w[is has].include?(key)
554
+ "#{m[1]}:#{m[2]}".downcase
555
+ end
556
+
423
557
  # Static cost rank per spec field for AND evaluation order: compiled
424
558
  # date compare < header/tag index lookup < body matching (resolves
425
559
  # part-ids and walks every body segment). Used by order_specs.
426
560
  SPEC_COST = {
427
561
  date: 0,
428
562
  from: 1, recipients: 1, cc: 1, subject: 1, address_any: 1, any: 1,
429
- tag: 1, keyword: 1, state: 1,
563
+ tag: 1, keyword: 1, state: 1, header: 1,
430
564
  body: 2, message_or_body: 2,
431
565
  }.freeze
432
566
 
@@ -438,6 +572,7 @@ module Mailmate
438
572
  "flagged" => :flagged, "starred" => :flagged,
439
573
  "replied" => :replied, "answered" => :replied,
440
574
  "draft" => :draft,
575
+ "archived" => :archived, "archive" => :archived,
441
576
  "attachment" => :attachment, "attachments" => :attachment,
442
577
  }.freeze
443
578
 
@@ -528,16 +663,23 @@ module Mailmate
528
663
  if term =~ /\A(\d+)([dwmy])\z/
529
664
  n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
530
665
  return nil if n.zero? # a zero-length window matches nothing
531
- # N units ENDING today: `1d` = today only, `7d` = the last 7
532
- # calendar days including today. (Off-by-one fixed 2026-08-11 to
533
- # match the MailMate app, where `d 1d` is today's mail the old
534
- # cutoff of today-N made `d 1d` span two calendar days. For a
535
- # rolling 24-hour clock window, that's `d 24h` now.)
666
+ # Calendar units floored to the unit start, matching the app's
667
+ # documented semantics ("1y means this year and not 365 days"):
668
+ # 1d = today, 1w = this ISO week (from Monday), 1m = this month,
669
+ # 1y = this year; N reaches back N-1 further units. Only `Nh` is a
670
+ # rolling clock window that split is deliberate (2026-08-18):
671
+ # calendar words mean calendar spans, and "the last 24 hours" is
672
+ # spelled d 24h.
536
673
  cutoff = case u
537
674
  when "d" then today - (n - 1)
538
- when "w" then today - (n * 7 - 1)
539
- when "m" then (today << n) + 1
540
- when "y" then (today << (n * 12)) + 1
675
+ when "w"
676
+ start = today - (7 * (n - 1))
677
+ start - (start.cwday - 1)
678
+ when "m"
679
+ start = today << (n - 1)
680
+ Date.new(start.year, start.month, 1)
681
+ when "y"
682
+ Date.new(today.year - (n - 1), 1, 1)
541
683
  end
542
684
  return [ymd_int(cutoff), 9999_12_31]
543
685
  end
@@ -547,15 +689,34 @@ module Mailmate
547
689
 
548
690
  case parts.size
549
691
  when 1
550
- y = parts[0].to_i
551
- return nil if y.zero?
552
- [y * 10_000 + 101, y * 10_000 + 1231]
692
+ if parts[0].length == 4
693
+ y = parts[0].to_i
694
+ return nil if y.zero?
695
+ [y * 10_000 + 101, y * 10_000 + 1231]
696
+ else
697
+ # App semantics: a bare small number is a day of the current
698
+ # month — or the most recent month containing that day when it
699
+ # hasn't happened yet (`d 7` on the 5th = last month's 7th).
700
+ most_recent_day_range(parts[0].to_i, today)
701
+ end
553
702
  when 2
554
- # Year-first (2026-08) or month-first with a 4-digit year (8/2026).
555
- y, m = parts[1].length == 4 ? [parts[1], parts[0]] : [parts[0], parts[1]]
556
- y, m = y.to_i, m.to_i
557
- return nil if y.zero? || !(1..12).cover?(m)
558
- [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
703
+ if parts[1].length == 4
704
+ # Month-first with a 4-digit year (8/2026).
705
+ y, m = parts[1].to_i, parts[0].to_i
706
+ return nil if y.zero? || !(1..12).cover?(m)
707
+ [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
708
+ elsif parts[0].length == 4
709
+ # Year-first (2026-08).
710
+ y, m = parts[0].to_i, parts[1].to_i
711
+ return nil if y.zero? || !(1..12).cover?(m)
712
+ [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
713
+ else
714
+ # No year: month + day, ordered per date_order, most recent
715
+ # occurrence (`d 12-25` in August = last year's Dec 25).
716
+ a, b = parts.map(&:to_i)
717
+ m, d = date_order == :dmy ? [b, a] : [a, b]
718
+ most_recent_month_day_range(m, d, today)
719
+ end
559
720
  when 3
560
721
  # ISO year-first, or slash-date with trailing 4-digit year ordered
561
722
  # per date_order. Impossible calendar dates (2026-02-31, month 13)
@@ -590,7 +751,14 @@ module Mailmate
590
751
  # would otherwise quietly match no message ever.
591
752
  if field == :state && !STATE_CANON.key?(term.split(":", 2).last)
592
753
  return "state term cannot match anything: #{term} " \
593
- "(known: is:unread is:read is:flagged is:replied is:draft has:attachment)"
754
+ "(known: is:unread is:read is:flagged is:replied is:draft is:archived has:attachment)"
755
+ end
756
+ if field == :header
757
+ name = term.split(":", 2).first.sub(/\..*/, "")
758
+ if reader_for(name).nil?
759
+ return "no '#{name}' header index — this MailMate store has never seen that " \
760
+ "header. Quote the token (\"#{term}\") to search it as literal text."
761
+ end
594
762
  end
595
763
  next unless field == :date
596
764
  range = hour_range_for(term) || date_range_for(term)
@@ -615,6 +783,33 @@ module Mailmate
615
783
  nil
616
784
  end
617
785
 
786
+ # Single day for the most recent occurrence of day-of-month `day`,
787
+ # stepping back past months that lack it (`d 31` in early March =
788
+ # Jan 31). nil when no month within a year works (day > 31).
789
+ def most_recent_day_range(day, today)
790
+ return nil unless (1..31).cover?(day)
791
+ 0.upto(12) do |back|
792
+ m = today << back
793
+ next unless Date.valid_date?(m.year, m.month, day)
794
+ candidate = Date.new(m.year, m.month, day)
795
+ return [ymd_int(candidate), ymd_int(candidate)] if candidate <= today
796
+ end
797
+ nil
798
+ end
799
+
800
+ # Single day for the most recent occurrence of month+day: this year if
801
+ # it has happened, else last year. nil for impossible dates.
802
+ def most_recent_month_day_range(month, day, today)
803
+ return nil unless (1..12).cover?(month) && (1..31).cover?(day)
804
+ [0, 1].each do |back|
805
+ y = today.year - back
806
+ next unless Date.valid_date?(y, month, day)
807
+ candidate = Date.new(y, month, day)
808
+ return [ymd_int(candidate), ymd_int(candidate)] if candidate <= today
809
+ end
810
+ nil
811
+ end
812
+
618
813
  # `13/8/2026` under month-first ordering is month 13 — almost certainly
619
814
  # a day-first date (and vice versa). Name the likely fix instead of
620
815
  # leaving the generic cannot-match.
@@ -785,27 +980,54 @@ module Mailmate
785
980
  end
786
981
 
787
982
  # term is the full lowercased token ("is:unread", "has:attachment").
788
- # Flag states read the #flags index; attachment presence reads the
789
- # indexed root content-type multipart/mixed is the standard
790
- # attachment layout (a Mail fallback checks real attachments when the
791
- # message is already loaded). Unknown state values never reach here:
983
+ # Flag states read the #flags index; archive state reads the path
984
+ # (same source as the flags output column); attachment presence reads
985
+ # the indexed root content-type multipart/mixed is the standard
986
+ # attachment layout. Wrapper types that can HIDE attachments
987
+ # (signed/encrypted/related) fall back to reading the message and
988
+ # asking Mail for real attachments; plain and alternative roots are
989
+ # trusted as attachment-free. Unknown state values never reach here:
792
990
  # date_spec_error rejects them up front.
793
- def state_matches?(eml_id, mail, term)
991
+ def state_matches?(eml_id, mail, path, term)
794
992
  state = STATE_CANON[term.split(":", 2).last]
795
993
  return false unless state
796
994
 
797
995
  case state
798
996
  when :unread
799
997
  eml_id ? !message_flags(eml_id).include?("\\Seen") : false
998
+ when :archived
999
+ path.to_s.include?("/Archive.mailbox/")
800
1000
  when :attachment
801
1001
  ct = eml_id ? (reader_for("content-type")&.value_for(eml_id.to_i) rescue nil).to_s : ""
802
- return ct.downcase.include?("multipart/mixed") unless ct.empty?
803
- mail ? mail.attachments.any? : false
1002
+ if ct.empty?
1003
+ m = mail || (path && (Mail.read(path) rescue nil))
1004
+ return m ? m.attachments.any? : false
1005
+ end
1006
+ ctl = ct.downcase
1007
+ return true if ctl.include?("multipart/mixed")
1008
+ if ctl.match?(%r{multipart/(signed|encrypted|related)})
1009
+ m = mail || (path && (Mail.read(path) rescue nil))
1010
+ return m ? m.attachments.any? : false
1011
+ end
1012
+ false
804
1013
  else
805
1014
  message_flags(eml_id).include?(STATE_FLAGS[state])
806
1015
  end
807
1016
  end
808
1017
 
1018
+ # term is the downcased "name:value" (subpath allowed on the name and
1019
+ # ignored: `x-mailer.name:mailmate` searches the whole x-mailer value,
1020
+ # which substring matching covers anyway). Index-only by design — a
1021
+ # header this store has never seen has no index, and date_spec_error
1022
+ # reports that up front instead of this method quietly missing.
1023
+ def header_matches?(eml_id, mail, term)
1024
+ name, value = term.split(":", 2)
1025
+ name = name.sub(/\..*/, "")
1026
+ v = eml_id ? (reader_for(name)&.value_for(eml_id.to_i) rescue nil) : nil
1027
+ v = (mail[name]&.to_s rescue nil) if v.nil? && mail
1028
+ v.to_s.b.downcase.include?(value.b)
1029
+ end
1030
+
809
1031
  def message_flags(eml_id)
810
1032
  return [] unless eml_id
811
1033
  reader_for("#flags")&.flags_for(eml_id.to_i) || []
@@ -906,7 +1128,9 @@ module Mailmate
906
1128
  when :date
907
1129
  date_matches?(mail, eml_id, term)
908
1130
  when :state
909
- state_matches?(eml_id, mail, term)
1131
+ state_matches?(eml_id, mail, path, term)
1132
+ when :header
1133
+ header_matches?(eml_id, mail, term)
910
1134
  when :any
911
1135
  %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
912
1136
  end
@@ -1052,7 +1276,7 @@ module Mailmate
1052
1276
  # Absolute send time for an eml_id, preferring the MailMate `#date` index
1053
1277
  # (cheap, no .eml read). Falls back to the parsed mail's Date header.
1054
1278
  def message_time(eml_id, mail)
1055
- s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
1279
+ s = (reader_for("#date")&.value_for(eml_id.to_i) rescue nil)
1056
1280
  if s && !s.empty?
1057
1281
  t = fast_time(s) || (Time.parse(s) rescue nil)
1058
1282
  return t if t
@@ -1077,7 +1301,7 @@ module Mailmate
1077
1301
  # safely interleave with UTF-8 strings in joined output rows.
1078
1302
  def header_index_value(eml_id, name)
1079
1303
  return nil if eml_id.nil?
1080
- v = Mailmate::IndexReader.for(name).value_for(eml_id.to_i)
1304
+ v = reader_for(name)&.value_for(eml_id.to_i)
1081
1305
  v && v.dup.force_encoding("UTF-8").scrub
1082
1306
  rescue ArgumentError
1083
1307
  nil
@@ -1200,6 +1424,11 @@ module Mailmate
1200
1424
 
1201
1425
  def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
1202
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?
1203
1432
  rows = []
1204
1433
  catch(:done) do
1205
1434
  dirs.each do |dir|
@@ -1240,8 +1469,10 @@ module Mailmate
1240
1469
  end
1241
1470
  end
1242
1471
 
1243
- rows << fields.map { |f| extract(f, eml_id, path, mail) }
1244
- throw :done if opts[:limit] && rows.size >= opts[:limit]
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]
1245
1476
  end
1246
1477
  end
1247
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: "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
+ 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", enum: %w[asc desc none], description: "Sort by date+time. Default: asc." },
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", args["limit"].to_i.to_s) if args["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"]
@@ -24,10 +24,11 @@ module Mailmate
24
24
  ["a <term>", "any address header contains"],
25
25
  ["b <term>", "body contains"],
26
26
  ["m <term>", "common headers OR body (same as a bare term)"],
27
- ["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (N calendar units ending today; 1d = today), or Y, Y-M, Y-M-D"],
27
+ ["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (calendar, floored: 1d = today, 1w = this week, 1y = this year), Y | Y-M | Y-M-D, or D | M-D (day of month, most recent occurrence)"],
28
28
  ["T <tag>", "tag / IMAP keyword contains (K is a synonym)"],
29
- ["is:<state>", "message state: unread, read, flagged, replied, draft"],
30
- ["has:attachment", "root MIME is multipart/mixed (the standard attachment layout)"],
29
+ ["is:<state>", "message state: unread, read, flagged, replied, draft, archived"],
30
+ ["has:attachment", "root MIME is multipart/mixed (wrappers like signed/related are opened and checked)"],
31
+ ["<header>:<term>", "any indexed header contains (delivered-to:joe); unknown header = error"],
31
32
  ].freeze
32
33
 
33
34
  EXAMPLES = [
@@ -39,7 +40,8 @@ module Mailmate
39
40
  ["d 24h", "received in the last 24 hours (rolling, not calendar)"],
40
41
  ["d >=2026-05 d <2026-08", "received May through July 2026"],
41
42
  ["d 1h or 2026-08-09", "last hour, plus everything from Aug 9"],
42
- ["is:unread d 7d", "unread, received in the last 7 days"],
43
+ ["is:unread d 1w", "unread, received this week"],
44
+ ["d 7", "day 7 of this month (last month's if the 7th is still ahead)"],
43
45
  ["T urgent", "tagged 'urgent'"],
44
46
  ].freeze
45
47
 
@@ -260,6 +262,14 @@ module Mailmate
260
262
  (rel = parse_relative(value)) && "d #{rel}"
261
263
  when "older_than", "older"
262
264
  (rel = parse_relative(value)) && "d !#{rel}"
265
+ when "in"
266
+ # Archive state is path-derived, so Gmail's location idiom maps onto
267
+ # the is:archived state spec. Other locations (in:trash, in:spam)
268
+ # have no equivalent and stay flagged.
269
+ case value.downcase
270
+ when "archive", "archived" then "is:archived"
271
+ when "inbox" then "!is:archived"
272
+ end
263
273
  end
264
274
  end
265
275
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "1.8.1"
4
+ VERSION = "2.0.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.8.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Murphy-Dye