mailmate 0.2.0 → 1.1.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.
@@ -69,9 +69,10 @@ module Mailmate
69
69
  o.banner = <<~BANNER
70
70
  Usage: mm-modify <id> <action> [args...] [<action> [args...]]...
71
71
 
72
- <id> can be either a local eml-id (e.g. 183715) or an RFC Message-ID
73
- (with or without angle brackets, e.g. <abc@example.com>). Quote the
74
- Message-ID in your shell so the < > aren't interpreted as redirection.
72
+ <id> can be a local eml-id (e.g. 183715), an RFC Message-ID (with or
73
+ without angle brackets, e.g. <abc@example.com>), or a message://%3C...%3E
74
+ URL. Quote the Message-ID in your shell so the < > aren't interpreted as
75
+ redirection.
75
76
 
76
77
  Selects the message in MailMate (via the `mid:` URL) and runs one or
77
78
  more AppleScript key-binding selectors against the now-selected message.
@@ -86,7 +87,13 @@ module Mailmate
86
87
  untag <name> Remove IMAP keyword <name>
87
88
  clear-tags Remove all keywords
88
89
  archive Move to the archive mailbox
89
- move <mailbox-uuid> Move to a specific mailbox (use UUID from MailMate)
90
+ move <mailbox> Move to a specific mailbox. Bare names like 'Archive'
91
+ or 'Folder/Sub' resolve within the same account and
92
+ take a fast path (direct .eml rename — no UI, no
93
+ focus theft). Mailbox UUIDs and cross-account moves
94
+ fall back to the AppleScript driver. Chained with
95
+ other actions: tag/flag/etc. run first at the
96
+ original location, the rename happens last.
90
97
  junk / not-junk Mark as junk / not junk
91
98
  mute Toggle mute state
92
99
  delete Delete (move to trash)
@@ -135,6 +142,117 @@ module Mailmate
135
142
  end
136
143
 
137
144
  def drive(eml_id, message_id, actions, opts)
145
+ # Fast-path moves apply ONLY when every requested action is a move.
146
+ # When the chain includes any non-move action (tag, flag, archive, …)
147
+ # we're paying for MailMate's UI anyway, so we let MailMate handle the
148
+ # move in-UI alongside the rest. Why this beats reordering moves to
149
+ # the end of the chain:
150
+ #
151
+ # - The marginal cost of one extra AppleScript `moveToMailbox:` call
152
+ # is small next to the UI activation we're already eating.
153
+ # - MailMate sees the move it just made — no #source-index staleness
154
+ # inside the same invocation or for follow-ups.
155
+ # - Simpler mental model: pure-move = silent + fast; mixed = all-UI.
156
+ fast_moves, other = actions.partition { |name, _, _| name == "move" }
157
+
158
+ if other.empty? && !fast_moves.empty?
159
+ current_path = Mailmate::EmlLookup.path_for(eml_id)
160
+ fast_moves.each do |_name, selector, args|
161
+ new_path = try_fast_move(eml_id, current_path, args.first, opts)
162
+ if new_path
163
+ current_path = new_path
164
+ else
165
+ # Fast-path declined for this one move (cross-account, target
166
+ # not found, perm error, …) — single UI-driven move as fallback.
167
+ drive_via_applescript(eml_id, message_id, [["move", selector, args]], opts)
168
+ end
169
+ end
170
+ else
171
+ # Mixed chain (or pure non-move chain): everything goes through the
172
+ # AppleScript driver in the user-supplied order.
173
+ drive_via_applescript(eml_id, message_id, actions, opts)
174
+ end
175
+ end
176
+
177
+ # Returns the new path on success (or in dry-run, the path we *would*
178
+ # have moved to). Returns nil if the caller should fall back to the
179
+ # AppleScript driver for this action (cross-account, unknown target,
180
+ # permission error, …).
181
+ def try_fast_move(eml_id, current_path, target_spec, opts)
182
+ return nil if current_path.nil?
183
+ account_dir = account_dir_for(current_path)
184
+ return nil if account_dir.nil?
185
+
186
+ dest_messages = find_target_in_account(account_dir, target_spec)
187
+ return nil if dest_messages.nil?
188
+
189
+ dest_path = File.join(dest_messages, "#{eml_id}.eml")
190
+ if dest_path == current_path
191
+ $stdout.puts "move (fast): #{eml_id}.eml is already in #{target_spec} — no-op"
192
+ return dest_path
193
+ end
194
+
195
+ 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}"
199
+ return dest_path
200
+ end
201
+
202
+ File.rename(current_path, dest_path)
203
+ # The #source index still points at the old location until MailMate
204
+ # rescans; bust it so any subsequent path_for in this process re-reads
205
+ # (and eventually picks up MailMate's refreshed value).
206
+ Mailmate::IndexReader.reset!("#source") if defined?(Mailmate::IndexReader)
207
+ $stdout.puts "move (fast): renamed #{eml_id}.eml → #{relative_to_imap_root(dest_messages)}"
208
+ dest_path
209
+ rescue Errno::EACCES, Errno::EXDEV, Errno::ENOENT, Errno::EEXIST => e
210
+ warn "move (fast): rename failed (#{e.class}: #{e.message}); falling back to AppleScript"
211
+ nil
212
+ end
213
+
214
+ # The account directory is the first path segment under imap_root.
215
+ def account_dir_for(path)
216
+ imap_root = Mailmate.config.imap_root
217
+ return nil unless path.start_with?("#{imap_root}/")
218
+ rel = path.sub("#{imap_root}/", "")
219
+ first = rel.split("/", 2).first
220
+ return nil if first.nil? || first.empty?
221
+ File.join(imap_root, first)
222
+ end
223
+
224
+ # Resolve `target_spec` to a `.../Messages` directory within `account_dir`.
225
+ # Returns nil if not found unambiguously in the same account — caller
226
+ # should then fall back to AppleScript (which can handle cross-account
227
+ # moves, UUIDs, special mailboxes, etc.).
228
+ def find_target_in_account(account_dir, target_spec)
229
+ spec = target_spec.to_s.sub(%r{/Messages\z}, "").sub(/\.mailbox\z/, "")
230
+ return nil if spec.empty?
231
+
232
+ # 1. Exact relative path under the account, each segment .mailbox-suffixed.
233
+ nested = spec.split("/").map { |s| "#{s}.mailbox" }.join("/")
234
+ cand = File.join(account_dir, nested, "Messages")
235
+ return cand if File.directory?(cand)
236
+
237
+ # 2. Bare-name match anywhere inside the account.
238
+ matches = Dir.glob(File.join(account_dir, "**", "#{spec}.mailbox", "Messages"))
239
+ .select { |p| File.directory?(p) }
240
+ case matches.size
241
+ when 0 then nil
242
+ when 1 then matches.first
243
+ else
244
+ warn "move (fast): ambiguous target '#{spec}' in account; matches:"
245
+ matches.each { |m| warn " #{m}" }
246
+ nil
247
+ end
248
+ end
249
+
250
+ def relative_to_imap_root(path)
251
+ root = Mailmate.config.imap_root
252
+ path.start_with?("#{root}/") ? path.sub("#{root}/", "") : path
253
+ end
254
+
255
+ def drive_via_applescript(eml_id, message_id, actions, opts)
138
256
  driver = Mailmate::AppleScriptDriver.new(dry_run: opts[:dry_run])
