mailmate 0.2.0 → 1.1.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: 5e25504c9cfa00a8d2c0c94e387b593c9151a66981150c17e1fbd328d531707d
4
+ data.tar.gz: 4f0ac4ca053f30eed50305e2f333e9969871bbf02565115c2a72dacb0072c023
5
5
  SHA512:
6
- metadata.gz: ac99eb6fee19f727a953228a599f5ec29d741339776388547ff514403a838c990635c586881dfee5750c3a0c6a72f6c66bdcc513f3dba4ed85eb0e980b588357
7
- data.tar.gz: 6a293bf66d563b80d9f9a0e2f0808fa32db6ba4bd40bea65fbb364a73e0fbef3d0aa715b284ad07f464a7cbb695825da4d2f614903699f1d3e527101e7135edc
6
+ metadata.gz: 8953a409f2855ca1b04a1f4c574c73c4a724e29398d5a5951423b61fd0313f06a21242581219d14f1de3c28436d777f09ee1289f5cb69ca02deeb70bedb8a5ef
7
+ data.tar.gz: b4dc5f74fa58a58c592ce125c2e42dce062ebbeb841284d74c878e12c6202856f2624f2da4adba6bc24a2919d150a1b8f5b54c302fd71507d1c9085d6d5e4c35
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,64 +141,127 @@ 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.
92
-
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:
151
+ 1. **Non-move `mm-modify` actions still take over the UI.** Same-account `move` actions use a fast patha 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
152
  - **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.
153
+ - **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
154
 
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.
155
+ 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
156
 
99
- 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.
157
+ 2. **`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
158
 
101
- 4. **MailMate must be running.** Anything that goes through `mm-modify` requires MailMate open and unblocked by modal dialogs. `mm-send` likewise needs MailMate running — `emate mailto` opens a draft window in the running MailMate process, so without MailMate up there's nowhere for the draft to land (this is true with or without `--send-now`). Headless / unattended use isn't supported.
159
+ 3. **MailMate must be running.** Anything that goes through `mm-modify` requires MailMate open and unblocked by modal dialogs. `mm-send` likewise needs MailMate running — `emate mailto` opens a draft window in the running MailMate process, so without MailMate up there's nowhere for the draft to land (this is true with or without `--send-now`). Headless / unattended use isn't supported.
102
160
 
103
- 5. **Single-account `mm-send` defaults.** `mm-send` passes flags straight through to `emate mailto`. If you have multiple identities configured in MailMate and don't pass `-f`, MailMate picks the default identity — there's no opinionated multi-account routing in the wrapper.
161
+ 4. **Single-account `mm-send` defaults.** `mm-send` passes flags straight through to `emate mailto`. If you have multiple identities configured in MailMate and don't pass `-f`, MailMate picks the default identity — there's no opinionated multi-account routing in the wrapper.
104
162
 
105
163
  ## Status
106
164
 
107
- Pre-1.0 (0.x). Breaking changes allowed without version bumps. See [`docs/roadmap/Mailmate gem.md`](../claude/people/docs/roadmap/Mailmate%20gem.md) in the sibling `people` repo for the design history and remaining work.
165
+ 1.1.0 — `reverse_markdown` (and its transitive `nokogiri` dep) is now opt-in rather than auto-installed. Run `gem install reverse_markdown` if you want `mmmessage --markdown`; everything else is unchanged.
166
+
167
+ 1.0.0 — initial public release; API stable from this point. Breaking changes bump the major version going forward.
108
168
 
109
169
  ## Install
110
170
 
171
+ ### Requirements
172
+
173
+ - **macOS** with **MailMate** installed (and running, for any command that drives the UI or sends mail).
174
+ - **Ruby ≥ 3.0**.
175
+ - No third-party CLI tools — the gem only shells out to macOS-bundled `plutil`, `osascript`, and `open`, plus MailMate's bundled `emate`.
176
+
111
177
  ```bash
112
178
  gem install mailmate
113
179
  ```
114
180
 
115
- Then bootstrap your config:
181
+ Then optionally bootstrap your config (will happen automatically on first invocation of any command from an interactive shell if it hasn't been run before):
116
182
 
117
183
  ```bash
118
184
  mmdiscover
119
185
  ```
120
186
 
121
- `mmdiscover` reads MailMate's `Sources.plist` and `Identities.plist`, shows you the accounts and addresses it found, and offers to write `~/.config/mailmate/config.yml` from them. It also writes `~/.config/mailmate/bundle_loader.rb` for MailMate bundles.
187
+ `mmdiscover` reads MailMate's `Sources.plist` and `Identities.plist`, shows you the accounts and addresses it found, and offers to write `~/.config/mailmate/config.yml` from them. It also writes `~/.config/mailmate/bundle_loader.rb` for MailMate bundles. Running it explicitly is only needed in non-TTY contexts (cron jobs, MCP servers) — there, the gem falls back to built-in defaults and warns once.
188
+
189
+ ### Optional: `mmmessage --markdown`
190
+
191
+ **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.
192
+
193
+ `mmmessage --markdown` renders HTML-only message bodies as readable markdown. It needs the `reverse_markdown` gem, which has `nokogiri` as a transitive dependency:
194
+
195
+ ```bash
196
+ gem install reverse_markdown
197
+ ```
198
+
199
+ 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.
200
+
201
+ If you never use `--markdown`, you never pay any of this. If you do invoke `--markdown` without the gem already being installed (default on most Ruby versions), `mmmessage` exits with a clear install hint.
122
202
 
123
203
  ### From source (development)
124
204
 
125
- If you're hacking on the gem itself, skip `gem install` and put the repo's `exe/` on your `PATH`:
205
+ If you're hacking on the gem itself, skip `gem install` and put the repo's `exe/` on your `PATH`. Clone wherever you keep source repos, then prepend its `exe/` to `PATH` from your shell's rc file (`~/.zshrc`, `~/.bashrc`, etc.):
126
206
 
127
207
  ```bash
