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.
@@ -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,15 @@ 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 direction party flags read archive].freeze
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
- "from" => :header, "to" => :header, "cc" => :header, "bcc" => :header,
43
- "reply-to" => :header, "subject" => :header, "message-id" => :header,
44
- "direction" => :header, "party" => :header,
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
- extra_fields = fields_arg.strip.split(/\s+/).reject(&:empty?)
62
- fields = (["id"] + extra_fields).uniq
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
- 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
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 Space-separated columns to add (id is always first)."
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
- 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)
349
407
  case field
350
408
  when :from
351
- [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 : ""
352
412
  when :recipients
353
- [Array(mail.to), Array(mail.cc), mail[:to]&.value.to_s, mail[:cc]&.value.to_s]
354
- .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 : ""
355
416
  when :cc
356
- [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 : ""
357
420
  when :subject
358
- 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 : ""
359
424
  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
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
- 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)
374
517
  specs.all? do |field, term, negate|
375
518
  hit =
376
519
  case field
377
- when :from, :recipients, :cc, :subject, :address_any, :tag, :keyword
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 : 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)
381
526
  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))
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
- 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
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, specs, smart_literals = [])
415
- return true if !can_prefilter?(specs) && smart_literals.empty?
560
+ def prefilter_pass?(path, _specs, smart_literals = [])
561
+ return true if smart_literals.empty?
416
562
  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
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
- 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)
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 = 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
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
- others = Mailmate::Identity.reject_mine(Array(mail.to) + Array(mail.cc))
461
- 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?
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 "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
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
@@ -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