mailmate 0.1.0 → 1.0.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 +89 -14
- 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 +63 -12
- data/lib/mailmate/cli/modify.rb +122 -4
- data/lib/mailmate/cli/open.rb +71 -0
- data/lib/mailmate/cli/search.rb +268 -75
- 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 +28 -1
|
@@ -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
|
data/lib/mailmate/cli/search.rb
CHANGED
|
@@ -22,10 +22,15 @@ module Mailmate
|
|
|
22
22
|
"d" => :date, "T" => :tag, "K" => :keyword
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# Filter modifiers that read from MailMate's per-header indexes —
|
|
26
|
+
# zero .eml reads when matching. `field_value` consults them via
|
|
27
|
+
# `header_index_value_lc`. Kept as a constant for documentation;
|
|
28
|
+
# the prefilter no longer uses it (indexes are the prefilter now).
|
|
29
|
+
INDEXED_FILTER_FIELDS = %i[from recipients cc subject address_any any].freeze
|
|
26
30
|
|
|
27
31
|
VALID_FIELDS = %w[id path mailbox from to cc bcc reply-to subject date time
|
|
28
|
-
message-id
|
|
32
|
+
message-id message-url references in-reply-to
|
|
33
|
+
direction party flags read archive tags keywords].freeze
|
|
29
34
|
|
|
30
35
|
HEADER_LABELS = {
|
|
31
36
|
"direction" => "dir",
|
|
@@ -33,23 +38,33 @@ module Mailmate
|
|
|
33
38
|
"archive" => "a",
|
|
34
39
|
}.freeze
|
|
35
40
|
|
|
41
|
+
# All output fields are now index-tier: MailMate maintains a per-header
|
|
42
|
+
# binary index under Database.noindex/Headers/, so extracting from/to/
|
|
43
|
+
# subject/etc. doesn't require opening the .eml. Spec/filter matching
|
|
44
|
+
# (the `f`/`t`/`s` modifiers in the search string) still parses the
|
|
45
|
+
# .eml header block — migrating that side is a separate change.
|
|
36
46
|
FIELD_TIERS = {
|
|
37
47
|
"id" => :index, "path" => :index, "mailbox" => :index,
|
|
38
48
|
"date" => :index, "time" => :index,
|
|
39
49
|
"read" => :index,
|
|
40
50
|
"archive" => :index,
|
|
41
51
|
"flags" => :index,
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
52
|
+
"tags" => :index,
|
|
53
|
+
"keywords" => :index,
|
|
54
|
+
"from" => :index, "to" => :index, "cc" => :index, "bcc" => :index,
|
|
55
|
+
"reply-to" => :index, "subject" => :index, "message-id" => :index,
|
|
56
|
+
"message-url" => :index,
|
|
57
|
+
"references" => :index, "in-reply-to" => :index,
|
|
58
|
+
"direction" => :index, "party" => :index,
|
|
45
59
|
}.freeze
|
|
46
60
|
|
|
47
61
|
DEFAULT_SEARCH = "d 1d"
|
|
48
|
-
DEFAULT_FIELDS = "flags date time direction party subject"
|
|
62
|
+
DEFAULT_FIELDS = "id flags date time direction party subject"
|
|
49
63
|
|
|
50
64
|
def run(argv)
|
|
51
65
|
opts = {
|
|
52
|
-
mailbox: "all", limit: nil, headers_only: false,
|
|
66
|
+
mailbox: "all", limit: nil, headers_only: false, all: false,
|
|
67
|
+
exclude_quoted: false,
|
|
53
68
|
header: true, align: true, sort: :asc,
|
|
54
69
|
}
|
|
55
70
|
|
|
@@ -57,9 +72,16 @@ module Mailmate
|
|
|
57
72
|
parser.parse!(argv)
|
|
58
73
|
|
|
59
74
|
search_string = argv[0] || DEFAULT_SEARCH
|
|
60
|
-
fields_arg = (opts[:fields] || argv[1] || DEFAULT_FIELDS).to_s
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
fields_arg = (opts[:fields] || argv[1] || DEFAULT_FIELDS).to_s.strip
|
|
76
|
+
# `+...` means "defaults plus these"; bare list = exactly those columns.
|
|
77
|
+
# Defaults already include `id` as the first column, so `+x` keeps id
|
|
78
|
+
# automatic while a bare list lets callers omit it (useful for
|
|
79
|
+
# `mmsearch foo 'message-id' | sort | uniq` where leading per-row ids
|
|
80
|
+
# would defeat the dedup).
|
|
81
|
+
fields_arg = "#{DEFAULT_FIELDS} #{fields_arg[1..]}" if fields_arg.start_with?("+")
|
|
82
|
+
# Split on whitespace OR commas (or both) so callers can pass
|
|
83
|
+
# 'subject message-id', 'subject,message-id', or any mix.
|
|
84
|
+
fields = fields_arg.split(/[\s,]+/).reject(&:empty?).uniq
|
|
63
85
|
|
|
64
86
|
imap_root = Mailmate.config.imap_root
|
|
65
87
|
unless File.directory?(imap_root)
|
|
@@ -100,16 +122,11 @@ module Mailmate
|
|
|
100
122
|
end
|
|
101
123
|
|
|
102
124
|
filter_tier = composed_ast ? Mailmate::FilterClassifier.tier(composed_ast) : :index
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
elsif specs.all? { |field, _, _| field == :date }
|
|
109
|
-
:index
|
|
110
|
-
else
|
|
111
|
-
:header
|
|
112
|
-
end
|
|
125
|
+
# Every spec is now index-tier. Body matching reads MailMate's
|
|
126
|
+
# `#unquoted#lc`/`#quoted#lc` indexes (zero .eml read for indexed
|
|
127
|
+
# messages); `body_value` lazily Mail.reads the .eml for the rare
|
|
128
|
+
# misses. Header/tag/date specs all hit per-header indexes too.
|
|
129
|
+
specs_tier = :index
|
|
113
130
|
fields_tier_ = fields_tier(fields)
|
|
114
131
|
filter_only_tier = Mailmate::FilterClassifier.combine_tiers(filter_tier, specs_tier)
|
|
115
132
|
load_tier = Mailmate::FilterClassifier.combine_tiers(filter_only_tier, fields_tier_)
|
|
@@ -156,14 +173,18 @@ module Mailmate
|
|
|
156
173
|
o.separator ""
|
|
157
174
|
o.separator "POSITIONAL ARGS"
|
|
158
175
|
o.separator " search-string Quicksearch expression. Default: 'd 1d'. Pass '' to disable."
|
|
159
|
-
o.separator " fields
|
|
160
|
-
o.separator " Default: 'flags date time direction party subject'."
|
|
176
|
+
o.separator " fields Columns to show. Space- or comma-separated."
|
|
177
|
+
o.separator " Default: 'id flags date time direction party subject'."
|
|
178
|
+
o.separator " Bare list = exactly those columns (omit 'id' to drop it)."
|
|
179
|
+
o.separator " Prefix with '+' to extend the defaults: '+tags' = defaults + tags."
|
|
161
180
|
o.separator ""
|
|
162
181
|
o.separator "OPTIONS"
|
|
163
182
|
o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v }
|
|
164
183
|
o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v }
|
|
165
184
|
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 }
|
|
185
|
+
o.on("--headers-only", "Skip body matching entirely") { opts[:headers_only] = true }
|
|
186
|
+
o.on("--all", "Include un-indexed messages in body matching by lazily reading and parsing each .eml. Slow (tens of seconds to minutes on large archives). Default behavior matches MailMate's UI: only check messages MailMate has body-indexed — fast, but bounded.") { opts[:all] = true }
|
|
187
|
+
o.on("--exclude-quoted", "Match body only against #unquoted text — skip MailMate's #quoted index (forwarded/replied-to text). Tightens search to fresh content; gets you closer to MailMate UI's body-search result set, at the cost of missing hits in quoted sections.") { opts[:exclude_quoted] = true }
|
|
167
188
|
o.on("--no-header", "Suppress column header row") { opts[:header] = false }
|
|
168
189
|
o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
|
|
169
190
|
o.on("--sort MODE", %w[asc desc none],
|
|
@@ -188,6 +209,30 @@ module Mailmate
|
|
|
188
209
|
o.separator " mmsearch 'f medium d 7d' from Medium in last 7 days"
|
|
189
210
|
o.separator " mmsearch 's \"rent due\" !draft' subject has rent due, no 'draft'"
|
|
190
211
|
o.separator " mmsearch 'd 2026-05' received in May 2026"
|
|
212
|
+
o.separator ""
|
|
213
|
+
o.separator "FIELDS (for the fields argument / --fields)"
|
|
214
|
+
o.separator " id eml-id (always included as first column)"
|
|
215
|
+
o.separator " path full path to the .eml file"
|
|
216
|
+
o.separator " mailbox account/mailbox path (no /Messages/<id>.eml suffix)"
|
|
217
|
+
o.separator " from From header"
|
|
218
|
+
o.separator " to To header"
|
|
219
|
+
o.separator " cc Cc header"
|
|
220
|
+
o.separator " bcc Bcc header"
|
|
221
|
+
o.separator " reply-to Reply-To header"
|
|
222
|
+
o.separator " subject Subject header"
|
|
223
|
+
o.separator " message-id RFC Message-ID header"
|
|
224
|
+
o.separator " message-url message://%3C<MID>%3E — portable, paste-ready cross-machine ref"
|
|
225
|
+
o.separator " references RFC References header (space-joined when multiple)"
|
|
226
|
+
o.separator " in-reply-to RFC In-Reply-To header"
|
|
227
|
+
o.separator " date received date, YYYY-MM-DD (local time)"
|
|
228
|
+
o.separator " time received time, HH:MM (local time)"
|
|
229
|
+
o.separator " direction '→' outbound, '←' inbound (column header: 'dir')"
|
|
230
|
+
o.separator " party counterparty (recipients if outbound, sender if inbound)"
|
|
231
|
+
o.separator " flags archive + read combined, e.g. 'AR', 'PU'"
|
|
232
|
+
o.separator " read 'R' read or 'U' unread (column header: 'r')"
|
|
233
|
+
o.separator " archive 'A' archived or 'P' present elsewhere (column header: 'a')"
|
|
234
|
+
o.separator " tags user tags (IMAP keywords), comma-joined; system flags (\\… , $…) excluded"
|
|
235
|
+
o.separator " keywords raw IMAP keyword list (incl. \\Seen, \\Draft, \\Flagged, \$Forwarded, user tags)"
|
|
191
236
|
end
|
|
192
237
|
end
|
|
193
238
|
|
|
@@ -345,58 +390,159 @@ module Mailmate
|
|
|
345
390
|
|
|
346
391
|
# ---- field-value matching -----------------------------------------------
|
|
347
392
|
|
|
348
|
-
|
|
393
|
+
# Lowercased index value for a header — tries `<name>#lc` (MailMate's
|
|
394
|
+
# pre-downcased index) first, falls back to `<name>` + downcase. Returns
|
|
395
|
+
# nil if neither index has a record for this eml-id.
|
|
396
|
+
def header_index_value_lc(eml_id, name)
|
|
397
|
+
v = header_index_value(eml_id, "#{name}#lc")
|
|
398
|
+
return v unless v.nil?
|
|
399
|
+
raw = header_index_value(eml_id, name)
|
|
400
|
+
raw&.downcase
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Substring-match haystack for a filter modifier. Index-first; mail
|
|
404
|
+
# fallback only kicks in for the no-index case (tests, fresh installs,
|
|
405
|
+
# messages MailMate hasn't indexed yet).
|
|
406
|
+
def field_value(eml_id, mail, field)
|
|
349
407
|
case field
|
|
350
408
|
when :from
|
|
351
|
-
|
|
409
|
+
idx = header_index_value_lc(eml_id, "from")
|
|
410
|
+
return idx if idx && !idx.empty?
|
|
411
|
+
mail ? [Array(mail.from), mail[:from]&.value.to_s].flatten.join(" ").downcase : ""
|
|
352
412
|
when :recipients
|
|
353
|
-
[
|
|
354
|
-
|
|
413
|
+
parts = %w[to cc].map { |n| header_index_value_lc(eml_id, n) }.compact.reject(&:empty?)
|
|
414
|
+
return parts.join(" ") unless parts.empty?
|
|
415
|
+
mail ? [Array(mail.to), Array(mail.cc), mail[:to]&.value.to_s, mail[:cc]&.value.to_s].flatten.join(" ").downcase : ""
|
|
355
416
|
when :cc
|
|
356
|
-
|
|
417
|
+
idx = header_index_value_lc(eml_id, "cc")
|
|
418
|
+
return idx if idx && !idx.empty?
|
|
419
|
+
mail ? [Array(mail.cc), mail[:cc]&.value.to_s].flatten.join(" ").downcase : ""
|
|
357
420
|
when :subject
|
|
358
|
-
|
|
421
|
+
idx = header_index_value_lc(eml_id, "subject")
|
|
422
|
+
return idx if idx && !idx.empty?
|
|
423
|
+
mail ? mail.subject.to_s.downcase : ""
|
|
359
424
|
when :address_any
|
|
360
|
-
[
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
[mail["x-keywords"]&.value, mail["keywords"]&.value].compact.join(" ").downcase
|
|
425
|
+
parts = %w[from to cc reply-to sender].map { |n| header_index_value_lc(eml_id, n) }.compact.reject(&:empty?)
|
|
426
|
+
return parts.join(" ") unless parts.empty?
|
|
427
|
+
mail ? [mail[:from], mail[:to], mail[:cc], mail[:reply_to], mail[:sender]].compact.map { |h| h.value.to_s }.join(" ").downcase : ""
|
|
364
428
|
end
|
|
365
429
|
end
|
|
366
430
|
|
|
431
|
+
# MailMate stores user tags as IMAP keywords in the `#flags` index — not
|
|
432
|
+
# as `X-Keywords`/`Keywords` headers in the .eml — so tag matching has to
|
|
433
|
+
# go through the index, not the parsed mail. Strips `\…` (RFC) and `$…`
|
|
434
|
+
# (Thunderbird/Apple) system flags so substring matches only hit user tags.
|
|
435
|
+
def tag_value(eml_id)
|
|
436
|
+
return "" unless eml_id
|
|
437
|
+
flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
|
|
438
|
+
flags.reject { |f| f.start_with?("\\", "$") }.join(" ").downcase
|
|
439
|
+
end
|
|
440
|
+
|
|
367
441
|
def text_body(mail)
|
|
368
442
|
(mail.text_part&.decoded || mail.body.decoded).to_s.force_encoding("UTF-8").scrub.downcase
|
|
369
443
|
rescue StandardError
|
|
370
444
|
""
|
|
371
445
|
end
|
|
372
446
|
|
|
373
|
-
|
|
447
|
+
# Lowercased body substring-match haystack. Three-layer fallback:
|
|
448
|
+
#
|
|
449
|
+
# 1. MailMate's #unquoted#lc + #quoted#lc indexes — pre-decoded,
|
|
450
|
+
# pre-downcased body text. Zero .eml read. The fast path; covers
|
|
451
|
+
# the overwhelming majority of indexed mail. Body indexes are
|
|
452
|
+
# keyed by body-part-id (not envelope-id), so we resolve the
|
|
453
|
+
# envelope to its child parts via PartLookup, then aggregate every
|
|
454
|
+
# segment record across both indexes.
|
|
455
|
+
# 2. If no index record AND the caller already has a parsed Mail
|
|
456
|
+
# object, use text_body(mail) (same as before the migration).
|
|
457
|
+
# 3. If no index record AND no preloaded Mail, lazily Mail.read the
|
|
458
|
+
# .eml on demand. Slow, but only happens for the rare message
|
|
459
|
+
# MailMate hasn't body-indexed yet — far cheaper than the old
|
|
460
|
+
# always-load behavior.
|
|
461
|
+
#
|
|
462
|
+
# `index_only: true` short-circuits after step 1 (no fallback to mail or
|
|
463
|
+
# to disk). Same coverage and speed as MailMate's own UI body search:
|
|
464
|
+
# instant, but limited to messages MailMate has body-indexed.
|
|
465
|
+
def body_value(eml_id, mail, path, index_only: false, exclude_quoted: false)
|
|
466
|
+
texts = body_index_records(eml_id, exclude_quoted: exclude_quoted)
|
|
467
|
+
return texts.join(" ") unless texts.empty?
|
|
468
|
+
return "" if index_only
|
|
469
|
+
return text_body(mail) if mail
|
|
470
|
+
return "" if path.nil?
|
|
471
|
+
begin
|
|
472
|
+
text_body(Mail.read(path))
|
|
473
|
+
rescue StandardError
|
|
474
|
+
""
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Lowercased body-text segments from MailMate's #unquoted#lc and
|
|
479
|
+
# #quoted#lc indexes, aggregated across every body-part of the envelope.
|
|
480
|
+
# Returns [] if MailMate hasn't body-indexed the message.
|
|
481
|
+
#
|
|
482
|
+
# Body indexes are keyed by body-part-id and are multi-record (one
|
|
483
|
+
# record per text segment — paragraph/line/table row). For multipart
|
|
484
|
+
# messages we ask PartLookup for the child part-ids. For single-part
|
|
485
|
+
# messages PartLookup returns [] (envelope-id == body-part-id is not
|
|
486
|
+
# recorded in #root-body-part); we fall back to looking up the envelope
|
|
487
|
+
# eml-id directly so those messages still match.
|
|
488
|
+
#
|
|
489
|
+
# `exclude_quoted: true` drops #quoted#lc (forwarded / replied-to text),
|
|
490
|
+
# tightening recall toward MailMate UI's body-search semantics.
|
|
491
|
+
def body_index_records(eml_id, exclude_quoted: false)
|
|
492
|
+
return [] if eml_id.nil?
|
|
493
|
+
envelope = eml_id.to_i
|
|
494
|
+
part_ids = Mailmate::PartLookup.body_parts_of(envelope)
|
|
495
|
+
part_ids = [envelope] if part_ids.empty?
|
|
496
|
+
|
|
497
|
+
index_names = exclude_quoted ? %w[#unquoted#lc] : %w[#unquoted#lc #quoted#lc]
|
|
498
|
+
texts = []
|
|
499
|
+
index_names.each do |name|
|
|
500
|
+
reader =
|
|
501
|
+
begin
|
|
502
|
+
Mailmate::IndexReader.for(name)
|
|
503
|
+
rescue ArgumentError
|
|
504
|
+
next
|
|
505
|
+
end
|
|
506
|
+
part_ids.each do |pid|
|
|
507
|
+
reader.values_for(pid).each do |v|
|
|
508
|
+
next if v.nil? || v.empty?
|
|
509
|
+
texts << v.dup.force_encoding("UTF-8").scrub
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
texts
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def matches?(mail, eml_id, specs, headers_only, path = nil, index_only: false, exclude_quoted: false)
|
|
374
517
|
specs.all? do |field, term, negate|
|
|
375
518
|
hit =
|
|
376
519
|
case field
|
|
377
|
-
when :from, :recipients, :cc, :subject, :address_any
|
|
378
|
-
field_value(mail, field).include?(term)
|
|
520
|
+
when :from, :recipients, :cc, :subject, :address_any
|
|
521
|
+
field_value(eml_id, mail, field).include?(term)
|
|
522
|
+
when :tag, :keyword
|
|
523
|
+
tag_value(eml_id).include?(term)
|
|
379
524
|
when :body
|
|
380
|
-
headers_only ? false :
|
|
525
|
+
headers_only ? false : body_value(eml_id, mail, path, index_only: index_only, exclude_quoted: exclude_quoted).include?(term)
|
|
381
526
|
when :message_or_body
|
|
382
|
-
common = %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
|
|
383
|
-
common || (!headers_only &&
|
|
527
|
+
common = %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term) }
|
|
528
|
+
common || (!headers_only && body_value(eml_id, mail, path, index_only: index_only, exclude_quoted: exclude_quoted).include?(term))
|
|
384
529
|
when :date
|
|
385
530
|
date_matches?(mail, eml_id, term)
|
|
386
531
|
when :any
|
|
387
|
-
%i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
|
|
532
|
+
%i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term) }
|
|
388
533
|
end
|
|
389
534
|
negate ? !hit : hit
|
|
390
535
|
end
|
|
391
536
|
end
|
|
392
537
|
|
|
393
538
|
# ---- pre-filter ---------------------------------------------------------
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
539
|
+
#
|
|
540
|
+
# Filter modifiers (f/t/s/c/a) now match through MailMate's per-header
|
|
541
|
+
# indexes — index lookup IS the prefilter, no .eml read needed. The
|
|
542
|
+
# only remaining use of the .eml header-block grep is smart-mailbox
|
|
543
|
+
# filters that reference literal strings in arbitrary headers; those
|
|
544
|
+
# still benefit from a quick header-block scan to skip non-matching
|
|
545
|
+
# messages before any full evaluation.
|
|
400
546
|
|
|
401
547
|
def header_block(path)
|
|
402
548
|
bytes = +""
|
|
@@ -411,19 +557,10 @@ module Mailmate
|
|
|
411
557
|
bytes.downcase
|
|
412
558
|
end
|
|
413
559
|
|
|
414
|
-
def prefilter_pass?(path,
|
|
415
|
-
return true if
|
|
560
|
+
def prefilter_pass?(path, _specs, smart_literals = [])
|
|
561
|
+
return true if smart_literals.empty?
|
|
416
562
|
hdr = header_block(path)
|
|
417
|
-
|
|
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
|
|
563
|
+
smart_literals.all? { |lit| hdr.include?(lit) }
|
|
427
564
|
rescue StandardError
|
|
428
565
|
true
|
|
429
566
|
end
|
|
@@ -447,21 +584,65 @@ module Mailmate
|
|
|
447
584
|
|
|
448
585
|
# ---- field extraction ---------------------------------------------------
|
|
449
586
|
|
|
450
|
-
|
|
587
|
+
# MailMate keeps a per-header binary index under Database.noindex/Headers/
|
|
588
|
+
# — one cache/offsets file per RFC header name. Reading from there is
|
|
589
|
+
# O(1) and skips the .eml entirely. Returns nil if the index is missing
|
|
590
|
+
# (e.g. tests against a synthetic config) or if the eml-id isn't in it,
|
|
591
|
+
# so callers can fall back to a parsed `Mail` object.
|
|
592
|
+
#
|
|
593
|
+
# IndexReader returns the cache substring as ASCII-8BIT (raw bytes from
|
|
594
|
+
# File.binread). Force UTF-8 + scrub here so values from the index can
|
|
595
|
+
# safely interleave with UTF-8 strings in joined output rows.
|
|
596
|
+
def header_index_value(eml_id, name)
|
|
597
|
+
return nil if eml_id.nil?
|
|
598
|
+
v = Mailmate::IndexReader.for(name).value_for(eml_id.to_i)
|
|
599
|
+
v && v.dup.force_encoding("UTF-8").scrub
|
|
600
|
+
rescue ArgumentError
|
|
601
|
+
nil
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def index_or_mail(eml_id, name, fallback)
|
|
605
|
+
v = header_index_value(eml_id, name)
|
|
606
|
+
return v if v && !v.empty?
|
|
607
|
+
fallback.to_s
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# First bare email address from a header value, lower-cased. Accepts
|
|
611
|
+
# either "Name <addr>" or "addr"; for comma-separated lists, returns
|
|
612
|
+
# the first one.
|
|
613
|
+
def first_address(value)
|
|
614
|
+
return nil if value.nil? || value.empty?
|
|
615
|
+
first = value.split(",").first.to_s.strip
|
|
616
|
+
addr = first =~ /<([^>]+)>/ ? Regexp.last_match(1) : first
|
|
617
|
+
addr.to_s.downcase
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Split a comma-separated address-list header value into individual
|
|
621
|
+
# tokens, each kept in its original "Name <addr>" form.
|
|
622
|
+
def split_addresses(value)
|
|
623
|
+
return [] if value.nil? || value.empty?
|
|
624
|
+
value.split(",").map(&:strip).reject(&:empty?)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def outbound?(path, mail, eml_id = nil)
|
|
451
628
|
return true if path.include?("/Sent Mail.mailbox/") ||
|
|
452
629
|
path.include?("/Sent Messages.mailbox/") ||
|
|
453
630
|
path.include?("/Drafts.mailbox/")
|
|
454
|
-
from =
|
|
631
|
+
from = first_address(header_index_value(eml_id, "from")) ||
|
|
632
|
+
Array(mail&.from).first.to_s.downcase
|
|
455
633
|
Mailmate::Identity.mine?(from)
|
|
456
634
|
end
|
|
457
635
|
|
|
458
|
-
def party_for(mail, outbound)
|
|
636
|
+
def party_for(eml_id, mail, outbound)
|
|
459
637
|
if outbound
|
|
460
|
-
|
|
461
|
-
|
|
638
|
+
to_str = index_or_mail(eml_id, "to", mail ? Array(mail.to).join(", ") : "")
|
|
639
|
+
cc_str = index_or_mail(eml_id, "cc", mail ? Array(mail.cc).join(", ") : "")
|
|
640
|
+
tokens = split_addresses(to_str) + split_addresses(cc_str)
|
|
641
|
+
others = Mailmate::Identity.reject_mine(tokens.map { |t| first_address(t) || t })
|
|
642
|
+
others = split_addresses(to_str) if others.empty?
|
|
462
643
|
others.join("; ")
|
|
463
644
|
else
|
|
464
|
-
Array(mail.from).join("; ")
|
|
645
|
+
index_or_mail(eml_id, "from", mail ? Array(mail.from).join("; ") : "")
|
|
465
646
|
end
|
|
466
647
|
end
|
|
467
648
|
|
|
@@ -485,15 +666,25 @@ module Mailmate
|
|
|
485
666
|
archive = path.include?("/Archive.mailbox/") ? "A" : "P"
|
|
486
667
|
seen = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).include?("\\Seen")
|
|
487
668
|
"#{archive}#{seen ? 'R' : 'U'}"
|
|
488
|
-
when "
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
when "
|
|
492
|
-
|
|
493
|
-
when "
|
|
494
|
-
when "
|
|
495
|
-
when "
|
|
496
|
-
when "
|
|
669
|
+
when "tags"
|
|
670
|
+
flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
|
|
671
|
+
flags.reject { |f| f.start_with?("\\", "$") }.join(",")
|
|
672
|
+
when "keywords"
|
|
673
|
+
(Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).join(",")
|
|
674
|
+
when "from" then index_or_mail(eml_id, "from", mail ? Array(mail.from).join("; ") : nil)
|
|
675
|
+
when "to" then index_or_mail(eml_id, "to", mail ? Array(mail.to).join("; ") : nil)
|
|
676
|
+
when "cc" then index_or_mail(eml_id, "cc", mail ? Array(mail.cc).join("; ") : nil)
|
|
677
|
+
when "bcc" then index_or_mail(eml_id, "bcc", mail ? Array(mail.bcc).join("; ") : nil)
|
|
678
|
+
when "reply-to" then index_or_mail(eml_id, "reply-to", mail ? Array(mail.reply_to).join("; ") : nil)
|
|
679
|
+
when "subject" then index_or_mail(eml_id, "subject", mail&.subject)
|
|
680
|
+
when "message-id" then index_or_mail(eml_id, "message-id", mail&.message_id)
|
|
681
|
+
when "message-url"
|
|
682
|
+
mid = index_or_mail(eml_id, "message-id", mail&.message_id)
|
|
683
|
+
mid.empty? ? "" : Mailmate::MidUrl.message_url_for(mid)
|
|
684
|
+
when "references" then index_or_mail(eml_id, "references", mail ? Array(mail.references).join(" ") : nil)
|
|
685
|
+
when "in-reply-to" then index_or_mail(eml_id, "in-reply-to", mail ? Array(mail.in_reply_to).join(" ") : nil)
|
|
686
|
+
when "direction" then outbound?(path, mail, eml_id) ? "→" : "←"
|
|
687
|
+
when "party" then party_for(eml_id, mail, outbound?(path, mail, eml_id))
|
|
497
688
|
end.to_s
|
|
498
689
|
end
|
|
499
690
|
|
|
@@ -541,7 +732,8 @@ module Mailmate
|
|
|
541
732
|
next unless smart_evaluator.matches?(Mailmate::Message.new(nil, eml_id, path))
|
|
542
733
|
end
|
|
543
734
|
if !specs.empty?
|
|
544
|
-
next unless matches?(nil, eml_id, specs, opts[:headers_only]
|
|
735
|
+
next unless matches?(nil, eml_id, specs, opts[:headers_only], path,
|
|
736
|
+
index_only: !opts[:all], exclude_quoted: opts[:exclude_quoted])
|
|
545
737
|
end
|
|
546
738
|
end
|
|
547
739
|
|
|
@@ -557,7 +749,8 @@ module Mailmate
|
|
|
557
749
|
|
|
558
750
|
if filter_only_tier != :index
|
|
559
751
|
if !specs.empty?
|
|
560
|
-
next unless matches?(mail, eml_id, specs, opts[:headers_only]
|
|
752
|
+
next unless matches?(mail, eml_id, specs, opts[:headers_only], path,
|
|
753
|
+
index_only: !opts[:all], exclude_quoted: opts[:exclude_quoted])
|
|
561
754
|
end
|
|
562
755
|
if smart_evaluator
|
|
563
756
|
next unless smart_evaluator.matches?(Mailmate::Message.new(mail, eml_id, path))
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Mailmate
|
|
7
|
+
module CLI
|
|
8
|
+
# `mmtags` — list user tags MailMate knows about.
|
|
9
|
+
#
|
|
10
|
+
# Default: tags actually present on messages, counted from the `#flags`
|
|
11
|
+
# index (system flags like `\Seen`, `$Forwarded` are excluded — only
|
|
12
|
+
# user tags). Sorted by count desc.
|
|
13
|
+
#
|
|
14
|
+
# `--defined`: tags MailMate has registered in Preferences → Tags
|
|
15
|
+
# (read from Tags.plist). May include tags that aren't on any message yet.
|
|
16
|
+
# @api private
|
|
17
|
+
module Tags
|
|
18
|
+
extend self
|
|
19
|
+
|
|
20
|
+
TAGS_PLIST_FILENAME = "Tags.plist"
|
|
21
|
+
|
|
22
|
+
def run(argv)
|
|
23
|
+
opts = parse_options(argv)
|
|
24
|
+
if opts[:defined]
|
|
25
|
+
emit_defined
|
|
26
|
+
else
|
|
27
|
+
emit_used
|
|
28
|
+
end
|
|
29
|
+
0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse_options(argv)
|
|
33
|
+
opts = { defined: false }
|
|
34
|
+
OptionParser.new do |o|
|
|
35
|
+
o.banner = "Usage: mmtags [--defined]"
|
|
36
|
+
o.separator ""
|
|
37
|
+
o.separator "Default: tags actually applied to messages, with usage counts"
|
|
38
|
+
o.separator "(read from MailMate's #flags index; system flags excluded)."
|
|
39
|
+
o.separator ""
|
|
40
|
+
o.on("--defined", "List tags defined in MailMate Preferences → Tags") { opts[:defined] = true }
|
|
41
|
+
end.parse!(argv)
|
|
42
|
+
opts
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ---- tags in use (#flags index) ----------------------------------------
|
|
46
|
+
|
|
47
|
+
def emit_used
|
|
48
|
+
counts = used_tag_counts
|
|
49
|
+
width = counts.keys.map(&:length).max || 3
|
|
50
|
+
width = [width, "tag".length].max
|
|
51
|
+
$stdout.puts "#{"tag".ljust(width)} count"
|
|
52
|
+
counts.sort_by { |tag, n| [-n, tag] }.each do |tag, n|
|
|
53
|
+
$stdout.puts "#{tag.ljust(width)} #{n}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def used_tag_counts
|
|
58
|
+
counts = Hash.new(0)
|
|
59
|
+
Mailmate::IndexReader.for("#flags").each_record do |_eml_id, raw|
|
|
60
|
+
next if raw.nil? || raw.empty?
|
|
61
|
+
raw.split.each do |token|
|
|
62
|
+
next if token.start_with?("\\", "$") # IMAP/Thunderbird system flags
|
|
63
|
+
counts[token] += 1
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
counts
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
# #flags index not available — fail gracefully (returns no tags).
|
|
69
|
+
{}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ---- tags defined in Preferences (Tags.plist) --------------------------
|
|
73
|
+
|
|
74
|
+
def emit_defined
|
|
75
|
+
defined_tag_names.each { |name| $stdout.puts name }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def defined_tag_names
|
|
79
|
+
path = File.join(Mailmate.config.app_support_dir, TAGS_PLIST_FILENAME)
|
|
80
|
+
return [] unless File.exist?(path)
|
|
81
|
+
# plutil -convert json is more robust than reading binary plist directly.
|
|
82
|
+
data = JSON.parse(`plutil -convert json -o - #{shellesc(path)}`)
|
|
83
|
+
Array(data["tags"]).map { |t| t["displayName"] }.compact
|
|
84
|
+
rescue StandardError
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def shellesc(s)
|
|
89
|
+
"'#{s.gsub("'", "'\\\\''")}'"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/mailmate/config.rb
CHANGED
|
@@ -127,8 +127,11 @@ module Mailmate
|
|
|
127
127
|
end
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
-
# Convenience: `Mailmate.config.imap_root` etc.
|
|
130
|
+
# Convenience: `Mailmate.config.imap_root` etc. Triggers first-run
|
|
131
|
+
# discovery if `~/.config/mailmate/config.yml` doesn't exist yet
|
|
132
|
+
# (interactive on a TTY, warn-and-continue otherwise).
|
|
131
133
|
def self.config
|
|
134
|
+
ensure_configured! if respond_to?(:ensure_configured!)
|
|
132
135
|
Config.instance
|
|
133
136
|
end
|
|
134
137
|
end
|