Module: Mailmate::CLI::Modify Private

Extended by:
Modify
Included in:
Modify
Defined in:
lib/mailmate/cli/modify.rb

Overview

This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.

‘mm-modify` — apply AppleScript-driven actions (read, flag, tag, archive, move, …) to a MailMate message by its eml-id.

Ports mailmate-modify. Multiple actions in one invocation share a single open+wait cycle so chained operations are batched.

Constant Summary collapse

ACTIONS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

action → [selector] or [selector, arg-count]. arg-count default 0.

{
  "read"       => ["markAsRead:"],
  "unread"     => ["markAsUnread:"],
  "flag"       => [:ensure_flagged],
  "unflag"     => [:ensure_not_flagged],
  "tag"        => ["setTag:", 1],
  "untag"      => ["removeTag:", 1],
  "clear-tags" => ["clearTags:"],
  "archive"    => ["archive:"],
  "junk"       => ["markAsJunk:"],
  "not-junk"   => ["markAsNotJunk:"],
  "mute"       => ["toggleMuteState:"],
  "delete"     => ["deleteMessage:"],
  "move"       => ["moveToMailbox:", 1],
}.freeze

Instance Method Summary collapse

Instance Method Details

#account_dir_for(path) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The account directory is the first path segment under imap_root.



215
216
217
218
219
220
221
222
# File 'lib/mailmate/cli/modify.rb', line 215

def (path)
  imap_root = Mailmate.config.imap_root
  return nil unless path.start_with?("#{imap_root}/")
  rel = path.sub("#{imap_root}/", "")
  first = rel.split("/", 2).first
  return nil if first.nil? || first.empty?
  File.join(imap_root, first)
end

#current_flags(eml_id) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



315
316
317
318
319
320
321
322
# File 'lib/mailmate/cli/modify.rb', line 315

def current_flags(eml_id)
  # AppleScript actions write the index asynchronously — bust just the
  # #flags cache to pick up the latest values without throwing away
  # other warmed indexes (#message-id, #source) that this same
  # invocation may still need.
  Mailmate::IndexReader.reset!("#flags")
  Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i)
end

#drive(eml_id, message_id, actions, opts) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/mailmate/cli/modify.rb', line 144

def drive(eml_id, message_id, actions, opts)
  # Fast-path moves apply ONLY when every requested action is a move.
  # When the chain includes any non-move action (tag, flag, archive, …)
  # we're paying for MailMate's UI anyway, so we let MailMate handle the
  # move in-UI alongside the rest. Why this beats reordering moves to
  # the end of the chain:
  #
  # - The marginal cost of one extra AppleScript `moveToMailbox:` call
  #   is small next to the UI activation we're already eating.
  # - MailMate sees the move it just made — no #source-index staleness
  #   inside the same invocation or for follow-ups.
  # - Simpler mental model: pure-move = silent + fast; mixed = all-UI.
  fast_moves, other = actions.partition { |name, _, _| name == "move" }

  if other.empty? && !fast_moves.empty?
    current_path = Mailmate::EmlLookup.path_for(eml_id)
    fast_moves.each do |_name, selector, args|
      new_path = try_fast_move(eml_id, current_path, args.first, opts)
      if new_path
        current_path = new_path
      else
        # Fast-path declined for this one move (cross-account, target
        # not found, perm error, …) — single UI-driven move as fallback.
        drive_via_applescript(eml_id, message_id, [["move", selector, args]], opts)
      end
    end
  else
    # Mixed chain (or pure non-move chain): everything goes through the
    # AppleScript driver in the user-supplied order.
    drive_via_applescript(eml_id, message_id, actions, opts)
  end
end

#drive_via_applescript(eml_id, message_id, actions, opts) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/mailmate/cli/modify.rb', line 255

def drive_via_applescript(eml_id, message_id, actions, opts)
  driver  = Mailmate::AppleScriptDriver.new(dry_run: opts[:dry_run])
  mid_url = Mailmate::MidUrl.for(message_id)

  windows_before = driver.window_ids
  driver.open_url(mid_url)
  # Active wait: poll for the new viewer window instead of sleeping a
  # fixed `settle`. Windows typically spawn in 200-500ms; the previous
  # fixed 3.5s sleep was wildly conservative. `--settle` now controls
  # the timeout for this wait rather than the sleep duration.
  new_windows = opts[:dry_run] ? [] : wait_for_new_window(driver, windows_before, timeout: opts[:settle])

  # Send each action's selector without sleeping between them.
  # AppleEvents queue per-app and process in order, so a subsequent
  # `close window id N` will execute only after `perform` is committed.
  actions.each do |name, selector, args|
    case selector
    when :ensure_flagged, :ensure_not_flagged
      want  = selector == :ensure_flagged
      flags = opts[:dry_run] ? [] : current_flags(eml_id)
      has   = flags.include?("\\Flagged")
      if has == want
        $stdout.puts "#{name}: already #{want ? "flagged" : "not flagged"} — no-op"
      else
        driver.perform("toggleFlag:")
      end
    else
      driver.perform(selector, *args)
    end
  end

  # --verify is now permitted in --dry-run mode so callers can use
  # `mm-modify <id> <any-action> --dry-run --verify` as a post-hoc
  # "what's the current flag state?" probe after a separate action run.
  # In non-dry-run mode, a short fixed wait gives MailMate time to
  # flush #flags before we read it back; in dry-run no wait is needed.
  if opts[:verify]
    sleep(1) unless opts[:dry_run]
    $stdout.puts "Flags now: #{current_flags(eml_id).inspect}"
  end

  unless opts[:keep_window] || opts[:dry_run] || new_windows.empty?
    driver.close_windows(new_windows)
  end
end

#find_target_in_account(account_dir, target_spec) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Resolve ‘target_spec` to a `…/Messages` directory within `account_dir`. Returns nil if not found unambiguously in the same account — caller should then fall back to AppleScript (which can handle cross-account moves, UUIDs, special mailboxes, etc.).



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/mailmate/cli/modify.rb', line 228

