Module: Mailmate::CLI::Search Private
Overview
This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.
mmsearch — search MailMate's .eml files using a subset of MailMate's
quicksearch syntax. Output is CSV with optional column-aligned padding.
Ported from the standalone mailmate-search script. See ~/.claude/skills/email/SKILL.md
for usage examples and the search-string syntax reference.
Constant Summary collapse
- MODIFIERS =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
{ "f" => :from, "t" => :recipients, "c" => :cc, "s" => :subject, "a" => :address_any, "b" => :body, "m" => :message_or_body, "d" => :date, "T" => :tag, "K" => :keyword }.freeze
- INDEXED_FILTER_FIELDS =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
Filter modifiers that read from MailMate's per-header indexes — zero .eml reads when matching.
field_valueconsults them viaheader_index_value_lc. Kept as a constant for documentation; the prefilter no longer uses it (indexes are the prefilter now). %i[from recipients cc subject address_any any].freeze
- VALID_FIELDS =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
%w[id path mailbox from to cc bcc reply-to subject date time message-id message-url references in-reply-to direction party flags read archive tags keywords].freeze
- HEADER_LABELS =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
{ "direction" => "dir", "read" => "r", "archive" => "a", }.freeze
- FIELD_TIERS =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
All output fields are now index-tier: MailMate maintains a per-header binary index under Database.noindex/Headers/, so extracting from/to/ subject/etc. doesn't require opening the .eml. Spec/filter matching (the
f/t/smodifiers 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"- 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, 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, "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
-
#all_message_dirs ⇒ Object
private
---- mailbox resolution -------------------------------------------------.
-
#body_candidates(term_b, exclude_quoted: false) ⇒ Object
private
Envelope-id candidate set for a body term: every message with at least one body segment containing the bytes.
-
#body_index_records(eml_id, exclude_quoted: false) ⇒ Object
private
Lowercased body-text segments from MailMate's #unquoted#lc and #quoted#lc indexes, aggregated across every body-part of the envelope.
-
#body_indexed?(env, exclude_quoted: false) ⇒ Boolean
private
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).
-
#body_matches?(eml_id, mail, path, term, term_b, index_only: false, exclude_quoted: false) ⇒ Boolean
private
---- body matching --------------------------------------------------.
-
#body_value(eml_id, mail, path, index_only: false, exclude_quoted: false) ⇒ Object
private
Lowercased body substring-match haystack.
-
#build_parser(opts) ⇒ Object
private
---- option parsing -----------------------------------------------------.
- #collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:) ⇒ Object private
-
#compile_date_range(term, today) ⇒ Object
private
A term is an optional comparison prefix (>, >=, <, <=) on a period.
- #compile_period_range(term, today) ⇒ Object private
- #compose_smart_filters(filters) ⇒ Object private
-
#csv_quote(cell) ⇒ Object
private
---- output -------------------------------------------------------------.
-
#date_matches?(mail, eml_id, term) ⇒ Boolean
private
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.
-
#date_order ⇒ Object
private
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). - #date_order=(order) ⇒ Object private
-
#date_range_for(term) ⇒ Object
private
Compiled day-range for a date term, memoized per term.
-
#date_spec_error(specs) ⇒ Object
private
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.
-
#date_term_hint(term) ⇒ Object
private
13/8/2026under month-first ordering is month 13 — almost certainly a day-first date (and vice versa). - #emit_output(rows, fields, opts) ⇒ Object private
-
#envelope_of(part_id) ⇒ Object
private
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.
- #extract(field, eml_id, path, mail) ⇒ Object private
-
#fast_time(s) ⇒ Object
private
Slice-parse a
#dateindex value ("2026-03-19 18:55:19 -0600") into a Time, preserving the embedded UTC offset. -
#field_value(eml_id, mail, field) ⇒ Object
private
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). - #fields_tier(fields) ⇒ Object private
-
#first_address(value) ⇒ Object
private
First bare email address from a header value, lower-cased.
-
#header_block(path) ⇒ Object
private
---- pre-filter ---------------------------------------------------------.
-
#header_index_value(eml_id, name) ⇒ Object
private
MailMate keeps a per-header binary index under Database.noindex/Headers/ — one cache/offsets file per RFC header name.
-
#header_index_value_lc(eml_id, name) ⇒ Object
private
Lowercased 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). -
#header_index_value_raw(eml_id, name) ⇒ Object
private
Unscrubbed twin of header_index_value, for match paths only.
-
#hour_range_for(term) ⇒ Object
private
Rolling clock windows:
24h= the last 24 hours as an instant range, unlike d/w/m/y which are calendar windows. - #index_or_mail(eml_id, name, fallback) ⇒ Object private
-
#load_message(path, tier) ⇒ Object
private
---- driver loop --------------------------------------------------------.
- #matches?(mail, eml_id, groups, headers_only, path = nil, index_only: false, exclude_quoted: false) ⇒ Boolean private
- #message_flags(eml_id) ⇒ Object private
-
#message_time(eml_id, mail) ⇒ Object
private
Absolute send time for an eml_id, preferring the MailMate
#dateindex (cheap, no .eml read). -
#order_specs(groups) ⇒ Object
private
Evaluate cheap, selective specs before expensive ones, within each or-group.
- #outbound?(path, mail, eml_id = nil) ⇒ Boolean private
- #parse_group(tokens, inherited_field) ⇒ Object private
-
#parse_search(str) ⇒ Object
private
A bare
orsplits the query into groups: specs within a group AND together, groups OR together —and(juxtaposition) binds tighter thanor, and there are no parens:(f bob or f ann) s invoiceis written out asf bob s invoice or f ann s invoice. - #party_for(eml_id, mail, outbound) ⇒ Object private
- #prefilter_pass?(path, _specs, smart_literals = []) ⇒ Boolean private
-
#reader_for(name) ⇒ Object
private
Per-name reader memo for the match loop.
- #reset_run_caches! ⇒ Object private
- #resolve_account(name) ⇒ Object private
- #resolve_mailbox(arg) ⇒ Object private
- #resolve_mailbox_with_graph(arg) ⇒ Object private
- #run(argv) ⇒ Object private
-
#sort_rows!(rows, mode) ⇒ Object
private
Sorts
rowsin place by the message's absolute send instant (UTC), so senders in different timezones still order correctly. -
#split_addresses(value) ⇒ Object
private
Split a comma-separated address-list header value into individual tokens, each kept in its original "Name
" form. -
#state_matches?(eml_id, mail, term) ⇒ Boolean
private
term is the full lowercased token ("is:unread", "has:attachment").
-
#tag_value(eml_id) ⇒ Object
private
MailMate stores user tags as IMAP keywords in the
#flagsindex — not asX-Keywords/Keywordsheaders in the .eml — so tag matching has to go through the index, not the parsed mail. - #text_body(mail) ⇒ Object private
-
#tokenize(str) ⇒ Object
private
---- search-string parsing ----------------------------------------------.
-
#tokenize_q(str) ⇒ Object
private
[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. - #ymd_int(d) ⇒ Object private
Instance Method Details
#all_message_dirs ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
---- mailbox resolution -------------------------------------------------
265 266 267 |
# File 'lib/mailmate/cli/search.rb', line 265 def Dir.glob("#{Mailmate.config.imap_root}/*/**/Messages").select { |p| File.directory?(p) } 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.
960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 |
# File 'lib/mailmate/cli/search.rb', line 960 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.
866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 |
# File 'lib/mailmate/cli/search.rb', line 866 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).
993 994 995 996 997 998 999 1000 1001 |
# File 'lib/mailmate/cli/search.rb', line 993 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.
930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 |
# File 'lib/mailmate/cli/search.rb', line 930 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.
840 841 842 843 844 845 846 847 848 849 850 851 |
# File 'lib/mailmate/cli/search.rb', line 840 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 -----------------------------------------------------
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 |
# File 'lib/mailmate/cli/search.rb', line 200 def build_parser(opts) OptionParser.new do |o| o. = "Usage: mmsearch [search-string] [fields] [options]" o.separator "" o.separator "Search MailMate's `.eml` files. Output is CSV with column-aligned padding." o.separator "" o.separator "POSITIONAL ARGS" o.separator " search-string Quicksearch expression. Default: 'd 1d'. Pass '' to disable." o.separator " fields Columns to show. Space- or comma-separated." o.separator " Default: 'id flags date time direction party subject'." o.separator " Bare list = exactly those columns (omit 'id' to drop it)." o.separator " Prefix with '+' to extend the defaults: '+tags' = defaults + tags." o.separator "" o.separator "OPTIONS" o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v } o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v } o.on("--limit N", Integer, "Stop after N matches") { |n| opts[:limit] = n } o.on("--headers-only", "Skip body matching entirely") { opts[:headers_only] = true } o.on("--all", "Include un-indexed messages in body matching by lazily reading and parsing each .eml. Slow (tens of seconds to minutes on large archives). Default behavior matches MailMate's UI: only check messages MailMate has body-indexed — fast, but bounded.") { opts[:all] = true } o.on("--exclude-quoted", "Match body only against #unquoted text — skip MailMate's #quoted index (forwarded/replied-to text). Tightens search to fresh content; gets you closer to MailMate UI's body-search result set, at the cost of missing hits in quoted sections.") { opts[:exclude_quoted] = true } o.on("--no-header", "Suppress column header row") { opts[:header] = false } o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false } o.on("--sort MODE", %w[asc desc none], "Sort rows by date+time: asc (default), desc, none") { |v| opts[:sort] = v.to_sym } o.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.
1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 |
# File 'lib/mailmate/cli/search.rb', line 1201 def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:) reset_run_caches! rows = [] catch(:done) do dirs.each do |dir| Dir.each_child(dir) do |fname| next unless fname.end_with?(".eml") eml_id = fname.sub(".eml", "") path = "#{dir}/#{fname}" next unless prefilter_pass?(path, specs, smart_literals) if filter_only_tier == :index if smart_evaluator next unless smart_evaluator.matches?(Mailmate::Message.new(nil, eml_id, path)) end if !specs.empty? next unless matches?(nil, eml_id, specs, opts[:headers_only], path, index_only: !opts[:all], exclude_quoted: opts[:exclude_quoted]) end end mail = nil if load_tier != :index begin mail = (path, load_tier) rescue StandardError => e warn "[skip] #{path}: #{e.}" next end end if filter_only_tier != :index if !specs.empty? next unless matches?(mail, eml_id, specs, opts[:headers_only], path, index_only: !opts[:all], exclude_quoted: opts[:exclude_quoted]) end if smart_evaluator next unless smart_evaluator.matches?(Mailmate::Message.new(mail, eml_id, path)) end end rows << fields.map { |f| extract(f, eml_id, path, mail) } throw :done if opts[:limit] && rows.size >= opts[:limit] end end end rows end |
#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.
509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 |
# File 'lib/mailmate/cli/search.rb', line 509 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.
527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 |
# File 'lib/mailmate/cli/search.rb', line 527 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 # N units ENDING today: `1d` = today only, `7d` = the last 7 # calendar days including today. (Off-by-one fixed 2026-08-11 to # match the MailMate app, where `d 1d` is today's mail — the old # cutoff of today-N made `d 1d` span two calendar days. For a # rolling 24-hour clock window, that's `d 24h` now.) cutoff = case u when "d" then today - (n - 1) when "w" then today - (n * 7 - 1) when "m" then (today << n) + 1 when "y" then (today << (n * 12)) + 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 y = parts[0].to_i return nil if y.zero? [y * 10_000 + 101, y * 10_000 + 1231] when 2 # Year-first (2026-08) or month-first with a 4-digit year (8/2026). y, m = parts[1].length == 4 ? [parts[1], parts[0]] : [parts[0], parts[1]] y, m = y.to_i, m.to_i return nil if y.zero? || !(1..12).cover?(m) [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31] 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.
321 322 323 324 325 |
# File 'lib/mailmate/cli/search.rb', line 321 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 -------------------------------------------------------------
1253 1254 1255 1256 1257 1258 1259 1260 |
# File 'lib/mailmate/cli/search.rb', line 1253 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.)
664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 |
# File 'lib/mailmate/cli/search.rb', line 664 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_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.
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).
477 478 479 |
# File 'lib/mailmate/cli/search.rb', line 477 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.
481 482 483 |
# File 'lib/mailmate/cli/search.rb', line 481 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.
489 490 491 492 493 494 495 496 497 498 |
# File 'lib/mailmate/cli/search.rb', line 489 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.
585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 |
# File 'lib/mailmate/cli/search.rb', line 585 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 has:attachment)" 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.
621 622 623 624 625 626 627 628 629 630 |
# File 'lib/mailmate/cli/search.rb', line 621 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 |
#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.
1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 |
# File 'lib/mailmate/cli/search.rb', line 1262 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.
985 986 987 988 |
# File 'lib/mailmate/cli/search.rb', line 985 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.
1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 |
# File 'lib/mailmate/cli/search.rb', line 1131 def extract(field, eml_id, path, mail) case field when "id" then eml_id when "path" then path when "mailbox" then path.sub("#{Mailmate.config.imap_root}/", "").sub(%r{/Messages/[^/]+\.eml\z}, "") when "date" t = (eml_id, mail) Mailmate.localize(t)&.strftime("%Y-%m-%d") when "time" t = (eml_id, mail) Mailmate.localize(t)&.strftime("%H:%M") when "read" flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []) flags.include?("\\Seen") ? "R" : "U" when "archive" path.include?("/Archive.mailbox/") ? "A" : "P" when "flags" archive = path.include?("/Archive.mailbox/") ? "A" : "P" seen = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).include?("\\Seen") "#{archive}#{seen ? 'R' : 'U'}" when "tags" flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []) flags.reject { |f| f.start_with?("\\", "$") }.join(",") when "keywords" (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).join(",") when "from" then index_or_mail(eml_id, "from", mail ? Array(mail.from).join("; ") : nil) when "to" then index_or_mail(eml_id, "to", mail ? Array(mail.to).join("; ") : nil) when "cc" then index_or_mail(eml_id, "cc", mail ? Array(mail.cc).join("; ") : nil) when "bcc" then index_or_mail(eml_id, "bcc", mail ? Array(mail.bcc).join("; ") : nil) when "reply-to" then index_or_mail(eml_id, "reply-to", mail ? Array(mail.reply_to).join("; ") : nil) when "subject" then index_or_mail(eml_id, "subject", mail&.subject) when "message-id" then index_or_mail(eml_id, "message-id", mail&.) when "message-url" mid = index_or_mail(eml_id, "message-id", mail&.) mid.empty? ? "" : Mailmate::MidUrl.(mid) when "references" then index_or_mail(eml_id, "references", mail ? Array(mail.references).join(" ") : nil) when "in-reply-to" then index_or_mail(eml_id, "in-reply-to", mail ? Array(mail.in_reply_to).join(" ") : nil) when "direction" then outbound?(path, mail, eml_id) ? "→" : "←" when "party" then party_for(eml_id, mail, outbound?(path, mail, eml_id)) end.to_s end |
#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).
1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 |
# File 'lib/mailmate/cli/search.rb', line 1039 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).
752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 |
# File 'lib/mailmate/cli/search.rb', line 752 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.
1173 1174 1175 1176 1177 1178 |
# File 'lib/mailmate/cli/search.rb', line 1173 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
1095 1096 1097 1098 1099 1100 |
# File 'lib/mailmate/cli/search.rb', line 1095 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.
1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 |
# File 'lib/mailmate/cli/search.rb', line 1012 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.
1078 1079 1080 1081 1082 1083 1084 |
# File 'lib/mailmate/cli/search.rb', line 1078 def header_index_value(eml_id, name) return nil if eml_id.nil? v = Mailmate::IndexReader.for(name).value_for(eml_id.to_i) v && v.dup.force_encoding("UTF-8").scrub rescue ArgumentError nil end |
#header_index_value_lc(eml_id, name) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Lowercased 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.
736 737 738 739 740 |
# File 'lib/mailmate/cli/search.rb', line 736 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.
743 744 745 746 |
# File 'lib/mailmate/cli/search.rb', line 743 def header_index_value_raw(eml_id, name) return nil if eml_id.nil? reader_for(name)&.value_for(eml_id.to_i) 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.
642 643 644 645 646 647 648 649 650 651 652 653 654 655 |
# File 'lib/mailmate/cli/search.rb', line 642 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.
1086 1087 1088 1089 1090 |
# File 'lib/mailmate/cli/search.rb', line 1086 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 --------------------------------------------------------
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 1182 def (path, tier) case tier when :index then nil when :header bytes = +"" File.open(path, "rb") do |f| while (chunk = f.read(4096)) bytes << chunk idx = bytes.index("\r\n\r\n") || bytes.index("\n\n") break if idx break if bytes.bytesize > 65_536 end end Mail.new(bytes) when :full Mail.read(path) end end |
#matches?(mail, eml_id, 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.
891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 |
# File 'lib/mailmate/cli/search.rb', line 891 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, 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.
809 810 811 812 813 814 |
# File 'lib/mailmate/cli/search.rb', line 809 def (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.
1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 |
# File 'lib/mailmate/cli/search.rb', line 1054 def (eml_id, mail) s = (Mailmate::IndexReader.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 |
#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.
454 455 456 457 458 |
# File 'lib/mailmate/cli/search.rb', line 454 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 |
#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.
1109 1110 1111 1112 1113 1114 1115 1116 |
# File 'lib/mailmate/cli/search.rb', line 1109 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.
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 |
# File 'lib/mailmate/cli/search.rb', line 383 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] 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_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.
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/mailmate/cli/search.rb', line 365 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.
1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 |
# File 'lib/mailmate/cli/search.rb', line 1118 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.
1025 1026 1027 1028 1029 1030 1031 |
# File 'lib/mailmate/cli/search.rb', line 1025 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.
712 713 714 715 716 717 718 719 720 721 722 723 724 725 |
# File 'lib/mailmate/cli/search.rb', line 712 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.
727 728 729 |
# File 'lib/mailmate/cli/search.rb', line 727 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.
269 270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'lib/mailmate/cli/search.rb', line 269 def resolve_account(name) root = Mailmate.config.imap_root return name if File.directory?("#{root}/#{name}") encoded = name.gsub("@", "%40") candidates = Dir.glob("#{root}/#{encoded}@*").map { |p| File.basename(p) } case candidates.size when 0 then nil when 1 then candidates.first else warn "Ambiguous account '#{name}': #{candidates.join(", ")}" nil end end |
#resolve_mailbox(arg) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/mailmate/cli/search.rb', line 283 def resolve_mailbox(arg) root = Mailmate.config.imap_root return [, []] if arg == "all" if arg.include?("/") account, rest = arg.split("/", 2) if (encoded = resolve_account(account)) nested = rest.split("/").map { |s| "#{s}.mailbox" }.join("/") cand = "#{root}/#{encoded}/#{nested}/Messages" return [[cand], []] if File.directory?(cand) end end if (encoded = resolve_account(arg)) dirs = Dir.glob("#{root}/#{encoded}/**/Messages").select { |p| File.directory?(p) } return [dirs, []] end matches = Dir.glob("#{root}/*/**/#{arg}.mailbox/Messages").select { |p| File.directory?(p) } return [matches, []] unless matches.empty? # Fall back: try MailMate's smart-mailbox graph. graph = Mailmate::MailboxGraph.load if (uuid = graph.by_name[arg]) || graph.by_uuid[arg] uuid ||= arg res = Mailmate::SourceResolver.new(graph).resolve(uuid) return [res[:dirs], res[:filters], graph] end warn "Mailbox not resolved: '#{arg}'." [[], []] end |
#resolve_mailbox_with_graph(arg) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
316 317 318 319 |
# File 'lib/mailmate/cli/search.rb', line 316 def resolve_mailbox_with_graph(arg) result = resolve_mailbox(arg) result.size == 2 ? [*result, nil] : result end |
#run(argv) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 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 |
# File 'lib/mailmate/cli/search.rb', line 64 def run(argv) opts = { mailbox: "all", limit: nil, headers_only: false, all: false, exclude_quoted: false, header: true, align: true, sort: :asc, } parser = build_parser(opts) parser.parse!(argv) self.date_order = opts[:european] ? :dmy : :mdy search_string = 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(search_string, 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.}\n filter: #{composed_str}" return 1 end end filter_tier = composed_ast ? Mailmate::FilterClassifier.tier(composed_ast) : :index # Every spec is now index-tier. Body matching reads MailMate's # `#unquoted#lc`/`#quoted#lc` indexes (zero .eml read for indexed # messages); `body_value` lazily Mail.reads the .eml for the rare # misses. Header/tag/date specs all hit per-header indexes too. specs_tier = :index fields_tier_ = fields_tier(fields) filter_only_tier = Mailmate::FilterClassifier.combine_tiers(filter_tier, specs_tier) load_tier = Mailmate::FilterClassifier.combine_tiers(filter_only_tier, fields_tier_) smart_literals = composed_ast ? Mailmate::FilterClassifier.header_literals(composed_ast) : [] rows = collect_rows( dirs: dirs, specs: specs, fields: fields, smart_evaluator: smart_evaluator, smart_literals: smart_literals, filter_only_tier: filter_only_tier, load_tier: load_tier, opts: opts, ) sort_rows!(rows, opts[:sort]) emit_output(rows, fields, opts) # 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. if rows.empty? && (hint = Mailmate::SearchSyntax.zero_result_hint(search_string)) warn hint end 0 end |
#sort_rows!(rows, mode) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Sorts rows in place by the message's absolute send instant (UTC), so
senders in different timezones still order correctly. The first column
is always id (forced in run), which lets us hit the #date index
without re-reading any .eml.
186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/mailmate/cli/search.rb', line 186 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? && (fast_time(s) || (Time.parse(s) rescue nil))) || epoch end rows.reverse! if mode == :desc rows end |
#split_addresses(value) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Split a comma-separated address-list header value into individual
tokens, each kept in its original "Name
1104 1105 1106 1107 |
# File 'lib/mailmate/cli/search.rb', line 1104 def split_addresses(value) return [] if value.nil? || value.empty? value.split(",").map(&:strip).reject(&:empty?) end |
#state_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 full lowercased token ("is:unread", "has:attachment"). Flag states read the #flags index; attachment presence reads the indexed root content-type — multipart/mixed is the standard attachment layout (a Mail fallback checks real attachments when the message is already loaded). Unknown state values never reach here: date_spec_error rejects them up front.
793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 |
# File 'lib/mailmate/cli/search.rb', line 793 def state_matches?(eml_id, mail, term) state = STATE_CANON[term.split(":", 2).last] return false unless state case state when :unread eml_id ? !(eml_id).include?("\\Seen") : false when :attachment ct = eml_id ? (reader_for("content-type")&.value_for(eml_id.to_i) rescue nil).to_s : "" return ct.downcase.include?("multipart/mixed") unless ct.empty? mail ? mail..any? : false else (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.
781 782 783 784 785 |
# File 'lib/mailmate/cli/search.rb', line 781 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.
816 817 818 819 820 |
# File 'lib/mailmate/cli/search.rb', line 816 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 ----------------------------------------------
329 330 331 |
# File 'lib/mailmate/cli/search.rb', line 329 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.
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/mailmate/cli/search.rb', line 336 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.
632 633 634 |
# File 'lib/mailmate/cli/search.rb', line 632 def ymd_int(d) d.year * 10_000 + d.month * 100 + d.day end |