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 +4 -4
- data/README.md +76 -9
- 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 +63 -12
- data/lib/mailmate/cli/modify.rb +122 -4
- data/lib/mailmate/cli/open.rb +71 -0
- data/lib/mailmate/cli/search.rb +222 -76
- 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 +28 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d762c124b353823c34d4b87eb5748727ed2901a507f8bc67a0d74f22b1ab1350
|
|
4
|
+
data.tar.gz: c02381fd0d81be1e8793d85d3d838d40378bf2e99bc4d96f82405144171a73ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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. **
|
|
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. **
|
|
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
|
|
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
|
|
141
|
-
| `
|
|
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
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,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
|
-
|
|
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
|
|
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 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
|
data/lib/mailmate/cli/modify.rb
CHANGED
|
@@ -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
|
|
73
|
-
|
|
74
|
-
Message-ID in your shell so the < > aren't interpreted as
|
|
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
|
|
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
|
|