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.
- checksums.yaml +4 -4
- data/README.md +143 -20
- data/exe/mailmate-mcp +8 -0
- data/exe/mm-mailboxes +8 -0
- data/exe/mmopen +8 -0
- data/exe/mmtags +8 -0
- data/lib/mailmate/cli/mailboxes.rb +142 -0
- data/lib/mailmate/cli/message.rb +70 -12
- data/lib/mailmate/cli/modify.rb +122 -4
- data/lib/mailmate/cli/open.rb +71 -0
- data/lib/mailmate/cli/search.rb +223 -77
- data/lib/mailmate/cli/tags.rb +93 -0
- data/lib/mailmate/config.rb +4 -1
- data/lib/mailmate/eml_lookup.rb +23 -3
- data/lib/mailmate/index_reader.rb +49 -12
- data/lib/mailmate/mcp.rb +394 -0
- data/lib/mailmate/mid_url.rb +30 -10
- data/lib/mailmate/part_lookup.rb +62 -0
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +24 -0
- metadata +29 -2
data/lib/mailmate/cli/modify.rb
CHANGED
|
@@ -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
|
|
73
|
-
|
|
74
|
-
Message-ID in your shell so the < > aren't interpreted as
|
|
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
|
|
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
|