Module: Mailmate::CLI::Search Private

Extended by:
Search
Included in:
Search
Defined in:
lib/mailmate/cli/search.rb

Overview

This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.

‘mmsearch` — search MailMate’s ‘.eml` files using a subset of MailMate’s quicksearch syntax. Output is CSV with optional column-aligned padding.

Ported from the standalone mailmate-search script. See ‘~/.claude/skills/email/SKILL.md` for usage examples and the search-string syntax reference.

Constant Summary collapse

MODIFIERS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

{
  "f" => :from, "t" => :recipients, "c" => :cc, "s" => :subject,
  "a" => :address_any, "b" => :body, "m" => :message_or_body,
  "d" => :date, "T" => :tag, "K" => :keyword
}.freeze
INDEXED_FILTER_FIELDS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Filter modifiers that read from MailMate’s per-header indexes —zero .eml reads when matching. ‘field_value` consults them via `header_index_value_lc`. Kept as a constant for documentation; the prefilter no longer uses it (indexes are the prefilter now).

%i[from recipients cc subject address_any any].freeze
VALID_FIELDS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

%w[id path mailbox from to cc bcc reply-to subject date time
message-id message-url references in-reply-to
direction party flags read archive tags keywords].freeze
HEADER_LABELS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

{
  "direction" => "dir",
  "read"      => "r",
  "archive"   => "a",
}.freeze
FIELD_TIERS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

