mailmate 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52941d76f7875e2944527026ed3d8584eb15e718dded7c7db5b75cad880be7d2
4
- data.tar.gz: bb5f9463d23b07f6b6755c1c1c54326a060b9bd5680554399ff97f23137f7b6c
3
+ metadata.gz: d762c124b353823c34d4b87eb5748727ed2901a507f8bc67a0d74f22b1ab1350
4
+ data.tar.gz: c02381fd0d81be1e8793d85d3d838d40378bf2e99bc4d96f82405144171a73ed
5
5
  SHA512:
6
- metadata.gz: ac99eb6fee19f727a953228a599f5ec29d741339776388547ff514403a838c990635c586881dfee5750c3a0c6a72f6c66bdcc513f3dba4ed85eb0e980b588357
7
- data.tar.gz: 6a293bf66d563b80d9f9a0e2f0808fa32db6ba4bd40bea65fbb364a73e0fbef3d0aa715b284ad07f464a7cbb695825da4d2f614903699f1d3e527101e7135edc
6
+ metadata.gz: cf2901c2df5341834cdcabeed160b33ea4d797c36cdaf24ee14ce1f48020f3a34ac8ff37f5c06a29f659ff486246838b575f73e5cf34e2fae0e986a64949650e
7
+ data.tar.gz: bf8f8e7fc3944a32b279a7ac8448def35020a430c38bad95f8f08d63e4e67d9eeef08ddb98f6775b0ba1b428fc6c20e93f27379833e73de28dd6425788158e07
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # mailmate
2
2
 