def (, target_spec)
  spec = target_spec.to_s.sub(%r{/Messages\z}, "").sub(/\.mailbox\z/, "")
  return nil if spec.empty?

  # 1. Exact relative path under the account, each segment .mailbox-suffixed.
  nested = spec.split("/").map { |s| "#{s}.mailbox" }.join("/")
  cand = File.join(, nested, "Messages")
  return cand if File.directory?(cand)

  # 2. Bare-name match anywhere inside the account.
  matches = Dir.glob(File.join(, "**", "#{spec}.mailbox", "Messages"))
               .select { |p| File.directory?(p) }
  case matches.size
  when 0 then nil
  when 1 then matches.first
  else
    warn "move (fast): ambiguous target '#{spec}' in account; matches:"
    matches.each { |m| warn "  #{m}" }
    nil
  end
end

#parse_actions(argv, parser) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/mailmate/cli/modify.rb', line 110

def parse_actions(argv, parser)
  actions = []
  i = 0
  while i < argv.length
    name = argv[i]
    spec = ACTIONS[name]
    unless spec
      warn "mm-modify: unknown action #{name.inspect}"
      warn parser.help
      return nil
    end
    arg_count = spec.is_a?(Symbol) ? 0 : (spec[1] || 0)
    args = argv[(i + 1)...(i + 1 + arg_count)] || []
    if args.length < arg_count
      warn "mm-modify: action '#{name}' needs #{arg_count} arg(s); got #{args.length}"
      return nil
    end
    actions << [name, spec.first, args]
    i += 1 + arg_count
  end
  actions
end

#parse_options(argv) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/mailmate/cli/modify.rb', line 66

def parse_options(argv)
  opts = { verify: false, dry_run: false, settle: 3.5, keep_window: false }
  parser = OptionParser.new do |o|
    o.banner = <<~BANNER
      Usage: mm-modify <id> <action> [args...] [<action> [args...]]...

      <id> can be a local eml-id (e.g. 183715), an RFC Message-ID (with or
      without angle brackets, e.g. <abc@example.com>), or a message://%3C...%3E
      URL. Quote the Message-ID in your shell so the < > aren't interpreted as
      redirection.

      Selects the message in MailMate (via the `mid:` URL) and runs one or
      more AppleScript key-binding selectors against the now-selected message.
      Multiple actions share one open+wait cycle.

      ACTIONS
        read                          Mark seen (\\Seen)
        unread                        Mark unseen
        flag                          Ensure \\Flagged is set (no-op if already)
        unflag                        Ensure \\Flagged is cleared (no-op if already)
        tag <name>                    Set IMAP keyword <name> (e.g. urgent, $Followup)
        untag <name>                  Remove IMAP keyword <name>
        clear-tags                    Remove all keywords
        archive                       Move to the archive mailbox
        move <mailbox>                Move to a specific mailbox. Bare names like 'Archive'
                                      or 'Folder/Sub' resolve within the same account and
                                      take a fast path (direct .eml rename — no UI, no
                                      focus theft). Mailbox UUIDs and cross-account moves
                                      fall back to the AppleScript driver. Chained with
                                      other actions: tag/flag/etc. run first at the
                                      original location, the rename happens last.
        junk / not-junk               Mark as junk / not junk
        mute                          Toggle mute state
        delete                        Delete (move to trash)
    BANNER
    o.on("--verify", "Print the message's current flags. Works with --dry-run as a post-hoc 'check state' probe (run the action first, then re-run with --dry-run --verify).") { opts[:verify] = true }
    o.on("--dry-run", "Print the actions; don't run") { opts[:dry_run] = true }
    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 }
    o.on("--keep-window", "Don't close the spawned message-viewer window") { opts[:keep_window] = true }
  end
  parser.parse!(argv)
  [opts, parser]