All output fields are now index-tier: MailMate maintains a per-header binary index under Database.noindex/Headers/, so extracting from/to/ subject/etc. doesn’t require opening the .eml. Spec/filter matching (the ‘f`/`t`/`s` modifiers in the search string) still parses the .eml header block — migrating that side is a separate change.

{
  "id" => :index, "path" => :index, "mailbox" => :index,
  "date" => :index, "time" => :index,
  "read" => :index,
  "archive" => :index,
  "flags" => :index,
  "tags" => :index,
  "keywords" => :index,
  "from" => :index, "to" => :index, "cc" => :index, "bcc" => :index,
  "reply-to" => :index, "subject" => :index, "message-id" => :index,
  "message-url" => :index,
  "references" => :index, "in-reply-to" => :index,
  "direction" => :index, "party" => :index,
}.freeze
DEFAULT_SEARCH =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

"d 1d"
DEFAULT_FIELDS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

"id flags date time direction party subject"

Instance Method Summary collapse

Instance Method Details

#all_message_dirsObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

—- mailbox resolution ————————————————-



241
242
243
# File 'lib/mailmate/cli/search.rb', line 241

def all_message_dirs
  Dir.glob("#{Mailmate.config.imap_root}/*/**/Messages").select { |p| File.directory?(p) }
end

#body_index_records(eml_id, exclude_quoted: false) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Lowercased body-text segments from MailMate’s #unquoted#lc and #quoted#lc indexes, aggregated across every body-part of the envelope. Returns [] if MailMate hasn’t body-indexed the message.

Body indexes are keyed by body-part-id and are multi-record (one record per text segment — paragraph/line/table row). For multipart messages we ask PartLookup for the child part-ids. For single-part messages PartLookup returns [] (envelope-id == body-part-id is not recorded in #root-body-part); we fall back to looking up the envelope eml-id directly so those messages still match.

‘exclude_quoted: true` drops #quoted#lc (forwarded / replied-to text), tightening recall toward MailMate UI’s body-search semantics.



491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
# File 'lib/mailmate/cli/search.rb', line 491

def body_index_records(eml_id, exclude_quoted: false)
  return [] if eml_id.nil?
  envelope = eml_id.to_i
  part_ids = Mailmate::PartLookup.body_parts_of(envelope)
  part_ids = [envelope] if part_ids.empty?

  index_names = exclude_quoted ? %w[#unquoted#lc] : %w[#unquoted#lc #quoted#lc]
  texts = []
  index_names.each do |name|
    reader =
      begin
        Mailmate::IndexReader.for(name)
      rescue ArgumentError
        next
      end
    part_ids.each do |pid|
      reader.values_for(pid).each do |v|
        next if v.nil? || v.empty?
        texts << v.dup.force_encoding("UTF-8").scrub
      end
    end
  end
  texts
end

#body_value(eml_id, mail, path, index_only: false, exclude_quoted: false) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Lowercased body substring-match haystack. Three-layer fallback:

1. MailMate's #unquoted#lc + #quoted#lc indexes — pre-decoded,
   pre-downcased body text. Zero .eml read. The fast path; covers
   the overwhelming majority of indexed mail. Body indexes are
   keyed by body-part-id (not envelope-id), so we resolve the
   envelope to its child parts via PartLookup, then aggregate every
   segment record across both indexes.
2. If no index record AND the caller already has a parsed Mail
   object, use text_body(mail) (same as before the migration).
3. If no index record AND no preloaded Mail, lazily Mail.read the
   .eml on demand. Slow, but only happens for the rare message
   MailMate hasn't body-indexed yet — far cheaper than the old
   always-load behavior.

‘index_only: true` short-circuits after step 1 (no fallback to mail or to disk). Same coverage and speed as MailMate’s own UI body search: instant, but limited to messages MailMate has body-indexed.



465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/mailmate/cli/search.rb', line 465

def body_value(eml_id, mail, path, index_only: false, exclude_quoted: false)
  texts = body_index_records(eml_id, exclude_quoted: exclude_quoted)
  return texts.join(" ") unless texts.empty?
  return "" if index_only
  return text_body(mail) if mail
  return "" if path.nil?
  begin
    text_body(Mail.read(path))
  rescue StandardError
    ""
  end
end

#build_parser(opts) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

—- option parsing —————————————————–



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/mailmate/cli/search.rb', line 168

def build_parser(opts)
  OptionParser.new do |o|
    o.banner = "Usage: mmsearch [search-string] [fields] [options]"
    o.separator ""
    o.separator "Search MailMate's `.eml` files. Output is CSV with column-aligned padding."
    o.separator ""
    o.separator "POSITIONAL ARGS"
    o.separator "  search-string  Quicksearch expression. Default: 'd 1d'. Pass '' to disable."
    o.separator "  fields         Columns to show. Space- or comma-separated."
    o.separator "                 Default: 'id flags date time direction party subject'."
    o.separator "                 Bare list = exactly those columns (omit 'id' to drop it)."
    o.separator "                 Prefix with '+' to extend the defaults: '+tags' = defaults + tags."
    o.separator ""
    o.separator "OPTIONS"
    o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v }
    o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v }
    o.on("--limit N", Integer, "Stop after N matches") { |n| opts[:limit] = n }
    o.on("--headers-only", "Skip body matching entirely") { opts[:headers_only] = true }
    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 }
    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 }
    o.on("--no-header", "Suppress column header row") { opts[:header] = false }
    o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
    o.on("--sort MODE", %w[asc desc none],
         "Sort rows by date+time: asc (default), desc, none") { |v| opts[:sort] = v.to_sym }
    o.separator ""
    o.separator "SEARCH-STRING SYNTAX"
    o.separator "  Mirrors MailMate's toolbar quicksearch. Specs combine with AND."
    o.separator "  Wrap multi-word terms in \"double quotes\". Prefix operand with ! to negate."
    o.separator ""
    o.separator "    <term>    common headers (from/to/cc/subject) OR body contains <term>"
    o.separator "    f <term>  from contains"
    o.separator "    t <term>  to/cc (recipients) contains"
    o.separator "    c <term>  cc contains"
    o.separator "    s <term>  subject contains"
    o.separator "    a <term>  any address header contains"
    o.separator "    b <term>  body contains (reads MailMate's body indexes; --all for un-indexed too)"
    o.separator "    m <term>  common headers OR body (same as bare term)"
    o.separator "    d <date>  received date: Nd|Nw|Nm|Ny (relative), or Y, Y-M, Y-M-D"
    o.separator "    T <tag>   tag / IMAP keyword contains  (K is a synonym)"
    o.separator ""
    o.separator "  Examples:"
    o.separator "    mmsearch 'f medium d 7d'           from Medium in last 7 days"
    o.separator "    mmsearch 's \"rent due\" !draft'     subject has rent due, no 'draft'"
    o.separator "    mmsearch 'd 2026-05'               received in May 2026"
    o.separator ""
    o.separator "FIELDS (for the fields argument / --fields)"
    o.separator "  id          eml-id (always included as first column)"
    o.separator "  path        full path to the .eml file"
    o.separator "  mailbox     account/mailbox path (no /Messages/<id>.eml suffix)"
    o.separator "  from        From header"
    o.separator "  to          To header"
    o.separator "  cc          Cc header"
    o.separator "  bcc         Bcc header"
    o.separator "  reply-to    Reply-To header"
    o.separator "  subject       Subject header"
    o.separator "  message-id    RFC Message-ID header"
    o.separator "  message-url   message://%3C<MID>%3E — portable, paste-ready cross-machine ref"
    o.separator "  references    RFC References header (space-joined when multiple)"
    o.separator "  in-reply-to   RFC In-Reply-To header"
    o.separator "  date        received date, YYYY-MM-DD (local time)"
    o.separator "  time        received time, HH:MM (local time)"
    o.separator "  direction   '→' outbound, '←' inbound (column header: 'dir')"
    o.separator "  party       counterparty (recipients if outbound, sender if inbound)"
    o.separator "  flags       archive + read combined, e.g. 'AR', 'PU'"
    o.separator "  read        'R' read or 'U' unread (column header: 'r')"
    o.separator "  archive     'A' archived or 'P' present elsewhere (column header: 'a')"
    o.separator "  tags        user tags (IMAP keywords), comma-joined; system flags (\\… , $…) excluded"
    o.separator "  keywords    raw IMAP keyword list (incl. \\Seen, \\Draft, \\Flagged, \$Forwarded, user tags)"
  end
end

#collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
# File 'lib/mailmate/cli/search.rb', line 719

def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
  rows = []
  catch(:done) do
    dirs.each do |dir|
      Dir.each_child(dir) do |fname|
        next unless fname.end_with?(".eml")
        eml_id = fname.sub(".eml", "")
        path = "#{dir}/#{fname}"

        next unless prefilter_pass?(path, specs, smart_literals)

        if filter_only_tier == :index
          if smart_evaluator
            next unless smart_evaluator.matches?(Mailmate::Message.new(nil, eml_id, path))
          end
          if !specs.empty?
            next unless matches?(nil, eml_id, specs, opts[:headers_only], path,
                                 index_only: !opts[:all], exclude_quoted: opts[:exclude_quoted])
          end
        end

        mail = nil
        if load_tier != :index
          begin
            mail = load_message(path, load_tier)
          rescue StandardError => e
            warn "[skip] #{path}: #{e.message}"
            next
          end
        end

        if filter_only_tier != :index
          if !specs.empty?
            next unless matches?(mail, eml_id, specs, opts[:headers_only], path,
                                 index_only: !opts[:all], exclude_quoted: opts[:exclude_quoted])
          end
          if smart_evaluator
            next unless smart_evaluator.matches?(Mailmate::Message.new(mail, eml_id, path))
          end
        end

        rows << fields.map { |f| extract(f, eml_id, path, mail) }
        throw :done if opts[:limit] && rows.size >= opts[:limit]
      end
    end
  end
  rows
end

#compose_smart_filters(filters) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



297
298
299
300
301
# File 'lib/mailmate/cli/search.rb', line 297

def compose_smart_filters(filters)
  return "" if filters.empty?
  return filters.first if filters.size == 1
  "(#{filters.map { |f| "(#{f})" }.join(" and ")})"
end

#csv_quote(cell) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

—- output ————————————————————-



770
771
772
773
774
775
776
777
# File 'lib/mailmate/cli/search.rb', line 770

def csv_quote(cell)
  cell = cell.to_s.gsub(/[\r\n]+/, " ")
  if cell.include?(",") || cell.include?("\"")
    "\"#{cell.gsub("\"", "\"\"")}\""
  else
    cell
  end
end

#date_matches?(mail, eml_id, term) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

—- date matching ——————————————————

Returns:

  • (Boolean)


354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/mailmate/cli/search.rb', line 354

def date_matches?(mail, eml_id, term)
  d = nil
  if eml_id
    s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
    if s && !s.empty?
      d = (Time.parse(s) rescue nil)
    end
  end
  if d.nil? && mail
    raw = mail.date
    d = raw.respond_to?(:to_time) ? raw.to_time : raw
  end
  return false unless d

  if term =~ /\A(\d+)([dwmy])\z/
    n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
    cutoff = case u
             when "d" then Date.today - n
             when "w" then Date.today - (n * 7)
             when "m" then Date.today << n
             when "y" then Date.today << (n * 12)
             end
    return d.to_date >= cutoff
  end

  norm = term.tr("/.", "-")
  parts = norm.split("-")
  case parts.size
  when 1 then d.year.to_s == parts[0]
  when 2 then d.year.to_s == parts[0] && d.month == parts[1].to_i
  when 3 then d.to_date == Date.new(parts[0].to_i, parts[1].to_i, parts[2].to_i)
  else false
  end
rescue StandardError
  false
end

#emit_output(rows, fields, opts) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
# File 'lib/mailmate/cli/search.rb', line 779

def emit_output(rows, fields, opts)
  header_row = fields.map { |f| HEADER_LABELS[f] || f }

  if opts[:align]
    display_rows = rows.map { |r| r.map { |c| csv_quote(c) } }
    display_rows.unshift(header_row) if opts[:header]
    widths = Array.new(fields.size, 0)
    display_rows.each do |r|
      r.each_with_index { |c, i| widths[i] = c.length if c.length > widths[i] }
    end
    display_rows.each do |r|
      padded = r.each_with_index.map do |c, i|
        i == r.size - 1 ? c : c.ljust(widths[i])
      end
      puts padded.join(",")
    end
  else
    puts CSV.generate_line(header_row) if opts[:header]
    rows.each { |r| puts CSV.generate_line(r) }
  end
end

#extract(field, eml_id, path, mail) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
# File 'lib/mailmate/cli/search.rb', line 649

def extract(field, eml_id, path, mail)
  case field
  when "id"         then eml_id
  when "path"       then path
  when "mailbox"    then path.sub("#{Mailmate.config.imap_root}/", "").sub(%r{/Messages/[^/]+\.eml\z}, "")
  when "date"
    t = message_time(eml_id, mail)
    Mailmate.localize(t)&.strftime("%Y-%m-%d")
  when "time"
    t = message_time(eml_id, mail)
    Mailmate.localize(t)&.strftime("%H:%M")
  when "read"
    flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
    flags.include?("\\Seen") ? "R" : "U"
  when "archive"
    path.include?("/Archive.mailbox/") ? "A" : "P"
  when "flags"
    archive = path.include?("/Archive.mailbox/") ? "A" : "P"
    seen    = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).include?("\\Seen")
    "#{archive}#{seen ? 'R' : 'U'}"
  when "tags"
    flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
    flags.reject { |f| f.start_with?("\\", "$") }.join(",")
  when "keywords"
    (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).join(",")
  when "from"        then index_or_mail(eml_id, "from",        mail ? Array(mail.from).join("; ")     : nil)
  when "to"          then index_or_mail(eml_id, "to",          mail ? Array(mail.to).join("; ")       : nil)
  when "cc"          then index_or_mail(eml_id, "cc",          mail ? Array(mail.cc).join("; ")       : nil)
  when "bcc"         then index_or_mail(eml_id, "bcc",         mail ? Array(mail.bcc).join("; ")      : nil)
  when "reply-to"    then index_or_mail(eml_id, "reply-to",    mail ? Array(mail.reply_to).join("; ") : nil)
  when "subject"     then index_or_mail(eml_id, "subject",     mail&.subject)
  when "message-id"  then index_or_mail(eml_id, "message-id",  mail&.message_id)
  when "message-url"
    mid = index_or_mail(eml_id, "message-id", mail&.message_id)
    mid.empty? ? "" : Mailmate::MidUrl.message_url_for(mid)
  when "references"  then index_or_mail(eml_id, "references",  mail ? Array(mail.references).join(" ")  : nil)
  when "in-reply-to" then index_or_mail(eml_id, "in-reply-to", mail ? Array(mail.in_reply_to).join(" ") : nil)
  when "direction"   then outbound?(path, mail, eml_id) ? "" : ""
  when "party"       then party_for(eml_id, mail, outbound?(path, mail, eml_id))
  end.to_s
end

#field_value(eml_id, mail, field) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Substring-match haystack for a filter modifier. Index-first; mail fallback only kicks in for the no-index case (tests, fresh installs, messages MailMate hasn’t indexed yet).



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
# File 'lib/mailmate/cli/search.rb', line 406

def field_value(eml_id, mail, field)
  case field
  when :from
    idx = header_index_value_lc(eml_id, "from")
    return idx if idx && !idx.empty?
    mail ? [Array(mail.from), mail[:from]&.value.to_s].flatten.join(" ").downcase : ""
  when :recipients
    parts = %w[to cc].map { |n| header_index_value_lc(eml_id, n) }.compact.reject(&:empty?)
    return parts.join(" ") unless parts.empty?
    mail ? [Array(mail.to), Array(mail.cc), mail[:to]&.value.to_s, mail[:cc]&.value.to_s].flatten.join(" ").downcase : ""
  when :cc
    idx = header_index_value_lc(eml_id, "cc")
    return idx if idx && !idx.empty?
    mail ? [Array(mail.cc), mail[:cc]&.value.to_s].flatten.join(" ").downcase : ""
  when :subject
    idx = header_index_value_lc(eml_id, "subject")
    return idx if idx && !idx.empty?
    mail ? mail.subject.to_s.downcase : ""
  when :address_any
    parts = %w[from to cc reply-to sender].map { |n| header_index_value_lc(eml_id, n) }.compact.reject(&:empty?)
    return parts.join(" ") unless parts.empty?
    mail ? [mail[:from], mail[:to], mail[:cc], mail[:reply_to], mail[:sender]].compact.map { |h| h.value.to_s }.join(" ").downcase : ""
  end
end

#fields_tier(fields) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



691
692
693
694
695
696
# File 'lib/mailmate/cli/search.rb', line 691

def fields_tier(fields)
  ts = fields.map { |f| FIELD_TIERS[f] || :header }.uniq
  return :full   if ts.include?(:full)
  return :header if ts.include?(:header)
  :index
end

#first_address(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

First bare email address from a header value, lower-cased. Accepts either “Name <addr>” or “addr”; for comma-separated lists, returns the first one.



613
614
615
616
617
618
# File 'lib/mailmate/cli/search.rb', line 613

def first_address(value)
  return nil if value.nil? || value.empty?
  first = value.split(",").first.to_s.strip
  addr = first =~ /<([^>]+)>/ ? Regexp.last_match(1) : first
  addr.to_s.downcase
end

#header_block(path) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

—- pre-filter ———————————————————

Filter modifiers (f/t/s/c/a) now match through MailMate’s per-header indexes — index lookup IS the prefilter, no .eml read needed. The only remaining use of the .eml header-block grep is smart-mailbox filters that reference literal strings in arbitrary headers; those still benefit from a quick header-block scan to skip non-matching messages before any full evaluation.



547
548
549
550
551
552
553
554
555
556
557
558
# File 'lib/mailmate/cli/search.rb', line 547

def header_block(path)
  bytes = +""
  File.open(path, "rb") do |f|
    while (chunk = f.read(4096))
      bytes << chunk
      idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
      return bytes[0..idx].downcase if idx
      break if bytes.bytesize > 65_536
    end
  end
  bytes.downcase
end

#header_index_value(eml_id, name) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

MailMate keeps a per-header binary index under Database.noindex/Headers/ — one cache/offsets file per RFC header name. Reading from there is O(1) and skips the .eml entirely. Returns nil if the index is missing (e.g. tests against a synthetic config) or if the eml-id isn’t in it, so callers can fall back to a parsed ‘Mail` object.

IndexReader returns the cache substring as ASCII-8BIT (raw bytes from File.binread). Force UTF-8 + scrub here so values from the index can safely interleave with UTF-8 strings in joined output rows.



596
597
598
599
600
601
602
# File 'lib/mailmate/cli/search.rb', line 596

def header_index_value(eml_id, name)
  return nil if eml_id.nil?
  v = Mailmate::IndexReader.for(name).value_for(eml_id.to_i)
  v && v.dup.force_encoding("UTF-8").scrub
rescue ArgumentError
  nil
end

#header_index_value_lc(eml_id, name) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Lowercased index value for a header — tries ‘<name>#lc` (MailMate’s pre-downcased index) first, falls back to ‘<name>` + downcase. Returns nil if neither index has a record for this eml-id.



396
397
398
399
400
401
# File 'lib/mailmate/cli/search.rb', line 396

def header_index_value_lc(eml_id, name)
  v = header_index_value(eml_id, "#{name}#lc")
  return v unless v.nil?
  raw = header_index_value(eml_id, name)
  raw&.downcase
end

#index_or_mail(eml_id, name, fallback) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



604
605
606
607
608
# File 'lib/mailmate/cli/search.rb', line 604

def index_or_mail(eml_id, name, fallback)
  v = header_index_value(eml_id, name)
  return v if v && !v.empty?
  fallback.to_s
end

#load_message(path, tier) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

—- driver loop ——————————————————–



700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
# File 'lib/mailmate/cli/search.rb', line 700

def load_message(path, tier)
  case tier
  when :index then nil
  when :header
    bytes = +""
    File.open(path, "rb") do |f|
      while (chunk = f.read(4096))
        bytes << chunk
        idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
        break if idx
        break if bytes.bytesize > 65_536
      end
    end
    Mail.new(bytes)
  when :full
    Mail.read(path)
  end
end

#matches?(mail, eml_id, specs, headers_only, path = nil, index_only: false, exclude_quoted: false) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/mailmate/cli/search.rb', line 516

def matches?(mail, eml_id, specs, headers_only, path = nil, index_only: false, exclude_quoted: false)
  specs.all? do |field, term, negate|
    hit =
      case field
      when :from, :recipients, :cc, :subject, :address_any
        field_value(eml_id, mail, field).include?(term)
      when :tag, :keyword
        tag_value(eml_id).include?(term)
      when :body
        headers_only ? false : body_value(eml_id, mail, path, index_only: index_only, exclude_quoted: exclude_quoted).include?(term)
      when :message_or_body
        common = %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term) }
        common || (!headers_only && body_value(eml_id, mail, path, index_only: index_only, exclude_quoted: exclude_quoted).include?(term))
      when :date
        date_matches?(mail, eml_id, term)
      when :any
        %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term) }
      end
    negate ? !hit : hit
  end
