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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/config.yml.example +21 -0
- data/exe/mm-modify +8 -0
- data/exe/mm-send +8 -0
- data/exe/mmdiscover +8 -0
- data/exe/mmmessage +8 -0
- data/exe/mmsearch +8 -0
- data/lib/mailmate/applescript_driver.rb +103 -0
- data/lib/mailmate/ast.rb +33 -0
- data/lib/mailmate/attributes.rb +289 -0
- data/lib/mailmate/cli/discover.rb +170 -0
- data/lib/mailmate/cli/message.rb +109 -0
- data/lib/mailmate/cli/modify.rb +190 -0
- data/lib/mailmate/cli/search.rb +609 -0
- data/lib/mailmate/cli/send.rb +29 -0
- data/lib/mailmate/config.rb +134 -0
- data/lib/mailmate/duplicate_scanner.rb +65 -0
- data/lib/mailmate/eml_lookup.rb +86 -0
- data/lib/mailmate/evaluator.rb +93 -0
- data/lib/mailmate/filter_classifier.rb +123 -0
- data/lib/mailmate/header_reader.rb +74 -0
- data/lib/mailmate/identity.rb +35 -0
- data/lib/mailmate/index_reader.rb +126 -0
- data/lib/mailmate/lexer.rb +136 -0
- data/lib/mailmate/mailbox_graph.rb +77 -0
- data/lib/mailmate/message.rb +31 -0
- data/lib/mailmate/mid_url.rb +23 -0
- data/lib/mailmate/operators.rb +110 -0
- data/lib/mailmate/parser.rb +218 -0
- data/lib/mailmate/platform_error.rb +21 -0
- data/lib/mailmate/source_resolver.rb +104 -0
- data/lib/mailmate/var_resolver.rb +108 -0
- data/lib/mailmate/version.rb +5 -0
- data/lib/mailmate.rb +73 -0
- metadata +146 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Attribute resolver: maps a path like ["from", "name"] or ["#any-address"]
|
|
4
|
+
# to one or more values on a parsed Mail::Message. Implements the subset of
|
|
5
|
+
# /Applications/MailMate.app/Contents/Resources/specifiers.plist that Brian's
|
|
6
|
+
# smart-mailbox filters actually reference.
|
|
7
|
+
#
|
|
8
|
+
# Returns:
|
|
9
|
+
# - String for single-valued paths
|
|
10
|
+
# - Array<String> for multi-valued paths (e.g. recipient lists, all-addresses)
|
|
11
|
+
# - Time for date paths
|
|
12
|
+
# - nil when the value is missing/empty
|
|
13
|
+
#
|
|
14
|
+
# Stage A subset (no #flags / #date-last-viewed access; those need IndexReader).
|
|
15
|
+
|
|
16
|
+
require "time"
|
|
17
|
+
require_relative "index_reader"
|
|
18
|
+
|
|
19
|
+
# @api private
|
|
20
|
+
module Mailmate
|
|
21
|
+
module Attributes
|
|
22
|
+
SHORTHANDS = {
|
|
23
|
+
"#recipient" => %w[to cc bcc],
|
|
24
|
+
"#any-address" => %w[from to cc bcc],
|
|
25
|
+
"#mailer" => %w[x-mailer user-agent x-newsreader],
|
|
26
|
+
"#date" => :date,
|
|
27
|
+
"#date-received" => :date_received,
|
|
28
|
+
"#date-sent" => :date,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Resolve a path to value(s). Returns nil/[] when nothing.
|
|
32
|
+
# `mail_or_message` may be a Mail::Message OR a Mailmate::Message (which
|
|
33
|
+
# carries the `eml_id` needed for index-based attributes like `#flags`).
|
|
34
|
+
def self.resolve(mail_or_message, path)
|
|
35
|
+
mail = mail_or_message.respond_to?(:mail) ? mail_or_message.mail : mail_or_message
|
|
36
|
+
eml_id = mail_or_message.respond_to?(:eml_id) ? mail_or_message.eml_id : nil
|
|
37
|
+
|
|
38
|
+
head, *rest = path
|
|
39
|
+
values = head_values(mail, head, eml_id)
|
|
40
|
+
values = Array(values).flatten.compact
|
|
41
|
+
return nil if values.empty?
|
|
42
|
+
|
|
43
|
+
rest.each do |seg|
|
|
44
|
+
values = values.flat_map { |v| step(v, seg) }.compact
|
|
45
|
+
return nil if values.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
values.size == 1 ? values.first : values
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ---- head resolution ----
|
|
52
|
+
|
|
53
|
+
INDEX_DATE_HEADS = %w[#date #date-sent #date-received #date-last-viewed].freeze
|
|
54
|
+
|
|
55
|
+
def self.head_values(mail, head, eml_id = nil)
|
|
56
|
+
# Index-backed paths: never need the Mail object.
|
|
57
|
+
if INDEX_DATE_HEADS.include?(head) && eml_id
|
|
58
|
+
s = (IndexReader.for(head).value_for(eml_id) rescue nil)
|
|
59
|
+
return nil if s.nil? || s.empty?
|
|
60
|
+
begin
|
|
61
|
+
return Time.parse(s)
|
|
62
|
+
rescue ArgumentError
|
|
63
|
+
return nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
case head
|
|
68
|
+
when "##thread-id"
|
|
69
|
+
# Heuristic thread-id: root Message-ID of the References chain (or
|
|
70
|
+
# In-Reply-To, or the message's own Message-ID). Requires the headers
|
|
71
|
+
# — return nil cleanly if we're in index-only mode without a Mail.
|
|
72
|
+
return nil if mail.nil?
|
|
73
|
+
thread_id_for(mail)
|
|
74
|
+
when "#flags"
|
|
75
|
+
# Returns the raw flag tokens as strings (e.g. ["\\Seen", "$Forwarded"]).
|
|
76
|
+
# Empty array if the message has no flags / isn't indexed.
|
|
77
|
+
# Resolved via the binary `Database.noindex/Headers/#flags` index.
|
|
78
|
+
return [] if eml_id.nil?
|
|
79
|
+
IndexReader.for("#flags").flags_for(eml_id)
|
|
80
|
+
when "##tags"
|
|
81
|
+
return [] if eml_id.nil?
|
|
82
|
+
# ##tags is a related index (multiValue) for user-facing tag names.
|
|
83
|
+
# Best-effort: fall back to #flags if ##tags isn't present.
|
|
84
|
+
begin
|
|
85
|
+
IndexReader.for("##tags").flags_for(eml_id)
|
|
86
|
+
rescue ArgumentError
|
|
87
|
+
IndexReader.for("#flags").flags_for(eml_id)
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
# All other heads need the Mail object. In index-only mode, mail is
|
|
91
|
+
# nil — return nil so comparisons fail cleanly rather than crashing.
|
|
92
|
+
return nil if mail.nil?
|
|
93
|
+
head_values_from_mail(mail, head)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.head_values_from_mail(mail, head)
|
|
98
|
+
case head
|
|
99
|
+
when "from"
|
|
100
|
+
addrs(mail, :from)
|
|
101
|
+
when "to"
|
|
102
|
+
addrs(mail, :to)
|
|
103
|
+
when "cc"
|
|
104
|
+
addrs(mail, :cc)
|
|
105
|
+
when "bcc"
|
|
106
|
+
addrs(mail, :bcc)
|
|
107
|
+
when "reply-to"
|
|
108
|
+
addrs(mail, :reply_to)
|
|
109
|
+
when "subject"
|
|
110
|
+
mail.subject.to_s
|
|
111
|
+
when "list-id"
|
|
112
|
+
v = header_value(mail, "list-id")
|
|
113
|
+
v && [v]
|
|
114
|
+
when "in-reply-to"
|
|
115
|
+
v = header_value(mail, "in-reply-to")
|
|
116
|
+
v && [v]
|
|
117
|
+
when "message-id"
|
|
118
|
+
mail.message_id
|
|
119
|
+
when "x-mailer", "user-agent", "x-newsreader"
|
|
120
|
+
v = header_value(mail, head)
|
|
121
|
+
v && [v]
|
|
122
|
+
when "#recipient", "#any-address"
|
|
123
|
+
SHORTHANDS[head].flat_map { |h| addrs(mail, h.to_sym) || [] }
|
|
124
|
+
when "#mailer"
|
|
125
|
+
SHORTHANDS[head].map { |h| header_value(mail, h) }.compact
|
|
126
|
+
when "#date", "#date-sent"
|
|
127
|
+
mail.date
|
|
128
|
+
when "#date-received"
|
|
129
|
+
# Best available proxy without IMAP: prefer the latest Received: timestamp,
|
|
130
|
+
# falling back to the Date header. Receiving servers stamp Received headers
|
|
131
|
+
# in reverse-chrono order; the topmost is the most recent.
|
|
132
|
+
recv = mail.received
|
|
133
|
+
recv = [recv].flatten.compact.first
|
|
134
|
+
recv&.date_time && Time.parse(recv.date_time.to_s) rescue mail.date
|
|
135
|
+
else
|
|
136
|
+
# Unknown header — try as a raw header lookup.
|
|
137
|
+
v = header_value(mail, head)
|
|
138
|
+
v && [v]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Address fields can carry either an email or a display name. We return
|
|
143
|
+
# *one Address-shaped string* per recipient, plus separately accessible
|
|
144
|
+
# display names — `step` decomposes further on `.name` / `.address`.
|
|
145
|
+
def self.addrs(mail, sym)
|
|
146
|
+
field = mail[sym]
|
|
147
|
+
return nil unless field
|
|
148
|
+
list = Array(field.respond_to?(:addrs) ? field.addrs : nil)
|
|
149
|
+
return [field.value.to_s] if list.empty?
|
|
150
|
+
# Each Mail::Address has #display_name, #address, #name
|
|
151
|
+
list.map { |a| AddressValue.new(a) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# AddressValue wraps Mail::Address so `.step` knows what to extract.
|
|
155
|
+
AddressValue = Struct.new(:addr) do
|
|
156
|
+
def to_s
|
|
157
|
+
if addr.display_name && !addr.display_name.empty?
|
|
158
|
+
"#{addr.display_name} <#{addr.address}>"
|
|
159
|
+
else
|
|
160
|
+
addr.address.to_s
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def name
|
|
165
|
+
addr.display_name
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def address
|
|
169
|
+
addr.address
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.header_value(mail, name)
|
|
174
|
+
h = mail[name]
|
|
175
|
+
return nil unless h
|
|
176
|
+
h.value.to_s
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# ---- decomposition step ----
|
|
180
|
+
|
|
181
|
+
def self.step(value, seg)
|
|
182
|
+
case value
|
|
183
|
+
when AddressValue
|
|
184
|
+
case seg
|
|
185
|
+
when "name" then value.name
|
|
186
|
+
when "address" then value.address
|
|
187
|
+
when "domain"
|
|
188
|
+
a = value.address.to_s
|
|
189
|
+
a.include?("@") ? a.split("@", 2).last : nil
|
|
190
|
+
when "user"
|
|
191
|
+
a = value.address.to_s
|
|
192
|
+
a.include?("@") ? a.split("@", 2).first : a
|
|
193
|
+
when "top-level", "second-level", "third-level", "final-level"
|
|
194
|
+
a = value.address.to_s
|
|
195
|
+
dom = a.include?("@") ? a.split("@", 2).last : nil
|
|
196
|
+
dom && domain_level(dom, seg)
|
|
197
|
+
else nil
|
|
198
|
+
end
|
|
199
|
+
when String
|
|
200
|
+
case seg
|
|
201
|
+
when "flag", "tag"
|
|
202
|
+
# `#flags.flag` / `##tags.tag` — each value of the multi-value head
|
|
203
|
+
# is already an individual flag/tag string. Passthrough.
|
|
204
|
+
value
|
|
205
|
+
when "body" # e.g. subject.body — strip Re:/Fwd: prefixes and [bracketed] blobs
|
|
206
|
+
strip_subject_prefixes(value)
|
|
207
|
+
when "blob"
|
|
208
|
+
subject_blob(value)
|
|
209
|
+
when "prefix"
|
|
210
|
+
subject_prefix(value)
|
|
211
|
+
when "identifier" # list-id <foo@bar>
|
|
212
|
+
value =~ /<([^>]+)>/ ? Regexp.last_match(1) : value.strip
|
|
213
|
+
when "description" # list-id "Description" <foo@bar>
|
|
214
|
+
value =~ /^\s*"([^"]+)"|^([^<]+?)\s*</ ? (Regexp.last_match(1) || Regexp.last_match(2)).strip : nil
|
|
215
|
+
when "user"
|
|
216
|
+
value.include?("@") ? value.split("@", 2).first : value
|
|
217
|
+
when "domain"
|
|
218
|
+
value.include?("@") ? value.split("@", 2).last : nil
|
|
219
|
+
when "top-level", "second-level", "third-level", "final-level"
|
|
220
|
+
dom = value.include?("@") ? value.split("@", 2).last : value
|
|
221
|
+
domain_level(dom, seg)
|
|
222
|
+
else nil
|
|
223
|
+
end
|
|
224
|
+
when Time, DateTime
|
|
225
|
+
t = value.respond_to?(:to_time) ? value.to_time : value
|
|
226
|
+
case seg
|
|
227
|
+
when "year" then t.year.to_s
|
|
228
|
+
when "month" then t.month.to_s
|
|
229
|
+
when "day" then t.day.to_s
|
|
230
|
+
when "hour" then t.hour.to_s
|
|
231
|
+
else nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def self.strip_subject_prefixes(s)
|
|
237
|
+
v = s.to_s.dup
|
|
238
|
+
# Remove leading "Re:", "Fwd:", "[Tag]" sequences
|
|
239
|
+
loop do
|
|
240
|
+
if v.sub!(/\A\s*(?i:re|fw|fwd|sv|aw|antw|wg|tr)(?:\[\d+\])?\s*[::]\s*/, "")
|
|
241
|
+
next
|
|
242
|
+
end
|
|
243
|
+
if v.sub!(/\A\s*\[[^\[\]]+\]\s*/, "")
|
|
244
|
+
next
|
|
245
|
+
end
|
|
246
|
+
break
|
|
247
|
+
end
|
|
248
|
+
v
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def self.subject_blob(s)
|
|
252
|
+
m = s.to_s.match(/\A(?:\s*(?i:re|fw|fwd|sv|aw|antw|wg|tr)(?:\[\d+\])?\s*[::]\s*)*\s*\[([^\[\]]+)\]/)
|
|
253
|
+
m && m[1]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def self.subject_prefix(s)
|
|
257
|
+
m = s.to_s.match(/\A((?:\s*(?i:re|fw|fwd|sv|aw|antw|wg|tr)(?:\[\d+\])?\s*[::]\s*)+)/)
|
|
258
|
+
m && m[1].strip
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Thread-id heuristic: use the FIRST Message-ID in the References header
|
|
262
|
+
# as the thread root, falling back to In-Reply-To, falling back to the
|
|
263
|
+
# message's own Message-ID. Matches what most threading algorithms do.
|
|
264
|
+
def self.thread_id_for(mail)
|
|
265
|
+
refs = header_value(mail, "references").to_s
|
|
266
|
+
if (m = refs.match(/<([^>]+)>/))
|
|
267
|
+
return m[1]
|
|
268
|
+
end
|
|
269
|
+
irt = header_value(mail, "in-reply-to").to_s
|
|
270
|
+
if (m = irt.match(/<([^>]+)>/))
|
|
271
|
+
return m[1]
|
|
272
|
+
end
|
|
273
|
+
mid = mail.message_id.to_s
|
|
274
|
+
mid = mid.tr("<>", "") if mid && !mid.empty?
|
|
275
|
+
mid.empty? ? nil : mid
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def self.domain_level(domain, level)
|
|
279
|
+
parts = domain.to_s.split(".")
|
|
280
|
+
return nil if parts.empty?
|
|
281
|
+
case level
|
|
282
|
+
when "top-level" then parts[-1]
|
|
283
|
+
when "second-level" then parts[-2]
|
|
284
|
+
when "third-level" then parts[-3]
|
|
285
|
+
when "final-level" then parts[0]
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Mailmate
|
|
8
|
+
module CLI
|
|
9
|
+
# `mmdiscover` — bootstrap ~/.config/mailmate/{config.yml,bundle_loader.rb}
|
|
10
|
+
# from MailMate's own Sources.plist and Identities.plist.
|
|
11
|
+
#
|
|
12
|
+
# First-run UX: a user installs the gem, runs `mmdiscover`, sees their
|
|
13
|
+
# accounts and identity addresses laid out, confirms, and the gem now
|
|
14
|
+
# knows who they are without any hand-editing of YAML or `plutil`
|
|
15
|
+
# archaeology.
|
|
16
|
+
# @api private
|
|
17
|
+
module Discover
|
|
18
|
+
extend self
|
|
19
|
+
|
|
20
|
+
CONFIG_DIR = File.expand_path("~/.config/mailmate")
|
|
21
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
|
22
|
+
LOADER_FILE = File.join(CONFIG_DIR, "bundle_loader.rb")
|
|
23
|
+
|
|
24
|
+
def run(argv)
|
|
25
|
+
opts = parse_options(argv)
|
|
26
|
+
app_support_dir = opts[:app_support_dir] || File.expand_path("~/Library/Application Support/MailMate")
|
|
27
|
+
|
|
28
|
+
unless File.directory?(app_support_dir)
|
|
29
|
+
warn "mmdiscover: MailMate app-support directory not found at #{app_support_dir}"
|
|
30
|
+
warn " Pass --app-support-dir if your install is elsewhere, or check that MailMate is installed."
|
|
31
|
+
return 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sources_plist = File.join(app_support_dir, "Sources.plist")
|
|
35
|
+
identities_plist = File.join(app_support_dir, "Identities.plist")
|
|
36
|
+
|
|
37
|
+
accounts = read_accounts(sources_plist)
|
|
38
|
+
identities = read_identities(identities_plist)
|
|
39
|
+
|
|
40
|
+
$stdout.puts "Found #{accounts.size} account#{"s" if accounts.size != 1} in Sources.plist:"
|
|
41
|
+
accounts.each { |a| $stdout.puts " #{a[:name].ljust(35)}(#{a[:host]})" }
|
|
42
|
+
$stdout.puts
|
|
43
|
+
$stdout.puts "Found #{identities.size} identity address#{"es" if identities.size != 1} in Identities.plist:"
|
|
44
|
+
identities.each { |addr| $stdout.puts " #{addr}" }
|
|
45
|
+
$stdout.puts
|
|
46
|
+
|
|
47
|
+
# Compare against existing config, if any, and surface differences.
|
|
48
|
+
# Without --force we won't silently overwrite manual additions
|
|
49
|
+
# (e.g. aliases that aren't outgoing identities in MailMate).
|
|
50
|
+
existing_identities = read_existing_identities(CONFIG_FILE)
|
|
51
|
+
if existing_identities && !opts[:force] && !opts[:dry_run]
|
|
52
|
+
only_in_existing = existing_identities - identities
|
|
53
|
+
only_in_new = identities - existing_identities
|
|
54
|
+
|
|
55
|
+
if !only_in_existing.empty? || !only_in_new.empty?
|
|
56
|
+
$stdout.puts "Existing #{CONFIG_FILE} has a different identity set:"
|
|
57
|
+
only_in_existing.each { |a| $stdout.puts " - #{a} (in current config, NOT in Identities.plist)" }
|
|
58
|
+
only_in_new.each { |a| $stdout.puts " + #{a} (in Identities.plist, NOT in current config)" }
|
|
59
|
+
$stdout.puts
|
|
60
|
+
$stdout.puts "Re-run with --force to overwrite, or hand-merge if you have manual additions to preserve."
|
|
61
|
+
return 0
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if opts[:dry_run]
|
|
66
|
+
$stdout.puts "(--dry-run) Would write #{CONFIG_FILE} and #{LOADER_FILE}; skipping."
|
|
67
|
+
return 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unless opts[:yes]
|
|
71
|
+
$stdout.print "Write these to #{CONFIG_FILE}? [y/N] "
|
|
72
|
+
response = $stdin.gets&.strip&.downcase
|
|
73
|
+
unless %w[y yes].include?(response)
|
|
74
|
+
$stdout.puts "Aborted."
|
|
75
|
+
return 0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
80
|
+
write_config(identities, app_support_dir, opts[:app_support_dir])
|
|
81
|
+
write_bundle_loader
|
|
82
|
+
|
|
83
|
+
$stdout.puts "Wrote #{CONFIG_FILE}"
|
|
84
|
+
$stdout.puts "Wrote #{LOADER_FILE}"
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_options(argv)
|
|
89
|
+
opts = { dry_run: false, yes: false, force: false, app_support_dir: nil }
|
|
90
|
+
OptionParser.new do |o|
|
|
91
|
+
o.banner = "Usage: mmdiscover [--app-support-dir PATH] [--dry-run] [--yes] [--force]"
|
|
92
|
+
o.on("--app-support-dir PATH", "Override the MailMate app-support location") { |p| opts[:app_support_dir] = File.expand_path(p) }
|
|
93
|
+
o.on("--dry-run", "Print what would be written; don't write") { opts[:dry_run] = true }
|
|
94
|
+
o.on("-y", "--yes", "Skip the y/N prompt") { opts[:yes] = true }
|
|
95
|
+
o.on("--force", "Overwrite existing config even if it has identities not in Identities.plist") { opts[:force] = true }
|
|
96
|
+
end.parse!(argv)
|
|
97
|
+
opts
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Read the existing config.yml's `identities:` list, if the file exists.
|
|
101
|
+
# Returns Array<String> (possibly empty) or nil if the file is missing.
|
|
102
|
+
def read_existing_identities(path)
|
|
103
|
+
return nil unless File.exist?(path)
|
|
104
|
+
require "yaml"
|
|
105
|
+
data = YAML.safe_load_file(path) rescue nil
|
|
106
|
+
return [] unless data.is_a?(Hash)
|
|
107
|
+
Array(data["identities"]).map { |a| a.to_s.downcase.strip }.reject(&:empty?)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Read Sources.plist via plutil. Returns Array<{name:, host:}>.
|
|
111
|
+
def read_accounts(path)
|
|
112
|
+
return [] unless File.exist?(path)
|
|
113
|
+
json = `plutil -convert json -o - #{shellesc(path)}`
|
|
114
|
+
data = JSON.parse(json) rescue {}
|
|
115
|
+
sources = data["sources"] || data["Sources"] || []
|
|
116
|
+
sources.map do |s|
|
|
117
|
+
name = s["name"] || s["Name"] || "(unnamed)"
|
|
118
|
+
server_url = s["serverURL"] || s["ServerURL"] || ""
|
|
119
|
+
host = server_url.split("@").last || "(no host)"
|
|
120
|
+
{ name: name, host: host }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Read Identities.plist via plutil. Returns Array<String> of email
|
|
125
|
+
# addresses, lowercased and deduplicated. Each identity entry has an
|
|
126
|
+
# `emailAddresses` field that may be a single string or a newline-
|
|
127
|
+
# delimited list.
|
|
128
|
+
def read_identities(path)
|
|
129
|
+
return [] unless File.exist?(path)
|
|
130
|
+
json = `plutil -convert json -o - #{shellesc(path)}`
|
|
131
|
+
data = JSON.parse(json) rescue {}
|
|
132
|
+
ids = data["identities"] || data["Identities"] || []
|
|
133
|
+
|
|
134
|
+
all = ids.flat_map do |i|
|
|
135
|
+
raw = i["emailAddresses"] || i["EmailAddresses"] || i["emailAddress"] || ""
|
|
136
|
+
raw.to_s.split(/[\s,]+/)
|
|
137
|
+
end
|
|
138
|
+
all.map { |a| a.strip.downcase }.reject(&:empty?).uniq
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def write_config(identities, _app_support_dir, app_support_override)
|
|
142
|
+
File.open(CONFIG_FILE, "w") do |f|
|
|
143
|
+
f.puts "# Mailmate gem configuration — written by mmdiscover on #{Time.now.strftime("%Y-%m-%d %H:%M")}"
|
|
144
|
+
f.puts
|
|
145
|
+
if app_support_override
|
|
146
|
+
f.puts "app_support_dir: #{app_support_override}"
|
|
147
|
+
f.puts
|
|
148
|
+
end
|
|
149
|
+
f.puts "identities:"
|
|
150
|
+
identities.each { |addr| f.puts " - #{addr}" }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def write_bundle_loader
|
|
155
|
+
gem_lib = File.expand_path("../..", __dir__) # → ~/code/claude/mailmate/lib
|
|
156
|
+
File.open(LOADER_FILE, "w") do |f|
|
|
157
|
+
f.puts "# Mailmate gem bundle-loader — written by mmdiscover."
|
|
158
|
+
f.puts "# MailMate bundle handlers `load` this file then `require \"mailmate\"`."
|
|
159
|
+
f.puts "# Re-run mmdiscover to refresh if the gem's location changes."
|
|
160
|
+
f.puts
|
|
161
|
+
f.puts "$LOAD_PATH.unshift #{gem_lib.inspect} unless $LOAD_PATH.include?(#{gem_lib.inspect})"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def shellesc(s)
|
|
166
|
+
"'#{s.gsub("'", "'\\\\''")}'"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "mail"
|
|
5
|
+
|
|
6
|
+
module Mailmate
|
|
7
|
+
module CLI
|
|
8
|
+
# `mmmessage` — print a decoded MailMate message by its eml-id.
|
|
9
|
+
# Ports the standalone mailmate-message script. Headers + plain-text body
|
|
10
|
+
# by default; `--raw` for the original .eml bytes; `--text-only` for just
|
|
11
|
+
# the body.
|
|
12
|
+
# @api private
|
|
13
|
+
module Message
|
|
14
|
+
extend self
|
|
15
|
+
|
|
16
|
+
def run(argv)
|
|
17
|
+
opts = parse_options(argv)
|
|
18
|
+
input = argv.first
|
|
19
|
+
|
|
20
|
+
return usage_error("missing <id>") if input.nil? || input.empty?
|
|
21
|
+
|
|
22
|
+
# Accept either eml-id (all digits) or RFC Message-ID (with or without
|
|
23
|
+
# angle brackets). Lets you copy/paste from any machine without caring
|
|
24
|
+
# whether the local eml-id matches.
|
|
25
|
+
eml_id = Mailmate::EmlLookup.resolve_id(input)
|
|
26
|
+
if eml_id.nil? || eml_id.zero?
|
|
27
|
+
warn "Not found: #{input.inspect} (couldn't resolve as eml-id or Message-ID)"
|
|
28
|
+
return 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
path = Mailmate::EmlLookup.path_for(eml_id)
|
|
32
|
+
unless path
|
|
33
|
+
warn "Not found: #{eml_id}.eml"
|
|
34
|
+
return 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if opts[:raw]
|
|
38
|
+
$stdout.binmode
|
|
39
|
+
$stdout.write(File.binread(path))
|
|
40
|
+
return 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
mail = Mail.read(path)
|
|
44
|
+
print_headers(mail, eml_id, path) unless opts[:text_only]
|
|
45
|
+
$stdout.puts text_body(mail)
|
|
46
|
+
0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_options(argv)
|
|
50
|
+
opts = { raw: false, text_only: false }
|
|
51
|
+
OptionParser.new do |o|
|
|
52
|
+
o.banner = "Usage: mmmessage <id> [--raw|--text-only]"
|
|
53
|
+
o.separator ""
|
|
54
|
+
o.separator "<id> can be either a local eml-id (e.g. 183715) or an RFC"
|
|
55
|
+
o.separator "Message-ID (with or without angle brackets, e.g."
|
|
56
|
+
o.separator "<abc@example.com>). Message-IDs are portable across machines"
|
|
57
|
+
o.separator "and survive copy/paste between desktop/laptop/iPad."
|
|
58
|
+
o.on("--raw", "Output raw .eml bytes") { opts[:raw] = true }
|
|
59
|
+
o.on("--text-only", "Output decoded body only (no headers block)") { opts[:text_only] = true }
|
|
60
|
+
end.parse!(argv)
|
|
61
|
+
opts
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def usage_error(msg)
|
|
65
|
+
warn "mmmessage: #{msg}"
|
|
66
|
+
warn "Usage: mmmessage <id> [--raw|--text-only]"
|
|
67
|
+
warn " <id> is either an eml-id (digits) or an RFC Message-ID."
|
|
68
|
+
2
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def print_headers(mail, eml_id, path)
|
|
72
|
+
imap_root = Mailmate.config.imap_root
|
|
73
|
+
mailbox = path.sub("#{imap_root}/", "").sub(%r{/Messages/[^/]+\.eml\z}, "")
|
|
74
|
+
$stdout.puts "eml-id: #{eml_id}"
|
|
75
|
+
$stdout.puts "path: #{path}"
|
|
76
|
+
$stdout.puts "mailbox: #{mailbox}"
|
|
77
|
+
$stdout.puts "from: #{Array(mail.from).join(", ")}" if mail.from
|
|
78
|
+
$stdout.puts "to: #{Array(mail.to).join(", ")}" if mail.to
|
|
79
|
+
$stdout.puts "cc: #{Array(mail.cc).join(", ")}" if mail.cc
|
|
80
|
+
$stdout.puts "subject: #{mail.subject}"
|
|
81
|
+
$stdout.puts "date: #{Mailmate.localize(mail.date)&.iso8601}"
|
|
82
|
+
$stdout.puts "message-id: #{mail.message_id}"
|
|
83
|
+
if mail.attachments.any?
|
|
84
|
+
$stdout.puts "attachments:"
|
|
85
|
+
mail.attachments.each do |a|
|
|
86
|
+
sz = begin
|
|
87
|
+
a.body.decoded.bytesize
|
|
88
|
+
rescue StandardError
|
|
89
|
+
0
|
|
90
|
+
end
|
|
91
|
+
$stdout.puts " - #{a.filename || "(no name)"} #{a.mime_type} #{sz}b"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
$stdout.puts
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def text_body(mail)
|
|
98
|
+
if mail.text_part
|
|
99
|
+
mail.text_part.decoded.force_encoding("UTF-8").scrub
|
|
100
|
+
elsif mail.html_part
|
|
101
|
+
"[no text/plain part — HTML rendered below; use --raw for original]\n\n" +
|
|
102
|
+
mail.html_part.decoded.force_encoding("UTF-8").scrub
|
|
103
|
+
else
|
|
104
|
+
mail.body.decoded.to_s.force_encoding("UTF-8").scrub
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|