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:value form at all, so a term like date:today is 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 for date-received: in good faith (dogfood 2026-08-13: an agent queried date-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_viewed has no faithful d equivalent (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

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