end

#message_time(eml_id, mail) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Absolute send time for an eml_id, preferring the MailMate ‘#date` index (cheap, no .eml read). Falls back to the parsed mail’s Date header.



572
573
574
575
576
577
578
579
580
581
582
583
# File 'lib/mailmate/cli/search.rb', line 572

def message_time(eml_id, mail)
  s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
  if s && !s.empty?
    t = (Time.parse(s) rescue nil)
    return t if t
  end
  raw = mail&.date
  return nil unless raw
  raw.respond_to?(:to_time) ? raw.to_time : raw
rescue StandardError
  nil
end

#outbound?(path, mail, eml_id = nil) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


627
628
629
630
631
632
633
634
# File 'lib/mailmate/cli/search.rb', line 627

def outbound?(path, mail, eml_id = nil)
  return true if path.include?("/Sent Mail.mailbox/") ||
                 path.include?("/Sent Messages.mailbox/") ||
                 path.include?("/Drafts.mailbox/")
  from = first_address(header_index_value(eml_id, "from")) ||
         Array(mail&.from).first.to_s.downcase
  Mailmate::Identity.mine?(from)
end

#parse_search(str) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/mailmate/cli/search.rb', line 326

def parse_search(str)
  tokens = tokenize(str)
  specs = []
  i = 0
  while i < tokens.size
    tok = tokens[i]
    field = MODIFIERS[tok]
    if field && i + 1 < tokens.size
      operand = tokens[i + 1]
      negate = operand.start_with?("!")
      operand = operand[1..] if negate
      specs << [field, operand.downcase, negate]
      i += 2
    else
      negate = tok.start_with?("!")
      operand = negate ? tok[1..] : tok
      # Bare terms default to MailMate's "Common" specifier — common
      # headers OR body — matching the UI quicksearch behavior. Pass
      # --headers-only to skip the body scan when speed matters.
      specs << [:message_or_body, operand.downcase, negate]
      i += 1
    end
  end
  specs
