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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 88d6dabd89e0d5e37026235c1e97fff082e6f1e2a81c6fe372ba224dacacbe7c
4
- data.tar.gz: 816b35b9b2fa41b41e4f08e851af0b125c7fc7240c0deafdd29349b68672c107
3
+ metadata.gz: 34b3eb13fdd2c2a9ec690ec623a543c074014dc1e6b3f82aa3abe6e6cb8de2c7
4
+ data.tar.gz: 11417aa45bfa1980f946b6838ca4451c79e6fb2244976c5879253c37b96cbb72
5
5
  SHA512:
6
- metadata.gz: 046523126145151e5c82cfbced95b6a00edba71825c2fa87d411054e26363f4a893c3afad22a0bb013ac08779da0ebf8c25e4787adfb84cf7670dfb4c5aada50
7
- data.tar.gz: 9eb1bb09367e35cb763789857152258a4d104172fe93655f6966e8f473cc4b127df87cfd2e534cbce42415733560cffcf18009034ea77e72d43066dc3bf0d620
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
 
@@ -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
@@ -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
- 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.1.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.1.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