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,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