139
257
  mid_url = Mailmate::MidUrl.for(message_id)
140
258
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Mailmate
6
+ module CLI
7
+ # `mmopen` — open one MailMate message in the MailMate UI by handing the
8
+ # `mid:` URL to macOS's `open`. Read-side (doesn't change message state),
9
+ # but activates MailMate's window and brings it forward.
10
+ #
11
+ # Accepts any of the six id forms `EmlLookup.resolve_id` understands —
12
+ # eml-id, RFC Message-ID with or without angle brackets, message://… URL,
13
+ # or mid:… URL.
14
+ # @api private
15
+ module Open
16
+ extend self
17
+
18
+ def run(argv)
19
+ opts = parse_options(argv)
20
+ input = argv.first
21
+ return usage_error("missing <id>") if input.nil? || input.empty?
22
+
23
+ eml_id = Mailmate::EmlLookup.resolve_id(input)
24
+ if eml_id.nil? || eml_id.zero?
25
+ warn "mmopen: not found: #{input.inspect} (couldn't resolve as eml-id or Message-ID)"
26
+ return 1
27
+ end
28
+
29
+ path = Mailmate::EmlLookup.path_for(eml_id)
30
+ unless path
31
+ warn "mmopen: not found: #{eml_id}.eml"
32
+ return 1
33
+ end
34
+
35
+ message_id = Mailmate::HeaderReader.message_id(path)
36
+ unless message_id
37
+ warn "mmopen: could not find Message-ID in #{path}"
38
+ return 1
39
+ end
40
+
41
+ url = Mailmate::MidUrl.for(message_id)
42
+ if opts[:print_only]
43
+ $stdout.puts url
44
+ return 0
45
+ end
46
+
47
+ system("/usr/bin/open", url)
48
+ $?.exitstatus
49
+ end
50
+
51
+ def parse_options(argv)
52
+ opts = { print_only: false }
53
+ OptionParser.new do |o|
54
+ o.banner = "Usage: mmopen <id> [--print]"
55
+ o.separator ""
56
+ o.separator "Open a MailMate message in MailMate's UI. <id> can be a local"
57
+ o.separator "eml-id, an RFC Message-ID (with or without angle brackets), or"
58
+ o.separator "a message://… or mid:… URL."
59
+ o.on("--print", "Print the mid: URL instead of opening it (for piping)") { opts[:print_only] = true }
60
+ end.parse!(argv)
61
+ opts
62
+ end
63
+
64
+ def usage_error(msg)
65
+ warn "mmopen: #{msg}"
66
+ warn "Usage: mmopen <id> [--print]"
67
+ 2
68
+ end
69
+ end
70
+ end
71
+ end