3
- Ruby toolkit for [MailMate](https://freron.com) on macOS — a smart-mailbox filter engine, on-disk index readers, and CLI tools (`mmsearch`, `mmmessage`, `mm-modify`, `mm-send`, `mmdiscover`) for searching, reading, modifying, and sending mail.
3
+ Ruby toolkit for [MailMate](https://freron.com) on macOS — a smart-mailbox filter engine, on-disk index readers, and CLI tools (`mmsearch`, `mmmessage`, `mmopen`, `mm-mailboxes`, `mmtags`, `mm-modify`, `mm-send`, `mmdiscover`) for searching, reading, modifying, and sending mail.
4
4
 
5
5
  **Requires macOS with MailMate installed.** The library code (filter parser, evaluator) works anywhere, but the integration with MailMate itself — AppleScript, on-disk index reads, the `emate` binary — is macOS-only by way of MailMate being macOS-only.
6
6
 
@@ -39,8 +39,56 @@ mmmessage 183715 --raw
39
39
 
40
40
  # Body only, no headers block
41
41
  mmmessage 183715 --text-only
42
+
43
+ # Open in MailMate's UI instead of printing (delegates to mmopen)
44
+ mmmessage 183715 --mailmate
45
+
46
+ # Render an HTML-only message as clean markdown (no-op for text/plain bodies)
47
+ mmmessage 183715 --markdown
48
+ ```
49
+
50
+ `--markdown` uses `reverse_markdown` + Nokogiri preprocessing to drop `<style>`/`<script>` blocks and strip newsletter preview-text padding (zero-width chars, runs of non-breaking / figure / narrow spaces, etc.). Conversion quality is good for plain replies/threads and rough for marketing-newsletter HTML (which uses nested layout tables); the raw source is still available with `--raw`.
51
+
52
+ ### `mmopen` — open a message in MailMate's UI
53
+
54
+ ```bash
55
+ # By eml-id, Message-ID, message:// URL, or mid: URL — all six input forms
56
+ # that EmlLookup.resolve_id understands.
57
+ mmopen 183715
58
+ mmopen '<abc@example.com>'
59
+ mmopen 'message://%3Cabc%40example.com%3E'
60
+
61
+ # Print the mid: URL instead of opening it (useful in pipelines)
62
+ mmopen 183715 --print
63
+ ```
64
+
65
+ ### `mm-mailboxes` — list accounts and mailboxes
66
+
67
+ ```bash
68
+ # Grouped by account, with .eml counts per IMAP mailbox + smart-mailbox names
69
+ mm-mailboxes
70
+
71
+ # Skip counts (much faster on large stores)
72
+ mm-mailboxes --no-count
73
+
74
+ # Flat CSV (one row per mailbox; account repeated in column 1)
75
+ mm-mailboxes --csv
42
76
  ```
43
77
 
78
+ Account names are URL-decoded for display (`%40` → `@`). Smart-mailbox names appear in their own section with `-` for count (not calculated — would require evaluating each filter).
79
+
80
+ ### `mmtags` — list tags
81
+
82
+ ```bash
83
+ # Tags actually applied to messages, sorted by usage count
84
+ mmtags
85
+
86
+ # Tags defined in MailMate → Preferences → Tags (may include unused ones)
87
+ mmtags --defined
88
+ ```
89
+
90
+ The default reads MailMate's `#flags` index (IMAP keywords, system flags excluded). The two views can differ: tags can be applied programmatically (via IMAP keyword) without being registered in Preferences, and tags can be defined in Preferences without being applied to any message yet.
91
+
44
92
  ### `mm-modify` — change message state
45
93
 
46
94
  ```bash
@@ -50,6 +98,16 @@ mm-modify 183715 read flag archive
50
98
  # Add a tag
51
99
  mm-modify 183715 tag urgent
52
100
 
101
+ # Pure-move (one or more moves, nothing else): same-account moves take a
102
+ # fast path — direct .eml rename on disk, no UI, no focus theft.
103
+ mm-modify 183715 move Archive
104
+
105
+ # Chain with other actions: everything (including the move) goes through
106
+ # MailMate's UI in user-supplied order. We're already paying for the UI for
107
+ # the tag/read; letting MailMate do the move too keeps its state in sync
108
+ # with itself, no #source-index staleness window.
109
+ mm-modify 183715 tag processed read move Archive
110
+
53
111
  # Dry-run first
54
112
  mm-modify 183715 archive --dry-run
55
113
 
@@ -57,6 +115,8 @@ mm-modify 183715 archive --dry-run
57
115
  mm-modify 183715 read --verify
58
116
  ```
59
117
 
118
+ **Tip — batch your actions.** Doing all related changes in **one** `mm-modify` invocation is still worthwhile: one open/close cycle instead of two, and the chain runs deterministically without you having to think about ordering. Splitting is safe — `path_for` falls back to a filesystem glob if MailMate's `#source` index is briefly stale after a fast-move — just slower than batching.
119
+
60
120
  ### `mm-send` — send mail
61
121
 
62
122
  `mm-send` is a thin wrapper around MailMate's bundled `emate mailto`, with `--markup markdown` enforced. The body is read from stdin.
@@ -81,20 +141,24 @@ mm-send -t friend@example.com -s "Photos" /path/to/photo1.jpg /path/to/photo2.jp
81
141
 
82
142
  The `mm` prefix is for tab completion: typing `mm<tab>` in a shell lists every command in the toolkit. The dash matters:
83
143
 
84
- - **`mm<name>`** (no dash) — **read** operations. `mmsearch`, `mmmessage`, `mmdiscover` only observe MailMate's on-disk state.
85
- - **`mm-<name>`** (with dash) — **write** operations. `mm-modify`, `mm-send` change state (or send mail). Typing `mm-<tab>` filters to just the write commands so you can see at a glance what mutates.
144
+ - **`mm<name>`** (no dash) — **read** operations. `mmsearch`, `mmmessage`, `mmopen`, `mmtags`, `mmdiscover` observe MailMate's on-disk state without changing it. (`mmopen` activates MailMate's UI but doesn't modify any message.)
145
+ - **`mm-<name>`** (with dash) — **write** operations. `mm-modify`, `mm-send` change state (or send mail). `mm-mailboxes` is an exception: read-only, but uses the dash to keep `mmm<tab>` free for the daily-driver `mmmessage`. Typing `mm-<tab>` filters to the write-leaning commands.
86
146
 
87
147
  ## Limitations
88
148
 
89
149
  A few rough edges to be aware of:
90
150
 
91
- 1. **Search is slow against this wrapper, even though MailMate itself is fast.** MailMate has a fantastic search engine — its native quicksearch is near-instant even against large stores — but `mmsearch` doesn't yet route through it. The current implementation uses index prefilters plus direct `.eml` walks, which works but is orders of magnitude slower than MailMate's own UI search, and **painfully** slow once a body-text term (`b <term>`, a bare term that falls through to body matching, or a `--no-headers-only` query) disables the prefilter. Prefer narrowing with `f`/`t`/`s`/`d` first, and use `--headers-only` when you don't need the body matched. Finding a way to drive MailMate's native engine from the outside is open work.
151
+ 1. **Body search speed depends on MailMate's body-index coverage; everything else is fully index-driven.** Header-based filters (`f`/`t`/`c`/`s`/`a`/`T`/`K`/`d`) and every output column (subject, from, to, cc, date, tags, flags, …) read directly from MailMate's binary header indexes under `Database.noindex/Headers/`. No `.eml` is opened for those queries performance is in the same ballpark as MailMate's own UI search (sub-second across large stores).
152
+
153
+ Body queries (`b <term>`, a bare term that falls through to `:message_or_body`, or explicit `m <term>` without `--headers-only`) check MailMate's `#unquoted` and `#quoted` body indexes. **Default behavior matches MailMate's UI body search** — only messages MailMate has body-indexed are searched. Sub-second on any archive size; the catch is that MailMate populates those indexes lazily (typically when you view or search messages in its UI), so a search may miss content in messages MailMate hasn't yet indexed. Coverage varies by user: a fresh install has a handful indexed; a heavy MailMate-search user has most.
154
+
155
+ Pass `--all` to fall back to reading and parsing the `.eml` on disk for messages without an index record. This finds everything but takes tens of seconds to minutes on large archives. Use `--all` when you need an exhaustive answer; stick with the default when you want a predictable-fast one.
92
156
 
93
- 2. **Bulk `mm-modify` takes over the whole computer, not just MailMate.** Each invocation opens a message-viewer window via the `mid:` URL, runs AppleScript key-binding selectors against it, then closes the window. Two things follow from that:
157
+ 2. **Non-move `mm-modify` actions still take over the UI.** Same-account `move` actions use a fast path — a direct `.eml` rename on disk — so they're silent and don't activate MailMate. Everything else (`read`, `flag`, `tag`, `archive`, `junk`, `delete`, etc.) still drives MailMate's UI: each invocation opens a message-viewer window via the `mid:` URL, runs AppleScript key-binding selectors against it, then closes the window. Two consequences:
94
158
  - **Focus is stolen.** When the `mid:` URL fires, macOS brings MailMate forward and the spawned message-viewer window takes keyboard focus. Anything you were typing into another app goes to MailMate instead.
95
- - **The close at the end can close the wrong window.** `mm-modify` ends by sending the standard "close window" keystroke. If focus has drifted (or the next app's window has come forward in the meantime), that keystroke lands on **your** window — your editor, your browser tab — not MailMate's viewer.
159
+ - **The close at the end can close the wrong window.** `mm-modify` ends by sending the standard "close window" keystroke. If focus has drifted (or another app's window has come forward in the meantime), that keystroke lands on **your** window — your editor, your browser tab — not MailMate's viewer.
96
160
 
97
- For one-off changes this is just annoying; for a loop of hundreds of messages it makes the machine unusable while it runs. Batch multiple actions into one `mm-modify` invocation when you can — they share a single open/close cycle. The `--keep-window` flag avoids the close-keystroke entirely if you don't mind cleaning up viewers manually.
161
+ For one-off changes this is just annoying; for a loop of hundreds of messages it makes the machine unusable while it runs. Batch multiple actions into one `mm-modify` invocation when you can — they share a single open/close cycle (or skip it entirely for pure-move). The `--keep-window` flag avoids the close-keystroke entirely if you don't mind cleaning up viewers manually.
98
162
 
99
163
  3. **`eml-id` is machine-local; prefer `Message-ID:`.** The integer eml-id (also shown as MailMate's "Msg ID" column) is just the filename of the `.eml` on disk and differs on every install — copy/pasting an eml-id from your desktop to your laptop will refer to a different message (or none at all). For anything you want to keep, store the RFC `Message-ID:` header (which `mmmessage` prints) and pass that to the CLIs. The `mid:%3C<message-id>%3E` URL scheme works portably for the same reason.
100
164
 
@@ -137,8 +201,11 @@ Then `mmdiscover` as above.
137
201
  | Command | What it does |
138
202
  |---|---|
139
203
  | `mmsearch` | List messages matching a quicksearch expression. Output is aligned CSV. |
140
- | `mmmessage` | Print one message by `.eml` id (decoded headers + plain-text body). |
141
- | `mm-modify` | Mark read/flag/tag/archive/move a message via AppleScript. |
204
+ | `mmmessage` | Print one message by id (decoded headers + plain-text body). `--mailmate` opens in MailMate instead; `--markdown` renders HTML-only bodies as clean markdown. |
205
+ | `mmopen` | Open one message in MailMate's UI (via `open mid:…`). `--print` returns the URL. |
206
+ | `mm-mailboxes` | List accounts, IMAP mailboxes (with optional counts), and smart-mailbox names. |
207
+ | `mmtags` | List user tags applied to messages (with counts) or defined in Preferences. |
208
+ | `mm-modify` | Mark read/flag/tag/archive a message via AppleScript; same-account `move` uses a fast `.eml`-rename path with no UI takeover. |
142
209
  | `mm-send` | Send mail through `emate` with a markdown body on stdin. |
143
210
  | `mmdiscover` | First-run bootstrap; (re-)writes the user config from MailMate's plists. |
144
211
 
data/exe/mailmate-mcp ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/mcp"
7
+
8
+ exit Mailmate::MCP.run
data/exe/mm-mailboxes ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/mailboxes"
7
+
8
+ exit Mailmate::CLI::Mailboxes.run(ARGV)
data/exe/mmopen ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/open"
7
+
8
+ exit Mailmate::CLI::Open.run(ARGV)
data/exe/mmtags ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/tags"
7
+
8
+ exit Mailmate::CLI::Tags.run(ARGV)
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "uri"
5
+
6
+ module Mailmate
7
+ module CLI
8
+ # `mm-mailboxes` — enumerate accounts, their IMAP mailboxes, and the
9
+ # smart mailboxes MailMate has defined. Read-side (no UI activation), but
10
+ # uses the `mm-` prefix anyway so it doesn't shadow `mmmessage` in
11
+ # tab-completion: `mmm<tab>` keeps resolving to `mmmessage`.
12
+ # @api private
13
+ module Mailboxes
14
+ extend self
15
+
16
+ def run(argv)
17
+ opts = parse_options(argv)
18
+
19
+ accounts = enumerate_imap_accounts(count: opts[:count])
20
+ smart = enumerate_smart_mailboxes
21
+
22
+ if opts[:csv]
23
+ emit_csv(accounts, smart, opts)
24
+ else
25
+ emit_grouped(accounts, smart, opts)
26
+ end
27
+ 0
28
+ end
29
+
30
+ def parse_options(argv)
31
+ opts = { count: true, csv: false, align: true }
32
+ OptionParser.new do |o|
33
+ o.banner = "Usage: mm-mailboxes [options]"
34
+ o.separator ""
35
+ o.separator "List MailMate accounts, their IMAP mailboxes, and smart mailboxes."
36
+ o.separator "Default output groups by account with a section header per account."
37
+ o.on("--no-count", "Skip .eml counts (faster on large stores)") { opts[:count] = false }
38
+ o.on("--csv", "Flat CSV output (one row per mailbox, account repeated)") { opts[:csv] = true }
39
+ o.on("--no-align", "Plain CSV (no column padding) — implies --csv") { opts[:csv] = true; opts[:align] = false }
40
+ end.parse!(argv)
41
+ opts
42
+ end
43
+
44
+ # ---- enumeration --------------------------------------------------------
45
+
46
+ # Returns [[account_display, [{mailbox: 'INBOX', count: 127}, …]], …]
47
+ # Account names are pulled from on-disk dir names under imap_root, with
48
+ # MailMate's URL-encoding decoded for display (`%40` → `@`).
49
+ def enumerate_imap_accounts(count: true)
50
+ root = Mailmate.config.imap_root
51
+ return [] unless File.directory?(root)
52
+
53
+ Dir.children(root).sort.filter_map do |dirname|
54
+ account_dir = File.join(root, dirname)
55
+ next unless File.directory?(account_dir)
56
+
57
+ mailboxes = collect_mailboxes(account_dir, count: count)
58
+ next if mailboxes.empty?
59
+
60
+ [decode_account(dirname), mailboxes]
61
+ end
62
+ end
63
+
64
+ def collect_mailboxes(account_dir, count:)
65
+ prefix = "#{account_dir}/"
66
+ Dir.glob("#{account_dir}/**/Messages").sort.filter_map do |messages_dir|
67
+ next unless File.directory?(messages_dir)
68
+ rel = messages_dir.sub(prefix, "").sub(%r{/Messages\z}, "")
69
+ # Strip the .mailbox suffix from each segment for display.
70
+ mailbox_name = rel.split("/").map { |s| s.sub(/\.mailbox\z/, "") }.join("/")
71
+ row = { mailbox: mailbox_name }
72
+ row[:count] = count ? Dir.children(messages_dir).count { |f| f.end_with?(".eml") } : nil
73
+ row
74
+ end
75
+ end
76
+
77
+ # Returns array of smart-mailbox names (sorted).
78
+ def enumerate_smart_mailboxes
79
+ graph = Mailmate::MailboxGraph.load
80
+ graph.by_uuid.values
81
+ .select { |m| m[:filter] }
82
+ .map { |m| m[:name] }
83
+ .compact
84
+ .uniq
85
+ .sort
86
+ rescue StandardError
87
+ []
88
+ end
89
+
90
+ # MailMate's account dirs encode `@` as `%40` (they're literally IMAP
91
+ # URL fragments). Decode for display so users see real addresses.
92
+ def decode_account(dirname)
93
+ URI.decode_www_form_component(dirname)
94
+ end
95
+
96
+ # ---- output -------------------------------------------------------------
97
+
98
+ def emit_grouped(accounts, smart, opts)
99
+ accounts.each do |account, mailboxes|
100
+ $stdout.puts account
101
+ mailboxes.each do |m|
102
+ count_str = opts[:count] ? format_count(m[:count]) : ""
103
+ $stdout.puts " #{m[:mailbox].ljust(50)}imap #{count_str}"
104
+ end
105
+ end
106
+ unless smart.empty?
107
+ $stdout.puts
108
+ $stdout.puts "Smart Mailboxes"
109
+ smart.each { |name| $stdout.puts " #{name.ljust(50)}smart -" }
110
+ end
111
+ end
112
+
113
+ def emit_csv(accounts, smart, opts)
114
+ rows = []
115
+ accounts.each do |account, mailboxes|
116
+ mailboxes.each do |m|
117
+ rows << [account, m[:mailbox], "imap", opts[:count] ? format_count(m[:count]) : ""]
118
+ end
119
+ end
120
+ smart.each { |name| rows << ["(smart)", name, "smart", "-"] }
121
+
122
+ header = %w[account mailbox type count]
123
+ if opts[:align]
124
+ display = ([header] + rows)
125
+ widths = Array.new(header.size, 0)
126
+ display.each { |r| r.each_with_index { |c, i| widths[i] = c.to_s.length if c.to_s.length > widths[i] } }
127
+ display.each do |r|
128
+ $stdout.puts r.each_with_index.map { |c, i| i == r.size - 1 ? c.to_s : c.to_s.ljust(widths[i]) }.join(",")
129
+ end
130
+ else
131
+ require "csv"
132
+ $stdout.puts CSV.generate_line(header)
133
+ rows.each { |r| $stdout.puts CSV.generate_line(r) }
134
+ end
135
+ end
136
+
137
+ def format_count(n)
138
+ n.nil? ? "-" : n.to_s
139
+ end
140
+ end
141
+ end
142
+ end
@@ -34,6 +34,13 @@ module Mailmate
34
34
  return 1
35
35
  end
36
36
 
37
+ if opts[:mailmate]
38
+ require_relative "open"
39
+ # Hand off to mmopen — same id resolution already done, but Open re-resolves
40
+ # so the two paths stay symmetric. Tiny double-resolve is fine.
41
+ return Mailmate::CLI::Open.run([input])
42
+ end
43
+
37
44
  if opts[:raw]
38
45
  $stdout.binmode
39
46
  $stdout.write(File.binread(path))
@@ -42,28 +49,30 @@ module Mailmate
42
49
 
43
50
  mail = Mail.read(path)
44
51
  print_headers(mail, eml_id, path) unless opts[:text_only]
45
- $stdout.puts text_body(mail)
52
+ $stdout.puts text_body(mail, markdown: opts[:markdown])
46
53
  0
47
54
  end
48
55
 
49
56
  def parse_options(argv)
50
- opts = { raw: false, text_only: false }
57
+ opts = { raw: false, text_only: false, mailmate: false, markdown: false }
51
58
  OptionParser.new do |o|
52
- o.banner = "Usage: mmmessage <id> [--raw|--text-only]"
59
+ o.banner = "Usage: mmmessage <id> [--raw|--text-only|--mailmate|--markdown]"
53
60
  o.separator ""
54
- o.separator "<id> can be either a local eml-id (e.g. 183715) or an RFC"
55
- o.separator "Message-ID (with or without angle brackets, e.g."
56
- o.separator "<abc@example.com>). Message-IDs are portable across machines"
57
- o.separator "and survive copy/paste between desktop/laptop/iPad."
61
+ o.separator "<id> can be a local eml-id (e.g. 183715), an RFC Message-ID"
62
+ o.separator "(with or without angle brackets, e.g. <abc@example.com>), or"
63
+ o.separator "a message://%3C...%3E URL. The latter two are portable across"
64
+ o.separator "machines and survive copy/paste between desktop/laptop/iPad."
58
65
  o.on("--raw", "Output raw .eml bytes") { opts[:raw] = true }
59
66
  o.on("--text-only", "Output decoded body only (no headers block)") { opts[:text_only] = true }
67
+ o.on("--mailmate", "Open in MailMate's UI instead of printing (alias for `mmopen <id>`)") { opts[:mailmate] = true }
68
+ o.on("--markdown", "Render HTML body as clean markdown (no-op for plain-text messages)") { opts[:markdown] = true }
60
69
  end.parse!(argv)
61
70
  opts
62
71
  end
63
72
 
64
73
  def usage_error(msg)
65
74
  warn "mmmessage: #{msg}"
66
- warn "Usage: mmmessage <id> [--raw|--text-only]"
75
+ warn "Usage: mmmessage <id> [--raw|--text-only|--mailmate|--markdown]"
67
76
  warn " <id> is either an eml-id (digits) or an RFC Message-ID."
68
77
  2
69
78
  end
@@ -94,16 +103,58 @@ module Mailmate
94
103
  $stdout.puts
95
104
  end
96
105
 
97
- def text_body(mail)
106
+ def text_body(mail, markdown: false)
98
107
  if mail.text_part
108
+ # text/plain is already plain — markdown flag is a no-op here.
99
109
  mail.text_part.decoded.force_encoding("UTF-8").scrub
100
110
  elsif mail.html_part
101
- "[no text/plain part — HTML rendered below; use --raw for original]\n\n" +
102
- mail.html_part.decoded.force_encoding("UTF-8").scrub
111
+ html = mail.html_part.decoded.force_encoding("UTF-8").scrub
112
+ markdown ? html_to_markdown(html) : "[no text/plain part — HTML rendered below; use --raw for original]\n\n#{html}"
103
113
  else
104
- mail.body.decoded.to_s.force_encoding("UTF-8").scrub
114
+ body = mail.body.decoded.to_s.force_encoding("UTF-8").scrub
115
+ if markdown && html_like?(mail, body)
116
+ html_to_markdown(body)
117
+ else
118
+ body
119
+ end
105
120
  end
106
121
  end
122
+
123
+ # Heuristic for "this body is HTML even though Mail couldn't structure it"
124
+ # — covers single-part text/html messages where mail.html_part is nil and
125
+ # we'd otherwise dump the raw markup.
126
+ def html_like?(mail, body)
127
+ ct = mail.content_type.to_s.downcase
128
+ ct.include?("text/html") || body =~ /\A\s*<(?:!doctype html|html|body|head)\b/i
129
+ end
130
+
131
+ # HTML → clean markdown for terminal reading. Three preprocessing /
132
+ # postprocessing passes beyond plain reverse_markdown:
133
+ # 1. Drop <style> and <script> blocks before conversion — pure clutter
134
+ # that reverse_markdown otherwise dumps as inline text.
135
+ # 2. Strip zero-width spacers that newsletters use to control inbox
136
+ # preview text (U+034F, U+200B/C/D, U+FEFF). Without this, you get
137
+ # long runs of `͏ ` in the output.
138
+ # 3. Collapse 3+ consecutive blank lines into a single blank line.
139
+ def html_to_markdown(html)
140
+ require "nokogiri"
141
+ require "reverse_markdown"
142
+ doc = Nokogiri::HTML(html)
143
+ doc.css("style, script").remove
144
+ md = ReverseMarkdown.convert(doc.to_html)
145
+ # U+034F combining grapheme joiner, U+200B ZWSP, U+200C ZWNJ,
146
+ # U+200D ZWJ, U+FEFF BOM/ZWNBSP — newsletter preview-text padding.
147
+ md.gsub!(/[\u034F\u200B\u200C\u200D\uFEFF]/, "")
148
+ # Convert non-breaking spaces to regular spaces so rstrip can collapse
149
+ # them. Newsletter preview-text padding often uses runs of &nbsp; which
150
+ # Ruby's .rstrip leaves alone otherwise.
151
+ md.gsub!(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/, " ")
152
+ # Strip trailing whitespace per line - the spaces between the
153
+ # now-removed zero-width chars otherwise leave long whitespace runs.
154
+ md = md.lines.map(&:rstrip).join("\n")
155
+ md.gsub!(/\n{3,}/, "\n\n")
156
+ md.strip
157
+ end
107
158
  end
108
159
  end
109
160
  end
@@ -69,9 +69,10 @@ module Mailmate
69
69
  o.banner = <<~BANNER
70
70
  Usage: mm-modify <id> <action> [args...] [<action> [args...]]...
71
71
 
72
- <id> can be either a local eml-id (e.g. 183715) or an RFC Message-ID
73
- (with or without angle brackets, e.g. <abc@example.com>). Quote the
74
- Message-ID in your shell so the < > aren't interpreted as redirection.
72
+ <id> can be a local eml-id (e.g. 183715), an RFC Message-ID (with or
73
+ without angle brackets, e.g. <abc@example.com>), or a message://%3C...%3E
74
+ URL. Quote the Message-ID in your shell so the < > aren't interpreted as
75
+ redirection.
75
76
 
76
77
  Selects the message in MailMate (via the `mid:` URL) and runs one or
77
78
  more AppleScript key-binding selectors against the now-selected message.
@@ -86,7 +87,13 @@ module Mailmate
86
87
  untag <name> Remove IMAP keyword <name>
87
88
  clear-tags Remove all keywords
88
89
  archive Move to the archive mailbox
89
- move <mailbox-uuid> Move to a specific mailbox (use UUID from MailMate)
90
+ move <mailbox> Move to a specific mailbox. Bare names like 'Archive'
91
+ or 'Folder/Sub' resolve within the same account and
92
+ take a fast path (direct .eml rename — no UI, no
93
+ focus theft). Mailbox UUIDs and cross-account moves
94
+ fall back to the AppleScript driver. Chained with
95
+ other actions: tag/flag/etc. run first at the
96
+ original location, the rename happens last.
90
97
  junk / not-junk Mark as junk / not junk
91
98
  mute Toggle mute state
92
99
  delete Delete (move to trash)
@@ -135,6 +142,117 @@ module Mailmate
135
142
  end
136
143
 
137
144
  def drive(eml_id, message_id, actions, opts)
145
+ # Fast-path moves apply ONLY when every requested action is a move.
146
+ # When the chain includes any non-move action (tag, flag, archive, …)
147
+ # we're paying for MailMate's UI anyway, so we let MailMate handle the
148
+ # move in-UI alongside the rest. Why this beats reordering moves to
149
+ # the end of the chain:
150
+ #
151
+ # - The marginal cost of one extra AppleScript `moveToMailbox:` call
152
+ # is small next to the UI activation we're already eating.
153
+ # - MailMate sees the move it just made — no #source-index staleness
154
+ # inside the same invocation or for follow-ups.
155
+ # - Simpler mental model: pure-move = silent + fast; mixed = all-UI.
156
+ fast_moves, other = actions.partition { |name, _, _| name == "move" }
157
+
158
+ if other.empty? && !fast_moves.empty?
159
+ current_path = Mailmate::EmlLookup.path_for(eml_id)
160
+ fast_moves.each do |_name, selector, args|
161
+ new_path = try_fast_move(eml_id, current_path, args.first, opts)
162
+ if new_path
163
+ current_path = new_path
164
+ else
165
+ # Fast-path declined for this one move (cross-account, target
166
+ # not found, perm error, …) — single UI-driven move as fallback.
167
+ drive_via_applescript(eml_id, message_id, [["move", selector, args]], opts)
168
+ end
169
+ end
170
+ else
171
+ # Mixed chain (or pure non-move chain): everything goes through the
172
+ # AppleScript driver in the user-supplied order.
173
+ drive_via_applescript(eml_id, message_id, actions, opts)
174
+ end
175
+ end
176
+
177
+ # Returns the new path on success (or in dry-run, the path we *would*
178
+ # have moved to). Returns nil if the caller should fall back to the
179
+ # AppleScript driver for this action (cross-account, unknown target,
180
+ # permission error, …).
181
+ def try_fast_move(eml_id, current_path, target_spec, opts)
182
+ return nil if current_path.nil?
183
+ account_dir = account_dir_for(current_path)
184
+ return nil if account_dir.nil?
185
+
186
+ dest_messages = find_target_in_account(account_dir, target_spec)
187
+ return nil if dest_messages.nil?
188
+
189
+ dest_path = File.join(dest_messages, "#{eml_id}.eml")
190
+ if dest_path == current_path
191
+ $stdout.puts "move (fast): #{eml_id}.eml is already in #{target_spec} — no-op"
192
+ return dest_path
193
+ end
194
+
195
+ if opts[:dry_run]
196
+ $stdout.puts "move (fast, dry-run): would rename"
197
+ $stdout.puts " from: #{current_path}"
198
+ $stdout.puts " to: #{dest_path}"
199
+ return dest_path
200
+ end
201
+
202
+ File.rename(current_path, dest_path)
203
+ # The #source index still points at the old location until MailMate
204
+ # rescans; bust it so any subsequent path_for in this process re-reads
205
+ # (and eventually picks up MailMate's refreshed value).
206
+ Mailmate::IndexReader.reset!("#source") if defined?(Mailmate::IndexReader)
207
+ $stdout.puts "move (fast): renamed #{eml_id}.eml → #{relative_to_imap_root(dest_messages)}"
208
+ dest_path
209
+ rescue Errno::EACCES, Errno::EXDEV, Errno::ENOENT, Errno::EEXIST => e
210
+ warn "move (fast): rename failed (#{e.class}: #{e.message}); falling back to AppleScript"
211
+ nil
212
+ end
213
+
214
+ # The account directory is the first path segment under imap_root.
215
+ def account_dir_for(path)
216
+ imap_root = Mailmate.config.imap_root
217
+ return nil unless path.start_with?("#{imap_root}/")
218
+ rel = path.sub("#{imap_root}/", "")
219
+ first = rel.split("/", 2).first
220
+ return nil if first.nil? || first.empty?
221
+ File.join(imap_root, first)
222
+ end
223
+
224
+ # Resolve `target_spec` to a `.../Messages` directory within `account_dir`.
225
+ # Returns nil if not found unambiguously in the same account — caller
226
+ # should then fall back to AppleScript (which can handle cross-account
227
+ # moves, UUIDs, special mailboxes, etc.).
228
+ def find_target_in_account(account_dir, target_spec)
229
+ spec = target_spec.to_s.sub(%r{/Messages\z}, "").sub(/\.mailbox\z/, "")
230
+ return nil if spec.empty?
231
+
232
+ # 1. Exact relative path under the account, each segment .mailbox-suffixed.
233
+ nested = spec.split("/").map { |s| "#{s}.mailbox" }.join("/")
234
+ cand = File.join(account_dir, nested, "Messages")
235
+ return cand if File.directory?(cand)
236
+
237
+ # 2. Bare-name match anywhere inside the account.
238
+ matches = Dir.glob(File.join(account_dir, "**", "#{spec}.mailbox", "Messages"))
239
+ .select { |p| File.directory?(p) }
240
+ case matches.size
241
+ when 0 then nil
242
+ when 1 then matches.first
243
+ else
244
+ warn "move (fast): ambiguous target '#{spec}' in account; matches:"
245
+ matches.each { |m| warn " #{m}" }
246
+ nil
247
+ end
248
+ end
249
+
250
+ def relative_to_imap_root(path)
251
+ root = Mailmate.config.imap_root
252
+ path.start_with?("#{root}/") ? path.sub("#{root}/", "") : path
253
+ end
254
+
255
+ def drive_via_applescript(eml_id, message_id, actions, opts)
138
256
  driver = Mailmate::AppleScriptDriver.new(dry_run: opts[:dry_run])
139
257
  mid_url = Mailmate::MidUrl.for(message_id)
140
258