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.

Defined Under Namespace

Classes: Row

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
DEFAULT_SORT =

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.

--sort / --limit-by values: { key: <field>, dir: :asc|:desc }, or :none (sort only). Omitted --sort keeps the historical date-ascending output; omitted --limit-by keeps the N newest.

{ key: "date", dir: :asc }.freeze
DEFAULT_LIMIT_BY =

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.

{ key: "date", dir: :desc }.freeze
DESC_BY_DEFAULT =

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.

A bare KEY (no :DIR) reads the way the key does: dates newest-first, text A→Z.

%w[date time].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"
STATS_PREFIX =

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.

The --stats contract: this prefix, then one JSON object, on the first stderr line. matches, returned and scan_capped are always present; keys are additive-only, and schema increments only on a change that breaks that promise. Consumers (markdownr's search_mail, mailmate-mcp) parse this instead of the prose notices.

"stats: "
STATS_SCHEMA =

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.

1
SPEC_COST =

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.

Static cost rank per spec field for AND evaluation order: compiled date compare < header/tag index lookup < body matching (resolves part-ids and walks every body segment). Used by order_specs.

{
  date: 0,
  from: 1, recipients: 1, cc: 1, subject: 1, address_any: 1, any: 1,
  tag: 1, keyword: 1, state: 1, header: 1,
  body: 2, message_or_body: 2,
}.freeze
STATE_CANON =

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.

Canonical state names for is:/has: specs, including the spellings Gmail callers actually use. Values map to a #flags IMAP flag except :unread (absence of Seen) and :attachment (root MIME layout).

{
  "unread" => :unread, "read" => :read,
  "flagged" => :flagged, "starred" => :flagged,
  "replied" => :replied, "answered" => :replied,
  "draft" => :draft,
  "archived" => :archived, "archive" => :archived,
  "attachment" => :attachment, "attachments" => :attachment,
}.freeze
STATE_FLAGS =

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.

{
  read: "\\Seen", flagged: "\\Flagged", replied: "\\Answered", draft: "\\Draft",
}.freeze
LC_NAMES =

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.

Memoized "#lc" strings — interpolating per lookup costs an allocation per header per message.

Hash.new { |h, n| h[n] = "#{n}#lc" }

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 -------------------------------------------------



434
435
436
# File 'lib/mailmate/cli/search.rb', line 434

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

#apply_limit(rows, limit, offset, limit_by, 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.

Keeps rows offset through offset + limit of the limit_by ordering, taken over the FULL match set. Returns the same array when no cap applies. Rows come back in limit-by order; sort_rows! re-orders them for output.



344
345
346
347
348
349
# File 'lib/mailmate/cli/search.rb', line 344

def apply_limit(rows, limit, offset, limit_by, fields)
  return rows if offset.zero? && (limit.nil? || rows.size <= limit)
  order_rows!(rows, limit_by, fields)
  window = rows.drop(offset)
  limit ? window.first(limit) : window
end

#body_candidates(term_b, 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.

Envelope-id candidate set for a body term: every message with at least one body segment containing the bytes. Returns nil when the body indexes are unavailable (callers fall back to the per-message walk). Memoized per (term, exclude_quoted) and pinned to the reader objects it was built from, so an index rebuild (staleness, reset!) invalidates naturally; the size cap stops distinct-term buildup in the long-lived MCP server.



1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
# File 'lib/mailmate/cli/search.rb', line 1243

def body_candidates(term_b, exclude_quoted: false)
  names = exclude_quoted ? ["#unquoted#lc"] : ["#unquoted#lc", "#quoted#lc"]
  readers = names.map { |n| (Mailmate::IndexReader.for(n) rescue nil) }.compact
  return nil if readers.empty?

  @body_cands ||= {}
  key = [term_b, exclude_quoted]
  entry = @body_cands[key]
  if entry && entry[:readers].size == readers.size &&
     entry[:readers].zip(readers).all? { |a, b| a.equal?(b) }
    return entry[:set]
  end

  @body_cands.clear if @body_cands.size > 32
  set = {}
  readers.each do |r|
    r.ids_matching(term_b).each_key { |pid| set[envelope_of(pid)] = true }
  end
  @body_cands[key] = { readers: readers, set: set }
  set
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.



1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
# File 'lib/mailmate/cli/search.rb', line 1147

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_indexed?(env, 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.

Does this envelope have any body-index records at all? Distinguishes "indexed, doesn't contain the term" (no match) from "MailMate hasn't body-indexed it" (eligible for the --all Mail.read fallback).

Returns:

  • (Boolean)


1276
1277
1278
1279
1280
1281
1282
1283
1284
# File 'lib/mailmate/cli/search.rb', line 1276

def body_indexed?(env, exclude_quoted: false)
  part_ids = Mailmate::PartLookup.body_parts_of(env)
  part_ids = [env] if part_ids.empty?
  names = exclude_quoted ? ["#unquoted#lc"] : ["#unquoted#lc", "#quoted#lc"]
  names.any? do |n|
    r = (Mailmate::IndexReader.for(n) rescue nil)
    r && part_ids.any? { |pid| r.key?(pid) }
  end
end

#body_matches?(eml_id, mail, path, term, term_b, 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.

---- body matching --------------------------------------------------

Body matching is inverted: instead of fetching and testing every body segment of every candidate message (which reallocates most of the body cache per search), one ids_matching scan per body index finds every part-id containing the term, mapped once to a set of envelope ids. Per message the test is then a hash lookup. The per-message segment walk (body_index_records / body_value) survives as the fallback when the body indexes aren't on disk at all (tests, fresh installs), and the Mail.read fallback for unindexed messages under --all is unchanged.

Returns:

  • (Boolean)


1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
# File 'lib/mailmate/cli/search.rb', line 1213

def body_matches?(eml_id, mail, path, term, term_b, index_only: false, exclude_quoted: false)
  env = eml_id&.to_i
  cands = env && body_candidates(term_b, exclude_quoted: exclude_quoted)
  if cands
    return true if cands.key?(env)
    return false if index_only
    # Indexed but not a candidate = a real non-match; only unindexed
    # messages get the --all read-the-eml fallback below.
    return false if body_indexed?(env, exclude_quoted: exclude_quoted)
  else
    segs = body_index_records(eml_id, exclude_quoted: exclude_quoted)
    return segs.any? { |s| s.b.include?(term_b) } unless segs.empty?
    return false if index_only
  end
  return text_body(mail).include?(term) if mail
  return false if path.nil?
  begin
    text_body(Mail.read(path)).include?(term)
  rescue StandardError
    false
  end
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.



1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
# File 'lib/mailmate/cli/search.rb', line 1121

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 -----------------------------------------------------



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
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'lib/mailmate/cli/search.rb', line 357

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,
         "Return at most N rows: the top N by --limit-by (default: the N newest), taken after the full scan. Announces truncation on stderr.") { |n| opts[:limit] = n }
    o.on("--limit-by KEY[:DIR]",
         "Which rows --limit keeps. KEY is any output field; DIR is asc|desc (bare date/time = desc, text keys = asc). Default: date:desc") { |v| opts[:limit_by] = parse_order(v) }
    o.on("--offset N", Integer,
         "Skip the first N rows of the --limit-by ordering before taking --limit: rows 1001-1200 newest-first is --offset 1000 --limit 200. Pages of a live mailbox drift as mail arrives; a date term (d <2026-08-25) is the stable way to page.") { |n|
      raise OptionParser::InvalidArgument, "#{n}: offset cannot be negative" if n.negative?
      opts[:offset] = n
    }
    o.on("--scan-limit N", Integer,
         "Stop SCANNING after N matches, in undefined order — a speed bound for slow --all body scans, never a way to pick rows. Announces on stderr.") { |n| opts[:scan_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 KEY[:DIR]",
         "Order of the emitted rows. asc|desc|none alone mean date; or any output field, e.g. from, subject:desc (bare date/time = desc, text keys = asc). Ties break newest-first. Default: date:asc") { |v| opts[:sort] = parse_order(v, allow_none: true) }
    o.on("--stats",
         "Write one machine-readable line FIRST on stderr — stats: {\"schema\":1,\"matches\":M,\"returned\":N,\"scan_capped\":false,...} — in place of the [limit]/[scan-limit] prose. Keys are additive-only; other stderr follows it.") { opts[:stats] = true }
    o.on("--european",
         "Slash dates are day-first: d 9/8/2026 = Aug 9 (default: month-first American)") { opts[:european] = true }
    o.separator ""
    o.separator "SEARCH-STRING SYNTAX"
    o.separator "  Mirrors MailMate's toolbar quicksearch, plus native state specs"
    o.separator "  (is:unread, has:attachment). Other familiar key:value tokens are"
    o.separator "  auto-translated (see FOREIGN SYNTAX below)."
    o.separator Mailmate::SearchSyntax.reference(indent: "  ")
    o.separator "  (b also takes --all to include un-indexed messages.)"
    o.separator ""
    o.separator "FOREIGN SYNTAX (Gmail/Outlook-style, auto-translated, announced on stderr)"
    o.separator Mailmate::SearchSyntax.translation_reference(indent: "  ")
    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.



1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
# File 'lib/mailmate/cli/search.rb', line 1484

def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
  reset_run_caches!
  # Sort/limit keys that are not output columns are extracted here,
  # while the message is live, and ride along in Row#keys. `date` and
  # `id` never need extracting: every row carries instant + eml_id.
  extra_keys = [opts[:sort], opts[:limit_by]].grep(Hash).map { |o| o[:key] } - fields - %w[date id]
  need_instant = opts[:sort] != :none || !opts[:limit].nil? || opts[:offset].positive?
  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

        cells = fields.map { |f| extract(f, eml_id, path, mail) }
        keys  = extra_keys.to_h { |k| [k, extract(k, eml_id, path, mail)] }
        rows << Row.new(eml_id, rows.size, need_instant ? message_time(eml_id, mail) : nil, cells, keys)
        throw :done if opts[:scan_limit] && rows.size >= opts[:scan_limit]
      end
    end
  end
  rows
end

#compile_date_range(term, today) ⇒ 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.

A term is an optional comparison prefix (>, >=, <, <=) on a period. The prefix reshapes the period's inclusive [lo, hi] window: >2026-08 is "after August" = [20260901, max], <2026-08 is "before August" = [min, 20260731]. Bounds are compared as YYYYMMDD integers, so ±1 on a synthetic bound (a month's "day 31", a year's "Dec 31"+1) is safe — no real date falls in the gap. A comparison can produce an empty window (>3d — nothing is after a window that already reaches the future); date_spec_error reports those up front rather than letting them silently match nothing.



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

def compile_date_range(term, today)
  op = nil
  if term =~ /\A(>=|<=|>|<)(.+)\z/
    op, term = Regexp.last_match(1), Regexp.last_match(2)
  end
  base = compile_period_range(term, today)
  return nil unless base
  return base unless op

  lo, hi = base
  case op
  when ">"  then [hi + 1, 9999_12_31]
  when ">=" then [lo, 9999_12_31]
  when "<"  then [0, lo - 1]
  when "<=" then [0, hi]
  end
end

#compile_period_range(term, today) ⇒ 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.



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
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
# File 'lib/mailmate/cli/search.rb', line 721

def compile_period_range(term, today)
  if term =~ /\A(\d+)([dwmy])\z/
    n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
    return nil if n.zero? # a zero-length window matches nothing
    # Calendar units floored to the unit start, matching the app's
    # documented semantics ("1y means this year and not 365 days"):
    # 1d = today, 1w = this ISO week (from Monday), 1m = this month,
    # 1y = this year; N reaches back N-1 further units. Only `Nh` is a
    # rolling clock window — that split is deliberate (2026-08-18):
    # calendar words mean calendar spans, and "the last 24 hours" is
    # spelled d 24h.
    cutoff = case u
             when "d" then today - (n - 1)
             when "w"
               start = today - (7 * (n - 1))
               start - (start.cwday - 1)
             when "m"
               start = today << (n - 1)
               Date.new(start.year, start.month, 1)
             when "y"
               Date.new(today.year - (n - 1), 1, 1)
             end
    return [ymd_int(cutoff), 9999_12_31]
  end

  parts = term.tr("/.", "-").split("-")
  return nil unless parts.any? && parts.all? { |p| p.match?(/\A\d+\z/) }

  case parts.size
  when 1
    if parts[0].length == 4
      y = parts[0].to_i
      return nil if y.zero?
      [y * 10_000 + 101, y * 10_000 + 1231]
    else
      # App semantics: a bare small number is a day of the current
      # month — or the most recent month containing that day when it
      # hasn't happened yet (`d 7` on the 5th = last month's 7th).
      most_recent_day_range(parts[0].to_i, today)
    end
  when 2
    if parts[1].length == 4
      # Month-first with a 4-digit year (8/2026).
      y, m = parts[1].to_i, parts[0].to_i
      return nil if y.zero? || !(1..12).cover?(m)
      [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
    elsif parts[0].length == 4
      # Year-first (2026-08).
      y, m = parts[0].to_i, parts[1].to_i
      return nil if y.zero? || !(1..12).cover?(m)
      [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
    else
      # No year: month + day, ordered per date_order, most recent
      # occurrence (`d 12-25` in August = last year's Dec 25).
      a, b = parts.map(&:to_i)
      m, d = date_order == :dmy ? [b, a] : [a, b]
      most_recent_month_day_range(m, d, today)
    end
  when 3
    # ISO year-first, or slash-date with trailing 4-digit year ordered
    # per date_order. Impossible calendar dates (2026-02-31, month 13)
    # compile to nil so date_spec_error names them instead of the
    # search silently matching nothing.
    y, m, d =
      if parts[0].length == 4
        parts.map(&:to_i)
      elsif parts[2].length == 4
        a, b, yr = parts.map(&:to_i)
        date_order == :dmy ? [yr, b, a] : [yr, a, b]
      end
    return nil unless y && Date.valid_date?(y, m, d)
    [ymd = y * 10_000 + m * 100 + d, ymd]
  end
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.



490
491
492
493
494
# File 'lib/mailmate/cli/search.rb', line 490

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 -------------------------------------------------------------



1543
1544
1545
1546
1547
1548
1549
1550
# File 'lib/mailmate/cli/search.rb', line 1543

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.

Match on the message's absolute send instant, converted to the display zone via Mailmate.localize — the SAME conversion the date/time output columns use, so the day a term matches is always the day the caller sees in the output. (The raw #date index value is sender-local time; matching on its sliced day — the old fast path — made d 1d return mail displayed under yesterday's date whenever the sender's calendar ran ahead of the display zone, e.g. a UTC sender after 6pm MDT.)

Returns:

  • (Boolean)


918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
# File 'lib/mailmate/cli/search.rb', line 918

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

  if (hours = hour_range_for(term))
    f = t.to_f
    return f >= hours[0] && f <= hours[1]
  end

  range = date_range_for(term)
  return false unless range

  local = Mailmate.localize(t)
  ymd = local.year * 10_000 + local.month * 100 + local.day
  ymd >= range[0] && ymd <= range[1]
rescue StandardError
  false
end

#date_orderObject

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.

Slash-date ordering for three-part dates with a trailing 4-digit year: :mdy (American month-first, the default — 8/9/2026 = Aug 9) or :dmy (day-first, the --european flag — 9/8/2026 = Aug 9). ISO Y-M-D is unaffected. Module-level because the compiled-range memo must reset when it flips (the MCP server outlives any one call).



671
672
673
# File 'lib/mailmate/cli/search.rb', line 671

def date_order
  @date_order || :mdy
end

#date_order=(order) ⇒ 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.



675
676
677
# File 'lib/mailmate/cli/search.rb', line 675

def date_order=(order)
  @date_order = order
end

#date_range_for(term) ⇒ 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.

Compiled day-range for a date term, memoized per term. nil = term can't match anything. The memo resets when the calendar day rolls over (so relative terms like "1d" stay correct in long-lived processes — the MCP server) or when date_order flips.



683
684
685
686
687
688
689
690
691
692
# File 'lib/mailmate/cli/search.rb', line 683

def date_range_for(term)
  today = Date.today
  if @date_ranges_day != today || @date_ranges_order != date_order
    @date_ranges_day = today
    @date_ranges_order = date_order
    @date_ranges = {}
  end
  return @date_ranges[term] if @date_ranges.key?(term)
  @date_ranges[term] = compile_date_range(term, today)
end

#date_spec_error(specs) ⇒ 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.

Usage-error string for the date specs in ONE or-group (specs within a group AND together; the caller decides how errors across groups combine), nil when they're fine. Two failure classes, both of which would otherwise surface as a clean, successful, empty result — the silent-nothing this gem keeps having to fight: a single term that cannot match anything (d >3d, d garbage), and positive terms whose windows don't intersect (d >2026 d <2025). Negated terms subtract rather than intersect, so they're validated individually but excluded from the intersection.



805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
# File 'lib/mailmate/cli/search.rb', line 805

def date_spec_error(specs)
  day_terms, hour_terms = [], []
  specs.each do |field, term, negate|
    # State specs validate here too (same pre-pass, same
    # silent-nothing failure being prevented): an unknown state value
    # would otherwise quietly match no message ever.
    if field == :state && !STATE_CANON.key?(term.split(":", 2).last)
      return "state term cannot match anything: #{term} " \
             "(known: is:unread is:read is:flagged is:replied is:draft is:archived has:attachment)"
    end
    if field == :header
      name = term.split(":", 2).first.sub(/\..*/, "")
      if reader_for(name).nil?
        return "no '#{name}' header index — this MailMate store has never seen that " \
               "header. Quote the token (\"#{term}\") to search it as literal text."
      end
    end
    next unless field == :date
    range = hour_range_for(term) || date_range_for(term)
    if range.nil? || range[0] > range[1]
      return "date term cannot match anything: d #{term}#{date_term_hint(term)}"
    end
    next if negate
    (term.end_with?("h") ? hour_terms : day_terms) << [term, range]
  end

  # Day windows intersect with day windows and hour windows with hour
  # windows; the two families use different scales (YYYYMMDD ints vs
  # epoch seconds), and a cross-family contradiction is not worth the
  # unit conversion to detect.
  [day_terms, hour_terms].each do |family|
    next if family.size < 2
    lo = family.map { |_, r| r[0] }.max
    hi = family.map { |_, r| r[1] }.min
    next if lo <= hi
    return "impossible date range (empty intersection): #{family.map { |t, _| "d #{t}" }.join(" ")}"
  end
  nil
end

#date_term_hint(term) ⇒ 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.

13/8/2026 under month-first ordering is month 13 — almost certainly a day-first date (and vice versa). Name the likely fix instead of leaving the generic cannot-match.



875
876
877
878
879
880
881
882
883
884
# File 'lib/mailmate/cli/search.rb', line 875

def date_term_hint(term)
  parts = term.sub(/\A(>=|<=|>|<)/, "").tr("/.", "-").split("-")
  return nil unless parts.size == 3 && parts[2].length == 4
  a, b = parts[0].to_i, parts[1].to_i
  if date_order == :mdy && a > 12 && (1..12).cover?(b)
    " (day-first date? pass --european)"
  elsif date_order == :dmy && b > 12 && (1..12).cover?(a)
    " (month-first date? drop --european)"
  end
end

#describe_order(order) ⇒ 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.



300
301
302
303
304
305
# File 'lib/mailmate/cli/search.rb', line 300

def describe_order(order)
  return "scan order" if order == :none
  what = order[:key] == "date" ? (order[:dir] == :desc ? "newest first" : "oldest first")
                               : "#{order[:key]} #{order[:dir] == :desc ? 'descending' : 'ascending'}"
  "#{what} by #{order[:key]}"
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.



1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
# File 'lib/mailmate/cli/search.rb', line 1552

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

#envelope_of(part_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.

Map a body-part-id back to its envelope (.eml) id via #root-body-part; single-part messages have no entry there (the envelope IS the body part), so fall through to the part-id itself.



1268
1269
1270
1271
# File 'lib/mailmate/cli/search.rb', line 1268

def envelope_of(part_id)
  root = (Mailmate::IndexReader.for("#root-body-part").value_for(part_id) rescue nil)
  root && !root.empty? ? root.to_i : part_id
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.



1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
# File 'lib/mailmate/cli/search.rb', line 1414

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

#fast_time(s) ⇒ 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.

Slice-parse a #date index value ("2026-03-19 18:55:19 -0600") into a Time, preserving the embedded UTC offset. ~10× faster than Time.parse. Returns nil when the value isn't exactly that shape (caller falls back to Time.parse).



1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
# File 'lib/mailmate/cli/search.rb', line 1322

def fast_time(s)
  return nil unless s && s.length >= 25 &&
                    s.getbyte(4) == 0x2D && s.getbyte(7) == 0x2D &&
                    s.getbyte(13) == 0x3A && s.getbyte(16) == 0x3A
  off = s[20, 5]
  return nil unless off.match?(/\A[+-]\d{4}\z/)
  Time.new(s[0, 4].to_i, s[5, 2].to_i, s[8, 2].to_i,
           s[11, 2].to_i, s[14, 2].to_i, s[17, 2].to_i,
           "#{off[0, 3]}:#{off[3, 2]}")
rescue ArgumentError
  nil
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, as raw bytes (mail fallbacks are downcased then .b'd so every return path has the same encoding). Index-first; mail fallback only kicks in for the no-index case (tests, fresh installs, unindexed messages).



1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
# File 'lib/mailmate/cli/search.rb', line 1006

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.b : "".b
  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.b : "".b
  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.b : "".b
  when :subject
    idx = header_index_value_lc(eml_id, "subject")
    return idx if idx && !idx.empty?
    mail ? mail.subject.to_s.downcase.b : "".b
  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.b : "".b
  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.



1456
1457
1458
1459
1460
1461
# File 'lib/mailmate/cli/search.rb', line 1456

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 " or "addr"; for comma-separated lists, returns the first one.



1378
1379
1380
1381
1382
1383
# File 'lib/mailmate/cli/search.rb', line 1378

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.



1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
# File 'lib/mailmate/cli/search.rb', line 1295

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.



1361
1362
1363
1364
1365
1366
1367
# File 'lib/mailmate/cli/search.rb', line 1361

def header_index_value(eml_id, name)
  return nil if eml_id.nil?
  v = reader_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 raw index value for a header — tries <name>#lc (MailMate's pre-downcased index) first, falls back to <name> + downcase (byte-wise, i.e. ASCII-only — fine: the #lc index exists for every header MailMate matches on, so the fallback is for tests and fresh installs). Returns nil if neither index has a record.



990
991
992
993
994
# File 'lib/mailmate/cli/search.rb', line 990

def header_index_value_lc(eml_id, name)
  v = header_index_value_raw(eml_id, LC_NAMES[name])
  return v unless v.nil?
  header_index_value_raw(eml_id, name)&.downcase
end

#header_index_value_raw(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.

Unscrubbed twin of header_index_value, for match paths only.



997
998
999
1000
# File 'lib/mailmate/cli/search.rb', line 997

def header_index_value_raw(eml_id, name)
  return nil if eml_id.nil?
  reader_for(name)&.value_for(eml_id.to_i)
end

#header_matches?(eml_id, mail, 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.

term is the downcased "name:value" (subpath allowed on the name and ignored: x-mailer.name:mailmate searches the whole x-mailer value, which substring matching covers anyway). Index-only by design — a header this store has never seen has no index, and date_spec_error reports that up front instead of this method quietly missing.

Returns:

  • (Boolean)


1082
1083
1084
1085
1086
1087
1088
# File 'lib/mailmate/cli/search.rb', line 1082

def header_matches?(eml_id, mail, term)
  name, value = term.split(":", 2)
  name = name.sub(/\..*/, "")
  v = eml_id ? (reader_for(name)&.value_for(eml_id.to_i) rescue nil) : nil
  v = (mail[name]&.to_s rescue nil) if v.nil? && mail
  v.to_s.b.downcase.include?(value.b)
end

#header_spec_token(operand) ⇒ 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.

The downcased "name:value" for a bare token that should parse as an arbitrary-header spec, nil otherwise. Excluded: keys the translator owns (FOREIGN_KEYS — their untranslatable forms stay literal for the zero-result hint), state keys (is/has, handled first), and URL-shaped tokens (http://... is a term, not a search of the nonexistent "http" header).



606
607
608
609
610
611
612
613
614
# File 'lib/mailmate/cli/search.rb', line 606

def header_spec_token(operand)
  m = operand.match(/\A-?([A-Za-z][\w-]*(?:\.[\w.-]+)?):(\S+)\z/)
  return nil unless m
  return nil if m[2].start_with?("/")
  key = Mailmate::SearchSyntax.normalize_key(m[1].sub(/\..*/, ""))
  return nil if Mailmate::SearchSyntax::FOREIGN_KEYS.include?(key)
  return nil if %w[is has].include?(key)
  "#{m[1]}:#{m[2]}".downcase
end

#hour_range_for(term) ⇒ 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.

Rolling clock windows: 24h = the last 24 hours as an instant range, unlike d/w/m/y which are calendar windows. Returns [lo, hi] epoch floats (lo > hi means the term cannot match — date_spec_error reports it), or nil when the term isn't an hour form. Deliberately NOT memoized: the cutoff moves with the clock, and the MCP server process lives long enough for a cached one to go stale.



896
897
898
899
900
901
902
903
904
905
906
907
908
909
# File 'lib/mailmate/cli/search.rb', line 896

def hour_range_for(term)
  m = /\A(>=|<=|>|<)?(\d+)h\z/.match(term)
  return nil unless m
  op, n = m[1], m[2].to_i
  return [1.0, 0.0] if n.zero?

  cutoff = Time.now.to_f - (n * 3600)
  case op
  when nil, ">=" then [cutoff, Float::INFINITY]
  when ">"       then [1.0, 0.0] # the window already reaches the future
  when "<"       then [-Float::INFINITY, cutoff]
  when "<="      then [-Float::INFINITY, Float::INFINITY]
  end
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.



1369
1370
1371
1372
1373
# File 'lib/mailmate/cli/search.rb', line 1369

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 --------------------------------------------------------



1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
# File 'lib/mailmate/cli/search.rb', line 1465

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, groups, 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)


1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
# File 'lib/mailmate/cli/search.rb', line 1172

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

#message_flags(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.



1090
1091
1092
1093
1094
1095
# File 'lib/mailmate/cli/search.rb', line 1090

def message_flags(eml_id)
  return [] unless eml_id
  reader_for("#flags")&.flags_for(eml_id.to_i) || []
rescue StandardError
  []
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.



1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
# File 'lib/mailmate/cli/search.rb', line 1337

def message_time(eml_id, mail)
  s = (reader_for("#date")&.value_for(eml_id.to_i) rescue nil)
  if s && !s.empty?
    t = fast_time(s) || (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

#most_recent_day_range(day, today) ⇒ 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.

Single day for the most recent occurrence of day-of-month day, stepping back past months that lack it (d 31 in early March = Jan 31). nil when no month within a year works (day > 31).



848
849
850
851
852
853
854
855
856
857
# File 'lib/mailmate/cli/search.rb', line 848

def most_recent_day_range(day, today)
  return nil unless (1..31).cover?(day)
  0.upto(12) do |back|
    m = today << back
    next unless Date.valid_date?(m.year, m.month, day)
    candidate = Date.new(m.year, m.month, day)
    return [ymd_int(candidate), ymd_int(candidate)] if candidate <= today
  end
  nil
end

#most_recent_month_day_range(month, day, today) ⇒ 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.

Single day for the most recent occurrence of month+day: this year if it has happened, else last year. nil for impossible dates.



861
862
863
864
865
866
867
868
869
870
# File 'lib/mailmate/cli/search.rb', line 861

def most_recent_month_day_range(month, day, today)
  return nil unless (1..12).cover?(month) && (1..31).cover?(day)
  [0, 1].each do |back|
    y = today.year - back
    next unless Date.valid_date?(y, month, day)
    candidate = Date.new(y, month, day)
    return [ymd_int(candidate), ymd_int(candidate)] if candidate <= today
  end
  nil
end

#order_rows!(rows, order, 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.

Orders rows in place by order ({key:, dir:}). The order is TOTAL: ties break newest-first, then by eml-id (in the date's direction when date is the key, else newest-first) — so "sorted by sender" lists each sender's mail newest-first, and two runs of the same query page identically (--offset depends on that). Ruby's sort is not stable, and stable-over-readdir would only preserve the undefined order this exists to get rid of.



325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/mailmate/cli/search.rb', line 325

def order_rows!(rows, order, fields)
  return rows.sort_by!(&:index) if order == :none
  return rows if rows.size < 2
  key, sign = order[:key], (order[:dir] == :desc ? -1 : 1)
  id_sign = key == "date" ? sign : -1
  column = fields.index(key)
  rows.sort! do |a, b|
    c = (order_value(a, key, column) <=> order_value(b, key, column)) || 0
    c *= sign
    c = b.instant_or_epoch <=> a.instant_or_epoch if c.zero? && key != "date"
    c = (a.eml_id.to_i <=> b.eml_id.to_i) * id_sign if c.zero?
    c
  end
end

#order_specs(groups) ⇒ 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.

Evaluate cheap, selective specs before expensive ones, within each or-group. Specs in a group combine with AND (order-independent), and matches? short-circuits on the first miss — so b invoice d 7d should date-reject 47k messages before body matching ever runs, not after. Stable within a cost rank to keep the user's order deterministic.



648
649
650
651
652
# File 'lib/mailmate/cli/search.rb', line 648

def order_specs(groups)
  groups.map do |specs|
    specs.sort_by.with_index { |(field, _term, _negate), i| [SPEC_COST.fetch(field, 1), i] }
  end
end

#order_to_s(order) ⇒ 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.



296
297
298
# File 'lib/mailmate/cli/search.rb', line 296

def order_to_s(order)
  order == :none ? "none" : "#{order[:key]}:#{order[:dir]}"
end

#order_value(row, key, column) ⇒ 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.

The sort key value for one row. date is the absolute send instant (so senders in different timezones still order correctly), id is numeric, everything else is the column text, case-folded.



310
311
312
313
314
315
316
# File 'lib/mailmate/cli/search.rb', line 310

def order_value(row, key, column)
  case key
  when "date" then row.instant_or_epoch
  when "id"   then row.eml_id.to_i
  else (column ? row.cells[column] : row.keys[key]).to_s.downcase
  end
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)


1392
1393
1394
1395
1396
1397
1398
1399
# File 'lib/mailmate/cli/search.rb', line 1392

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_group(tokens, inherited_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.



552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
# File 'lib/mailmate/cli/search.rb', line 552

def parse_group(tokens, inherited_field)
  specs = []
  in_force = inherited_field
  i = 0
  while i < tokens.size
    tok, quoted = tokens[i]
    field = quoted ? nil : 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]
      in_force = field
      i += 2
    else
      negate = tok.start_with?("!")
      operand = negate ? tok[1..] : tok
      if !quoted && operand =~ /\A-?(?:is|has):\S+\z/i
        # First-class message-state specs (is:unread, has:attachment).
        # The app has no state vocabulary to mirror (its A modifier
        # searches attachment FILENAMES), so the familiar Gmail
        # spellings are native syntax here. `-` negates too — the form
        # Gmail callers actually write.
        negate ||= operand.start_with?("-")
        specs << [:state, operand.delete_prefix("-").downcase, negate]
      elsif !quoted && (hdr = header_spec_token(operand))
        # Arbitrary header specs (delivered-to:joe) — native app
        # syntax per the manual. Foreign keys (date:, from:, ...) never
        # reach here: translate() rewrote the translatable ones before
        # parsing, and the rest stay literal so zero_result_hint can
        # suggest their quicksearch equivalent.
        negate ||= operand.start_with?("-")
        specs << [:header, hdr, negate]
      else
        # A bare term opening an or-group inherits the modifier in
        # force (`d 2024 or 2025`). Elsewhere it is MailMate's
        # "Common" specifier — common headers OR body — matching the
        # UI quicksearch behavior. Pass --headers-only to skip the
        # body scan when speed matters.
        target = (i.zero? && !quoted && in_force) ? in_force : :message_or_body
        specs << [target, operand.downcase, negate]
      end
      i += 1
    end
  end
  [specs, in_force]
end

#parse_order(value, allow_none: 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.

Parses a --sort/--limit-by value: bare asc/desc (date, kept for existing invocations), none (sort only), KEY, or KEY:DIR.



278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/mailmate/cli/search.rb', line 278

def parse_order(value, allow_none: false)
  v = value.to_s.strip
  return :none if v == "none" && allow_none
  return { key: "date", dir: v.to_sym } if %w[asc desc].include?(v)
  key, dir = v.split(":", 2)
  key = "date" if key.nil? || key.empty?
  unless VALID_FIELDS.include?(key)
    raise OptionParser::InvalidArgument,
          "#{value}: unknown field '#{key}' (valid: #{VALID_FIELDS.join(' ')})"
  end
  dir = (DESC_BY_DEFAULT.include?(key) ? "desc" : "asc") if dir.nil? || dir.empty?
  unless %w[asc desc].include?(dir)
    raise OptionParser::InvalidArgument,
          "#{value}: direction must be asc or desc#{allow_none ? ' (or bare none)' : ''}"
  end
  { key: key, dir: dir.to_sym }
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.

A bare or splits the query into groups: specs within a group AND together, groups OR together — and (juxtaposition) binds tighter than or, and there are no parens: (f bob or f ann) s invoice is written out as f bob s invoice or f ann s invoice. A group that OPENS with a bare unquoted operand inherits the modifier in force at the end of the previous group — the app's d 2024 or 2025 or 2y shorthand. Returns an array of spec groups; empty groups (a dangling or) are dropped.



534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
# File 'lib/mailmate/cli/search.rb', line 534

def parse_search(str)
  token_groups = [[]]
  tokenize_q(str).each do |tok, quoted|
    if !quoted && tok.casecmp?("or")
      token_groups << []
    else
      token_groups.last << [tok, quoted]
    end
  end

  carried = nil
  groups = token_groups.map do |tokens|
    specs, carried = parse_group(tokens, carried)
    specs
  end
  groups.reject(&:empty?)
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.



1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
# File 'lib/mailmate/cli/search.rb', line 1401

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)


1308
1309
1310
1311
1312
1313
1314
# File 'lib/mailmate/cli/search.rb', line 1308

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

#reader_for(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.

Per-name reader memo for the match loop. IndexReader.for is cached but not free (cache-key allocation + staleness throttle check per call), and the loop calls it several times per message. The memo is keyed to the active db_headers (config swaps in tests) and reset at the top of collect_rows, so one search run sees one consistent index snapshot; staleness is re-checked between runs, which is the same granularity the MCP server needs.



966
967
968
969
970
971
972
973
974
975
976
977
978
979
# File 'lib/mailmate/cli/search.rb', line 966

def reader_for(name)
  dbh = Mailmate.config.db_headers
  if !defined?(@hdr_readers) || @hdr_readers.nil? || @hdr_readers_dbh != dbh
    @hdr_readers = {}
    @hdr_readers_dbh = dbh
  end
  return @hdr_readers[name] if @hdr_readers.key?(name)
  @hdr_readers[name] =
    begin
      Mailmate::IndexReader.for(name)
    rescue ArgumentError
      nil
    end
end

#reset_run_caches!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.



981
982
983
# File 'lib/mailmate/cli/search.rb', line 981

def reset_run_caches!
  @hdr_readers = nil
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.



438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'lib/mailmate/cli/search.rb', line 438

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.



452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'lib/mailmate/cli/search.rb', line 452

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.



485
486
487
488
# File 'lib/mailmate/cli/search.rb', line 485

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.



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
# File 'lib/mailmate/cli/search.rb', line 87

def run(argv)
  opts = {
    mailbox: "all", limit: nil, offset: 0, scan_limit: nil, limit_by: DEFAULT_LIMIT_BY,
    headers_only: false, all: false,
    exclude_quoted: false,
    header: true, align: true, sort: DEFAULT_SORT, stats: false,
  }

  parser = build_parser(opts)
  begin
    parser.parse!(argv)
  rescue OptionParser::ParseError => e
    warn e.message
    return 2
  end

  return search(argv, opts) unless opts[:stats]

  # --stats promises one machine-readable line FIRST on stderr, but
  # the run says other things there before the total is known (the
  # translation notice, dead-branch warnings, [skip]s). Hold all of it
  # back and release it, in order, after the stats line. A run that
  # never reached the scan (usage error) produces no stats line — the
  # held output is still released, so nothing is swallowed.
  held = StringIO.new
  real_err = $stderr
  $stderr = held
  begin
    search(argv, opts)
  ensure
    $stderr = real_err
    $stderr.puts "#{STATS_PREFIX}#{JSON.generate(opts[:stats_result])}" if opts[:stats_result]
    $stderr.print held.string
  end
end

#search(argv, 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.



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/mailmate/cli/search.rb', line 131

def search(argv, opts)
  self.date_order = opts[:european] ? :dmy : :mdy

  query = argv[0] || DEFAULT_SEARCH
  # Rewrite Gmail/Outlook-style key:value tokens to their exact
  # quicksearch equivalent — loudly, never silently: every rewrite is
  # announced on stderr so the transcript shows what actually ran (and
  # the caller learns the syntax). stdout stays clean CSV.
  search_string, translations = Mailmate::SearchSyntax.translate(query, european: !!opts[:european])
  if (notice = Mailmate::SearchSyntax.translation_notice(translations))
    warn notice
  end
  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 = order_specs(parse_search(search_string))
  # Validate date specs per or-group: only when EVERY branch is
  # unsatisfiable is the query itself an error. A single dead branch
  # in a multi-branch query gets a warning — the other branches still
  # mean something, and the dead one silently contributing nothing is
  # exactly the failure mode this validation exists to surface.
  date_errs = specs.filter_map { |group| date_spec_error(group) }
  if date_errs.any?
    if date_errs.size == specs.size
      warn date_errs.first
      return 2
    end
    date_errs.each { |e| warn "dead or-branch (matches nothing): #{e}" }
  end

  # 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,
  )

  # The scan stops exactly at --scan-limit, so hitting it is inferable.
  scan_capped = opts[:scan_limit] && rows.size >= opts[:scan_limit]
  total = rows.size
  rows = apply_limit(rows, opts[:limit], opts[:offset], opts[:limit_by], fields)
  sort_rows!(rows, opts[:sort], fields)
  emit_output(rows.map(&:cells), fields, opts)
  # Truncation announces itself on stderr — same idiom as the
  # zero-result hint below: stdout stays clean CSV, exit status stays
  # 0. With --stats the JSON line carries it and the prose is
  # suppressed. When the scan cap fired, the match total is itself a
  # sample, so the [limit] notice's "of M" would be false precision;
  # the scan notice speaks alone.
  if opts[:stats]
    opts[:stats_result] = {
      schema: STATS_SCHEMA, matches: total, returned: rows.size,
      limit: opts[:limit], offset: opts[:offset],
      limit_by: order_to_s(opts[:limit_by]), sort: order_to_s(opts[:sort]),
      scan_limit: opts[:scan_limit], scan_capped: !!scan_capped,
      query: query, effective_query: search_string,
    }
  elsif scan_capped
    warn "[scan-limit] stopped scanning after #{total} matches in undefined order — " \
         "this is a sample, not the newest #{total}"
  elsif rows.size < total
    from = opts[:offset].positive? ? ", from #{opts[:offset] + 1}" : ""
    warn "[limit] showing #{rows.size} of #{total} matches#{from} (#{describe_order(opts[:limit_by])}); " \
         "add a date term such as `d 3d` to narrow, or raise --limit"
  end
  # A query written in another mail system's dialect is not a syntax
  # error here — it parses as a literal term and quietly matches
  # nothing. Callers (people and agents alike) read that empty result
  # as "no such mail" and stop. Say so on stderr, so stdout stays
  # clean CSV and the exit status stays 0: the search DID run, it just
  # cannot have found what the caller meant. (Keyed on the match
  # total, not the emitted rows: an --offset past the end is not a
  # miss.)
  if total.zero? && (hint = Mailmate::SearchSyntax.zero_result_hint(search_string))
    warn hint
  end
  0
end

#sort_rows!(rows, order, 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.



351
352
353
# File 'lib/mailmate/cli/search.rb', line 351

def sort_rows!(rows, order, fields)
  order_rows!(rows, order, fields)
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 " form.



1387
1388
1389
1390
# File 'lib/mailmate/cli/search.rb', line 1387

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

#state_matches?(eml_id, mail, path, 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.

term is the full lowercased token ("is:unread", "has:attachment"). Flag states read the #flags index; archive state reads the path (same source as the flags output column); attachment presence reads the indexed root content-type — multipart/mixed is the standard attachment layout. Wrapper types that can HIDE attachments (signed/encrypted/related) fall back to reading the message and asking Mail for real attachments; plain and alternative roots are trusted as attachment-free. Unknown state values never reach here: date_spec_error rejects them up front.

Returns:

  • (Boolean)


1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
# File 'lib/mailmate/cli/search.rb', line 1050

def state_matches?(eml_id, mail, path, term)
  state = STATE_CANON[term.split(":", 2).last]
  return false unless state

  case state
  when :unread
    eml_id ? !message_flags(eml_id).include?("\\Seen") : false
  when :archived
    path.to_s.include?("/Archive.mailbox/")
  when :attachment
    ct = eml_id ? (reader_for("content-type")&.value_for(eml_id.to_i) rescue nil).to_s : ""
    if ct.empty?
      m = mail || (path && (Mail.read(path) rescue nil))
      return m ? m.attachments.any? : false
    end
    ctl = ct.downcase
    return true if ctl.include?("multipart/mixed")
    if ctl.match?(%r{multipart/(signed|encrypted|related)})
      m = mail || (path && (Mail.read(path) rescue nil))
      return m ? m.attachments.any? : false
    end
    false
  else
    message_flags(eml_id).include?(STATE_FLAGS[state])
  end
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.



1035
1036
1037
1038
1039
# File 'lib/mailmate/cli/search.rb', line 1035

def tag_value(eml_id)
  return "" unless eml_id
  flags = (reader_for("#flags")&.flags_for(eml_id.to_i) || [])
  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.



1097
1098
1099
1100
1101
# File 'lib/mailmate/cli/search.rb', line 1097

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 ----------------------------------------------



498
499
500
# File 'lib/mailmate/cli/search.rb', line 498

def tokenize(str)
  tokenize_q(str).map(&:first)
end

#tokenize_q(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.

[text, quoted] pairs — quoted-ness must survive tokenization so a deliberate search for the literal word "or" (s "or") is not taken as the group separator, and a quoted "f" is never read as a modifier.



505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
# File 'lib/mailmate/cli/search.rb', line 505

def tokenize_q(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], true]
      i = j + 1
    else
      j = i
      j += 1 while j < str.length && str[j] != " "
      tokens << [str[i...j], false]
      i = j
    end
  end
  tokens
end

#ymd_int(d) ⇒ 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.



886
887
888
# File 'lib/mailmate/cli/search.rb', line 886

def ymd_int(d)
  d.year * 10_000 + d.month * 100 + d.day
end