mailmate 2.0.0 → 2.2.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: 34b3eb13fdd2c2a9ec690ec623a543c074014dc1e6b3f82aa3abe6e6cb8de2c7
4
+ data.tar.gz: 11417aa45bfa1980f946b6838ca4451c79e6fb2244976c5879253c37b96cbb72
5
5
  SHA512:
6
- metadata.gz: 573429618b13cba013fcb7b4c07db6284335887f577fe0b8d4d2b3c91d2961815d6a32005254d7b848b5c71716a2ee7a0d011caa4a33ab7d35ac1c74c1448700
7
- data.tar.gz: 7e3ef0a93e0462a60f97028a84e9d360e0c8560552100349bd0f348a962e5b76ccf4b5d5febfdefa1b522e8c73aa4934c1d5a17b1bc3c5cc84a185e49709c022
6
+ metadata.gz: d724662f4b0084dbbe7bb712f4678976b30654d26fa67479d038d203034c951a95d90b08c5e7266c7461103299ddb9c6dab03cd8ff3c83d09c7d2a6a9e0c7321
7
+ data.tar.gz: 1a56382a2a6792e0a0df2fc5e83ad26c51158238720c21bc9f7d222b3047dc013eb473dbee623ad0b23f6c53075489603b37d41f789f3019551bda0532443213
data/README.md CHANGED
@@ -106,7 +106,7 @@ mmdiscover
106
106
 
107
107
  **On the vast majority of Ruby setups (stock `arm64-darwin` or `x86_64-darwin` Ruby) this step is a no-op — nokogiri ships a precompiled binary, you can skip the rest of this section and move on.** Keep reading only if your `gem install` actually fails.
108
108
 
109
- `mmmessage --markdown` renders HTML-only message bodies as readable markdown. It needs the `reverse_markdown` gem, which has `nokogiri` as a transitive dependency:
109
+ `mmmessage --markdown` renders HTML-only message bodies as readable markdown, and (since 2.2.0) the quoted original of a reply or forward is the parent's HTML part rendered the same way, so it reads as the author laid it out. Both need the `reverse_markdown` gem, which has `nokogiri` as a transitive dependency:
110
110
 
111
111
  ```bash
112
112
  gem install reverse_markdown
@@ -114,7 +114,7 @@ gem install reverse_markdown
114
114
 
115
115
  That single command pulls `nokogiri` in automatically — no separate `gem install nokogiri` step. This is kept out of the base install because nokogiri ships a native extension. On Ruby/platform combinations without a precompiled match nokogiri falls back to compiling from source — it vendors its own libxml2/libxslt, but it does need a C compiler, which on macOS means Xcode Command Line Tools (`xcode-select --install`). If `gem install reverse_markdown` fails, that's almost certainly the cause.
116
116
 
117
- If you never use `--markdown`, you never pay any of this. If you do invoke `--markdown` without the gem installed, `mmmessage` warns with a clear install hint and falls back to the raw HTML body (it does not abort — so the in-process MCP server survives a missing optional dependency). The plugin launcher and one-line installer attempt this gem automatically and degrade the same way if it fails to build.
117
+ If you never use `--markdown` or compose from a parent, you never pay any of this. Without the gem, a reply/forward quotes the plain-text part instead (or an honest placeholder for an HTML-only parent); if you invoke `--markdown` without it, `mmmessage` warns with a clear install hint and falls back to the raw HTML body (it does not abort — so the in-process MCP server survives a missing optional dependency). The plugin launcher and one-line installer attempt this gem automatically and degrade the same way if it fails to build.
118
118
 
119
119
  ### From source (development)
120
120
 
@@ -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
  |---|---|
@@ -385,7 +385,7 @@ mm-send -f you@x --reply-all-to 12345 --send-now <<<"body"
385
385
  mm-send -f you@x --forward 12345 -t someone@example.com <<<"FYI"
386
386
  ```
387
387
 
