Module: Mailmate::SearchSyntax
- Defined in:
- lib/mailmate/search_syntax.rb
Overview
THE one description of quicksearch syntax. Both surfaces that teach the
syntax — mmsearch --help and the MCP search tool description — render
from the tables here, so the two can no longer drift apart (they already
had: the CLI said "Nd|Nw|Nm|Ny (relative), or Y, Y-M, Y-M-D" while the MCP
said "Y, Y-M, Y-M-D, or relative 1d/2w/3m/1y" — same rules, two wordings,
two things to remember to update).
Downstream consumers should POINT at these surfaces rather than restate them. A copy of the syntax in someone else's system prompt is a copy that goes stale the next time a modifier is added here.
Constant Summary collapse
- MODIFIERS =
[spec, meaning]. Order is the teaching order, not alphabetical.
[ ["<term>", "common headers (from/to/cc/subject) OR body contains <term>"], ["f <term>", "from contains"], ["t <term>", "to/cc (recipients) contains"], ["c <term>", "cc contains"], ["s <term>", "subject contains"], ["a <term>", "any address header contains"], ["b <term>", "body contains"], ["m <term>", "common headers OR body (same as a bare term)"], ["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (calendar, floored: 1d = today, 1w = this week, 1y = this year), Y | Y-M | Y-M-D, or D | M-D (day of month, most recent occurrence)"], ["T <tag>", "tag / IMAP keyword contains (K is a synonym)"], ["is:<state>", "message state: unread, read, flagged, replied, draft, archived"], ["has:attachment", "root MIME is multipart/mixed (wrappers like signed/related are opened and checked)"], ["<header>:<term>", "any indexed header contains (delivered-to:joe); unknown header = error"], ].freeze
- EXAMPLES =
[ ["f substack d 7d", "from Substack in the last 7 days"], ["s \"invoice due\" !draft", "subject has 'invoice due', not 'draft'"], ["d 2026-05", "received in May 2026"], ["d 2026-08-10", "received on one specific day"], ["d 1d", "received today (the default); d 2d = yesterday + today"], ["d 24h", "received in the last 24 hours (rolling, not calendar)"], ["d >=2026-05 d <2026-08", "received May through July 2026"], ["d 1h or 2026-08-09", "last hour, plus everything from Aug 9"], ["is:unread d 1w", "unread, received this week"], ["d 7", "day 7 of this month (last month's if the 7th is still ahead)"], ["T urgent", "tagged 'urgent'"], ].freeze
- RULES =
[ "Specs combine with AND; `or` separates alternatives, and AND binds tighter", "(no parens): (f bob or f ann) s invoice = f bob s invoice or f ann s invoice.", "After `or`, a bare first term inherits the modifier in force: d 2024 or 2025.", "Wrap multi-word terms in \"double quotes\" (also how to search the word \"or\").", "Prefix an operand with ! to negate: f !smith = from does NOT contain smith.", "Negation works on dates too: d !3d = received MORE than 3 days ago.", "Absolute dates compare: d >2026-08 (after Aug), d <2026-08 (before), also >= <=.", "Slash dates are month-first American: d 8/9/2026 = Aug 9 (day-first: --european).", "An impossible date combination (d >2026 d <2025) is an error, not 0 results.", ].freeze
- FOREIGN_KEYS =
Search keys from OTHER mail systems (Gmail, Outlook, Apple Mail, IMAP dialects). Quicksearch has no
key:valueform at all, so a term likedate:todayis not a syntax error — it parses as a bare term and searches for the literal string "date:today" in headers and body, which matches nothing. That silence is the whole problem this list exists to break: an agent or a person gets an empty result set that is indistinguishable from "your mail really has nothing", and believes it. NOTE: is/has are absent — they are first-class quicksearch now (is:unread, has:attachment parse as native state specs). %w[ after before older newer older_than newer_than on since until date sent received time from to cc bcc subject body in label folder mailbox category filename date_last_viewed ].freeze
- KEY_ALIASES =
MailMate's OWN attribute vocabulary, minus the leading
#— a model that has seen the engine's internals or MailMate's docs reaches fordate-received:in good faith (dogfood 2026-08-13: an agent querieddate-received:8/13/2026-8/14/2026, got zero rows with no advisory, and reported the mailbox empty). Keys are normalized (-→_, lowercased) and then aliased to the base foreign key here, so a compound key flows through the SAME translation and flagging pipeline as its base — nothing downstream knows compounds exist.date_last_viewedhas no faithfuldequivalent (viewed ≠ received), so it is only flagged (via FOREIGN_KEYS above), never translated. { "date_received" => "date", "received_date" => "date", "datereceived" => "date", "date_sent" => "sent", "sent_date" => "sent", "datesent" => "sent", "sentdate" => "sent", }.freeze
- EQUIVALENTS =
Spec placeholders for the zero-result hint, for foreign keys whose value translate() could NOT rewrite (e.g.
after:8am). Keys absent here still get flagged, just without a suggested rewrite. { "from" => "f <term>", "to" => "t <term>", "cc" => "c <term>", "subject" => "s <term>", "body" => "b <term>", "label" => "T <tag>", "date" => "d <date>", "sent" => "d <date>", "received" => "d <date>", "on" => "d <date>", "after" => "d >=YYYY-MM-DD", "since" => "d >=YYYY-MM-DD", "before" => "d <YYYY-MM-DD", "until" => "d <=YYYY-MM-DD", "newer_than" => "d Nd", "older_than" => "d !Nd", "newer" => "d Nd", "older" => "d !Nd", }.freeze
- HEADER_EQUIV =
Foreign header-ish keys with a direct quicksearch spec. The value carries over unchanged, so these translate regardless of what it is.
{ "from" => "f", "to" => "t", "cc" => "c", "subject" => "s", "body" => "b", "label" => "T", }.freeze
- TRANSLATIONS_HELP =
The --help table for the translator. Symbolic, not computed —
is resolved against the current date at translation time. [ ["from:bob (to: cc: subject: body: label:)", "f bob (t c s b T)"], ["-from:bob or !from:bob", "f !bob"], ["date:today / date:yesterday", "d 1d / d <that day>"], ["date:2026-03-05 or date:3/5/2026 (M/D/Y)", "d 2026-03-05"], ["newer_than:2d / older_than:2w", "d 2d / d !2w"], ["after:2026-05 or since:2026-05", "d >=2026-05"], ["before:2026-08 / until:2026-08", "d <2026-08 / d <=2026-08"], ["date:8/13/2026-8/14/2026 (also A..B)", "d >=2026-08-13 d <=2026-08-14"], ["date-received: / date-sent: (MailMate heads)", "date: / sent:"], ].freeze
- TOKEN_RX =
One token: an (optionally key:-prefixed) quoted string, or a bare run of non-space. Quoted regions survive as single tokens so translate() can leave a deliberate literal search (
s "date:today") alone. /(?:[-!]?[A-Za-z_][A-Za-z_-]*:)?"[^"]*"|(?:[-!]?[A-Za-z_][A-Za-z_-]*:)?'[^']*'|\S+/
Class Method Summary collapse
-
.foreign_tokens(query) ⇒ Object
Foreign
key:valuetokens in a query, lowercased keys, in order of appearance and de-duplicated. -
.normalize_key(raw) ⇒ Object
Date-Received/date_received/datereceivedall mean the same thing to the person typing them; collapse to one spelling, then map MailMate-head aliases onto their base key. -
.normalize_period(value, european = false) ⇒ Object
"2026", "2026-05", "2026-03-05", "3/5/2026", "2026/3/5" → the normalized absolute period string quicksearch expects, or nil.
-
.parse_day(value, european = false) ⇒ Object
Y-M-D (any of - / . separators), or slash-dates with a trailing 4-digit year — US M/D/Y by default, D/M/Y when european.
-
.parse_relative(value) ⇒ Object
Gmail relative units (d/m/y, plus w) carry over as-is:
d N<u>uses the same calendar arithmetic. -
.reference(indent: " ") ⇒ Object
The shared syntax reference, indented for embedding.
-
.resolve_bound(value, today, european) ⇒ Object
One end of a range: a keyword day or any absolute period.
-
.translate(query, today: Date.today, european: false) ⇒ Object
Rewrite foreign
key:valuetokens to their exact quicksearch equivalent, leaving everything else byte-for-byte intact. -
.translate_after(value, _today, european = false) ⇒ Object
Gmail's after: includes the named day; since: likewise → >=.
-
.translate_before(value, today, op, european = false) ⇒ Object
Gmail's before: excludes the named day → <.
-
.translate_point_date(value, today, european = false) ⇒ Object
A day-, month-, or year-precision point in time.
-
.translate_range(value, today, european) ⇒ Object
A two-sided absolute range (
8/13/2026-8/14/2026,2026-08-13..2026-08-14) → the comparison paird >=A d <=B. -
.translate_token(token, today, european = false) ⇒ Object
nil = not a rewritable token (not key:value, unknown key, or a value with no faithful equivalent).
-
.translation_notice(notes) ⇒ Object
The stderr/tool-result announcement for a rewritten query.
-
.translation_reference(indent: " ") ⇒ Object
The --help table, indented for embedding.
- .unquote(value) ⇒ Object
-
.zero_result_hint(query) ⇒ Object
The advisory a caller should print when a search matched NOTHING and the query carries foreign syntax.
Class Method Details
.foreign_tokens(query) ⇒ Object
Foreign key:value tokens in a query, lowercased keys, in order of
appearance and de-duplicated. Quoted regions are skipped: a deliberate
search for the literal text s "date:today" is not a mistake.
191 192 193 194 195 196 197 198 |
# File 'lib/mailmate/search_syntax.rb', line 191 def foreign_tokens(query) unquoted = query.to_s.gsub(/"[^"]*"|'[^']*'/, " ") unquoted.scan(/(?<![\w-])!?([A-Za-z_][A-Za-z_-]*):(\S*)/).filter_map do |key, value| k = normalize_key(key) next unless FOREIGN_KEYS.include?(k) ["#{key}:#{value}", k] end.uniq { |_token, k| k } end |
.normalize_key(raw) ⇒ Object
Date-Received / date_received / datereceived all mean the same
thing to the person typing them; collapse to one spelling, then map
MailMate-head aliases onto their base key.
224 225 226 227 |
# File 'lib/mailmate/search_syntax.rb', line 224 def normalize_key(raw) k = raw.downcase.tr("-", "_") KEY_ALIASES.fetch(k, k) end |
.normalize_period(value, european = false) ⇒ Object
"2026", "2026-05", "2026-03-05", "3/5/2026", "2026/3/5" → the normalized absolute period string quicksearch expects, or nil.
357 358 359 360 361 362 363 364 365 |
# File 'lib/mailmate/search_syntax.rb', line 357 def normalize_period(value, european = false) if (day = parse_day(value, european)) day.strftime("%Y-%m-%d") elsif value =~ %r{\A(\d{4})[-/.](\d{1,2})\z} format("%04d-%02d", Regexp.last_match(1).to_i, Regexp.last_match(2).to_i) elsif value =~ /\A\d{4}\z/ value end end |
.parse_day(value, european = false) ⇒ Object
Y-M-D (any of - / . separators), or slash-dates with a trailing 4-digit year — US M/D/Y by default, D/M/Y when european. Two-digit years are ambiguous across dialects — refused rather than guessed.
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 |
# File 'lib/mailmate/search_syntax.rb', line 376 def parse_day(value, european = false) parts = value.split(%r{[-/.]}) return nil unless parts.size == 3 && parts.all? { |p| p =~ /\A\d+\z/ } y, m, d = if parts[0].length == 4 [parts[0], parts[1], parts[2]] elsif parts[2].length == 4 european ? [parts[2], parts[1], parts[0]] : [parts[2], parts[0], parts[1]] end return nil unless y y, m, d = y.to_i, m.to_i, d.to_i Date.valid_date?(y, m, d) ? Date.new(y, m, d) : nil end |
.parse_relative(value) ⇒ Object
Gmail relative units (d/m/y, plus w) carry over as-is: d N<u> uses
the same calendar arithmetic.
369 370 371 |
# File 'lib/mailmate/search_syntax.rb', line 369 def parse_relative(value) value =~ /\A(\d+)\s*([dwmy])\z/ ? "#{Regexp.last_match(1)}#{Regexp.last_match(2)}" : nil end |
.reference(indent: " ") ⇒ Object
The shared syntax reference, indented for embedding. Used verbatim by
mmsearch --help and by the MCP tool description.
130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/mailmate/search_syntax.rb', line 130 def reference(indent: " ") width = MODIFIERS.map { |spec, _| spec.length }.max lines = [] RULES.each { |r| lines << "#{indent}#{r}" } lines << "" MODIFIERS.each { |spec, meaning| lines << "#{indent} #{spec.ljust(width)} #{meaning}" } lines << "" lines << "#{indent}Examples:" ex_width = EXAMPLES.map { |q, _| q.length }.max EXAMPLES.each { |q, meaning| lines << "#{indent} #{q.ljust(ex_width)} #{meaning}" } lines.join("\n") end |
.resolve_bound(value, today, european) ⇒ Object
One end of a range: a keyword day or any absolute period.
329 330 331 332 333 334 335 |
# File 'lib/mailmate/search_syntax.rb', line 329 def resolve_bound(value, today, european) v = value.to_s.strip.downcase return today.strftime("%Y-%m-%d") if v == "today" return (today - 1).strftime("%Y-%m-%d") if v == "yesterday" normalize_period(v, european) end |
.translate(query, today: Date.today, european: false) ⇒ Object
Rewrite foreign key:value tokens to their exact quicksearch
equivalent, leaving everything else byte-for-byte intact. Returns
[query, notes] where notes is [[original_token, replacement], ...] —
callers MUST surface the notes (stderr, tool result); a silent rewrite
would show the reader a query that never ran.
Only rewrites where the equivalence is exact. A foreign key whose value
can't be translated faithfully (after:8am, date:next week) stays in
the query as literal text, and zero_result_hint still flags it there.
157 158 159 160 161 162 163 164 165 |
# File 'lib/mailmate/search_syntax.rb', line 157 def translate(query, today: Date.today, european: false) notes = [] translated = query.to_s.gsub(TOKEN_RX) do |token| replacement = translate_token(token, today, european) notes << [token, replacement] if replacement replacement || token end [translated, notes] end |
.translate_after(value, _today, european = false) ⇒ Object
Gmail's after: includes the named day; since: likewise → >=.
338 339 340 341 342 343 344 |
# File 'lib/mailmate/search_syntax.rb', line 338 def translate_after(value, _today, european = false) v = value.downcase return "d 1d" if v == "today" return "d 2d" if v == "yesterday" (period = normalize_period(v, european)) && "d >=#{period}" end |
.translate_before(value, today, op, european = false) ⇒ Object
Gmail's before: excludes the named day → <. until: includes it → <=.
347 348 349 350 351 352 353 |
# File 'lib/mailmate/search_syntax.rb', line 347 def translate_before(value, today, op, european = false) v = value.downcase v = today.strftime("%Y-%m-%d") if v == "today" v = (today - 1).strftime("%Y-%m-%d") if v == "yesterday" (period = normalize_period(v, european)) && "d #{op}#{period}" end |
.translate_point_date(value, today, european = false) ⇒ Object
A day-, month-, or year-precision point in time. d <period> matches
exactly that period in the engine, so these are exact.
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/mailmate/search_syntax.rb', line 285 def translate_point_date(value, today, european = false) v = value.downcase return "d 1d" if v == "today" return "d #{(today - 1).strftime("%Y-%m-%d")}" if v == "yesterday" # `date:8/10/2026-today` (seen in real transcripts): a range whose end # is now IS an after-window. return translate_after(v.delete_suffix("-today"), today, european) if v.end_with?("-today") return "d #{v}" if v =~ /\A\d+[dwmy]\z/ if (period = normalize_period(v, european)) "d #{period}" else translate_range(v, today, european) end end |
.translate_range(value, today, european) ⇒ Object
A two-sided absolute range (8/13/2026-8/14/2026, 2026-08-13..2026-08-14)
→ the comparison pair d >=A d <=B. Inclusive on both ends: "the 13th
to the 14th" includes the 14th in plain reading. Only fires when BOTH
sides resolve — a side we can't parse means this isn't a range we
understand, and no translation beats a wrong one. A reversed range
still translates: the engine rejects an impossible date combination
with an explicit error, which is exactly the visibility we want.
308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 |
# File 'lib/mailmate/search_syntax.rb', line 308 def translate_range(value, today, european) bounds = if value.include?("..") a, b = value.split("..", 2) [resolve_bound(a, today, european), resolve_bound(b, today, european)] else # Dates carry hyphens themselves (2026-08-13), so try each hyphen # as the separator and take the first split where both sides parse. value.length.times.filter_map { |i| next unless value[i] == "-" left = resolve_bound(value[0...i], today, european) right = resolve_bound(value[(i + 1)..], today, european) [left, right] if left && right }.first end return nil unless bounds&.all? "d >=#{bounds[0]} d <=#{bounds[1]}" end |
.translate_token(token, today, european = false) ⇒ Object
nil = not a rewritable token (not key:value, unknown key, or a value with no faithful equivalent).
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 265 266 267 268 269 270 271 272 273 274 |
# File 'lib/mailmate/search_syntax.rb', line 231 def translate_token(token, today, european = false) m = token.match(/\A(?<neg>[-!])?(?<key>[A-Za-z_][A-Za-z_-]*):(?<value>.+)\z/m) return nil unless m key = normalize_key(m[:key]) value = unquote(m[:value]) return nil if value.empty? if (spec = HEADER_EQUIV[key]) negated = !m[:neg].nil? # `f !"a b"` won't tokenize (the ! detaches the quotes) — leave a # negated multi-word value alone rather than emit a broken spec. return nil if negated && value =~ /\s/ operand = value =~ /\s/ ? "\"#{value}\"" : value return "#{spec} #{negated ? "!" : ""}#{operand}" end # Date keys: Gmail has no negated date form, so a -/! prefix here means # the caller is inventing syntax — don't guess at intent. return nil if m[:neg] case key when "date", "on", "sent", "received", "time" translate_point_date(value, today, european) when "after", "since" translate_after(value, today, european) when "before" translate_before(value, today, "<", european) when "until" translate_before(value, today, "<=", european) when "newer_than", "newer" (rel = parse_relative(value)) && "d #{rel}" when "older_than", "older" (rel = parse_relative(value)) && "d !#{rel}" when "in" # Archive state is path-derived, so Gmail's location idiom maps onto # the is:archived state spec. Other locations (in:trash, in:spam) # have no equivalent and stay flagged. case value.downcase when "archive", "archived" then "is:archived" when "inbox" then "!is:archived" end end end |
.translation_notice(notes) ⇒ Object
The stderr/tool-result announcement for a rewritten query. nil when nothing was rewritten.
169 170 171 172 173 174 175 176 |
# File 'lib/mailmate/search_syntax.rb', line 169 def translation_notice(notes) return nil if notes.empty? width = notes.map { |from, _| from.length }.max lines = ["translated foreign search syntax to MailMate quicksearch (`mmsearch --help`):"] notes.each { |from, to| lines << " #{from.ljust(width)} -> #{to}" } lines.join("\n") end |
.translation_reference(indent: " ") ⇒ Object
The --help table, indented for embedding.
179 180 181 182 183 184 185 186 |
# File 'lib/mailmate/search_syntax.rb', line 179 def translation_reference(indent: " ") width = TRANSLATIONS_HELP.map { |from, _| from.length }.max lines = TRANSLATIONS_HELP.map { |from, to| "#{indent} #{from.ljust(width)} -> #{to}" } lines << "" lines << "#{indent}Keys with no equivalent (is: has: in: filename: ...) are searched as" lines << "#{indent}literal text; an empty result will call them out." lines.join("\n") end |
.unquote(value) ⇒ Object
276 277 278 279 280 281 |
# File 'lib/mailmate/search_syntax.rb', line 276 def unquote(value) case value when /\A"(.*)"\z/m, /\A'(.*)'\z/m then Regexp.last_match(1) else value end end |
.zero_result_hint(query) ⇒ Object
The advisory a caller should print when a search matched NOTHING and the query carries foreign syntax. nil when there is nothing to say — an ordinary empty result stays silent, because polling for mail that has not arrived yet is a normal, correct thing to do.
204 205 206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/mailmate/search_syntax.rb', line 204 def zero_result_hint(query) tokens = foreign_tokens(query) return nil if tokens.empty? quoted = tokens.map { |token, _| "`#{token}`" }.join(", ") lines = ["0 results, and #{quoted} #{tokens.size == 1 ? "is not" : "are not"} " \ "MailMate quicksearch syntax — it was searched for as literal text."] tokens.each do |token, key| eq = EQUIVALENTS[key] or next lines << " #{token} -> #{eq}" end lines << "Run `mmsearch --help` for the full syntax." lines.join("\n") end |