mailmate 1.4.0 → 1.5.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 +38 -5
- data/exe/mm-verify +8 -0
- data/lib/mailmate/cli/message.rb +22 -3
- data/lib/mailmate/cli/modify.rb +151 -12
- data/lib/mailmate/cli/search.rb +266 -48
- data/lib/mailmate/cli/verify.rb +146 -0
- data/lib/mailmate/eml_lookup.rb +22 -5
- data/lib/mailmate/flag_check.rb +51 -0
- data/lib/mailmate/index_reader.rb +160 -29
- data/lib/mailmate/mcp.rb +67 -2
- data/lib/mailmate/part_lookup.rb +12 -3
- data/lib/mailmate/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b030228294a0ca2411434364c83c99f83822d10258ac94cb81bc065eac5ce66c
|
|
4
|
+
data.tar.gz: df735317a7c2747ca656db9fa71eee6edcb77ada30708561652305fc1748fe07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a9114cc082c2f302fc5b71f44bdc8ef3273c9f4f07e2c274f977a53f36db17533d2c693b111579587f0bd3a4ec2e5c02a979ebd4a8d4edc3beb748be762a877d
|
|
7
|
+
data.tar.gz: 0ab6609a1d3aa348a1de39ff8504d285b13adedae1c639060466aa343074435302d073a3f4dfe6a30c6ee6e2d5861c7d495b850bbfdf88a7499fe658a6a9a131
|
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`, `mmopen`, `mm-mailboxes`, `mmtags`, `mm-modify`, `mm-send`, `mm-draft`, `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-verify`, `mm-send`, `mm-draft`, `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
|
|
|
@@ -149,17 +149,47 @@ mm-modify 183715 tag processed read move Archive
|
|
|
149
149
|
# Dry-run first
|
|
150
150
|
mm-modify 183715 archive --dry-run
|
|
151
151
|
|
|
152
|
-
#
|
|
152
|
+
# Print the current flags after acting (raw probe)
|
|
153
153
|
mm-modify 183715 read --verify
|
|
154
154
|
|
|
155
155
|
# As of 1.2.0, `--verify` works in `--dry-run` mode too — pair them as a
|
|
156
156
|
# post-hoc state probe (run the action first, then re-run with --dry-run
|
|
157
157
|
# --verify to confirm the change took effect).
|
|
158
158
|
mm-modify 183715 read --dry-run --verify
|
|
159
|
+
|
|
160
|
+
# Effect verification (--check): confirm the action actually landed on THIS
|
|
161
|
+
# eml-id by re-reading its #flags index. This is the only way to catch a
|
|
162
|
+
# Message-ID that resolved to a different duplicate copy — AppleScript can't
|
|
163
|
+
# report which message it acted on, but the index can show whether OURS
|
|
164
|
+
# changed. A mismatch exits 3. Opt-in because MailMate flushes #flags ~5s
|
|
165
|
+
# after acting, so --check polls up to --check-timeout (default 8s).
|
|
166
|
+
mm-modify 183715 tag urgent --check
|
|
159
167
|
```
|
|
160
168
|
|
|
161
169
|
**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.
|
|
162
170
|
|
|
171
|
+
### `mm-verify` — confirm a batch of modifies with ONE flush-wait
|
|
172
|
+
|
|
173
|
+
Inline `--check` pays MailMate's ~5 s `#flags`-flush latency *per message* — verifying 50 modifies that way would cost ~250 s. But `#flags` is a single global file flushed once, so a whole batch can be confirmed by waiting for that flush **once**. `mm-modify --emit-check` performs the action and prints a JSON *check-ticket* instead of waiting; collect the tickets, then hand them to `mm-verify`.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
# Action phase: act on N messages, no per-message wait. Each --emit-check
|
|
177
|
+
# prints a one-line ticket {eml_id, message_id, expectations} to stdout.
|
|
178
|
+
for id in 183715 183720 183733; do
|
|
179
|
+
mm-modify "$id" tag triaged --emit-check >> tickets.jsonl
|
|
180
|
+
done
|
|
181
|
+
|
|
182
|
+
# Verify phase: confirm the whole batch in one index-flush wait.
|
|
183
|
+
mm-verify --file tickets.jsonl
|
|
184
|
+
# → JSON {checked, passed, failed, waited_seconds, results:[{eml_id, ok, flags, unmet}]}
|
|
185
|
+
# exit 0 if all confirmed, 3 if any failed.
|
|
186
|
+
|
|
187
|
+
# Tickets can also be piped, or passed as a JSON-array argument:
|
|
188
|
+
mm-modify 183715 flag --emit-check | mm-verify
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A failed ticket means that action didn't land on that eml-id — it registered on a different duplicate copy, or not at all. Chains containing `move`/`archive`/`delete` aren't flag-verifiable and carry empty expectations (auto-pass).
|
|
192
|
+
|
|
163
193
|
### `mm-send` — send mail
|
|
164
194
|
|
|
165
195
|
`mm-send` is a thin wrapper around MailMate's bundled `emate mailto`, with `--markup markdown` enforced. The body is read from stdin.
|
|
@@ -246,6 +276,8 @@ A few rough edges to be aware of:
|
|
|
246
276
|
|
|
247
277
|
## Status
|
|
248
278
|
|
|
279
|
+
1.5.0 — Reliability and batch-verification for `mm-modify`, plus search/read speedups. `mm-modify` gains a no-window retry guard (a `mid:` open that spawns no viewer would otherwise act on the wrong message) and opt-in effect verification: `--check` confirms a flag/tag/read action landed on the target eml-id by re-reading `#flags` (the only way to catch a duplicate-Message-ID misland). Because MailMate flushes `#flags` to disk ~5 s after acting, a new **`mm-verify`** command plus `mm-modify --emit-check` decouple acting from confirming — collect JSON check-tickets across a batch and verify them all in one flush-wait instead of paying the latency per message. `mmsearch` is substantially faster (compiled date ranges, cheapest-spec-first ordering, bulk-unpack index reader, inverted body search) with bit-identical output; the persistent MCP server now invalidates index caches on disk change. `mmmessage` shows user tags and lazy-loads the `mail` gem (`--raw`/`--mailmate` skip it). MCP: `message` gains `markdown`, `modify` gains a `check` mode (`none|inline|defer`), and a new `verify` tool batch-confirms deferred tickets.
|
|
280
|
+
|
|
249
281
|
1.2.0 — `mm-modify` no longer brings MailMate to the foreground and is roughly 8× faster on single-action invocations. Internally: the open call uses `open -g -a MailMate <url>` to keep MailMate in the background, and the fixed `--settle` sleeps are replaced by active waits (polling for the spawned viewer window to appear). `mmopen` gains a `--background` / `-g` flag for ad-hoc use. `mm-modify --verify` now works in `--dry-run` mode as a post-hoc state probe. `--settle` is preserved for backward compat; it now caps the active-wait timeout rather than fixing sleep duration.
|
|
250
282
|
|
|
251
283
|
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.
|
|
@@ -284,7 +316,7 @@ gem install reverse_markdown
|
|
|
284
316
|
|
|
285
317
|
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.
|
|
286
318
|
|
|
287
|
-
If you never use `--markdown`, you never pay any of this. If you do invoke `--markdown` without the gem
|
|
319
|
+
If you never use `--markdown`, you never pay any of this. If you do invoke `--markdown` without the gem installed, `mmmessage` warns with a clear install hint and falls back to the raw HTML body (it does not abort — so the in-process MCP server survives a missing optional dependency).
|
|
288
320
|
|
|
289
321
|
### From source (development)
|
|
290
322
|
|
|
@@ -303,7 +335,7 @@ Then `mmdiscover` as above.
|
|
|
303
335
|
|
|
304
336
|
### MCP server
|
|
305
337
|
|
|
306
|
-
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`.
|
|
338
|
+
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`, `verify`, `send`, `draft`, `open`, `list_mailboxes`, `list_tags`, `resolve_id`. After `gem install mailmate`, `mailmate-mcp` is on your `PATH`.
|
|
307
339
|
|
|
308
340
|
#### Claude Code (global, all projects)
|
|
309
341
|
|
|
@@ -347,7 +379,8 @@ Restart Claude Desktop after any change to server code or config.
|
|
|
347
379
|
| `mmopen` | Open one message in MailMate's UI (via `open mid:…`). `--print` returns the URL. |
|
|
348
380
|
| `mm-mailboxes` | List accounts, IMAP mailboxes (with optional counts), and smart-mailbox names. |
|
|
349
381
|
| `mmtags` | List user tags applied to messages (with counts) or defined in Preferences. |
|
|
350
|
-
| `mm-modify` | Mark read/flag/tag/archive a message via AppleScript; same-account `move` uses a fast `.eml`-rename path with no UI takeover. |
|
|
382
|
+
| `mm-modify` | Mark read/flag/tag/archive a message via AppleScript; same-account `move` uses a fast `.eml`-rename path with no UI takeover. `--check` confirms the change landed; `--emit-check` defers confirmation to `mm-verify`. |
|
|
383
|
+
| `mm-verify` | Batch-confirm `mm-modify --emit-check` tickets against the `#flags` index in one flush-wait. JSON in, JSON summary out. |
|
|
351
384
|
| `mm-send` | Send mail through `emate` with a markdown body on stdin. |
|
|
352
385
|
| `mm-draft` | Like `mm-send`, but only ever opens a draft — refuses `--send-now` (exit 2). |
|
|
353
386
|
| `mmdiscover` | First-run bootstrap; (re-)writes the user config from MailMate's plists. |
|
data/exe/mm-verify
ADDED
data/lib/mailmate/cli/message.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
-
|
|
4
|
+
# `mail` is required lazily inside run() — the --raw and --mailmate paths
|
|
5
|
+
# never parse the message, so they shouldn't pay ~50 ms loading the gem.
|
|
5
6
|
|
|
6
7
|
module Mailmate
|
|
7
8
|
module CLI
|
|
@@ -47,6 +48,7 @@ module Mailmate
|
|
|
47
48
|
return 0
|
|
48
49
|
end
|
|
49
50
|
|
|
51
|
+
require "mail"
|
|
50
52
|
mail = Mail.read(path)
|
|
51
53
|
print_headers(mail, eml_id, path) unless opts[:text_only]
|
|
52
54
|
$stdout.puts text_body(mail, markdown: opts[:markdown])
|
|
@@ -91,6 +93,8 @@ module Mailmate
|
|
|
91
93
|
$stdout.puts "message-id: #{mail.message_id}"
|
|
92
94
|
thread_id = Mailmate::Attributes.thread_id_for(mail)
|
|
93
95
|
$stdout.puts "thread-id: #{thread_id}" if thread_id
|
|
96
|
+
tags = user_tags(eml_id)
|
|
97
|
+
$stdout.puts "tags: #{tags.join(", ")}" unless tags.empty?
|
|
94
98
|
if mail.attachments.any?
|
|
95
99
|
$stdout.puts "attachments:"
|
|
96
100
|
mail.attachments.each do |a|
|
|
@@ -105,6 +109,18 @@ module Mailmate
|
|
|
105
109
|
$stdout.puts
|
|
106
110
|
end
|
|
107
111
|
|
|
112
|
+
# User tags applied to this message, from MailMate's `#flags` index —
|
|
113
|
+
# tags live there as IMAP keywords, not in the .eml, so the parsed Mail
|
|
114
|
+
# can't see them. Drops `\…` (RFC) and `$…` (Apple/Thunderbird) system
|
|
115
|
+
# flags so only user-facing tags show. Same derivation as mmsearch's
|
|
116
|
+
# `tags` column. Returns [] when the index is missing or the message
|
|
117
|
+
# has none.
|
|
118
|
+
def user_tags(eml_id)
|
|
119
|
+
return [] if eml_id.nil?
|
|
120
|
+
flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
|
|
121
|
+
flags.reject { |f| f.start_with?("\\", "$") }
|
|
122
|
+
end
|
|
123
|
+
|
|
108
124
|
def text_body(mail, markdown: false)
|
|
109
125
|
if mail.text_part
|
|
110
126
|
# text/plain is already plain — markdown flag is a no-op here.
|
|
@@ -145,8 +161,11 @@ module Mailmate
|
|
|
145
161
|
rescue LoadError => e
|
|
146
162
|
warn "mmmessage --markdown needs the reverse_markdown gem (which pulls nokogiri)."
|
|
147
163
|
warn "Install it with: gem install reverse_markdown"
|
|
148
|
-
warn "(underlying: #{e.message})"
|
|
149
|
-
exit
|
|
164
|
+
warn "(underlying: #{e.message}) — falling back to raw HTML."
|
|
165
|
+
# Degrade to the raw HTML rather than `exit`: a library method must
|
|
166
|
+
# not kill its host, and the in-process MCP server would otherwise
|
|
167
|
+
# die on the SystemExit (its dispatch rescues StandardError only).
|
|
168
|
+
return html
|
|
150
169
|
end
|
|
151
170
|
doc = Nokogiri::HTML(html)
|
|
152
171
|
doc.css("style, script").remove
|
data/lib/mailmate/cli/modify.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../flag_check"
|
|
4
6
|
|
|
5
7
|
module Mailmate
|
|
6
8
|
module CLI
|
|
@@ -59,12 +61,45 @@ module Mailmate
|
|
|
59
61
|
|
|
60
62
|
warn_on_duplicates(message_id, eml_id)
|
|
61
63
|
|
|
62
|
-
drive(eml_id, message_id, actions, opts)
|
|
63
|
-
|
|
64
|
+
ok = drive(eml_id, message_id, actions, opts)
|
|
65
|
+
|
|
66
|
+
# --emit-check: the action is sent; defer confirmation to a later
|
|
67
|
+
# batched `mm-verify` pass. Emit the ticket as the sole stdout line
|
|
68
|
+
# (operational notes went to stderr via `say`) and exit 0 — there's
|
|
69
|
+
# nothing to fail on yet.
|
|
70
|
+
if opts[:emit_check]
|
|
71
|
+
$stdout.puts JSON.generate(build_check_ticket(eml_id, message_id, actions))
|
|
72
|
+
return 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Exit 3 when an effect check fails — distinct from "couldn't resolve"
|
|
76
|
+
# (1) and "bad usage" (2). The MCP surfaces this as isError, and a
|
|
77
|
+
# caller scripting mm-modify can branch on it.
|
|
78
|
+
ok ? 0 : 3
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A deferred-verification ticket: the target eml-id plus the #flags
|
|
82
|
+
# expectations its action chain should satisfy once MailMate flushes.
|
|
83
|
+
# Non-flag-verifiable chains (move/archive/delete) carry an empty list,
|
|
84
|
+
# so mm-verify auto-passes them. Symbol kinds are stringified for JSON;
|
|
85
|
+
# mm-verify / FlagCheck.met? resolve either form.
|
|
86
|
+
def build_check_ticket(eml_id, message_id, actions)
|
|
87
|
+
exps = verifiable_expectations(actions) || []
|
|
88
|
+
{
|
|
89
|
+
"eml_id" => eml_id.to_i,
|
|
90
|
+
"message_id" => message_id,
|
|
91
|
+
"expectations" => exps.map { |kind, arg| [kind.to_s, arg] },
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Operational notes go to stderr in --emit-check mode (stdout must be
|
|
96
|
+
# pure JSON for the ticket); otherwise to stdout as before.
|
|
97
|
+
def say(opts, msg)
|
|
98
|
+
(opts[:emit_check] ? $stderr : $stdout).puts(msg)
|
|
64
99
|
end
|
|
65
100
|
|
|
66
101
|
def parse_options(argv)
|
|
67
|
-
opts = { verify: false, dry_run: false, settle: 3.5, keep_window: false }
|
|
102
|
+
opts = { verify: false, dry_run: false, settle: 3.5, keep_window: false, check: false, check_timeout: 8.0, emit_check: false }
|
|
68
103
|
parser = OptionParser.new do |o|
|
|
69
104
|
o.banner = <<~BANNER
|
|
70
105
|
Usage: mm-modify <id> <action> [args...] [<action> [args...]]...
|
|
@@ -102,6 +137,9 @@ module Mailmate
|
|
|
102
137
|
o.on("--dry-run", "Print the actions; don't run") { opts[:dry_run] = true }
|
|
103
138
|
o.on("--settle SECONDS", Float, "Timeout for waiting for MailMate's viewer window to spawn after open_url (default 3.5)") { |s| opts[:settle] = s }
|
|
104
139
|
o.on("--keep-window", "Don't close the spawned message-viewer window") { opts[:keep_window] = true }
|
|
140
|
+
o.on("--check", "Verify flag/tag/read actions actually landed on the TARGET eml-id by polling its #flags index after acting (mismatch → exit 3). This is the only way to detect a `mid:` open that resolved to a different duplicate copy. Opt-in, NOT default: MailMate flushes #flags to disk several seconds after an AppleScript action, so this waits up to --check-timeout (default 8s) for the index to catch up before deciding. Chains containing move/archive/delete/junk aren't flag-verifiable and are skipped.") { opts[:check] = true }
|
|
141
|
+
o.on("--check-timeout SECONDS", Float, "Max seconds to wait for the #flags index to reflect the action when --check is set (default 8.0; the index typically lags ~5s).") { |s| opts[:check_timeout] = s }
|
|
142
|
+
o.on("--emit-check", "Don't verify inline; instead print a one-line JSON check-ticket to stdout ({eml_id, message_id, expectations}) and exit 0 once the action is sent. Collect tickets across a batch of modifies and feed them to `mm-verify` to confirm them all with a SINGLE index-flush wait, instead of paying ~5s per message. Operational notes go to stderr so stdout stays pure JSON.") { opts[:emit_check] = true }
|
|
105
143
|
end
|
|
106
144
|
parser.parse!(argv)
|
|
107
145
|
[opts, parser]
|
|
@@ -156,6 +194,7 @@ module Mailmate
|
|
|
156
194
|
fast_moves, other = actions.partition { |name, _, _| name == "move" }
|
|
157
195
|
|
|
158
196
|
if other.empty? && !fast_moves.empty?
|
|
197
|
+
ok = true
|
|
159
198
|
current_path = Mailmate::EmlLookup.path_for(eml_id)
|
|
160
199
|
fast_moves.each do |_name, selector, args|
|
|
161
200
|
new_path = try_fast_move(eml_id, current_path, args.first, opts)
|
|
@@ -164,9 +203,10 @@ module Mailmate
|
|
|
164
203
|
else
|
|
165
204
|
# Fast-path declined for this one move (cross-account, target
|
|
166
205
|
# not found, perm error, …) — single UI-driven move as fallback.
|
|
167
|
-
drive_via_applescript(eml_id, message_id, [["move", selector, args]], opts)
|
|
206
|
+
ok &&= drive_via_applescript(eml_id, message_id, [["move", selector, args]], opts)
|
|
168
207
|
end
|
|
169
208
|
end
|
|
209
|
+
ok
|
|
170
210
|
else
|
|
171
211
|
# Mixed chain (or pure non-move chain): everything goes through the
|
|
172
212
|
# AppleScript driver in the user-supplied order.
|
|
@@ -188,14 +228,14 @@ module Mailmate
|
|
|
188
228
|
|
|
189
229
|
dest_path = File.join(dest_messages, "#{eml_id}.eml")
|
|
190
230
|
if dest_path == current_path
|
|
191
|
-
|
|
231
|
+
say(opts, "move (fast): #{eml_id}.eml is already in #{target_spec} — no-op")
|
|
192
232
|
return dest_path
|
|
193
233
|
end
|
|
194
234
|
|
|
195
235
|
if opts[:dry_run]
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
236
|
+
say(opts, "move (fast, dry-run): would rename")
|
|
237
|
+
say(opts, " from: #{current_path}")
|
|
238
|
+
say(opts, " to: #{dest_path}")
|
|
199
239
|
return dest_path
|
|
200
240
|
end
|
|
201
241
|
|
|
@@ -204,7 +244,7 @@ module Mailmate
|
|
|
204
244
|
# rescans; bust it so any subsequent path_for in this process re-reads
|
|
205
245
|
# (and eventually picks up MailMate's refreshed value).
|
|
206
246
|
Mailmate::IndexReader.reset!("#source") if defined?(Mailmate::IndexReader)
|
|
207
|
-
|
|
247
|
+
say(opts, "move (fast): renamed #{eml_id}.eml → #{relative_to_imap_root(dest_messages)}")
|
|
208
248
|
dest_path
|
|
209
249
|
rescue Errno::EACCES, Errno::EXDEV, Errno::ENOENT, Errno::EEXIST => e
|
|
210
250
|
warn "move (fast): rename failed (#{e.class}: #{e.message}); falling back to AppleScript"
|
|
@@ -271,12 +311,27 @@ module Mailmate
|
|
|
271
311
|
# the timeout for this wait rather than the sleep duration.
|
|
272
312
|
new_windows = opts[:dry_run] ? [] : wait_for_new_window(driver, windows_before, timeout: opts[:settle])
|
|
273
313
|
|
|
314
|
+
# No viewer window means the `mid:` open didn't take (MailMate busy,
|
|
315
|
+
# mid-launch, or the URL didn't resolve) — performing actions now
|
|
316
|
+
# would act on whatever is currently selected, i.e. the wrong message.
|
|
317
|
+
# Retry the open once before proceeding; if it still doesn't spawn,
|
|
318
|
+
# warn loudly. Effect verification below is the backstop for flag
|
|
319
|
+
# actions; for move/archive/delete this warning is the only signal.
|
|
320
|
+
if !opts[:dry_run] && new_windows.empty?
|
|
321
|
+
driver.open_url(mid_url)
|
|
322
|
+
new_windows = wait_for_new_window(driver, windows_before, timeout: opts[:settle])
|
|
323
|
+
if new_windows.empty?
|
|
324
|
+
warn "WARNING: no MailMate viewer window appeared for #{mid_url} (retried once)."
|
|
325
|
+
warn " The action target is UNCONFIRMED — it may hit the wrong message or no-op."
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
274
329
|
# Restore unread BEFORE user actions: a subsequent move / archive /
|
|
275
330
|
# delete moves the message out of the viewer's selection, after
|
|
276
331
|
# which a `markAsUnread:` would land on whatever MailMate selected
|
|
277
332
|
# next (or be a silent no-op).
|
|
278
333
|
if preserve_unread
|
|
279
|
-
|
|
334
|
+
say(opts, "preserve-read-state: re-marking #{eml_id}.eml unread (opening the mid: URL marks it read)")
|
|
280
335
|
driver.perform("markAsUnread:")
|
|
281
336
|
end
|
|
282
337
|
|
|
@@ -290,7 +345,7 @@ module Mailmate
|
|
|
290
345
|
flags = opts[:dry_run] ? [] : current_flags(eml_id)
|
|
291
346
|
has = flags.include?("\\Flagged")
|
|
292
347
|
if has == want
|
|
293
|
-
|
|
348
|
+
say(opts, "#{name}: already #{want ? "flagged" : "not flagged"} — no-op")
|
|
294
349
|
else
|
|
295
350
|
driver.perform("toggleFlag:")
|
|
296
351
|
end
|
|
@@ -299,6 +354,32 @@ module Mailmate
|
|
|
299
354
|
end
|
|
300
355
|
end
|
|
301
356
|
|
|
357
|
+
# Opt-in effect verification (--check): re-read the TARGET eml-id's
|
|
358
|
+
# flags and confirm the actions actually landed there. This is the
|
|
359
|
+
# only check that catches a `mid:` open resolving to a different
|
|
360
|
+
# duplicate copy — AppleScript can't tell us which message it acted
|
|
361
|
+
# on, but the index can tell us whether OUR eml-id changed.
|
|
362
|
+
#
|
|
363
|
+
# NOT default: MailMate flushes #flags to disk ~5s after an
|
|
364
|
+
# AppleScript write (measured), so verify_effects polls up to
|
|
365
|
+
# check_timeout for the index to catch up. A default-on check would
|
|
366
|
+
# either false-fail (timeout too short) or slow every modify by
|
|
367
|
+
# several seconds (timeout long enough) — neither is acceptable for
|
|
368
|
+
# the common path, so the latency is paid only when asked for.
|
|
369
|
+
# Skipped for location-changing chains (the .eml leaves the viewer).
|
|
370
|
+
verified = true
|
|
371
|
+
if opts[:check] && !opts[:emit_check] && !opts[:dry_run] && (exps = verifiable_expectations(actions))
|
|
372
|
+
verified, flags = verify_effects(eml_id, exps, timeout: opts[:check_timeout])
|
|
373
|
+
if verified
|
|
374
|
+
$stdout.puts "verify: ✓ #{exps.size} effect(s) confirmed on #{eml_id}.eml — flags: #{flags.inspect}"
|
|
375
|
+
else
|
|
376
|
+
warn "verify: ✗ effect check FAILED on #{eml_id}.eml after #{opts[:check_timeout]}s — flags: #{flags.inspect}"
|
|
377
|
+
warn " Expected: #{exps.map { |k, a| Mailmate::FlagCheck.label(k, a) }.join(", ")}"
|
|
378
|
+
warn " The action may have landed on a different duplicate copy, or the index"
|
|
379
|
+
warn " still hasn't flushed. Re-run, or raise --check-timeout."
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
302
383
|
# --verify is now permitted in --dry-run mode so callers can use
|
|
303
384
|
# `mm-modify <id> <any-action> --dry-run --verify` as a post-hoc
|
|
304
385
|
# "what's the current flag state?" probe after a separate action run.
|
|
@@ -306,12 +387,70 @@ module Mailmate
|
|
|
306
387
|
# flush #flags before we read it back; in dry-run no wait is needed.
|
|
307
388
|
if opts[:verify]
|
|
308
389
|
sleep(1) unless opts[:dry_run]
|
|
309
|
-
|
|
390
|
+
say(opts, "Flags now: #{current_flags(eml_id).inspect}")
|
|
310
391
|
end
|
|
311
392
|
|
|
312
393
|
unless opts[:keep_window] || opts[:dry_run] || new_windows.empty?
|
|
313
394
|
driver.close_windows(new_windows)
|
|
314
395
|
end
|
|
396
|
+
|
|
397
|
+
verified
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Actions that relocate or destroy the .eml — its #flags record moves
|
|
401
|
+
# or disappears, so post-action flag verification isn't meaningful.
|
|
402
|
+
LOCATION_ACTIONS = %w[move archive delete junk not-junk].freeze
|
|
403
|
+
|
|
404
|
+
# Build the set of #flags expectations a chain should satisfy after it
|
|
405
|
+
# runs, or nil when the chain isn't effect-verifiable. Returns nil if:
|
|
406
|
+
# - any action changes location (move/archive/delete/junk),
|
|
407
|
+
# - clear-tags is mixed with tag/untag (order-dependent net state we
|
|
408
|
+
# don't model — bail rather than risk a false failure),
|
|
409
|
+
# - nothing in the chain is flag-observable.
|
|
410
|
+
# Later actions on the same flag win (last-write); `mute` is ignored
|
|
411
|
+
# (no clean #flags signal) without blocking the rest.
|
|
412
|
+
def verifiable_expectations(actions)
|
|
413
|
+
return nil if actions.any? { |name, _, _| LOCATION_ACTIONS.include?(name) }
|
|
414
|
+
|
|
415
|
+
exp = {}
|
|
416
|
+
has_clear = false
|
|
417
|
+
has_tagop = false
|
|
418
|
+
actions.each do |name, _, args|
|
|
419
|
+
case name
|
|
420
|
+
when "read" then exp[:seen] = [:seen, true]
|
|
421
|
+
when "unread" then exp[:seen] = [:seen, false]
|
|
422
|
+
when "flag" then exp[:flagged] = [:flagged, true]
|
|
423
|
+
when "unflag" then exp[:flagged] = [:flagged, false]
|
|
424
|
+
when "tag" then has_tagop = true; exp["tag:#{args.first}"] = [:tag_present, args.first]
|
|
425
|
+
when "untag" then has_tagop = true; exp["tag:#{args.first}"] = [:tag_absent, args.first]
|
|
426
|
+
when "clear-tags" then has_clear = true
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
return nil if has_clear && has_tagop
|
|
430
|
+
exp[:clear] = [:no_user_tags, nil] if has_clear
|
|
431
|
+
exp.empty? ? nil : exp.values
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Poll the target eml-id's flags until every expectation holds or the
|
|
435
|
+
# timeout elapses (success returns on the first satisfying read, so the
|
|
436
|
+
# common case is fast; only genuine failures wait out the timeout).
|
|
437
|
+
# Returns [met?, last_flags_seen].
|
|
438
|
+
def verify_effects(eml_id, expectations, timeout:, poll: 0.1)
|
|
439
|
+
deadline = Time.now + timeout
|
|
440
|
+
flags = nil
|
|
441
|
+
loop do
|
|
442
|
+
flags = current_flags(eml_id)
|
|
443
|
+
return [true, flags] if Mailmate::FlagCheck.all_met?(flags, expectations)
|
|
444
|
+
break if Time.now >= deadline
|
|
445
|
+
sleep(poll)
|
|
446
|
+
end
|
|
447
|
+
[false, flags]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Thin delegator kept for the unit tests that target the predicate
|
|
451
|
+
# directly; the canonical logic lives in Mailmate::FlagCheck.
|
|
452
|
+
def flag_expectation_met?(flags, kind, arg)
|
|
453
|
+
Mailmate::FlagCheck.met?(flags, kind, arg)
|
|
315
454
|
end
|
|
316
455
|
|
|
317
456
|
# Poll for a new MailMate window appearing in `driver.window_ids` that
|