mailmate 2.1.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 +3 -3
- data/docs/Composing and threading.md +2 -0
- data/lib/mailmate/cli/message.rb +14 -35
- data/lib/mailmate/html_markdown.rb +54 -0
- data/lib/mailmate/reply_prefill.rb +37 -6
- data/lib/mailmate/version.rb +1 -1
- metadata +2 -1
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
|
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|