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 (N calendar units ending today; 1d = today), or Y, Y-M, Y-M-D"], ["T <tag>", "tag / IMAP keyword contains (K is a synonym)"], ["is:<state>", "message state: unread, read, flagged, replied, draft"], ["has:attachment", "root MIME is multipart/mixed (the standard attachment layout)"], ].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 7d", "unread, received in the last 7 days"], ["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.
189 190 191 192 193 194 195 196 |
# File 'lib/mailmate/search_syntax.rb', line 189 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.
222 223 224 225 |
# File 'lib/mailmate/search_syntax.rb', line 222 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.
347 348 349 350 351 352 353 354 355 |
# File 'lib/mailmate/search_syntax.rb', line 347 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.
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 |
# File 'lib/mailmate/search_syntax.rb', line 366 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.
359 360 361 |
# File 'lib/mailmate/search_syntax.rb', line 359 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.
128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/mailmate/search_syntax.rb', line 128 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.
319 320 321 322 323 324 325 |
# File 'lib/mailmate/search_syntax.rb', line 319 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.
155 156 157 158 159 160 161 162 163 |
# File 'lib/mailmate/search_syntax.rb', line 155 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 → >=.
328 329 330 331 332 333 334 |
# File 'lib/mailmate/search_syntax.rb', line 328 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 → <=.
337 338 339 340 341 342 343 |
# File 'lib/mailmate/search_syntax.rb', line 337 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.
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 |
# File 'lib/mailmate/search_syntax.rb', line 275 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.
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 |
# File 'lib/mailmate/search_syntax.rb', line 298 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).
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/search_syntax.rb', line 229 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}" end end |
.translation_notice(notes) ⇒ Object
The stderr/tool-result announcement for a rewritten query. nil when nothing was rewritten.
167 168 169 170 171 172 173 174 |
# File 'lib/mailmate/search_syntax.rb', line 167 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.
177 178 179 180 181 182 183 184 |
# File 'lib/mailmate/search_syntax.rb', line 177 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
266 267 268 269 270 271 |
# File 'lib/mailmate/search_syntax.rb', line 266 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.
202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/mailmate/search_syntax.rb', line 202 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 |