388
- The parent is an eml-id or an RFC Message-ID. **Fields you pass explicitly win; fields you omit follow normal reply rules**, and overriding a visible field never drops the threading headers. `--no-quote` suppresses the quoted original. `--print-prefill` prints the derived fields as JSON and sends nothing — the hook for other tools that fill their own compose form.
388
+ The parent is an eml-id or an RFC Message-ID. **Fields you pass explicitly win; fields you omit follow normal reply rules**, and overriding a visible field never drops the threading headers. `--no-quote` suppresses the quoted original (the parent's HTML part rendered to markdown when it has one, else its plain text — see `docs/Composing and threading.md`). `--print-prefill` prints the derived fields as JSON and sends nothing — the hook for other tools that fill their own compose form.
389
389
 
390
390
  Hand-assembly via `--header` still works and is the escape hatch when the parent isn't in MailMate's index. The `mailmate-mcp` `send` / `draft` tools take `in_reply_to` and `references` directly.
391
391
 
@@ -47,6 +47,8 @@ mm-send -f you@x -t them@y -s "Re: foo" \
47
47
 
48
48
  When a parent is supplied, **explicitly-passed fields always win; omitted fields follow normal reply rules** (parent's sender becomes the recipient, subject becomes `Re: <original>`, the quoted original seeds the body, reply-all additionally carries the other recipients minus your own identities).
49
49
 
50
+ **What "the quoted original" is** (2.2.0): the parent's HTML part rendered to markdown when it has one — so the quote reads as the author laid it out (headings, links, emphasis) rather than as the text/plain alternative many senders neglect — else the plain part, else an honest placeholder saying there is no readable text. Raw HTML is never quoted, and a quote is never silently empty. The rendering needs the optional `reverse_markdown` gem (the same one `mmmessage --markdown` uses); without it the plain part is used. Same in every mode; a forward wraps it in the `---------- Forwarded message ----------` header block, a reply prefixes it with `> `.
51
+
50
52
  This rule is uniform across every surface that composes from a parent — the CLI flags above and markdownr's compose popup — so a caller who learns it once can predict all of them. Overriding a visible field never drops the threading headers.
51
53
 
52
54
  ## Header safety
@@ -146,42 +146,21 @@ module Mailmate
146
146
  ct.include?("text/html") || body =~ /\A\s*<(?:!doctype html|html|body|head)\b/i
147
147
  end
148
148
 
149
- # HTML → clean markdown for terminal reading. Three preprocessing /
150
- # postprocessing passes beyond plain reverse_markdown:
151
- # 1. Drop <style> and <script> blocks before conversion pure clutter
152
- # that reverse_markdown otherwise dumps as inline text.
153
- # 2. Strip zero-width spacers that newsletters use to control inbox
154
- # preview text (U+034F, U+200B/C/D, U+FEFF). Without this, you get
155
- # long runs of `͏ ` in the output.
156
- # 3. Collapse 3+ consecutive blank lines into a single blank line.
149
+ # HTML → clean markdown for terminal reading, via the shared converter
150
+ # (Mailmate::HtmlMarkdown ReplyPrefill quotes HTML-only parents with
151
+ # the same passes). Degrades to the raw HTML with a hint rather than
152
+ # `exit`: a library method must not kill its host, and the in-process
153
+ # MCP server would otherwise die on the SystemExit (its dispatch rescues
154
+ # StandardError only).
157
155
  def html_to_markdown(html)
158
- begin
159
- require "nokogiri"
160
- require "reverse_markdown"
161
- rescue LoadError => e
162
- warn "mmmessage --markdown needs the reverse_markdown gem (which pulls nokogiri)."
163
- warn "Install it with: gem install reverse_markdown"
164
- warn "(underlying: #{e.message}) — falling back to raw HTML."
165
- # Degrade to the raw HTML rather than `exit`: a library method must
166
- # not kill its host, and the in-process MCP server would otherwise
167
- # die on the SystemExit (its dispatch rescues StandardError only).
168
- return html
169
- end
170
- doc = Nokogiri::HTML(html)
171
- doc.css("style, script").remove
172
- md = ReverseMarkdown.convert(doc.to_html)
173
- # U+034F combining grapheme joiner, U+200B ZWSP, U+200C ZWNJ,
174
- # U+200D ZWJ, U+FEFF BOM/ZWNBSP — newsletter preview-text padding.
175
- md.gsub!(/[\u034F\u200B\u200C\u200D\uFEFF]/, "")
176
- # Convert non-breaking spaces to regular spaces so rstrip can collapse
177
- # them. Newsletter preview-text padding often uses runs of &nbsp; which
178
- # Ruby's .rstrip leaves alone otherwise.
179
- md.gsub!(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/, " ")
180
- # Strip trailing whitespace per line - the spaces between the
181
- # now-removed zero-width chars otherwise leave long whitespace runs.
182
- md = md.lines.map(&:rstrip).join("\n")
183
- md.gsub!(/\n{3,}/, "\n\n")
184
- md.strip
156
+ require_relative "../html_markdown"
157
+ md = Mailmate::HtmlMarkdown.convert(html)
158
+ return md if md
159
+
160
+ warn "mmmessage --markdown needs the reverse_markdown gem (which pulls nokogiri)."
161
+ warn "Install it with: gem install reverse_markdown"
162
+ warn "Falling back to raw HTML."
163
+ html
185
164
  end
186
165
  end
187
166
  end
@@ -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|
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailmate
4
+ # @api public
5
+ #
6
+ # HTML → clean markdown, shared by `mmmessage --markdown` (terminal reading)
7
+ # and ReplyPrefill (quoting an HTML-only parent in a forward or reply).
8
+ #
9
+ # Depends on reverse_markdown (which pulls nokogiri), an OPTIONAL dependency
10
+ # so the base install stays free of native extensions. `available?` says
11
+ # whether the converter can run; `convert` returns nil when it can't, and
12
+ # each caller decides what an honest fallback looks like for its surface —
13
+ # mmmessage prints the raw HTML with a hint, a prefill uses a placeholder.
14
+ #
15
+ # Three passes beyond plain reverse_markdown, all learned from newsletters:
16
+ # 1. Drop <style> / <script> blocks and HTML comments before conversion —
17
+ # pure clutter that reverse_markdown otherwise dumps as inline text.
18
+ # 2. Strip zero-width spacers used to control inbox preview text (U+034F,
19
+ # U+200B/C/D, U+FEFF) and turn non-breaking spaces into plain ones, or
20
+ # the output carries long runs of `͏ `.
21
+ # 3. Trim trailing whitespace per line and collapse 3+ blank lines to one.
22
+ module HtmlMarkdown
23
+ extend self
24
+
25
+ def available?
26
+ require "nokogiri"
27
+ require "reverse_markdown"
28
+ true
29
+ rescue LoadError
30
+ false
31
+ end
32
+
33
+ # Markdown String, or nil when the optional gems are missing.
34
+ def convert(html)
35
+ return nil unless available?
36
+
37
+ doc = Nokogiri::HTML(html.to_s)
38
+ doc.css("style, script").remove
39
+ # Comments too: newsletter templates leave `<!-- tdMobZ1BottomNew-->`
40
+ # markers that reverse_markdown would otherwise print verbatim.
41
+ doc.xpath("//comment()").remove
42
+ md = ReverseMarkdown.convert(doc.to_html)
43
+ # U+034F combining grapheme joiner, U+200B ZWSP, U+200C ZWNJ,
44
+ # U+200D ZWJ, U+FEFF BOM/ZWNBSP — newsletter preview-text padding.
45
+ md.gsub!(/[\u034F\u200B\u200C\u200D\uFEFF]/, "")
46
+ # Non-breaking spaces (and their Unicode cousins) → plain spaces so the
47
+ # per-line rstrip below can collapse the padding they hold open.
48
+ md.gsub!(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/, " ")
49
+ md = md.lines.map(&:rstrip).join("\n")
50
+ md.gsub!(/\n{3,}/, "\n\n")
51
+ md.strip
52
+ end
53
+ end
54
+ end
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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "header_value"
4
+ require_relative "html_markdown"
4
5
 
5
6
  module Mailmate
6
7
  # @api public
@@ -138,30 +139,47 @@ module Mailmate
138
139
  # Reply: email-classic "On <date>, <sender> wrote:" + a `> `-prefixed body.
139
140
  # Forward: the conventional un-prefixed forwarded-message block with its
140
141
  # own header summary, since the recipient has never seen the original.
142
+ #
143
+ # Which body gets quoted, in both modes: the HTML part rendered to
144
+ # markdown first — so the quote reads as the author laid it out
145
+ # (headings, links, emphasis) rather than as the text/plain alternative
146
+ # senders often neglect — then the plain part, then an honest placeholder
147
+ # when neither yields text (an HTML-only source with reverse_markdown not
148
+ # installed). A quote is never silently empty, and raw HTML is never
149
+ # quoted. (Until 2026-09-04 a reply preferred the plain part and a forward
150
+ # of an HTML-only message quoted nothing at all — the bug that prompted
151
+ # this: a bank alert forwarded as a bare header block.)
141
152
  def derive_quoted_body(mail, mode)
142
- body = plain_body(mail)
143
153
  from = presence(mail["from"]&.value.to_s.strip) || "(unknown sender)"
144
154
  date = mail["date"]&.value.to_s.strip
145
155
 
146
156
  if mode == "forward"
157
+ body = quotable_body(mail) || FORWARD_PLACEHOLDER
147
158
  header = ["---------- Forwarded message ----------",
148
159
  "From: #{from}",
149
160
  ("Date: #{date}" unless date.empty?),
150
161
  "Subject: #{mail.subject.to_s.strip}",
151
162
  ("To: #{mail['to'].value}" if mail["to"])].compact.join("\n")
152
- return "#{header}\n\n#{body}"
163
+ return "#{header}\n\n#{body.sub(/\n+\z/, '')}\n"
153
164
  end
154
165
 
155
166
  attribution = date.empty? ? "#{from} wrote:" : "On #{date}, #{from} wrote:"
156
- return "#{attribution}\n> [no plain-text alternative — paste the original manually]\n" if body.strip.empty?
167
+ body = quotable_body(mail)
168
+ return "#{attribution}\n> #{REPLY_PLACEHOLDER}\n" if body.nil?
157
169
 
158
170
  quoted = body.sub(/\n+\z/, "").split("\n", -1).map { |l| "> #{l}".rstrip }.join("\n")
159
171
  "#{attribution}\n#{quoted}\n"
160
172
  end
161
173
 
162
- # The text/plain alternative, or "" when the message is HTML-only. We do
163
- # NOT synthesize text from the HTML part here: a lossy auto-conversion
164
- # quoted back to the original sender is worse than an honest placeholder.
174
+ REPLY_PLACEHOLDER = "[no plain-text alternative paste the original manually]"
175
+ FORWARD_PLACEHOLDER = "[the original has no readable text it is HTML-only and the reverse_markdown gem is not installed; attach it or paste manually]"
176
+
177
+ # HTML-as-markdown, else plain text, else nil.
178
+ def quotable_body(mail)
179
+ presence(html_body_markdown(mail)) || presence(plain_body(mail))
180
+ end
181
+
182
+ # The text/plain alternative, or "" when the message is HTML-only.
165
183
  def plain_body(mail)
166
184
  part = mail.multipart? ? mail.text_part : mail
167
185
  return "" if part.nil?
@@ -172,6 +190,19 @@ module Mailmate
172
190
  ""
173
191
  end
174
192
 
193
+ # The text/html part rendered to markdown (Mailmate::HtmlMarkdown), or ""
194
+ # when there is no HTML part or the optional converter is missing.
195
+ # `decoded` (not `body.decoded`) so the part's declared charset — the
196
+ # iso-8859-1 of a bank alert — is transcoded to UTF-8 before parsing.
197
+ def html_body_markdown(mail)
198
+ part = mail.multipart? ? mail.html_part : (mail.content_type.to_s.downcase.include?("text/html") ? mail : nil)
199
+ return "" if part.nil?
200
+
201
+ Mailmate::HtmlMarkdown.convert(part.decoded.to_s).to_s
202
+ rescue StandardError
203
+ ""
204
+ end
205
+
175
206
  # Mail's address fields raise on malformed input often enough that a reply
176
207
  # to a slightly-broken message shouldn't blow up the whole derivation.
177
208
  def addresses(field)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "2.0.0"
4
+ VERSION = "2.2.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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Murphy-Dye
@@ -145,6 +145,7 @@ files:
145
145
  - lib/mailmate/flag_check.rb
146
146
  - lib/mailmate/header_reader.rb
147
147
  - lib/mailmate/header_value.rb
148
+ - lib/mailmate/html_markdown.rb
148
149
  - lib/mailmate/identity.rb
149
150
  - lib/mailmate/index_reader.rb
150
151
  - lib/mailmate/lexer.rb
@@ -182,7 +183,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
182
183
  - !ruby/object:Gem::Version
183
184
  version: '0'
184
185
  requirements: []
185
- rubygems_version: 4.0.12
186
+ rubygems_version: 4.0.19
186
187
  specification_version: 4
187
188
  summary: Ruby toolkit for MailMate on macOS — search, read, modify, send, and smart-mailbox
188
189
  evaluation