128
- git clone <this repo> ~/code/claude/mailmate
129
- echo 'export PATH="$HOME/code/claude/mailmate/exe:$PATH"' >> ~/.zshrc
130
- source ~/.zshrc
208
+ git clone https://github.com/brianmd/mailmate.git
209
+ cd mailmate
210
+
211
+ # In your shell rc file, add (adjust the path to wherever you cloned):
212
+ # export PATH="/absolute/path/to/mailmate/exe:$PATH"
213
+ # Then reload the shell (open a new tab, or `source` the rc file).
131
214
  ```
132
215
 
133
216
  Then `mmdiscover` as above.
134
217
 
218
+ ### MCP server
219
+
220
+ The gem also ships an MCP server (`exe/mailmate-mcp`) that exposes the same surface to AI assistants as JSON-RPC tools: `search`, `message`, `modify`, `send`, `open`, `list_mailboxes`, `list_tags`, `resolve_id`. After `gem install mailmate`, `mailmate-mcp` is on your `PATH`.
221
+
222
+ #### Claude Code (global, all projects)
223
+
224
+ ```bash
225
+ claude mcp add --scope user mailmate "$(which mailmate-mcp)"
226
+ ```
227
+
228
+ Or add manually to `~/.claude.json` under `"mcpServers"`:
229
+
230
+ ```json
231
+ "mailmate": {
232
+ "type": "stdio",
233
+ "command": "/absolute/path/to/mailmate-mcp",
234
+ "args": [],
235
+ "env": {}
236
+ }
237
+ ```
238
+
239
+ #### Claude Desktop
240
+
241
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
242
+
243
+ ```json
244
+ {
245
+ "mcpServers": {
246
+ "mailmate": {
247
+ "command": "/absolute/path/to/mailmate-mcp"
248
+ }
249
+ }
250
+ }
251
+ ```
252
+
253
+ Restart Claude Desktop after any change to server code or config.
254
+
135
255
  ## Commands
136
256
 
137
257
  | Command | What it does |
138
258
  |---|---|
139
259
  | `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. |
260
+ | `mmmessage` | Print one message by id (decoded headers + plain-text body). `--mailmate` opens in MailMate instead; `--markdown` renders HTML-only bodies as clean markdown. |
261
+ | `mmopen` | Open one message in MailMate's UI (via `open mid:…`). `--print` returns the URL. |
262
+ | `mm-mailboxes` | List accounts, IMAP mailboxes (with optional counts), and smart-mailbox names. |
263
+ | `mmtags` | List user tags applied to messages (with counts) or defined in Preferences. |
264
+ | `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
265
  | `mm-send` | Send mail through `emate` with a markdown body on stdin. |
143
266
  | `mmdiscover` | First-run bootstrap; (re-)writes the user config from MailMate's plists. |
144
267
 
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,15 +103,64 @@ 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
120
+ end
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
+ begin
141
+ require "nokogiri"
142
+ require "reverse_markdown"
143
+ rescue LoadError => e
144
+ warn "mmmessage --markdown needs the reverse_markdown gem (which pulls nokogiri)."
145
+ warn "Install it with: gem install reverse_markdown"
146
+ warn "(underlying: #{e.message})"
147
+ exit 3
105
148
  end
149
+ doc = Nokogiri::HTML(html)
150
+ doc.css("style, script").remove
151
+ md = ReverseMarkdown.convert(doc.to_html)
152
+ # U+034F combining grapheme joiner, U+200B ZWSP, U+200C ZWNJ,
153
+ # U+200D ZWJ, U+FEFF BOM/ZWNBSP — newsletter preview-text padding.
154
+ md.gsub!(/[\u034F\u200B\u200C\u200D\uFEFF]/, "")
155
+ # Convert non-breaking spaces to regular spaces so rstrip can collapse
156
+ # them. Newsletter preview-text padding often uses runs of &nbsp; which
157
+ # Ruby's .rstrip leaves alone otherwise.
158
+ md.gsub!(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/, " ")
159
+ # Strip trailing whitespace per line - the spaces between the
160
+ # now-removed zero-width chars otherwise leave long whitespace runs.
161
+ md = md.lines.map(&:rstrip).join("\n")
162
+ md.gsub!(/\n{3,}/, "\n\n")
163
+ md.strip
106
164
  end
107
165
  end
108
166
  end