end

#party_for(eml_id, mail, outbound) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



636
637
638
639
640
641
642
643
644
645
646
647
# File 'lib/mailmate/cli/search.rb', line 636

def party_for(eml_id, mail, outbound)
  if outbound
    to_str = index_or_mail(eml_id, "to", mail ? Array(mail.to).join(", ") : "")
    cc_str = index_or_mail(eml_id, "cc", mail ? Array(mail.cc).join(", ") : "")
    tokens = split_addresses(to_str) + split_addresses(cc_str)
    others = Mailmate::Identity.reject_mine(tokens.map { |t| first_address(t) || t })
    others = split_addresses(to_str) if others.empty?
    others.join("; ")
  else
    index_or_mail(eml_id, "from", mail ? Array(mail.from).join("; ") : "")
  end
end

#prefilter_pass?(path, _specs, smart_literals = []) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


560
561
562
563
564
565
566
# File 'lib/mailmate/cli/search.rb', line 560

def prefilter_pass?(path, _specs, smart_literals = [])
  return true if smart_literals.empty?
  hdr = header_block(path)
  smart_literals.all? { |lit| hdr.include?(lit) }
rescue StandardError
  true
end

#resolve_account(name) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/mailmate/cli/search.rb', line 245

def (name)
  root = Mailmate.config.imap_root
  return name if File.directory?("#{root}/#{name}")
  encoded = name.gsub("@", "%40")
  candidates = Dir.glob("#{root}/#{encoded}@*").map { |p| File.basename(p) }
  case candidates.size
  when 0 then nil
  when 1 then candidates.first
  else
    warn "Ambiguous account '#{name}': #{candidates.join(", ")}"
    nil
  end