end

#relative_to_imap_root(path) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



250
251
252
253
# File 'lib/mailmate/cli/modify.rb', line 250

def relative_to_imap_root(path)
  root = Mailmate.config.imap_root
  path.start_with?("#{root}/") ? path.sub("#{root}/", "") : path
end

#run(argv) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/mailmate/cli/modify.rb', line 33

def run(argv)
  opts, parser = parse_options(argv)
  input = argv.shift
  return usage_error(parser, "missing <id>") if input.nil? || input.empty?
  return usage_error(parser, "no actions given") if argv.empty?

  actions = parse_actions(argv, parser)
  return 2 if actions.nil?

  eml_id = Mailmate::EmlLookup.resolve_id(input)
  if eml_id.nil? || eml_id.zero?
    warn "Not found: #{input.inspect} (couldn't resolve as eml-id or Message-ID)"
    return 1
  end

  path = Mailmate::EmlLookup.path_for(eml_id)
  unless path
    warn "Not found: #{eml_id}.eml"
    return 1
  end

  message_id = Mailmate::HeaderReader.message_id(path)
  unless message_id
    warn "Could not find Message-ID in #{path}"
    return 1
  end

  warn_on_duplicates(message_id, eml_id)

  drive(eml_id, message_id, actions, opts)
  0
end

#try_fast_move(eml_id, current_path, target_spec, opts) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the new path on success (or in dry-run, the path we would have moved to). Returns nil if the caller should fall back to the AppleScript driver for this action (cross-account, unknown target, permission error, …).



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/mailmate/cli/modify.rb', line 181

def try_fast_move(eml_id, current_path, target_spec, opts)
  return nil if current_path.nil?
   = (current_path)
  return nil if .nil?

  dest_messages = (, target_spec)
  return nil if dest_messages.nil?

  dest_path = File.join(dest_messages, "#{eml_id}.eml")
  if dest_path == current_path
    $stdout.puts "move (fast): #{eml_id}.eml is already in #{target_spec} — no-op"
    return dest_path
  end

  if opts[:dry_run]
    $stdout.puts "move (fast, dry-run): would rename"
    $stdout.puts "  from: #{current_path}"
    $stdout.puts "  to:   #{dest_path}"
    return dest_path
  end

  File.rename(current_path, dest_path)
  # The #source index still points at the old location until MailMate
  # rescans; bust it so any subsequent path_for in this process re-reads
  # (and eventually picks up MailMate's refreshed value).
  Mailmate::IndexReader.reset!("#source") if defined?(Mailmate::IndexReader)
  $stdout.puts "move (fast): renamed #{eml_id}.eml → #{relative_to_imap_root(dest_messages)}"
  dest_path
rescue Errno::EACCES, Errno::EXDEV, Errno::ENOENT, Errno::EEXIST => e
  warn "move (fast): rename failed (#{e.class}: #{e.message}); falling back to AppleScript"
  nil
end

#usage_error(parser, msg) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



324
325
326
327
328
# File 'lib/mailmate/cli/modify.rb', line 324

def usage_error(parser, msg)
  warn "mm-modify: #{msg}"
  warn parser.help
  2
end

#wait_for_new_window(driver, windows_before, timeout:, poll: 0.05) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Poll for a new MailMate window appearing in ‘driver.window_ids` that isn’t in ‘windows_before`. Returns the set of new window IDs, or an empty array if `timeout` elapses without a new window appearing.



304
305
306
307
308
309
310
311
312
313
# File 'lib/mailmate/cli/modify.rb', line 304

def wait_for_new_window(driver, windows_before, timeout:, poll: 0.05)
  deadline = Time.now + timeout
  loop do
    diff = driver.window_ids - windows_before
    return diff unless diff.empty?
    break if Time.now >= deadline
    sleep(poll)
  end
  []
end

#warn_on_duplicates(message_id, eml_id) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



133
134
135
136
137
138
139
140
141
142
# File 'lib/mailmate/cli/modify.rb', line 133

def warn_on_duplicates(message_id, eml_id)
  dup_ids = Mailmate::DuplicateScanner.eml_ids_for(message_id)
  return unless dup_ids.size > 1
  others = dup_ids.reject { |id| id == eml_id.to_i }
  warn "WARNING: Message-ID has #{dup_ids.size} copies in MailMate's tree."
  warn "         You targeted #{eml_id}.eml but the action may land on:"
  warn "           #{others.join(", ")}"
  warn "         (MailMate picks one candidate when resolving `mid:` URLs;"
  warn "          the choice is not deterministic by .eml id.)"
end