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)"],
].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"],
  ["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.

%w[
  after before older newer older_than newer_than on since until
  date sent received time
  from to cc bcc subject body
  is has in label folder mailbox category filename
].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"],
].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_]+:)?'[^']*'|\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.



166
167
168
169
170
171
172
173
# File 'lib/mailmate/search_syntax.rb', line 166

def foreign_tokens(query)
  unquoted = query.to_s.gsub(/"[^"]*"|'[^']*'/, " ")
  unquoted.scan(/(?<![\w-])!?([A-Za-z_]+):(\S*)/).filter_map do |key, value|
    k = key.downcase
    next unless FOREIGN_KEYS.include?(k)
    ["#{key}:#{value}", k]
  end.uniq { |_token, 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.



276
277
278
279
280
281
282
283
284
# File 'lib/mailmate/search_syntax.rb', line 276

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.



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/mailmate/search_syntax.rb', line 295

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.



288
289
290
# File 'lib/mailmate/search_syntax.rb', line 288

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.



105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/mailmate/search_syntax.rb', line 105

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

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



132
133
134
135
136
137
138
139
140
# File 'lib/mailmate/search_syntax.rb', line 132

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 → >=.



257
258
259
260
261
262
263
# File 'lib/mailmate/search_syntax.rb', line 257

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 → <=.



266
267
268
269
270
271
272
# File 'lib/mailmate/search_syntax.rb', line 266

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.



244
245
246
247
248
249
250
251
252
253
254
# File 'lib/mailmate/search_syntax.rb', line 244

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/

  (period = normalize_period(v, european)) && "d #{period}"
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).



198
199
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
# File 'lib/mailmate/search_syntax.rb', line 198

def translate_token(token, today, european = false)
  m = token.match(/\A(?<neg>[-!])?(?<key>[A-Za-z_]+):(?<value>.+)\z/m)
  return nil unless m

  key   = m[:key].downcase
  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.



144
145
146
147
148
149
150
151
# File 'lib/mailmate/search_syntax.rb', line 144

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.



154
155
156
157
158
159
160
161
# File 'lib/mailmate/search_syntax.rb', line 154

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



235
236
237
238
239
240
# File 'lib/mailmate/search_syntax.rb', line 235

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.



179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/mailmate/search_syntax.rb', line 179

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