mailmate 0.2.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 +76 -9
- 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 +222 -76
- 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,14 @@ 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 references in-reply-to
|
|
32
|
+
message-id message-url references in-reply-to
|
|
29
33
|
direction party flags read archive tags keywords].freeze
|
|
30
34
|
|
|
31
35
|
HEADER_LABELS = {
|
|
@@ -34,6 +38,11 @@ module Mailmate
|
|
|
34
38
|
"archive" => "a",
|
|
35
39
|
}.freeze
|
|
36
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.
|
|
37
46
|
FIELD_TIERS = {
|
|
38
47
|
"id" => :index, "path" => :index, "mailbox" => :index,
|
|
39
48
|
"date" => :index, "time" => :index,
|
|
@@ -42,18 +51,20 @@ module Mailmate
|
|
|
42
51
|
"flags" => :index,
|
|
43
52
|
"tags" => :index,
|
|
44
53
|
"keywords" => :index,
|
|
45
|
-
"from" => :
|
|
46
|
-
"reply-to" => :
|
|
47
|
-
"
|
|
48
|
-
"
|
|
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,
|
|
49
59
|
}.freeze
|
|
50
60
|
|
|
51
61
|
DEFAULT_SEARCH = "d 1d"
|
|
52
|
-
DEFAULT_FIELDS = "flags date time direction party subject"
|
|
62
|
+
DEFAULT_FIELDS = "id flags date time direction party subject"
|
|
53
63
|
|
|
54
64
|
def run(argv)
|
|
55
65
|
opts = {
|
|
56
|
-
mailbox: "all", limit: nil, headers_only: false,
|
|
66
|
+
mailbox: "all", limit: nil, headers_only: false, all: false,
|
|
67
|
+
exclude_quoted: false,
|
|
57
68
|
header: true, align: true, sort: :asc,
|
|
58
69
|
}
|
|
59
70
|
|
|
@@ -62,10 +73,15 @@ module Mailmate
|
|
|
62
73
|
|
|
63
74
|
search_string = argv[0] || DEFAULT_SEARCH
|
|
64
75
|
fields_arg = (opts[:fields] || argv[1] || DEFAULT_FIELDS).to_s.strip
|
|
65
|
-
# `+...` means "defaults plus these"; bare list
|
|
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).
|
|
66
81
|
fields_arg = "#{DEFAULT_FIELDS} #{fields_arg[1..]}" if fields_arg.start_with?("+")
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
69
85
|
|
|
70
86
|
imap_root = Mailmate.config.imap_root
|
|
71
87
|
unless File.directory?(imap_root)
|
|
@@ -106,16 +122,11 @@ module Mailmate
|
|
|
106
122
|
end
|
|
107
123
|
|
|
108
124
|
filter_tier = composed_ast ? Mailmate::FilterClassifier.tier(composed_ast) : :index
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
elsif specs.all? { |field, _, _| %i[date tag keyword].include?(field) }
|
|
115
|
-
:index
|
|
116
|
-
else
|
|
117
|
-
:header
|
|
118
|
-
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
|
|
119
130
|
fields_tier_ = fields_tier(fields)
|
|
120
131
|
filter_only_tier = Mailmate::FilterClassifier.combine_tiers(filter_tier, specs_tier)
|
|
121
132
|
load_tier = Mailmate::FilterClassifier.combine_tiers(filter_only_tier, fields_tier_)
|
|
@@ -162,15 +173,18 @@ module Mailmate
|
|
|
162
173
|
o.separator ""
|
|
163
174
|
o.separator "POSITIONAL ARGS"
|
|
164
175
|
o.separator " search-string Quicksearch expression. Default: 'd 1d'. Pass '' to disable."
|
|
165
|
-
o.separator " fields
|
|
166
|
-
o.separator " Default: 'flags date time direction party subject'."
|
|
167
|
-
o.separator "
|
|
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."
|
|
168
180
|
o.separator ""
|
|
169
181
|
o.separator "OPTIONS"
|
|
170
182
|
o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v }
|
|
171
183
|
o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v }
|
|
172
184
|
o.on("--limit N", Integer, "Stop after N matches") { |n| opts[:limit] = n }
|
|
173
|
-
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 }
|
|
174
188
|
o.on("--no-header", "Suppress column header row") { opts[:header] = false }
|
|
175
189
|
o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
|
|
176
190
|
o.on("--sort MODE", %w[asc desc none],
|
|
@@ -207,6 +221,7 @@ module Mailmate
|
|
|
207
221
|
o.separator " reply-to Reply-To header"
|
|
208
222
|
o.separator " subject Subject header"
|
|
209
223
|
o.separator " message-id RFC Message-ID header"
|
|
224
|
+
o.separator " message-url message://%3C<MID>%3E — portable, paste-ready cross-machine ref"
|
|
210
225
|
o.separator " references RFC References header (space-joined when multiple)"
|
|
211
226
|
o.separator " in-reply-to RFC In-Reply-To header"
|
|
212
227
|
o.separator " date received date, YYYY-MM-DD (local time)"
|
|
@@ -375,20 +390,41 @@ module Mailmate
|
|
|
375
390
|
|
|
376
391
|
# ---- field-value matching -----------------------------------------------
|
|
377
392
|
|
|
378
|
-
|
|
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)
|
|
379
407
|
case field
|
|
380
408
|
when :from
|
|
381
|
-
|
|
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 : ""
|
|
382
412
|
when :recipients
|
|
383
|
-
[
|
|
384
|
-
|
|
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 : ""
|
|
385
416
|
when :cc
|
|
386
|
-
|
|
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 : ""
|
|
387
420
|
when :subject
|
|
388
|
-
|
|
421
|
+
idx = header_index_value_lc(eml_id, "subject")
|
|
422
|
+
return idx if idx && !idx.empty?
|
|
423
|
+
mail ? mail.subject.to_s.downcase : ""
|
|
389
424
|
when :address_any
|
|
390
|
-
[
|
|
391
|
-
|
|
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 : ""
|
|
392
428
|
end
|
|
393
429
|
end
|
|
394
430
|
|
|
@@ -408,35 +444,105 @@ module Mailmate
|
|
|
408
444
|
""
|
|
409
445
|
end
|
|
410
446
|
|
|
411
|
-
|
|
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)
|
|
412
517
|
specs.all? do |field, term, negate|
|
|
413
518
|
hit =
|
|
414
519
|
case field
|
|
415
520
|
when :from, :recipients, :cc, :subject, :address_any
|
|
416
|
-
field_value(mail, field).include?(term)
|
|
521
|
+
field_value(eml_id, mail, field).include?(term)
|
|
417
522
|
when :tag, :keyword
|
|
418
523
|
tag_value(eml_id).include?(term)
|
|
419
524
|
when :body
|
|
420
|
-
headers_only ? false :
|
|
525
|
+
headers_only ? false : body_value(eml_id, mail, path, index_only: index_only, exclude_quoted: exclude_quoted).include?(term)
|
|
421
526
|
when :message_or_body
|
|
422
|
-
common = %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
|
|
423
|
-
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))
|
|
424
529
|
when :date
|
|
425
530
|
date_matches?(mail, eml_id, term)
|
|
426
531
|
when :any
|
|
427
|
-
%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) }
|
|
428
533
|
end
|
|
429
534
|
negate ? !hit : hit
|
|
430
535
|
end
|
|
431
536
|
end
|
|
432
537
|
|
|
433
538
|
# ---- pre-filter ---------------------------------------------------------
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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.
|
|
440
546
|
|
|
441
547
|
def header_block(path)
|
|
442
548
|
bytes = +""
|
|
@@ -451,19 +557,10 @@ module Mailmate
|
|
|
451
557
|
bytes.downcase
|
|
452
558
|
end
|
|
453
559
|
|
|
454
|
-
def prefilter_pass?(path,
|
|
455
|
-
return true if
|
|
560
|
+
def prefilter_pass?(path, _specs, smart_literals = [])
|
|
561
|
+
return true if smart_literals.empty?
|
|
456
562
|
hdr = header_block(path)
|
|
457
|
-
|
|
458
|
-
next if negate
|
|
459
|
-
next unless HEADER_FIELDS.include?(field)
|
|
460
|
-
next unless term.bytesize >= 3 && term.ascii_only?
|
|
461
|
-
return false unless hdr.include?(term)
|
|
462
|
-
end
|
|
463
|
-
smart_literals.each do |lit|
|
|
464
|
-
return false unless hdr.include?(lit)
|
|
465
|
-
end
|
|
466
|
-
true
|
|
563
|
+
smart_literals.all? { |lit| hdr.include?(lit) }
|
|
467
564
|
rescue StandardError
|
|
468
565
|
true
|
|
469
566
|
end
|
|
@@ -487,21 +584,65 @@ module Mailmate
|
|
|
487
584
|
|
|
488
585
|
# ---- field extraction ---------------------------------------------------
|
|
489
586
|
|
|
490
|
-
|
|
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)
|
|
491
628
|
return true if path.include?("/Sent Mail.mailbox/") ||
|
|
492
629
|
path.include?("/Sent Messages.mailbox/") ||
|
|
493
630
|
path.include?("/Drafts.mailbox/")
|
|
494
|
-
from =
|
|
631
|
+
from = first_address(header_index_value(eml_id, "from")) ||
|
|
632
|
+
Array(mail&.from).first.to_s.downcase
|
|
495
633
|
Mailmate::Identity.mine?(from)
|
|
496
634
|
end
|
|
497
635
|
|
|
498
|
-
def party_for(mail, outbound)
|
|
636
|
+
def party_for(eml_id, mail, outbound)
|
|
499
637
|
if outbound
|
|
500
|
-
|
|
501
|
-
|
|
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?
|
|
502
643
|
others.join("; ")
|
|
503
644
|
else
|
|
504
|
-
Array(mail.from).join("; ")
|
|
645
|
+
index_or_mail(eml_id, "from", mail ? Array(mail.from).join("; ") : "")
|
|
505
646
|
end
|
|
506
647
|
end
|
|
507
648
|
|
|
@@ -530,17 +671,20 @@ module Mailmate
|
|
|
530
671
|
flags.reject { |f| f.start_with?("\\", "$") }.join(",")
|
|
531
672
|
when "keywords"
|
|
532
673
|
(Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).join(",")
|
|
533
|
-
when "from"
|
|
534
|
-
when "to"
|
|
535
|
-
when "cc"
|
|
536
|
-
when "bcc"
|
|
537
|
-
when "reply-to"
|
|
538
|
-
when "subject" then mail&.subject
|
|
539
|
-
when "message-id" then mail&.message_id
|
|
540
|
-
when "
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
when "
|
|
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))
|
|
544
688
|
end.to_s
|
|
545
689
|
end
|
|
546
690
|
|
|
@@ -588,7 +732,8 @@ module Mailmate
|
|
|
588
732
|
next unless smart_evaluator.matches?(Mailmate::Message.new(nil, eml_id, path))
|
|
589
733
|
end
|
|
590
734
|
if !specs.empty?
|
|
591
|
-
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])
|
|
592
737
|
end
|
|
593
738
|
end
|
|
594
739
|
|
|
@@ -604,7 +749,8 @@ module Mailmate
|
|
|
604
749
|
|
|
605
750
|
if filter_only_tier != :index
|
|
606
751
|
if !specs.empty?
|
|
607
|
-
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])
|
|
608
754
|
end
|
|
609
755
|
if smart_evaluator
|
|
610
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
|