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,609 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mail"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "date"
|
|
6
|
+
require "csv"
|
|
7
|
+
|
|
8
|
+
module Mailmate
|
|
9
|
+
module CLI
|
|
10
|
+
# `mmsearch` — search MailMate's `.eml` files using a subset of MailMate's
|
|
11
|
+
# quicksearch syntax. Output is CSV with optional column-aligned padding.
|
|
12
|
+
#
|
|
13
|
+
# Ported from the standalone mailmate-search script. See `~/.claude/skills/email/SKILL.md`
|
|
14
|
+
# for usage examples and the search-string syntax reference.
|
|
15
|
+
# @api private
|
|
16
|
+
module Search
|
|
17
|
+
extend self
|
|
18
|
+
|
|
19
|
+
MODIFIERS = {
|
|
20
|
+
"f" => :from, "t" => :recipients, "c" => :cc, "s" => :subject,
|
|
21
|
+
"a" => :address_any, "b" => :body, "m" => :message_or_body,
|
|
22
|
+
"d" => :date, "T" => :tag, "K" => :keyword
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
HEADER_FIELDS = %i[from recipients cc subject address_any any].freeze
|
|
26
|
+
|
|
27
|
+
VALID_FIELDS = %w[id path mailbox from to cc bcc reply-to subject date time
|
|
28
|
+
message-id direction party flags read archive].freeze
|
|
29
|
+
|
|
30
|
+
HEADER_LABELS = {
|
|
31
|
+
"direction" => "dir",
|
|
32
|
+
"read" => "r",
|
|
33
|
+
"archive" => "a",
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
FIELD_TIERS = {
|
|
37
|
+
"id" => :index, "path" => :index, "mailbox" => :index,
|
|
38
|
+
"date" => :index, "time" => :index,
|
|
39
|
+
"read" => :index,
|
|
40
|
+
"archive" => :index,
|
|
41
|
+
"flags" => :index,
|
|
42
|
+
"from" => :header, "to" => :header, "cc" => :header, "bcc" => :header,
|
|
43
|
+
"reply-to" => :header, "subject" => :header, "message-id" => :header,
|
|
44
|
+
"direction" => :header, "party" => :header,
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
DEFAULT_SEARCH = "d 1d"
|
|
48
|
+
DEFAULT_FIELDS = "flags date time direction party subject"
|
|
49
|
+
|
|
50
|
+
def run(argv)
|
|
51
|
+
opts = {
|
|
52
|
+
mailbox: "all", limit: nil, headers_only: false,
|
|
53
|
+
header: true, align: true, sort: :asc,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
parser = build_parser(opts)
|
|
57
|
+
parser.parse!(argv)
|
|
58
|
+
|
|
59
|
+
search_string = argv[0] || DEFAULT_SEARCH
|
|
60
|
+
fields_arg = (opts[:fields] || argv[1] || DEFAULT_FIELDS).to_s
|
|
61
|
+
extra_fields = fields_arg.strip.split(/\s+/).reject(&:empty?)
|
|
62
|
+
fields = (["id"] + extra_fields).uniq
|
|
63
|
+
|
|
64
|
+
imap_root = Mailmate.config.imap_root
|
|
65
|
+
unless File.directory?(imap_root)
|
|
66
|
+
warn "MailMate IMAP root not found: #{imap_root}"
|
|
67
|
+
return 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unknown = fields - VALID_FIELDS
|
|
71
|
+
unless unknown.empty?
|
|
72
|
+
warn "Unknown field(s): #{unknown.join(", ")}"
|
|
73
|
+
warn "Valid: #{VALID_FIELDS.join(", ")}"
|
|
74
|
+
return 2
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
dirs, smart_filters, smart_graph = resolve_mailbox_with_graph(opts[:mailbox])
|
|
78
|
+
if dirs.empty?
|
|
79
|
+
warn "No mailbox directories resolved."
|
|
80
|
+
return 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
specs = parse_search(search_string)
|
|
84
|
+
|
|
85
|
+
# Compose + parse the smart-mailbox filter exactly once. The same AST
|
|
86
|
+
# feeds the evaluator, the tier classifier, and the literals extractor.
|
|
87
|
+
composed_ast = nil
|
|
88
|
+
composed_str = nil
|
|
89
|
+
smart_evaluator =
|
|
90
|
+
if smart_filters.any?
|
|
91
|
+
composed_str = compose_smart_filters(smart_filters)
|
|
92
|
+
begin
|
|
93
|
+
composed_ast = Mailmate.compile_filter(composed_str)
|
|
94
|
+
var_resolver = smart_graph ? Mailmate::VarResolver.new(smart_graph) : nil
|
|
95
|
+
Mailmate::Evaluator.new(composed_ast, var_resolver: var_resolver)
|
|
96
|
+
rescue Mailmate::Lexer::Error, Mailmate::Parser::Error => e
|
|
97
|
+
warn "Smart-mailbox filter parse error: #{e.message}\n filter: #{composed_str}"
|
|
98
|
+
return 1
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
filter_tier = composed_ast ? Mailmate::FilterClassifier.tier(composed_ast) : :index
|
|
103
|
+
specs_tier =
|
|
104
|
+
if specs.empty?
|
|
105
|
+
:index
|
|
106
|
+
elsif specs.any? { |field, _, _| (field == :body || field == :message_or_body) && !opts[:headers_only] }
|
|
107
|
+
:full
|
|
108
|
+
elsif specs.all? { |field, _, _| field == :date }
|
|
109
|
+
:index
|
|
110
|
+
else
|
|
111
|
+
:header
|
|
112
|
+
end
|
|
113
|
+
fields_tier_ = fields_tier(fields)
|
|
114
|
+
filter_only_tier = Mailmate::FilterClassifier.combine_tiers(filter_tier, specs_tier)
|
|
115
|
+
load_tier = Mailmate::FilterClassifier.combine_tiers(filter_only_tier, fields_tier_)
|
|
116
|
+
|
|
117
|
+
smart_literals = composed_ast ? Mailmate::FilterClassifier.header_literals(composed_ast) : []
|
|
118
|
+
|
|
119
|
+
rows = collect_rows(
|
|
120
|
+
dirs: dirs, specs: specs, fields: fields,
|
|
121
|
+
smart_evaluator: smart_evaluator, smart_literals: smart_literals,
|
|
122
|
+
filter_only_tier: filter_only_tier, load_tier: load_tier,
|
|
123
|
+
opts: opts,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
sort_rows!(rows, opts[:sort])
|
|
127
|
+
emit_output(rows, fields, opts)
|
|
128
|
+
0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ---- sort ---------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# Sorts `rows` in place by the message's absolute send instant (UTC), so
|
|
134
|
+
# senders in different timezones still order correctly. The first column
|
|
135
|
+
# is always `id` (forced in `run`), which lets us hit the `#date` index
|
|
136
|
+
# without re-reading any .eml.
|
|
137
|
+
def sort_rows!(rows, mode)
|
|
138
|
+
return rows if mode == :none || rows.size < 2
|
|
139
|
+
reader = Mailmate::IndexReader.for("#date") rescue nil
|
|
140
|
+
epoch = Time.at(0)
|
|
141
|
+
rows.sort_by! do |r|
|
|
142
|
+
s = reader && (reader.value_for(r[0].to_i) rescue nil)
|
|
143
|
+
(s && !s.empty? && (Time.parse(s) rescue nil)) || epoch
|
|
144
|
+
end
|
|
145
|
+
rows.reverse! if mode == :desc
|
|
146
|
+
rows
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ---- option parsing -----------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def build_parser(opts)
|
|
152
|
+
OptionParser.new do |o|
|
|
153
|
+
o.banner = "Usage: mmsearch [search-string] [fields] [options]"
|
|
154
|
+
o.separator ""
|
|
155
|
+
o.separator "Search MailMate's `.eml` files. Output is CSV with column-aligned padding."
|
|
156
|
+
o.separator ""
|
|
157
|
+
o.separator "POSITIONAL ARGS"
|
|
158
|
+
o.separator " search-string Quicksearch expression. Default: 'd 1d'. Pass '' to disable."
|
|
159
|
+
o.separator " fields Space-separated columns to add (id is always first)."
|
|
160
|
+
o.separator " Default: 'flags date time direction party subject'."
|
|
161
|
+
o.separator ""
|
|
162
|
+
o.separator "OPTIONS"
|
|
163
|
+
o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v }
|
|
164
|
+
o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v }
|
|
165
|
+
o.on("--limit N", Integer, "Stop after N matches") { |n| opts[:limit] = n }
|
|
166
|
+
o.on("--headers-only", "Skip body matching") { opts[:headers_only] = true }
|
|
167
|
+
o.on("--no-header", "Suppress column header row") { opts[:header] = false }
|
|
168
|
+
o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
|
|
169
|
+
o.on("--sort MODE", %w[asc desc none],
|
|
170
|
+
"Sort rows by date+time: asc (default), desc, none") { |v| opts[:sort] = v.to_sym }
|
|
171
|
+
o.separator ""
|
|
172
|
+
o.separator "SEARCH-STRING SYNTAX"
|
|
173
|
+
o.separator " Mirrors MailMate's toolbar quicksearch. Specs combine with AND."
|
|
174
|
+
o.separator " Wrap multi-word terms in \"double quotes\". Prefix operand with ! to negate."
|
|
175
|
+
o.separator ""
|
|
176
|
+
o.separator " <term> common headers (from/to/cc/subject) OR body contains <term>"
|
|
177
|
+
o.separator " f <term> from contains"
|
|
178
|
+
o.separator " t <term> to/cc (recipients) contains"
|
|
179
|
+
o.separator " c <term> cc contains"
|
|
180
|
+
o.separator " s <term> subject contains"
|
|
181
|
+
o.separator " a <term> any address header contains"
|
|
182
|
+
o.separator " b <term> body contains (slow — disables prefilter)"
|
|
183
|
+
o.separator " m <term> common headers OR body (same as bare term)"
|
|
184
|
+
o.separator " d <date> received date: Nd|Nw|Nm|Ny (relative), or Y, Y-M, Y-M-D"
|
|
185
|
+
o.separator " T <tag> tag / IMAP keyword contains (K is a synonym)"
|
|
186
|
+
o.separator ""
|
|
187
|
+
o.separator " Examples:"
|
|
188
|
+
o.separator " mmsearch 'f medium d 7d' from Medium in last 7 days"
|
|
189
|
+
o.separator " mmsearch 's \"rent due\" !draft' subject has rent due, no 'draft'"
|
|
190
|
+
o.separator " mmsearch 'd 2026-05' received in May 2026"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ---- mailbox resolution -------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def all_message_dirs
|
|
197
|
+
Dir.glob("#{Mailmate.config.imap_root}/*/**/Messages").select { |p| File.directory?(p) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def resolve_account(name)
|
|
201
|
+
root = Mailmate.config.imap_root
|
|
202
|
+
return name if File.directory?("#{root}/#{name}")
|
|
203
|
+
encoded = name.gsub("@", "%40")
|
|
204
|
+
candidates = Dir.glob("#{root}/#{encoded}@*").map { |p| File.basename(p) }
|
|
205
|
+
case candidates.size
|
|
206
|
+
when 0 then nil
|
|
207
|
+
when 1 then candidates.first
|
|
208
|
+
else
|
|
209
|
+
warn "Ambiguous account '#{name}': #{candidates.join(", ")}"
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def resolve_mailbox(arg)
|
|
215
|
+
root = Mailmate.config.imap_root
|
|
216
|
+
return [all_message_dirs, []] if arg == "all"
|
|
217
|
+
|
|
218
|
+
if arg.include?("/")
|
|
219
|
+
account, rest = arg.split("/", 2)
|
|
220
|
+
if (encoded = resolve_account(account))
|
|
221
|
+
nested = rest.split("/").map { |s| "#{s}.mailbox" }.join("/")
|
|
222
|
+
cand = "#{root}/#{encoded}/#{nested}/Messages"
|
|
223
|
+
return [[cand], []] if File.directory?(cand)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if (encoded = resolve_account(arg))
|
|
228
|
+
dirs = Dir.glob("#{root}/#{encoded}/**/Messages").select { |p| File.directory?(p) }
|
|
229
|
+
return [dirs, []]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
matches = Dir.glob("#{root}/*/**/#{arg}.mailbox/Messages").select { |p| File.directory?(p) }
|
|
233
|
+
return [matches, []] unless matches.empty?
|
|
234
|
+
|
|
235
|
+
# Fall back: try MailMate's smart-mailbox graph.
|
|
236
|
+
graph = Mailmate::MailboxGraph.load
|
|
237
|
+
if (uuid = graph.by_name[arg]) || graph.by_uuid[arg]
|
|
238
|
+
uuid ||= arg
|
|
239
|
+
res = Mailmate::SourceResolver.new(graph).resolve(uuid)
|
|
240
|
+
return [res[:dirs], res[:filters], graph]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
warn "Mailbox not resolved: '#{arg}'."
|
|
244
|
+
[[], []]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def resolve_mailbox_with_graph(arg)
|
|
248
|
+
result = resolve_mailbox(arg)
|
|
249
|
+
result.size == 2 ? [*result, nil] : result
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def compose_smart_filters(filters)
|
|
253
|
+
return "" if filters.empty?
|
|
254
|
+
return filters.first if filters.size == 1
|
|
255
|
+
"(#{filters.map { |f| "(#{f})" }.join(" and ")})"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ---- search-string parsing ----------------------------------------------
|
|
259
|
+
|
|
260
|
+
def tokenize(str)
|
|
261
|
+
tokens = []
|
|
262
|
+
i = 0
|
|
263
|
+
while i < str.length
|
|
264
|
+
c = str[i]
|
|
265
|
+
if c == " " || c == "\t"
|
|
266
|
+
i += 1
|
|
267
|
+
elsif c == "\""
|
|
268
|
+
j = str.index("\"", i + 1) || str.length
|
|
269
|
+
tokens << str[(i + 1)...j]
|
|
270
|
+
i = j + 1
|
|
271
|
+
else
|
|
272
|
+
j = i
|
|
273
|
+
j += 1 while j < str.length && str[j] != " "
|
|
274
|
+
tokens << str[i...j]
|
|
275
|
+
i = j
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
tokens
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def parse_search(str)
|
|
282
|
+
tokens = tokenize(str)
|
|
283
|
+
specs = []
|
|
284
|
+
i = 0
|
|
285
|
+
while i < tokens.size
|
|
286
|
+
tok = tokens[i]
|
|
287
|
+
field = MODIFIERS[tok]
|
|
288
|
+
if field && i + 1 < tokens.size
|
|
289
|
+
operand = tokens[i + 1]
|
|
290
|
+
negate = operand.start_with?("!")
|
|
291
|
+
operand = operand[1..] if negate
|
|
292
|
+
specs << [field, operand.downcase, negate]
|
|
293
|
+
i += 2
|
|
294
|
+
else
|
|
295
|
+
negate = tok.start_with?("!")
|
|
296
|
+
operand = negate ? tok[1..] : tok
|
|
297
|
+
# Bare terms default to MailMate's "Common" specifier — common
|
|
298
|
+
# headers OR body — matching the UI quicksearch behavior. Pass
|
|
299
|
+
# --headers-only to skip the body scan when speed matters.
|
|
300
|
+
specs << [:message_or_body, operand.downcase, negate]
|
|
301
|
+
i += 1
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
specs
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# ---- date matching ------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def date_matches?(mail, eml_id, term)
|
|
310
|
+
d = nil
|
|
311
|
+
if eml_id
|
|
312
|
+
s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
|
|
313
|
+
if s && !s.empty?
|
|
314
|
+
d = (Time.parse(s) rescue nil)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
if d.nil? && mail
|
|
318
|
+
raw = mail.date
|
|
319
|
+
d = raw.respond_to?(:to_time) ? raw.to_time : raw
|
|
320
|
+
end
|
|
321
|
+
return false unless d
|
|
322
|
+
|
|
323
|
+
if term =~ /\A(\d+)([dwmy])\z/
|
|
324
|
+
n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
|
|
325
|
+
cutoff = case u
|
|
326
|
+
when "d" then Date.today - n
|
|
327
|
+
when "w" then Date.today - (n * 7)
|
|
328
|
+
when "m" then Date.today << n
|
|
329
|
+
when "y" then Date.today << (n * 12)
|
|
330
|
+
end
|
|
331
|
+
return d.to_date >= cutoff
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
norm = term.tr("/.", "-")
|
|
335
|
+
parts = norm.split("-")
|
|
336
|
+
case parts.size
|
|
337
|
+
when 1 then d.year.to_s == parts[0]
|
|
338
|
+
when 2 then d.year.to_s == parts[0] && d.month == parts[1].to_i
|
|
339
|
+
when 3 then d.to_date == Date.new(parts[0].to_i, parts[1].to_i, parts[2].to_i)
|
|
340
|
+
else false
|
|
341
|
+
end
|
|
342
|
+
rescue StandardError
|
|
343
|
+
false
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# ---- field-value matching -----------------------------------------------
|
|
347
|
+
|
|
348
|
+
def field_value(mail, field)
|
|
349
|
+
case field
|
|
350
|
+
when :from
|
|
351
|
+
[Array(mail.from), mail[:from]&.value.to_s].flatten.join(" ").downcase
|
|
352
|
+
when :recipients
|
|
353
|
+
[Array(mail.to), Array(mail.cc), mail[:to]&.value.to_s, mail[:cc]&.value.to_s]
|
|
354
|
+
.flatten.join(" ").downcase
|
|
355
|
+
when :cc
|
|
356
|
+
[Array(mail.cc), mail[:cc]&.value.to_s].flatten.join(" ").downcase
|
|
357
|
+
when :subject
|
|
358
|
+
mail.subject.to_s.downcase
|
|
359
|
+
when :address_any
|
|
360
|
+
[mail[:from], mail[:to], mail[:cc], mail[:reply_to], mail[:sender]]
|
|
361
|
+
.compact.map { |h| h.value.to_s }.join(" ").downcase
|
|
362
|
+
when :tag, :keyword
|
|
363
|
+
[mail["x-keywords"]&.value, mail["keywords"]&.value].compact.join(" ").downcase
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def text_body(mail)
|
|
368
|
+
(mail.text_part&.decoded || mail.body.decoded).to_s.force_encoding("UTF-8").scrub.downcase
|
|
369
|
+
rescue StandardError
|
|
370
|
+
""
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def matches?(mail, eml_id, specs, headers_only)
|
|
374
|
+
specs.all? do |field, term, negate|
|
|
375
|
+
hit =
|
|
376
|
+
case field
|
|
377
|
+
when :from, :recipients, :cc, :subject, :address_any, :tag, :keyword
|
|
378
|
+
field_value(mail, field).include?(term)
|
|
379
|
+
when :body
|
|
380
|
+
headers_only ? false : text_body(mail).include?(term)
|
|
381
|
+
when :message_or_body
|
|
382
|
+
common = %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
|
|
383
|
+
common || (!headers_only && text_body(mail).include?(term))
|
|
384
|
+
when :date
|
|
385
|
+
date_matches?(mail, eml_id, term)
|
|
386
|
+
when :any
|
|
387
|
+
%i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
|
|
388
|
+
end
|
|
389
|
+
negate ? !hit : hit
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# ---- pre-filter ---------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def can_prefilter?(specs)
|
|
396
|
+
specs.any? do |field, term, negate|
|
|
397
|
+
!negate && HEADER_FIELDS.include?(field) && term.bytesize >= 3 && term.ascii_only?
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def header_block(path)
|
|
402
|
+
bytes = +""
|
|
403
|
+
File.open(path, "rb") do |f|
|
|
404
|
+
while (chunk = f.read(4096))
|
|
405
|
+
bytes << chunk
|
|
406
|
+
idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
|
|
407
|
+
return bytes[0..idx].downcase if idx
|
|
408
|
+
break if bytes.bytesize > 65_536
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
bytes.downcase
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def prefilter_pass?(path, specs, smart_literals = [])
|
|
415
|
+
return true if !can_prefilter?(specs) && smart_literals.empty?
|
|
416
|
+
hdr = header_block(path)
|
|
417
|
+
specs.each do |field, term, negate|
|
|
418
|
+
next if negate
|
|
419
|
+
next unless HEADER_FIELDS.include?(field)
|
|
420
|
+
next unless term.bytesize >= 3 && term.ascii_only?
|
|
421
|
+
return false unless hdr.include?(term)
|
|
422
|
+
end
|
|
423
|
+
smart_literals.each do |lit|
|
|
424
|
+
return false unless hdr.include?(lit)
|
|
425
|
+
end
|
|
426
|
+
true
|
|
427
|
+
rescue StandardError
|
|
428
|
+
true
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# ---- timestamp ----------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
# Absolute send time for an eml_id, preferring the MailMate `#date` index
|
|
434
|
+
# (cheap, no .eml read). Falls back to the parsed mail's Date header.
|
|
435
|
+
def message_time(eml_id, mail)
|
|
436
|
+
s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
|
|
437
|
+
if s && !s.empty?
|
|
438
|
+
t = (Time.parse(s) rescue nil)
|
|
439
|
+
return t if t
|
|
440
|
+
end
|
|
441
|
+
raw = mail&.date
|
|
442
|
+
return nil unless raw
|
|
443
|
+
raw.respond_to?(:to_time) ? raw.to_time : raw
|
|
444
|
+
rescue StandardError
|
|
445
|
+
nil
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# ---- field extraction ---------------------------------------------------
|
|
449
|
+
|
|
450
|
+
def outbound?(path, mail)
|
|
451
|
+
return true if path.include?("/Sent Mail.mailbox/") ||
|
|
452
|
+
path.include?("/Sent Messages.mailbox/") ||
|
|
453
|
+
path.include?("/Drafts.mailbox/")
|
|
454
|
+
from = Array(mail.from).first.to_s.downcase
|
|
455
|
+
Mailmate::Identity.mine?(from)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def party_for(mail, outbound)
|
|
459
|
+
if outbound
|
|
460
|
+
others = Mailmate::Identity.reject_mine(Array(mail.to) + Array(mail.cc))
|
|
461
|
+
others = Array(mail.to) if others.empty?
|
|
462
|
+
others.join("; ")
|
|
463
|
+
else
|
|
464
|
+
Array(mail.from).join("; ")
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def extract(field, eml_id, path, mail)
|
|
469
|
+
case field
|
|
470
|
+
when "id" then eml_id
|
|
471
|
+
when "path" then path
|
|
472
|
+
when "mailbox" then path.sub("#{Mailmate.config.imap_root}/", "").sub(%r{/Messages/[^/]+\.eml\z}, "")
|
|
473
|
+
when "date"
|
|
474
|
+
t = message_time(eml_id, mail)
|
|
475
|
+
Mailmate.localize(t)&.strftime("%Y-%m-%d")
|
|
476
|
+
when "time"
|
|
477
|
+
t = message_time(eml_id, mail)
|
|
478
|
+
Mailmate.localize(t)&.strftime("%H:%M")
|
|
479
|
+
when "read"
|
|
480
|
+
flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
|
|
481
|
+
flags.include?("\\Seen") ? "R" : "U"
|
|
482
|
+
when "archive"
|
|
483
|
+
path.include?("/Archive.mailbox/") ? "A" : "P"
|
|
484
|
+
when "flags"
|
|
485
|
+
archive = path.include?("/Archive.mailbox/") ? "A" : "P"
|
|
486
|
+
seen = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).include?("\\Seen")
|
|
487
|
+
"#{archive}#{seen ? 'R' : 'U'}"
|
|
488
|
+
when "from" then mail ? Array(mail.from).join("; ") : nil
|
|
489
|
+
when "to" then mail ? Array(mail.to).join("; ") : nil
|
|
490
|
+
when "cc" then mail ? Array(mail.cc).join("; ") : nil
|
|
491
|
+
when "bcc" then mail ? Array(mail.bcc).join("; ") : nil
|
|
492
|
+
when "reply-to" then mail ? Array(mail.reply_to).join("; ") : nil
|
|
493
|
+
when "subject" then mail&.subject.to_s
|
|
494
|
+
when "message-id" then mail&.message_id.to_s
|
|
495
|
+
when "direction" then mail ? (outbound?(path, mail) ? "→" : "←") : nil
|
|
496
|
+
when "party" then mail ? party_for(mail, outbound?(path, mail)) : nil
|
|
497
|
+
end.to_s
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def fields_tier(fields)
|
|
501
|
+
ts = fields.map { |f| FIELD_TIERS[f] || :header }.uniq
|
|
502
|
+
return :full if ts.include?(:full)
|
|
503
|
+
return :header if ts.include?(:header)
|
|
504
|
+
:index
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# ---- driver loop --------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
def load_message(path, tier)
|
|
510
|
+
case tier
|
|
511
|
+
when :index then nil
|
|
512
|
+
when :header
|
|
513
|
+
bytes = +""
|
|
514
|
+
File.open(path, "rb") do |f|
|
|
515
|
+
while (chunk = f.read(4096))
|
|
516
|
+
bytes << chunk
|
|
517
|
+
idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
|
|
518
|
+
break if idx
|
|
519
|
+
break if bytes.bytesize > 65_536
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
Mail.new(bytes)
|
|
523
|
+
when :full
|
|
524
|
+
Mail.read(path)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
|
|
529
|
+
rows = []
|
|
530
|
+
catch(:done) do
|
|
531
|
+
dirs.each do |dir|
|
|
532
|
+
Dir.each_child(dir) do |fname|
|
|
533
|
+
next unless fname.end_with?(".eml")
|
|
534
|
+
eml_id = fname.sub(".eml", "")
|
|
535
|
+
path = "#{dir}/#{fname}"
|
|
536
|
+
|
|
537
|
+
next unless prefilter_pass?(path, specs, smart_literals)
|
|
538
|
+
|
|
539
|
+
if filter_only_tier == :index
|
|
540
|
+
if smart_evaluator
|
|
541
|
+
next unless smart_evaluator.matches?(Mailmate::Message.new(nil, eml_id, path))
|
|
542
|
+
end
|
|
543
|
+
if !specs.empty?
|
|
544
|
+
next unless matches?(nil, eml_id, specs, opts[:headers_only])
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
mail = nil
|
|
549
|
+
if load_tier != :index
|
|
550
|
+
begin
|
|
551
|
+
mail = load_message(path, load_tier)
|
|
552
|
+
rescue StandardError => e
|
|
553
|
+
warn "[skip] #{path}: #{e.message}"
|
|
554
|
+
next
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
if filter_only_tier != :index
|
|
559
|
+
if !specs.empty?
|
|
560
|
+
next unless matches?(mail, eml_id, specs, opts[:headers_only])
|
|
561
|
+
end
|
|
562
|
+
if smart_evaluator
|
|
563
|
+
next unless smart_evaluator.matches?(Mailmate::Message.new(mail, eml_id, path))
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
rows << fields.map { |f| extract(f, eml_id, path, mail) }
|
|
568
|
+
throw :done if opts[:limit] && rows.size >= opts[:limit]
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
rows
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# ---- output -------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
def csv_quote(cell)
|
|
578
|
+
cell = cell.to_s.gsub(/[\r\n]+/, " ")
|
|
579
|
+
if cell.include?(",") || cell.include?("\"")
|
|
580
|
+
"\"#{cell.gsub("\"", "\"\"")}\""
|
|
581
|
+
else
|
|
582
|
+
cell
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def emit_output(rows, fields, opts)
|
|
587
|
+
header_row = fields.map { |f| HEADER_LABELS[f] || f }
|
|
588
|
+
|
|
589
|
+
if opts[:align]
|
|
590
|
+
display_rows = rows.map { |r| r.map { |c| csv_quote(c) } }
|
|
591
|
+
display_rows.unshift(header_row) if opts[:header]
|
|
592
|
+
widths = Array.new(fields.size, 0)
|
|
593
|
+
display_rows.each do |r|
|
|
594
|
+
r.each_with_index { |c, i| widths[i] = c.length if c.length > widths[i] }
|
|
595
|
+
end
|
|
596
|
+
display_rows.each do |r|
|
|
597
|
+
padded = r.each_with_index.map do |c, i|
|
|
598
|
+
i == r.size - 1 ? c : c.ljust(widths[i])
|
|
599
|
+
end
|
|
600
|
+
puts padded.join(",")
|
|
601
|
+
end
|
|
602
|
+
else
|
|
603
|
+
puts CSV.generate_line(header_row) if opts[:header]
|
|
604
|
+
rows.each { |r| puts CSV.generate_line(r) }
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailmate
|
|
4
|
+
module CLI
|
|
5
|
+
# `mm-send` — send mail through MailMate's `emate` CLI with a markdown body.
|
|
6
|
+
# Replaces the 6-line bash wrapper that previously lived at
|
|
7
|
+
# ~/.claude/skills/email/send-email. All flags pass through to `emate
|
|
8
|
+
# mailto`; `--markup markdown` is enforced.
|
|
9
|
+
# @api private
|
|
10
|
+
module Send
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
EMATE_PATH = "/Applications/MailMate.app/Contents/Resources/emate"
|
|
14
|
+
|
|
15
|
+
# Returns the exit status of the spawned `emate` invocation. Uses
|
|
16
|
+
# `system` (not `exec`) so the caller — and the test suite — can
|
|
17
|
+
# actually observe the result.
|
|
18
|
+
def run(argv)
|
|
19
|
+
Mailmate::PlatformError.check_darwin!(component: "mm-send")
|
|
20
|
+
unless File.executable?(EMATE_PATH)
|
|
21
|
+
warn "mm-send: emate not found at #{EMATE_PATH}. Is MailMate installed?"
|
|
22
|
+
return 1
|
|
23
|
+
end
|
|
24
|
+
system(EMATE_PATH, "mailto", "--markup", "markdown", *argv)
|
|
25
|
+
$?.exitstatus
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|