end

#resolve_mailbox(arg) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/mailmate/cli/search.rb', line 259

def resolve_mailbox(arg)
  root = Mailmate.config.imap_root
  return [all_message_dirs, []] if arg == "all"

  if arg.include?("/")
    , rest = arg.split("/", 2)
    if (encoded = ())
      nested = rest.split("/").map { |s| "#{s}.mailbox" }.join("/")
      cand = "#{root}/#{encoded}/#{nested}/Messages"
      return [[cand], []] if File.directory?(cand)
    end
  end

  if (encoded = (arg))
    dirs = Dir.glob("#{root}/#{encoded}/**/Messages").select { |p| File.directory?(p) }
    return [dirs, []]
  end

  matches = Dir.glob("#{root}/*/**/#{arg}.mailbox/Messages").select { |p| File.directory?(p) }
  return [matches, []] unless matches.empty?

  # Fall back: try MailMate's smart-mailbox graph.
  graph = Mailmate::MailboxGraph.load
  if (uuid = graph.by_name[arg]) || graph.by_uuid[arg]
    uuid ||= arg
    res = Mailmate::SourceResolver.new(graph).resolve(uuid)
    return [res[:dirs], res[:filters], graph]
  end

  warn "Mailbox not resolved: '#{arg}'."
  [[], []]
