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 +4 -4
- data/README.md +143 -20
- data/exe/mailmate-mcp +8 -0
- data/exe/mm-mailboxes +8 -0
- data/exe/mmopen +8 -0
- data/exe/mmtags +8 -0
- data/lib/mailmate/cli/mailboxes.rb +142 -0
- data/lib/mailmate/cli/message.rb +70 -12
- data/lib/mailmate/cli/modify.rb +122 -4
- data/lib/mailmate/cli/open.rb +71 -0
- data/lib/mailmate/cli/search.rb +223 -77
- data/lib/mailmate/cli/tags.rb +93 -0
- data/lib/mailmate/config.rb +4 -1
- data/lib/mailmate/eml_lookup.rb +23 -3
- data/lib/mailmate/index_reader.rb +49 -12
- data/lib/mailmate/mcp.rb +394 -0
- data/lib/mailmate/mid_url.rb +30 -10
- data/lib/mailmate/part_lookup.rb +62 -0
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +24 -0
- metadata +29 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e25504c9cfa00a8d2c0c94e387b593c9151a66981150c17e1fbd328d531707d
|
|
4
|
+
data.tar.gz: 4f0ac4ca053f30eed50305e2f333e9969871bbf02565115c2a72dacb0072c023
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
85
|
-
- **`mm-<name>`** (with dash) — **write** operations. `mm-modify`, `mm-send` change state (or send mail).
|
|
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. **
|
|
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 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
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
141
|
-
| `
|
|
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
data/exe/mm-mailboxes
ADDED
data/exe/mmopen
ADDED
data/exe/mmtags
ADDED
|
@@ -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
|
data/lib/mailmate/cli/message.rb
CHANGED
|
@@ -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
|
|
55
|
-
o.separator "
|
|
56
|
-
o.separator "
|
|
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
|
-
|
|
102
|
-
|
|
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 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
|