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.
- checksums.yaml +4 -4
- data/README.md +182 -95
- 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 +268 -50
- 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 +93 -8
- data/lib/mailmate/part_lookup.rb +12 -3
- data/lib/mailmate/version.rb +1 -1
- metadata +5 -1
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
|