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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2e41c1bf66c9a41c75d683f9e7edcfa2d6ac5982f6a54c2d3cc4b2cbb5c36a8
4
- data.tar.gz: 378d1b75d3485de7cf5285e8b12f4fa0ebd7b144b9ebe4a5d4322f6a5d592726
3
+ metadata.gz: b030228294a0ca2411434364c83c99f83822d10258ac94cb81bc065eac5ce66c
4
+ data.tar.gz: df735317a7c2747ca656db9fa71eee6edcb77ada30708561652305fc1748fe07
5
5
  SHA512:
6
- metadata.gz: 710617dc65666c1eec32f4a6d1e7c4ac15c3aab6ce295548d59edaefacd86863ed2b716246bceb250dd9f7f67c4e3a54701366a3c4c65a6b69321b6df3024587
7
- data.tar.gz: a54ee5f4a6cf7ff02c35f6239ab63e805404db1610fa470358ac382bf4870b1fb8176a0990e5a01a24195275e6c603fe44b2d124b9c6829765f222872f874a10
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
- # Verify the new flags after acting
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 already being installed (default on most Ruby versions), `mmmessage` exits with a clear install hint.
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
@@ -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/verify"
7
+
8
+ exit Mailmate::CLI::Verify.run(ARGV)
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
- require "mail"
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 3
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
@@ -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
- 0
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
- $stdout.puts "move (fast): #{eml_id}.eml is already in #{target_spec} — no-op"
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
- $stdout.puts "move (fast, dry-run): would rename"
197
- $stdout.puts " from: #{current_path}"
198
- $stdout.puts " to: #{dest_path}"
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
- $stdout.puts "move (fast): renamed #{eml_id}.eml → #{relative_to_imap_root(dest_messages)}"
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
- $stdout.puts "preserve-read-state: re-marking #{eml_id}.eml unread (opening the mid: URL marks it read)"
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
- $stdout.puts "#{name}: already #{want ? "flagged" : "not flagged"} — no-op"
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
- $stdout.puts "Flags now: #{current_flags(eml_id).inspect}"
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