end

#resolve_mailbox_with_graph(arg) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



292
293
294
295
# File 'lib/mailmate/cli/search.rb', line 292

def resolve_mailbox_with_graph(arg)
  result = resolve_mailbox(arg)
  result.size == 2 ? [*result, nil] : result
end

#run(argv) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/mailmate/cli/search.rb', line 64

def run(argv)
  opts = {
    mailbox: "all", limit: nil, headers_only: false, all: false,
    exclude_quoted: false,
    header: true, align: true, sort: :asc,
  }

  parser = build_parser(opts)
  parser.parse!(argv)

  search_string = argv[0] || DEFAULT_SEARCH
  fields_arg    = (opts[:fields] || argv[1] || DEFAULT_FIELDS).to_s.strip
  # `+...` means "defaults plus these"; bare list = exactly those columns.
  # Defaults already include `id` as the first column, so `+x` keeps id
  # automatic while a bare list lets callers omit it (useful for
  # `mmsearch foo 'message-id' | sort | uniq` where leading per-row ids
  # would defeat the dedup).
  fields_arg    = "#{DEFAULT_FIELDS} #{fields_arg[1..]}" if fields_arg.start_with?("+")
  # Split on whitespace OR commas (or both) so callers can pass
  # 'subject message-id', 'subject,message-id', or any mix.
  fields = fields_arg.split(/[\s,]+/).reject(&:empty?).uniq

  imap_root = Mailmate.config.imap_root
  unless File.directory?(imap_root)
    warn "MailMate IMAP root not found: #{imap_root}"
    return 1
  end

  unknown = fields - VALID_FIELDS
  unless unknown.empty?
    warn "Unknown field(s): #{unknown.join(", ")}"
    warn "Valid: #{VALID_FIELDS.join(", ")}"
    return 2
  end

  dirs, smart_filters, smart_graph = resolve_mailbox_with_graph(opts[:mailbox])
  if dirs.empty?
    warn "No mailbox directories resolved."
    return 1
  end

  specs = parse_search(search_string)

  # Compose + parse the smart-mailbox filter exactly once. The same AST
  # feeds the evaluator, the tier classifier, and the literals extractor.
  composed_ast = nil
  composed_str = nil
  smart_evaluator =
    if smart_filters.any?
      composed_str = compose_smart_filters(smart_filters)
      begin
        composed_ast = Mailmate.compile_filter(composed_str)
        var_resolver = smart_graph ? Mailmate::VarResolver.new(smart_graph) : nil
        Mailmate::Evaluator.new(composed_ast, var_resolver: var_resolver)
      rescue Mailmate::Lexer::Error, Mailmate::Parser::Error => e
        warn "Smart-mailbox filter parse error: #{e.message}\n  filter: #{composed_str}"
        return 1
      end
    end

  filter_tier      = composed_ast ? Mailmate::FilterClassifier.tier(composed_ast) : :index
  # Every spec is now index-tier. Body matching reads MailMate's
  # `#unquoted#lc`/`#quoted#lc` indexes (zero .eml read for indexed
  # messages); `body_value` lazily Mail.reads the .eml for the rare
  # misses. Header/tag/date specs all hit per-header indexes too.
  specs_tier = :index
  fields_tier_     = fields_tier(fields)
  filter_only_tier = Mailmate::FilterClassifier.combine_tiers(filter_tier, specs_tier)
  load_tier        = Mailmate::FilterClassifier.combine_tiers(filter_only_tier, fields_tier_)

  smart_literals = composed_ast ? Mailmate::FilterClassifier.header_literals(composed_ast) : []

  rows = collect_rows(
    dirs: dirs, specs: specs, fields: fields,
    smart_evaluator: smart_evaluator, smart_literals: smart_literals,
    filter_only_tier: filter_only_tier, load_tier: load_tier,
    opts: opts,
  )

  sort_rows!(rows, opts[:sort])
  emit_output(rows, fields, opts)
  0
