mailmate 0.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.
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Mailmate
6
+ module CLI
7
+ # `mm-modify` — apply AppleScript-driven actions (read, flag, tag, archive,
8
+ # move, …) to a MailMate message by its eml-id.
9
+ #
10
+ # Ports mailmate-modify. Multiple actions in one invocation share a single
11
+ # open+wait cycle so chained operations are batched.
12
+ # @api private
13
+ module Modify
14
+ extend self
15
+
16
+ # action → [selector] or [selector, arg-count]. arg-count default 0.
17
+ ACTIONS = {
18
+ "read" => ["markAsRead:"],
19
+ "unread" => ["markAsUnread:"],
20
+ "flag" => [:ensure_flagged],
21
+ "unflag" => [:ensure_not_flagged],
22
+ "tag" => ["setTag:", 1],
23
+ "untag" => ["removeTag:", 1],
24
+ "clear-tags" => ["clearTags:"],
25
+ "archive" => ["archive:"],
26
+ "junk" => ["markAsJunk:"],
27
+ "not-junk" => ["markAsNotJunk:"],
28
+ "mute" => ["toggleMuteState:"],
29
+ "delete" => ["deleteMessage:"],
30
+ "move" => ["moveToMailbox:", 1],
31
+ }.freeze
32
+
33
+ def run(argv)
34
+ opts, parser = parse_options(argv)
35
+ input = argv.shift
36
+ return usage_error(parser, "missing <id>") if input.nil? || input.empty?
37
+ return usage_error(parser, "no actions given") if argv.empty?
38
+
39
+ actions = parse_actions(argv, parser)
40
+ return 2 if actions.nil?
41
+
42
+ eml_id = Mailmate::EmlLookup.resolve_id(input)
43
+ if eml_id.nil? || eml_id.zero?
44
+ warn "Not found: #{input.inspect} (couldn't resolve as eml-id or Message-ID)"
45
+ return 1
46
+ end
47
+
48
+ path = Mailmate::EmlLookup.path_for(eml_id)
49
+ unless path
50
+ warn "Not found: #{eml_id}.eml"
51
+ return 1
52
+ end
53
+
54
+ message_id = Mailmate::HeaderReader.message_id(path)
55
+ unless message_id
56
+ warn "Could not find Message-ID in #{path}"
57
+ return 1
58
+ end
59
+
60
+ warn_on_duplicates(message_id, eml_id)
61
+
62
+ drive(eml_id, message_id, actions, opts)
63
+ 0
64
+ end
65
+
66
+ def parse_options(argv)
67
+ opts = { verify: false, dry_run: false, settle: 3.5, keep_window: false }
68
+ parser = OptionParser.new do |o|
69
+ o.banner = <<~BANNER
70
+ Usage: mm-modify <id> <action> [args...] [<action> [args...]]...
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.
75
+
76
+ Selects the message in MailMate (via the `mid:` URL) and runs one or
77
+ more AppleScript key-binding selectors against the now-selected message.
78
+ Multiple actions share one open+wait cycle.
79
+
80
+ ACTIONS
81
+ read Mark seen (\\Seen)
82
+ unread Mark unseen
83
+ flag Ensure \\Flagged is set (no-op if already)
84
+ unflag Ensure \\Flagged is cleared (no-op if already)
85
+ tag <name> Set IMAP keyword <name> (e.g. urgent, $Followup)
86
+ untag <name> Remove IMAP keyword <name>
87
+ clear-tags Remove all keywords
88
+ archive Move to the archive mailbox
89
+ move <mailbox-uuid> Move to a specific mailbox (use UUID from MailMate)
90
+ junk / not-junk Mark as junk / not junk
91
+ mute Toggle mute state
92
+ delete Delete (move to trash)
93
+ BANNER
94
+ o.on("--verify", "After running, print the message's current flags") { opts[:verify] = true }
95
+ o.on("--dry-run", "Print the actions; don't run") { opts[:dry_run] = true }
96
+ o.on("--settle SECONDS", Float, "Sleep between operations (default 3.5)") { |s| opts[:settle] = s }
97
+ o.on("--keep-window", "Don't close the spawned message-viewer window") { opts[:keep_window] = true }
98
+ end
99
+ parser.parse!(argv)
100
+ [opts, parser]
101
+ end
102
+
103
+ def parse_actions(argv, parser)
104
+ actions = []
105
+ i = 0
106
+ while i < argv.length
107
+ name = argv[i]
108
+ spec = ACTIONS[name]
109
+ unless spec
110
+ warn "mm-modify: unknown action #{name.inspect}"
111
+ warn parser.help
112
+ return nil
113
+ end
114
+ arg_count = spec.is_a?(Symbol) ? 0 : (spec[1] || 0)
115
+ args = argv[(i + 1)...(i + 1 + arg_count)] || []
116
+ if args.length < arg_count
117
+ warn "mm-modify: action '#{name}' needs #{arg_count} arg(s); got #{args.length}"
118
+ return nil
119
+ end
120
+ actions << [name, spec.first, args]
121
+ i += 1 + arg_count
122
+ end
123
+ actions
124
+ end
125
+
126
+ def warn_on_duplicates(message_id, eml_id)
127
+ dup_ids = Mailmate::DuplicateScanner.eml_ids_for(message_id)
128
+ return unless dup_ids.size > 1
129
+ others = dup_ids.reject { |id| id == eml_id.to_i }
130
+ warn "WARNING: Message-ID has #{dup_ids.size} copies in MailMate's tree."
131
+ warn " You targeted #{eml_id}.eml but the action may land on:"
132
+ warn " #{others.join(", ")}"
133
+ warn " (MailMate picks one candidate when resolving `mid:` URLs;"
134
+ warn " the choice is not deterministic by .eml id.)"
135
+ end
136
+
137
+ def drive(eml_id, message_id, actions, opts)
138
+ driver = Mailmate::AppleScriptDriver.new(dry_run: opts[:dry_run])
139
+ mid_url = Mailmate::MidUrl.for(message_id)
140
+
141
+ windows_before = driver.window_ids
142
+ driver.open_url(mid_url)
143
+ sleep(opts[:settle]) unless opts[:dry_run]
144
+ new_windows = driver.window_ids - windows_before
145
+
146
+ actions.each do |name, selector, args|
147
+ case selector
148
+ when :ensure_flagged, :ensure_not_flagged
149
+ want = selector == :ensure_flagged
150
+ flags = opts[:dry_run] ? [] : current_flags(eml_id)
151
+ has = flags.include?("\\Flagged")
152
+ if has == want
153
+ $stdout.puts "#{name}: already #{want ? "flagged" : "not flagged"} — no-op"
154
+ else
155
+ driver.perform("toggleFlag:")
156
+ sleep(opts[:settle]) unless opts[:dry_run]
157
+ end
158
+ else
159
+ driver.perform(selector, *args)
160
+ sleep(opts[:settle]) unless opts[:dry_run]
161
+ end
162
+ end
163
+
164
+ if opts[:verify] && !opts[:dry_run]
165
+ sleep(opts[:settle])
166
+ $stdout.puts "Flags now: #{current_flags(eml_id).inspect}"
167
+ end
168
+
169
+ unless opts[:keep_window] || opts[:dry_run] || new_windows.empty?
170
+ driver.close_windows(new_windows)
171
+ end
172
+ end
173
+
174
+ def current_flags(eml_id)
175
+ # AppleScript actions write the index asynchronously — bust just the
176
+ # #flags cache to pick up the latest values without throwing away
177
+ # other warmed indexes (#message-id, #source) that this same
178
+ # invocation may still need.
179
+ Mailmate::IndexReader.reset!("#flags")
180
+ Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i)
181
+ end
182
+
183
+ def usage_error(parser, msg)
184
+ warn "mm-modify: #{msg}"
185
+ warn parser.help
186
+ 2
187
+ end
188
+ end
189
+ end
190
+ end