mailmate 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +163 -95
- data/lib/mailmate/cli/search.rb +296 -95
- data/lib/mailmate/cli/send.rb +27 -6
- data/lib/mailmate/mcp.rb +34 -15
- data/lib/mailmate/search_syntax.rb +311 -0
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +1 -0
- metadata +2 -1
data/lib/mailmate/mcp.rb
CHANGED
|
@@ -74,25 +74,24 @@ module Mailmate
|
|
|
74
74
|
TOOLS = [
|
|
75
75
|
{
|
|
76
76
|
name: "search",
|
|
77
|
+
title: "Search mail",
|
|
78
|
+
annotations: { title: "Search mail", readOnlyHint: true },
|
|
77
79
|
description: <<~DESC.strip,
|
|
78
80
|
Search MailMate's .eml files using MailMate's quicksearch syntax.
|
|
79
81
|
Returns column-aligned CSV. Same engine as the `mmsearch` CLI.
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
query="f medium d 7d" from Medium in the last 7 days
|
|
94
|
-
query="T robot" tagged "robot"
|
|
95
|
-
query="s 'rent due' !draft" subject has 'rent due', not 'draft'
|
|
83
|
+
There is NO native key:value form — write quicksearch directly using
|
|
84
|
+
the modifiers below. Familiar foreign tokens (`from:bob`,
|
|
85
|
+
`date:today`, `after:2026-08-01`, `older_than:2w`) are auto-translated
|
|
86
|
+
to quicksearch, and the rewrite is announced in the result — that
|
|
87
|
+
announcement means your query was translated, not that it failed.
|
|
88
|
+
Unrecognized keys (`is:unread`, `has:attachment`) are searched as
|
|
89
|
+
literal text and match nothing, silently.
|
|
90
|
+
|
|
91
|
+
#{Mailmate::SearchSyntax.reference(indent: " ")}
|
|
92
|
+
|
|
93
|
+
The `mailbox` arg also accepts a smart-mailbox name (e.g. Newsletters,
|
|
94
|
+
Receipts, Priority) whose filter is ANDed into the search.
|
|
96
95
|
|
|
97
96
|
Fields default to: flags date time direction party subject.
|
|
98
97
|
Prefix with "+" to add to the defaults ("+tags +mailbox"); a bare
|
|
@@ -122,12 +121,15 @@ module Mailmate
|
|
|
122
121
|
limit: { type: "integer", description: "Stop after N matches." },
|
|
123
122
|
headers_only: { type: "boolean", description: "Skip body matching (much faster on text searches)." },
|
|
124
123
|
sort: { type: "string", enum: %w[asc desc none], description: "Sort by date+time. Default: asc." },
|
|
124
|
+
european: { type: "boolean", description: "Slash dates in the query are day-first (d 9/8/2026 = Aug 9). Default: month-first American." },
|
|
125
125
|
},
|
|
126
126
|
additionalProperties: false,
|
|
127
127
|
},
|
|
128
128
|
},
|
|
129
129
|
{
|
|
130
130
|
name: "message",
|
|
131
|
+
title: "Read message",
|
|
132
|
+
annotations: { title: "Read message", readOnlyHint: true },
|
|
131
133
|
description: "Read one MailMate message. Accepts either local eml-id (digits) or RFC Message-ID (with or without angle brackets). Default output: headers block (incl. any user tags) + plain-text body. For HTML-only mail (most newsletters), pass markdown:true to get clean readable markdown instead of raw HTML — strongly preferred for reading and far more token-efficient; it's a no-op on plain-text messages.",
|
|
132
134
|
inputSchema: {
|
|
133
135
|
type: "object",
|
|
@@ -143,6 +145,8 @@ module Mailmate
|
|
|
143
145
|
},
|
|
144
146
|
{
|
|
145
147
|
name: "modify",
|
|
148
|
+
title: "Modify message state",
|
|
149
|
+
annotations: { title: "Modify message state", readOnlyHint: false, destructiveHint: true },
|
|
146
150
|
description: <<~DESC.strip,
|
|
147
151
|
Apply state-change actions to a message via MailMate.
|
|
148
152
|
NOTE: drives MailMate's UI via AppleScript — it briefly takes focus,
|
|
@@ -192,6 +196,8 @@ module Mailmate
|
|
|
192
196
|
},
|
|
193
197
|
{
|
|
194
198
|
name: "verify",
|
|
199
|
+
title: "Verify modify results",
|
|
200
|
+
annotations: { title: "Verify modify results", readOnlyHint: true },
|
|
195
201
|
description: <<~DESC.strip,
|
|
196
202
|
Batch-confirm `modify` check-tickets (from check:"defer") against the
|
|
197
203
|
#flags index in ONE flush-wait. Pass the tickets you collected from a
|
|
@@ -218,6 +224,8 @@ module Mailmate
|
|
|
218
224
|
},
|
|
219
225
|
{
|
|
220
226
|
name: "send",
|
|
227
|
+
title: "Send mail",
|
|
228
|
+
annotations: { title: "Send mail", readOnlyHint: false, destructiveHint: true },
|
|
221
229
|
description: "Send mail via MailMate's `emate` (markdown body). Recipients and subject via fields; body is the markdown source. For replies, set `in_reply_to` and `references` so recipients' clients thread the message — without them a `Re:` subject alone is not enough. MailMate generates the outgoing Message-ID automatically.",
|
|
222
230
|
inputSchema: {
|
|
223
231
|
type: "object",
|
|
@@ -239,6 +247,8 @@ module Mailmate
|
|
|
239
247
|
},
|
|
240
248
|
{
|
|
241
249
|
name: "draft",
|
|
250
|
+
title: "Compose draft",
|
|
251
|
+
annotations: { title: "Compose draft", readOnlyHint: false, destructiveHint: false },
|
|
242
252
|
description: "Compose a draft via MailMate's `emate` (markdown body) — IDENTICAL to `send` but it never sends: the draft opens in MailMate and waits. There is no `send_now` option; use this whenever the instruction is 'write/compose but don't send'. For replies, set `in_reply_to` and `references` so the draft threads correctly. MailMate generates the outgoing Message-ID automatically.",
|
|
243
253
|
inputSchema: {
|
|
244
254
|
type: "object",
|
|
@@ -259,6 +269,8 @@ module Mailmate
|
|
|
259
269
|
},
|
|
260
270
|
{
|
|
261
271
|
name: "open",
|
|
272
|
+
title: "Open in MailMate",
|
|
273
|
+
annotations: { title: "Open in MailMate", readOnlyHint: false, destructiveHint: false },
|
|
262
274
|
description: "Open one MailMate message in MailMate's UI (activates the window). Accepts any of the id forms `resolve_id` takes. Read-side semantically, but does shift focus to MailMate.",
|
|
263
275
|
inputSchema: {
|
|
264
276
|
type: "object",
|
|
@@ -272,6 +284,8 @@ module Mailmate
|
|
|
272
284
|
},
|
|
273
285
|
{
|
|
274
286
|
name: "list_mailboxes",
|
|
287
|
+
title: "List mailboxes",
|
|
288
|
+
annotations: { title: "List mailboxes", readOnlyHint: true },
|
|
275
289
|
description: "Enumerate accounts, IMAP mailboxes (with optional message counts), and smart mailboxes MailMate has defined. Account names are decoded for display (`%40` → `@`).",
|
|
276
290
|
inputSchema: {
|
|
277
291
|
type: "object",
|
|
@@ -284,6 +298,8 @@ module Mailmate
|
|
|
284
298
|
},
|
|
285
299
|
{
|
|
286
300
|
name: "list_tags",
|
|
301
|
+
title: "List tags",
|
|
302
|
+
annotations: { title: "List tags", readOnlyHint: true },
|
|
287
303
|
description: "List user tags. Default: tags actually applied to messages, with usage counts (from MailMate's #flags index; system flags excluded). `defined: true`: tags MailMate has registered in Preferences → Tags (from Tags.plist).",
|
|
288
304
|
inputSchema: {
|
|
289
305
|
type: "object",
|
|
@@ -295,6 +311,8 @@ module Mailmate
|
|
|
295
311
|
},
|
|
296
312
|
{
|
|
297
313
|
name: "resolve_id",
|
|
314
|
+
title: "Resolve message identifiers",
|
|
315
|
+
annotations: { title: "Resolve message identifiers", readOnlyHint: true },
|
|
298
316
|
description: <<~DESC.strip,
|
|
299
317
|
Look up a message and return all its identifiers:
|
|
300
318
|
- eml_id local body-part id (changes per machine)
|
|
@@ -396,6 +414,7 @@ module Mailmate
|
|
|
396
414
|
argv.push("--limit", args["limit"].to_i.to_s) if args["limit"]
|
|
397
415
|
argv.push("--headers-only") if args["headers_only"]
|
|
398
416
|
argv.push("--sort", args["sort"].to_s) if args["sort"]
|
|
417
|
+
argv.push("--european") if args["european"]
|
|
399
418
|
# Positionals: search-string then fields. Only include if the caller
|
|
400
419
|
# gave us either — otherwise let the CLI apply its defaults.
|
|
401
420
|
if args.key?("query") || args["fields"]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Mailmate
|
|
6
|
+
# THE one description of quicksearch syntax. Both surfaces that teach the
|
|
7
|
+
# syntax — `mmsearch --help` and the MCP `search` tool description — render
|
|
8
|
+
# from the tables here, so the two can no longer drift apart (they already
|
|
9
|
+
# had: the CLI said "Nd|Nw|Nm|Ny (relative), or Y, Y-M, Y-M-D" while the MCP
|
|
10
|
+
# said "Y, Y-M, Y-M-D, or relative 1d/2w/3m/1y" — same rules, two wordings,
|
|
11
|
+
# two things to remember to update).
|
|
12
|
+
#
|
|
13
|
+
# Downstream consumers should POINT at these surfaces rather than restate
|
|
14
|
+
# them. A copy of the syntax in someone else's system prompt is a copy that
|
|
15
|
+
# goes stale the next time a modifier is added here.
|
|
16
|
+
module SearchSyntax
|
|
17
|
+
# [spec, meaning]. Order is the teaching order, not alphabetical.
|
|
18
|
+
MODIFIERS = [
|
|
19
|
+
["<term>", "common headers (from/to/cc/subject) OR body contains <term>"],
|
|
20
|
+
["f <term>", "from contains"],
|
|
21
|
+
["t <term>", "to/cc (recipients) contains"],
|
|
22
|
+
["c <term>", "cc contains"],
|
|
23
|
+
["s <term>", "subject contains"],
|
|
24
|
+
["a <term>", "any address header contains"],
|
|
25
|
+
["b <term>", "body contains"],
|
|
26
|
+
["m <term>", "common headers OR body (same as a bare term)"],
|
|
27
|
+
["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"],
|
|
28
|
+
["T <tag>", "tag / IMAP keyword contains (K is a synonym)"],
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
EXAMPLES = [
|
|
32
|
+
["f substack d 7d", "from Substack in the last 7 days"],
|
|
33
|
+
["s \"invoice due\" !draft", "subject has 'invoice due', not 'draft'"],
|
|
34
|
+
["d 2026-05", "received in May 2026"],
|
|
35
|
+
["d 2026-08-10", "received on one specific day"],
|
|
36
|
+
["d 1d", "received today (the default); d 2d = yesterday + today"],
|
|
37
|
+
["d 24h", "received in the last 24 hours (rolling, not calendar)"],
|
|
38
|
+
["d >=2026-05 d <2026-08", "received May through July 2026"],
|
|
39
|
+
["d 1h or 2026-08-09", "last hour, plus everything from Aug 9"],
|
|
40
|
+
["T urgent", "tagged 'urgent'"],
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
RULES = [
|
|
44
|
+
"Specs combine with AND; `or` separates alternatives, and AND binds tighter",
|
|
45
|
+
"(no parens): (f bob or f ann) s invoice = f bob s invoice or f ann s invoice.",
|
|
46
|
+
"After `or`, a bare first term inherits the modifier in force: d 2024 or 2025.",
|
|
47
|
+
"Wrap multi-word terms in \"double quotes\" (also how to search the word \"or\").",
|
|
48
|
+
"Prefix an operand with ! to negate: f !smith = from does NOT contain smith.",
|
|
49
|
+
"Negation works on dates too: d !3d = received MORE than 3 days ago.",
|
|
50
|
+
"Absolute dates compare: d >2026-08 (after Aug), d <2026-08 (before), also >= <=.",
|
|
51
|
+
"Slash dates are month-first American: d 8/9/2026 = Aug 9 (day-first: --european).",
|
|
52
|
+
"An impossible date combination (d >2026 d <2025) is an error, not 0 results.",
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
# Search keys from OTHER mail systems (Gmail, Outlook, Apple Mail, IMAP
|
|
56
|
+
# dialects). Quicksearch has no `key:value` form at all, so a term like
|
|
57
|
+
# `date:today` is not a syntax error — it parses as a bare term and
|
|
58
|
+
# searches for the literal string "date:today" in headers and body, which
|
|
59
|
+
# matches nothing. That silence is the whole problem this list exists to
|
|
60
|
+
# break: an agent or a person gets an empty result set that is
|
|
61
|
+
# indistinguishable from "your mail really has nothing", and believes it.
|
|
62
|
+
FOREIGN_KEYS = %w[
|
|
63
|
+
after before older newer older_than newer_than on since until
|
|
64
|
+
date sent received time
|
|
65
|
+
from to cc bcc subject body
|
|
66
|
+
is has in label folder mailbox category filename
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
# Spec placeholders for the zero-result hint, for foreign keys whose value
|
|
70
|
+
# translate() could NOT rewrite (e.g. `after:8am`). Keys absent here still
|
|
71
|
+
# get flagged, just without a suggested rewrite.
|
|
72
|
+
EQUIVALENTS = {
|
|
73
|
+
"from" => "f <term>", "to" => "t <term>", "cc" => "c <term>",
|
|
74
|
+
"subject" => "s <term>", "body" => "b <term>", "label" => "T <tag>",
|
|
75
|
+
"date" => "d <date>", "sent" => "d <date>", "received" => "d <date>",
|
|
76
|
+
"on" => "d <date>", "after" => "d >=YYYY-MM-DD", "since" => "d >=YYYY-MM-DD",
|
|
77
|
+
"before" => "d <YYYY-MM-DD", "until" => "d <=YYYY-MM-DD",
|
|
78
|
+
"newer_than" => "d Nd", "older_than" => "d !Nd",
|
|
79
|
+
"newer" => "d Nd", "older" => "d !Nd",
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
# Foreign header-ish keys with a direct quicksearch spec. The value
|
|
83
|
+
# carries over unchanged, so these translate regardless of what it is.
|
|
84
|
+
HEADER_EQUIV = {
|
|
85
|
+
"from" => "f", "to" => "t", "cc" => "c",
|
|
86
|
+
"subject" => "s", "body" => "b", "label" => "T",
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
# The --help table for the translator. Symbolic, not computed — <N> is
|
|
90
|
+
# resolved against the current date at translation time.
|
|
91
|
+
TRANSLATIONS_HELP = [
|
|
92
|
+
["from:bob (to: cc: subject: body: label:)", "f bob (t c s b T)"],
|
|
93
|
+
["-from:bob or !from:bob", "f !bob"],
|
|
94
|
+
["date:today / date:yesterday", "d 1d / d <that day>"],
|
|
95
|
+
["date:2026-03-05 or date:3/5/2026 (M/D/Y)", "d 2026-03-05"],
|
|
96
|
+
["newer_than:2d / older_than:2w", "d 2d / d !2w"],
|
|
97
|
+
["after:2026-05 or since:2026-05", "d >=2026-05"],
|
|
98
|
+
["before:2026-08 / until:2026-08", "d <2026-08 / d <=2026-08"],
|
|
99
|
+
].freeze
|
|
100
|
+
|
|
101
|
+
module_function
|
|
102
|
+
|
|
103
|
+
# The shared syntax reference, indented for embedding. Used verbatim by
|
|
104
|
+
# `mmsearch --help` and by the MCP tool description.
|
|
105
|
+
def reference(indent: " ")
|
|
106
|
+
width = MODIFIERS.map { |spec, _| spec.length }.max
|
|
107
|
+
lines = []
|
|
108
|
+
RULES.each { |r| lines << "#{indent}#{r}" }
|
|
109
|
+
lines << ""
|
|
110
|
+
MODIFIERS.each { |spec, meaning| lines << "#{indent} #{spec.ljust(width)} #{meaning}" }
|
|
111
|
+
lines << ""
|
|
112
|
+
lines << "#{indent}Examples:"
|
|
113
|
+
ex_width = EXAMPLES.map { |q, _| q.length }.max
|
|
114
|
+
EXAMPLES.each { |q, meaning| lines << "#{indent} #{q.ljust(ex_width)} #{meaning}" }
|
|
115
|
+
lines.join("\n")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# One token: an (optionally key:-prefixed) quoted string, or a bare run
|
|
119
|
+
# of non-space. Quoted regions survive as single tokens so translate()
|
|
120
|
+
# can leave a deliberate literal search (`s "date:today"`) alone.
|
|
121
|
+
TOKEN_RX = /(?:[-!]?[A-Za-z_]+:)?"[^"]*"|(?:[-!]?[A-Za-z_]+:)?'[^']*'|\S+/
|
|
122
|
+
|
|
123
|
+
# Rewrite foreign `key:value` tokens to their exact quicksearch
|
|
124
|
+
# equivalent, leaving everything else byte-for-byte intact. Returns
|
|
125
|
+
# [query, notes] where notes is [[original_token, replacement], ...] —
|
|
126
|
+
# callers MUST surface the notes (stderr, tool result); a silent rewrite
|
|
127
|
+
# would show the reader a query that never ran.
|
|
128
|
+
#
|
|
129
|
+
# Only rewrites where the equivalence is exact. A foreign key whose value
|
|
130
|
+
# can't be translated faithfully (`after:8am`, `date:next week`) stays in
|
|
131
|
+
# the query as literal text, and zero_result_hint still flags it there.
|
|
132
|
+
def translate(query, today: Date.today, european: false)
|
|
133
|
+
notes = []
|
|
134
|
+
translated = query.to_s.gsub(TOKEN_RX) do |token|
|
|
135
|
+
replacement = translate_token(token, today, european)
|
|
136
|
+
notes << [token, replacement] if replacement
|
|
137
|
+
replacement || token
|
|
138
|
+
end
|
|
139
|
+
[translated, notes]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The stderr/tool-result announcement for a rewritten query. nil when
|
|
143
|
+
# nothing was rewritten.
|
|
144
|
+
def translation_notice(notes)
|
|
145
|
+
return nil if notes.empty?
|
|
146
|
+
|
|
147
|
+
width = notes.map { |from, _| from.length }.max
|
|
148
|
+
lines = ["translated foreign search syntax to MailMate quicksearch (`mmsearch --help`):"]
|
|
149
|
+
notes.each { |from, to| lines << " #{from.ljust(width)} -> #{to}" }
|
|
150
|
+
lines.join("\n")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The --help table, indented for embedding.
|
|
154
|
+
def translation_reference(indent: " ")
|
|
155
|
+
width = TRANSLATIONS_HELP.map { |from, _| from.length }.max
|
|
156
|
+
lines = TRANSLATIONS_HELP.map { |from, to| "#{indent} #{from.ljust(width)} -> #{to}" }
|
|
157
|
+
lines << ""
|
|
158
|
+
lines << "#{indent}Keys with no equivalent (is: has: in: filename: ...) are searched as"
|
|
159
|
+
lines << "#{indent}literal text; an empty result will call them out."
|
|
160
|
+
lines.join("\n")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Foreign `key:value` tokens in a query, lowercased keys, in order of
|
|
164
|
+
# appearance and de-duplicated. Quoted regions are skipped: a deliberate
|
|
165
|
+
# search for the literal text `s "date:today"` is not a mistake.
|
|
166
|
+
def foreign_tokens(query)
|
|
167
|
+
unquoted = query.to_s.gsub(/"[^"]*"|'[^']*'/, " ")
|
|
168
|
+
unquoted.scan(/(?<![\w-])!?([A-Za-z_]+):(\S*)/).filter_map do |key, value|
|
|
169
|
+
k = key.downcase
|
|
170
|
+
next unless FOREIGN_KEYS.include?(k)
|
|
171
|
+
["#{key}:#{value}", k]
|
|
172
|
+
end.uniq { |_token, k| k }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The advisory a caller should print when a search matched NOTHING and the
|
|
176
|
+
# query carries foreign syntax. nil when there is nothing to say — an
|
|
177
|
+
# ordinary empty result stays silent, because polling for mail that has
|
|
178
|
+
# not arrived yet is a normal, correct thing to do.
|
|
179
|
+
def zero_result_hint(query)
|
|
180
|
+
tokens = foreign_tokens(query)
|
|
181
|
+
return nil if tokens.empty?
|
|
182
|
+
|
|
183
|
+
quoted = tokens.map { |token, _| "`#{token}`" }.join(", ")
|
|
184
|
+
lines = ["0 results, and #{quoted} #{tokens.size == 1 ? "is not" : "are not"} " \
|
|
185
|
+
"MailMate quicksearch syntax — it was searched for as literal text."]
|
|
186
|
+
tokens.each do |token, key|
|
|
187
|
+
eq = EQUIVALENTS[key] or next
|
|
188
|
+
lines << " #{token} -> #{eq}"
|
|
189
|
+
end
|
|
190
|
+
lines << "Run `mmsearch --help` for the full syntax."
|
|
191
|
+
lines.join("\n")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ---- translation internals -------------------------------------------
|
|
195
|
+
|
|
196
|
+
# nil = not a rewritable token (not key:value, unknown key, or a value
|
|
197
|
+
# with no faithful equivalent).
|
|
198
|
+
def translate_token(token, today, european = false)
|
|
199
|
+
m = token.match(/\A(?<neg>[-!])?(?<key>[A-Za-z_]+):(?<value>.+)\z/m)
|
|
200
|
+
return nil unless m
|
|
201
|
+
|
|
202
|
+
key = m[:key].downcase
|
|
203
|
+
value = unquote(m[:value])
|
|
204
|
+
return nil if value.empty?
|
|
205
|
+
|
|
206
|
+
if (spec = HEADER_EQUIV[key])
|
|
207
|
+
negated = !m[:neg].nil?
|
|
208
|
+
# `f !"a b"` won't tokenize (the ! detaches the quotes) — leave a
|
|
209
|
+
# negated multi-word value alone rather than emit a broken spec.
|
|
210
|
+
return nil if negated && value =~ /\s/
|
|
211
|
+
operand = value =~ /\s/ ? "\"#{value}\"" : value
|
|
212
|
+
return "#{spec} #{negated ? "!" : ""}#{operand}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Date keys: Gmail has no negated date form, so a -/! prefix here means
|
|
216
|
+
# the caller is inventing syntax — don't guess at intent.
|
|
217
|
+
return nil if m[:neg]
|
|
218
|
+
|
|
219
|
+
case key
|
|
220
|
+
when "date", "on", "sent", "received", "time"
|
|
221
|
+
translate_point_date(value, today, european)
|
|
222
|
+
when "after", "since"
|
|
223
|
+
translate_after(value, today, european)
|
|
224
|
+
when "before"
|
|
225
|
+
translate_before(value, today, "<", european)
|
|
226
|
+
when "until"
|
|
227
|
+
translate_before(value, today, "<=", european)
|
|
228
|
+
when "newer_than", "newer"
|
|
229
|
+
(rel = parse_relative(value)) && "d #{rel}"
|
|
230
|
+
when "older_than", "older"
|
|
231
|
+
(rel = parse_relative(value)) && "d !#{rel}"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def unquote(value)
|
|
236
|
+
case value
|
|
237
|
+
when /\A"(.*)"\z/m, /\A'(.*)'\z/m then Regexp.last_match(1)
|
|
238
|
+
else value
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A day-, month-, or year-precision point in time. `d <period>` matches
|
|
243
|
+
# exactly that period in the engine, so these are exact.
|
|
244
|
+
def translate_point_date(value, today, european = false)
|
|
245
|
+
v = value.downcase
|
|
246
|
+
return "d 1d" if v == "today"
|
|
247
|
+
return "d #{(today - 1).strftime("%Y-%m-%d")}" if v == "yesterday"
|
|
248
|
+
# `date:8/10/2026-today` (seen in real transcripts): a range whose end
|
|
249
|
+
# is now IS an after-window.
|
|
250
|
+
return translate_after(v.delete_suffix("-today"), today, european) if v.end_with?("-today")
|
|
251
|
+
return "d #{v}" if v =~ /\A\d+[dwmy]\z/
|
|
252
|
+
|
|
253
|
+
(period = normalize_period(v, european)) && "d #{period}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Gmail's after: includes the named day; since: likewise → >=.
|
|
257
|
+
def translate_after(value, today, european = false)
|
|
258
|
+
v = value.downcase
|
|
259
|
+
return "d 1d" if v == "today"
|
|
260
|
+
return "d 2d" if v == "yesterday"
|
|
261
|
+
|
|
262
|
+
(period = normalize_period(v, european)) && "d >=#{period}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Gmail's before: excludes the named day → <. until: includes it → <=.
|
|
266
|
+
def translate_before(value, today, op, european = false)
|
|
267
|
+
v = value.downcase
|
|
268
|
+
v = today.strftime("%Y-%m-%d") if v == "today"
|
|
269
|
+
v = (today - 1).strftime("%Y-%m-%d") if v == "yesterday"
|
|
270
|
+
|
|
271
|
+
(period = normalize_period(v, european)) && "d #{op}#{period}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# "2026", "2026-05", "2026-03-05", "3/5/2026", "2026/3/5" → the
|
|
275
|
+
# normalized absolute period string quicksearch expects, or nil.
|
|
276
|
+
def normalize_period(value, european = false)
|
|
277
|
+
if (day = parse_day(value, european))
|
|
278
|
+
day.strftime("%Y-%m-%d")
|
|
279
|
+
elsif value =~ %r{\A(\d{4})[-/.](\d{1,2})\z}
|
|
280
|
+
format("%04d-%02d", Regexp.last_match(1).to_i, Regexp.last_match(2).to_i)
|
|
281
|
+
elsif value =~ /\A\d{4}\z/
|
|
282
|
+
value
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Gmail relative units (d/m/y, plus w) carry over as-is: `d N<u>` uses
|
|
287
|
+
# the same calendar arithmetic.
|
|
288
|
+
def parse_relative(value)
|
|
289
|
+
value =~ /\A(\d+)\s*([dwmy])\z/ ? "#{Regexp.last_match(1)}#{Regexp.last_match(2)}" : nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Y-M-D (any of - / . separators), or slash-dates with a trailing
|
|
293
|
+
# 4-digit year — US M/D/Y by default, D/M/Y when european. Two-digit
|
|
294
|
+
# years are ambiguous across dialects — refused rather than guessed.
|
|
295
|
+
def parse_day(value, european = false)
|
|
296
|
+
parts = value.split(%r{[-/.]})
|
|
297
|
+
return nil unless parts.size == 3 && parts.all? { |p| p =~ /\A\d+\z/ }
|
|
298
|
+
|
|
299
|
+
y, m, d =
|
|
300
|
+
if parts[0].length == 4
|
|
301
|
+
[parts[0], parts[1], parts[2]]
|
|
302
|
+
elsif parts[2].length == 4
|
|
303
|
+
european ? [parts[2], parts[1], parts[0]] : [parts[2], parts[0], parts[1]]
|
|
304
|
+
end
|
|
305
|
+
return nil unless y
|
|
306
|
+
|
|
307
|
+
y, m, d = y.to_i, m.to_i, d.to_i
|
|
308
|
+
Date.valid_date?(y, m, d) ? Date.new(y, m, d) : nil
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
data/lib/mailmate/version.rb
CHANGED
data/lib/mailmate.rb
CHANGED
|
@@ -50,6 +50,7 @@ require_relative "mailmate/mailbox_graph"
|
|
|
50
50
|
require_relative "mailmate/source_resolver"
|
|
51
51
|
require_relative "mailmate/var_resolver"
|
|
52
52
|
require_relative "mailmate/filter_classifier"
|
|
53
|
+
require_relative "mailmate/search_syntax"
|
|
53
54
|
|
|
54
55
|
module Mailmate
|
|
55
56
|
# First-run bootstrap. If ~/.config/mailmate/config.yml is missing,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mailmate
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Murphy-Dye
|
|
@@ -153,6 +153,7 @@ files:
|
|
|
153
153
|
- lib/mailmate/parser.rb
|
|
154
154
|
- lib/mailmate/part_lookup.rb
|
|
155
155
|
- lib/mailmate/platform_error.rb
|
|
156
|
+
- lib/mailmate/search_syntax.rb
|
|
156
157
|
- lib/mailmate/source_resolver.rb
|
|
157
158
|
- lib/mailmate/var_resolver.rb
|
|
158
159
|
- lib/mailmate/version.rb
|