end

#sort_rows!(rows, mode) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Sorts ‘rows` in place by the message’s absolute send instant (UTC), so senders in different timezones still order correctly. The first column is always ‘id` (forced in `run`), which lets us hit the `#date` index without re-reading any .eml.



154
155
156
157
158
159
160
161
162
163
164
# File 'lib/mailmate/cli/search.rb', line 154

def sort_rows!(rows, mode)
  return rows if mode == :none || rows.size < 2
  reader = Mailmate::IndexReader.for("#date") rescue nil
  epoch = Time.at(0)
  rows.sort_by! do |r|
    s = reader && (reader.value_for(r[0].to_i) rescue nil)
    (s && !s.empty? && (Time.parse(s) rescue nil)) || epoch
  end
  rows.reverse! if mode == :desc
  rows
end

#split_addresses(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Split a comma-separated address-list header value into individual tokens, each kept in its original “Name <addr>” form.



622
623
624
625
# File 'lib/mailmate/cli/search.rb', line 622

def split_addresses(value)
  return [] if value.nil? || value.empty?
  value.split(",").map(&:strip).reject(&:empty?)
end

#tag_value(eml_id) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

MailMate stores user tags as IMAP keywords in the ‘#flags` index — not as `X-Keywords`/`Keywords` headers in the .eml — so tag matching has to go through the index, not the parsed mail. Strips `…` (RFC) and `$…` (Thunderbird/Apple) system flags so substring matches only hit user tags.



435
436
437
438
439
# File 'lib/mailmate/cli/search.rb', line 435

def tag_value(eml_id)
  return "" unless eml_id
  flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
  flags.reject { |f| f.start_with?("\\", "$") }.join(" ").downcase
end

#text_body(mail) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



441
442
443
444
445
# File 'lib/mailmate/cli/search.rb', line 441

def text_body(mail)
  (mail.text_part&.decoded || mail.body.decoded).to_s.force_encoding("UTF-8").scrub.downcase
rescue StandardError
  ""
end

#tokenize(str) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

—- search-string parsing ———————————————-



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/mailmate/cli/search.rb', line 305

def tokenize(str)
  tokens = []
  i = 0
  while i < str.length
    c = str[i]
    if c == " " || c == "\t"
      i += 1
    elsif c == "\""
      j = str.index("\"", i + 1) || str.length
      tokens << str[(i + 1)...j]
      i = j + 1
    else
      j = i
      j += 1 while j < str.length && str[j] != " "
      tokens << str[i...j]
      i = j
    end
  end
  tokens
end