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



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