module Mailmate::CLI::Search
def all_message_dirs
def all_message_dirs Dir.glob("#{Mailmate.config.imap_root}/*/**/Messages").select { |p| File.directory?(p) } end
def build_parser(opts)
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 Space-separated columns to show (id is always first)." o.separator " Default: 'flags date time direction party subject'." o.separator " Prefix with '+' to add to 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") { opts[:headers_only] = 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 (slow — disables prefilter)" 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 " 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
def can_prefilter?(specs)
def can_prefilter?(specs) specs.any? do |field, term, negate| !negate && HEADER_FIELDS.include?(field) && term.bytesize >= 3 && term.ascii_only? end end
def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
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]) 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]) 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
def compose_smart_filters(filters)
def compose_smart_filters(filters) return "" if filters.empty? return filters.first if filters.size == 1 "(#{filters.map { |f| "(#{f})" }.join(" and ")})" end
def csv_quote(cell)
def csv_quote(cell) cell = cell.to_s.gsub(/[\r\n]+/, " ") if cell.include?(",") || cell.include?("\"") "\"#{cell.gsub("\"", "\"\"")}\"" else cell end end
def date_matches?(mail, eml_id, term)
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
def emit_output(rows, fields, opts)
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
def extract(field, eml_id, path, mail)
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 mail ? Array(mail.from).join("; ") : nil when "to" then mail ? Array(mail.to).join("; ") : nil when "cc" then mail ? Array(mail.cc).join("; ") : nil when "bcc" then mail ? Array(mail.bcc).join("; ") : nil when "reply-to" then mail ? Array(mail.reply_to).join("; ") : nil when "subject" then mail&.subject.to_s when "message-id" then mail&.message_id.to_s when "references" then mail ? Array(mail.references).join(" ") : nil when "in-reply-to" then mail ? Array(mail.in_reply_to).join(" ") : nil when "direction" then mail ? (outbound?(path, mail) ? "→" : "←") : nil when "party" then mail ? party_for(mail, outbound?(path, mail)) : nil end.to_s end
def field_value(mail, field)
def field_value(mail, field) case field when :from [Array(mail.from), mail[:from]&.value.to_s].flatten.join(" ").downcase when :recipients [Array(mail.to), Array(mail.cc), mail[:to]&.value.to_s, mail[:cc]&.value.to_s] .flatten.join(" ").downcase when :cc [Array(mail.cc), mail[:cc]&.value.to_s].flatten.join(" ").downcase when :subject mail.subject.to_s.downcase when :address_any [mail[:from], mail[:to], mail[:cc], mail[:reply_to], mail[:sender]] .compact.map { |h| h.value.to_s }.join(" ").downcase end end
def fields_tier(fields)
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
def header_block(path)
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
def load_message(path, tier)
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
def matches?(mail, eml_id, specs, headers_only)
def matches?(mail, eml_id, specs, headers_only) specs.all? do |field, term, negate| hit = case field when :from, :recipients, :cc, :subject, :address_any field_value(mail, field).include?(term) when :tag, :keyword tag_value(eml_id).include?(term) when :body headers_only ? false : text_body(mail).include?(term) when :message_or_body common = %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) } common || (!headers_only && text_body(mail).include?(term)) when :date date_matches?(mail, eml_id, term) when :any %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) } end negate ? !hit : hit end end
def message_time(eml_id, mail)
Absolute send time for an eml_id, preferring the MailMate `#date` index
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
def outbound?(path, mail)
def outbound?(path, mail) return true if path.include?("/Sent Mail.mailbox/") || path.include?("/Sent Messages.mailbox/") || path.include?("/Drafts.mailbox/") from = Array(mail.from).first.to_s.downcase Mailmate::Identity.mine?(from) end
def parse_search(str)
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
def party_for(mail, outbound)
def party_for(mail, outbound) if outbound others = Mailmate::Identity.reject_mine(Array(mail.to) + Array(mail.cc)) others = Array(mail.to) if others.empty? others.join("; ") else Array(mail.from).join("; ") end end
def prefilter_pass?(path, specs, smart_literals = [])
def prefilter_pass?(path, specs, smart_literals = []) return true if !can_prefilter?(specs) && smart_literals.empty? hdr = header_block(path) specs.each do |field, term, negate| next if negate next unless HEADER_FIELDS.include?(field) next unless term.bytesize >= 3 && term.ascii_only? return false unless hdr.include?(term) end smart_literals.each do |lit| return false unless hdr.include?(lit) end true rescue StandardError true end
def resolve_account(name)
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
def resolve_mailbox(arg)
def resolve_mailbox(arg) root = Mailmate.config.imap_root return [all_message_dirs, []] 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
def resolve_mailbox_with_graph(arg)
def resolve_mailbox_with_graph(arg) result = resolve_mailbox(arg) result.size == 2 ? [*result, nil] : result end
def run(argv)
def run(argv) opts = { mailbox: "all", limit: nil, headers_only: 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 replaces defaults. fields_arg = "#{DEFAULT_FIELDS} #{fields_arg[1..]}" if fields_arg.start_with?("+") extra_fields = fields_arg.split(/\s+/).reject(&:empty?) fields = (["id"] + extra_fields).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 specs_tier = if specs.empty? :index elsif specs.any? { |field, _, _| (field == :body || field == :message_or_body) && !opts[:headers_only] } :full elsif specs.all? { |field, _, _| %i[date tag keyword].include?(field) } :index else :header end 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
def sort_rows!(rows, mode)
is always `id` (forced in `run`), which lets us hit the `#date` index
senders in different timezones still order correctly. The first column
Sorts `rows` in place by the message's absolute send instant (UTC), so
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
def tag_value(eml_id)
go through the index, not the parsed mail. Strips `\…` (RFC) and `$…`
as `X-Keywords`/`Keywords` headers in the .eml — so tag matching has to
MailMate stores user tags as IMAP keywords in the `#flags` index — not
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
def text_body(mail)
def text_body(mail) (mail.text_part&.decoded || mail.body.decoded).to_s.force_encoding("UTF-8").scrub.downcase rescue StandardError "" end
def tokenize(str)
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