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.
@@ -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
@@ -22,10 +22,14 @@ module Mailmate
22
22
  "d" => :date, "T" => :tag, "K" => :keyword
23
23
  }.freeze
24
24
 
25
- HEADER_FIELDS = %i[from recipients cc subject address_any any].freeze
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" => :header, "to" => :header, "cc" => :header, "bcc" => :header,
46
- "reply-to" => :header, "subject" => :header, "message-id" => :header,
47
- "references" => :header, "in-reply-to" => :header,
48
- "direction" => :header, "party" => :header,
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 replaces defaults.
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
- extra_fields = fields_arg.split(/\s+/).reject(&:empty?)
68
- fields = (["id"] + extra_fields).uniq
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
- specs_tier =
110
- if specs.empty?
111
- :index
112
- elsif specs.any? { |field, _, _| (field == :body || field == :message_or_body) && !opts[:headers_only] }
113
- :full
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 Space-separated columns to show (id is always first)."
166
- o.separator " Default: 'flags date time direction party subject'."
167
- o.separator " Prefix with '+' to add to the defaults: '+tags' = defaults + tags."
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
- def field_value(mail, field)
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
- [Array(mail.from), mail[:from]&.value.to_s].flatten.join(" ").downcase
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
- [Array(mail.to), Array(mail.cc), mail[:to]&.value.to_s, mail[:cc]&.value.to_s]
384
- .flatten.join(" ").downcase
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
- [Array(mail.cc), mail[:cc]&.value.to_s].flatten.join(" ").downcase
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
- mail.subject.to_s.downcase
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
- [mail[:from], mail[:to], mail[:cc], mail[:reply_to], mail[:sender]]
391
- .compact.map { |h| h.value.to_s }.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 : ""
392
428
  end
393
429
  end
394
430
 
@@ -408,35 +444,105 @@ module Mailmate
408
444
  ""
409
445
  end
410
446
 
411
- def matches?(mail, eml_id, specs, headers_only)
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 : text_body(mail).include?(term)
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 && text_body(mail).include?(term))
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
- def can_prefilter?(specs)
436
- specs.any? do |field, term, negate|
437
- !negate && HEADER_FIELDS.include?(field) && term.bytesize >= 3 && term.ascii_only?
438
- end
439
- end
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, specs, smart_literals = [])
455
- return true if !can_prefilter?(specs) && smart_literals.empty?
560
+ def prefilter_pass?(path, _specs, smart_literals = [])
561
+ return true if smart_literals.empty?
456
562
  hdr = header_block(path)
457
- specs.each do |field, term, negate|
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
- def outbound?(path, mail)
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 = Array(mail.from).first.to_s.downcase
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
- others = Mailmate::Identity.reject_mine(Array(mail.to) + Array(mail.cc))
501
- others = Array(mail.to) if others.empty?
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" then mail ? Array(mail.from).join("; ") : nil
534
- when "to" then mail ? Array(mail.to).join("; ") : nil
535
- when "cc" then mail ? Array(mail.cc).join("; ") : nil
536
- when "bcc" then mail ? Array(mail.bcc).join("; ") : nil
537
- when "reply-to" then mail ? Array(mail.reply_to).join("; ") : nil
538
- when "subject" then mail&.subject.to_s
539
- when "message-id" then mail&.message_id.to_s
540
- when "references" then mail ? Array(mail.references).join(" ") : nil
541
- when "in-reply-to" then mail ? Array(mail.in_reply_to).join(" ") : nil
542
- when "direction" then mail ? (outbound?(path, mail) ? "" : "←") : nil
543
- when "party" then mail ? party_for(mail, outbound?(path, mail)) : nil
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
@@ -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