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 +4 -4
- data/README.md +4 -4
- data/docs/Composing and threading.md +2 -0
- data/lib/mailmate/cli/message.rb +14 -35
- data/lib/mailmate/cli/search.rb +85 -26
- data/lib/mailmate/html_markdown.rb +54 -0
- data/lib/mailmate/mcp.rb +13 -1
- data/lib/mailmate/reply_prefill.rb +37 -6
- data/lib/mailmate/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34b3eb13fdd2c2a9ec690ec623a543c074014dc1e6b3f82aa3abe6e6cb8de2c7
|
|
4
|
+
data.tar.gz: 11417aa45bfa1980f946b6838ca4451c79e6fb2244976c5879253c37b96cbb72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
data/lib/mailmate/cli/message.rb
CHANGED
|
@@ -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
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 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
|
data/lib/mailmate/cli/search.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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.
|
|
202
|
-
# the
|
|
203
|
-
# notice
|
|
204
|
-
|
|
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
|
|
208
|
-
|
|
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
|
-
|
|
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:}`)
|
|
272
|
-
# newest-first
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
#
|
|
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.
|
|
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
|
|
291
|
-
# set. Returns the same array when
|
|
292
|
-
# limit-by order; `sort_rows!`
|
|
293
|
-
|
|
294
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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)
|
data/lib/mailmate/version.rb
CHANGED
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.
|
|
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.
|
|
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
|