mailmate 1.4.0 → 1.6.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.
@@ -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