Module: Mailmate::CLI::Search Private
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
-
#all_message_dirs ⇒ Object
private
—- mailbox resolution ————————————————-.
-
#body_index_records(eml_id, exclude_quoted: false) ⇒ Object
private
Lowercased body-text segments from MailMate’s #unquoted#lc and #quoted#lc indexes, aggregated across every body-part of the envelope.
-
#body_value(eml_id, mail, path, index_only: false, exclude_quoted: false) ⇒ Object
private
Lowercased body substring-match haystack.
-
#build_parser(opts) ⇒ Object
private
—- option parsing —————————————————–.
- #collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:) ⇒ Object private
- #compose_smart_filters(filters) ⇒ Object private
-
#csv_quote(cell) ⇒ Object
private
—- output ————————————————————-.
-
#date_matches?(mail, eml_id, term) ⇒ Boolean
private
—- date matching ——————————————————.
- #emit_output(rows, fields, opts) ⇒ Object private
- #extract(field, eml_id, path, mail) ⇒ Object private
-
#field_value(eml_id, mail, field) ⇒ Object
private
Substring-match haystack for a filter modifier.
- #fields_tier(fields) ⇒ Object private
-
#first_address(value) ⇒ Object
private
First bare email address from a header value, lower-cased.
-
#header_block(path) ⇒ Object
private
—- pre-filter ———————————————————.
-
#header_index_value(eml_id, name) ⇒ Object
private
MailMate keeps a per-header binary index under Database.noindex/Headers/ — one cache/offsets file per RFC header name.
-
#header_index_value_lc(eml_id, name) ⇒ Object
private
Lowercased index value for a header — tries ‘<name>#lc` (MailMate’s pre-downcased index) first, falls back to ‘<name>` + downcase.
- #index_or_mail(eml_id, name, fallback) ⇒ Object private
-
#load_message(path, tier) ⇒ Object
private
—- driver loop ——————————————————–.
- #matches?(mail, eml_id, specs, headers_only, path = nil, index_only: false, exclude_quoted: false) ⇒ Boolean private
-
#message_time(eml_id, mail) ⇒ Object
private
Absolute send time for an eml_id, preferring the MailMate ‘#date` index (cheap, no .eml read).
- #outbound?(path, mail, eml_id = nil) ⇒ Boolean private
- #parse_search(str) ⇒ Object private
- #party_for(eml_id, mail, outbound) ⇒ Object private
- #prefilter_pass?(path, _specs, smart_literals = []) ⇒ Boolean private
- #resolve_account(name) ⇒ Object private
- #resolve_mailbox(arg) ⇒ Object private
- #resolve_mailbox_with_graph(arg) ⇒ Object private
- #run(argv) ⇒ Object private
-
#sort_rows!(rows, mode) ⇒ Object
private
Sorts ‘rows` in place by the message’s absolute send instant (UTC), so senders in different timezones still order correctly.
-
#split_addresses(value) ⇒ Object
private
Split a comma-separated address-list header value into individual tokens, each kept in its original “Name <addr>” form.
-
#tag_value(eml_id) ⇒ Object
private
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.
- #text_body(mail) ⇒ Object private
-
#tokenize(str) ⇒ Object
private
—- search-string parsing ———————————————-.
Instance Method Details
#all_message_dirs ⇒ 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.
—- mailbox resolution ————————————————-
241 242 243 |
# File 'lib/mailmate/cli/search.rb', line 241 def 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. = "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 = (path, load_tier) rescue StandardError => e warn "[skip] #{path}: #{e.}" 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 ——————————————————
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 = (eml_id, mail) Mailmate.localize(t)&.strftime("%Y-%m-%d") when "time" t = (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&.) when "message-url" mid = index_or_mail(eml_id, "message-id", mail&.) mid.empty? ? "" : Mailmate::MidUrl.(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 (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.
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 (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.
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.
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 resolve_account(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 [, []] if arg == "all" if arg.include?("/") account, rest = arg.split("/", 2) if (encoded = resolve_account(account)) nested = rest.split("/").map { |s| "#{s}.mailbox" }.join("/") cand = "#{root}/#{encoded}/#{nested}/Messages" return [[cand], []] if File.directory?(cand) end end if (encoded = resolve_account(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.}\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 |