mailmate 1.6.0 → 1.8.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.
@@ -0,0 +1,316 @@
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
+ ["is:<state>", "message state: unread, read, flagged, replied, draft"],
30
+ ["has:attachment", "root MIME is multipart/mixed (the standard attachment layout)"],
31
+ ].freeze
32
+
33
+ EXAMPLES = [
34
+ ["f substack d 7d", "from Substack in the last 7 days"],
35
+ ["s \"invoice due\" !draft", "subject has 'invoice due', not 'draft'"],
36
+ ["d 2026-05", "received in May 2026"],
37
+ ["d 2026-08-10", "received on one specific day"],
38
+ ["d 1d", "received today (the default); d 2d = yesterday + today"],
39
+ ["d 24h", "received in the last 24 hours (rolling, not calendar)"],
40
+ ["d >=2026-05 d <2026-08", "received May through July 2026"],
41
+ ["d 1h or 2026-08-09", "last hour, plus everything from Aug 9"],
42
+ ["is:unread d 7d", "unread, received in the last 7 days"],
43
+ ["T urgent", "tagged 'urgent'"],
44
+ ].freeze
45
+
46
+ RULES = [
47
+ "Specs combine with AND; `or` separates alternatives, and AND binds tighter",
48
+ "(no parens): (f bob or f ann) s invoice = f bob s invoice or f ann s invoice.",
49
+ "After `or`, a bare first term inherits the modifier in force: d 2024 or 2025.",
50
+ "Wrap multi-word terms in \"double quotes\" (also how to search the word \"or\").",
51
+ "Prefix an operand with ! to negate: f !smith = from does NOT contain smith.",
52
+ "Negation works on dates too: d !3d = received MORE than 3 days ago.",
53
+ "Absolute dates compare: d >2026-08 (after Aug), d <2026-08 (before), also >= <=.",
54
+ "Slash dates are month-first American: d 8/9/2026 = Aug 9 (day-first: --european).",
55
+ "An impossible date combination (d >2026 d <2025) is an error, not 0 results.",
56
+ ].freeze
57
+
58
+ # Search keys from OTHER mail systems (Gmail, Outlook, Apple Mail, IMAP
59
+ # dialects). Quicksearch has no `key:value` form at all, so a term like
60
+ # `date:today` is not a syntax error — it parses as a bare term and
61
+ # searches for the literal string "date:today" in headers and body, which
62
+ # matches nothing. That silence is the whole problem this list exists to
63
+ # break: an agent or a person gets an empty result set that is
64
+ # indistinguishable from "your mail really has nothing", and believes it.
65
+ # NOTE: is/has are absent — they are first-class quicksearch now
66
+ # (is:unread, has:attachment parse as native state specs).
67
+ FOREIGN_KEYS = %w[
68
+ after before older newer older_than newer_than on since until
69
+ date sent received time
70
+ from to cc bcc subject body
71
+ in label folder mailbox category filename
72
+ ].freeze
73
+
74
+ # Spec placeholders for the zero-result hint, for foreign keys whose value
75
+ # translate() could NOT rewrite (e.g. `after:8am`). Keys absent here still
76
+ # get flagged, just without a suggested rewrite.
77
+ EQUIVALENTS = {
78
+ "from" => "f <term>", "to" => "t <term>", "cc" => "c <term>",
79
+ "subject" => "s <term>", "body" => "b <term>", "label" => "T <tag>",
80
+ "date" => "d <date>", "sent" => "d <date>", "received" => "d <date>",
81
+ "on" => "d <date>", "after" => "d >=YYYY-MM-DD", "since" => "d >=YYYY-MM-DD",
82
+ "before" => "d <YYYY-MM-DD", "until" => "d <=YYYY-MM-DD",
83
+ "newer_than" => "d Nd", "older_than" => "d !Nd",
84
+ "newer" => "d Nd", "older" => "d !Nd",
85
+ }.freeze
86
+
87
+ # Foreign header-ish keys with a direct quicksearch spec. The value
88
+ # carries over unchanged, so these translate regardless of what it is.
89
+ HEADER_EQUIV = {
90
+ "from" => "f", "to" => "t", "cc" => "c",
91
+ "subject" => "s", "body" => "b", "label" => "T",
92
+ }.freeze
93
+
94
+ # The --help table for the translator. Symbolic, not computed — <N> is
95
+ # resolved against the current date at translation time.
96
+ TRANSLATIONS_HELP = [
97
+ ["from:bob (to: cc: subject: body: label:)", "f bob (t c s b T)"],
98
+ ["-from:bob or !from:bob", "f !bob"],
99
+ ["date:today / date:yesterday", "d 1d / d <that day>"],
100
+ ["date:2026-03-05 or date:3/5/2026 (M/D/Y)", "d 2026-03-05"],
101
+ ["newer_than:2d / older_than:2w", "d 2d / d !2w"],
102
+ ["after:2026-05 or since:2026-05", "d >=2026-05"],
103
+ ["before:2026-08 / until:2026-08", "d <2026-08 / d <=2026-08"],
104
+ ].freeze
105
+
106
+ module_function
107
+
108
+ # The shared syntax reference, indented for embedding. Used verbatim by
109
+ # `mmsearch --help` and by the MCP tool description.
110
+ def reference(indent: " ")
111
+ width = MODIFIERS.map { |spec, _| spec.length }.max
112
+ lines = []
113
+ RULES.each { |r| lines << "#{indent}#{r}" }
114
+ lines << ""
115
+ MODIFIERS.each { |spec, meaning| lines << "#{indent} #{spec.ljust(width)} #{meaning}" }
116
+ lines << ""
117
+ lines << "#{indent}Examples:"
118
+ ex_width = EXAMPLES.map { |q, _| q.length }.max
119
+ EXAMPLES.each { |q, meaning| lines << "#{indent} #{q.ljust(ex_width)} #{meaning}" }
120
+ lines.join("\n")
121
+ end
122
+
123
+ # One token: an (optionally key:-prefixed) quoted string, or a bare run
124
+ # of non-space. Quoted regions survive as single tokens so translate()
125
+ # can leave a deliberate literal search (`s "date:today"`) alone.
126
+ TOKEN_RX = /(?:[-!]?[A-Za-z_]+:)?"[^"]*"|(?:[-!]?[A-Za-z_]+:)?'[^']*'|\S+/
127
+
128
+ # Rewrite foreign `key:value` tokens to their exact quicksearch
129
+ # equivalent, leaving everything else byte-for-byte intact. Returns
130
+ # [query, notes] where notes is [[original_token, replacement], ...] —
131
+ # callers MUST surface the notes (stderr, tool result); a silent rewrite
132
+ # would show the reader a query that never ran.
133
+ #
134
+ # Only rewrites where the equivalence is exact. A foreign key whose value
135
+ # can't be translated faithfully (`after:8am`, `date:next week`) stays in
136
+ # the query as literal text, and zero_result_hint still flags it there.
137
+ def translate(query, today: Date.today, european: false)
138
+ notes = []
139
+ translated = query.to_s.gsub(TOKEN_RX) do |token|
140
+ replacement = translate_token(token, today, european)
141
+ notes << [token, replacement] if replacement
142
+ replacement || token
143
+ end
144
+ [translated, notes]
145
+ end
146
+
147
+ # The stderr/tool-result announcement for a rewritten query. nil when
148
+ # nothing was rewritten.
149
+ def translation_notice(notes)
150
+ return nil if notes.empty?
151
+
152
+ width = notes.map { |from, _| from.length }.max
153
+ lines = ["translated foreign search syntax to MailMate quicksearch (`mmsearch --help`):"]
154
+ notes.each { |from, to| lines << " #{from.ljust(width)} -> #{to}" }
155
+ lines.join("\n")
156
+ end
157
+
158
+ # The --help table, indented for embedding.
159
+ def translation_reference(indent: " ")
160
+ width = TRANSLATIONS_HELP.map { |from, _| from.length }.max
161
+ lines = TRANSLATIONS_HELP.map { |from, to| "#{indent} #{from.ljust(width)} -> #{to}" }
162
+ lines << ""
163
+ lines << "#{indent}Keys with no equivalent (is: has: in: filename: ...) are searched as"
164
+ lines << "#{indent}literal text; an empty result will call them out."
165
+ lines.join("\n")
166
+ end
167
+
168
+ # Foreign `key:value` tokens in a query, lowercased keys, in order of
169
+ # appearance and de-duplicated. Quoted regions are skipped: a deliberate
170
+ # search for the literal text `s "date:today"` is not a mistake.
171
+ def foreign_tokens(query)
172
+ unquoted = query.to_s.gsub(/"[^"]*"|'[^']*'/, " ")
173
+ unquoted.scan(/(?<![\w-])!?([A-Za-z_]+):(\S*)/).filter_map do |key, value|
174
+ k = key.downcase
175
+ next unless FOREIGN_KEYS.include?(k)
176
+ ["#{key}:#{value}", k]
177
+ end.uniq { |_token, k| k }
178
+ end
179
+
180
+ # The advisory a caller should print when a search matched NOTHING and the
181
+ # query carries foreign syntax. nil when there is nothing to say — an
182
+ # ordinary empty result stays silent, because polling for mail that has
183
+ # not arrived yet is a normal, correct thing to do.
184
+ def zero_result_hint(query)
185
+ tokens = foreign_tokens(query)
186
+ return nil if tokens.empty?
187
+
188
+ quoted = tokens.map { |token, _| "`#{token}`" }.join(", ")
189
+ lines = ["0 results, and #{quoted} #{tokens.size == 1 ? "is not" : "are not"} " \
190
+ "MailMate quicksearch syntax — it was searched for as literal text."]
191
+ tokens.each do |token, key|
192
+ eq = EQUIVALENTS[key] or next
193
+ lines << " #{token} -> #{eq}"
194
+ end
195
+ lines << "Run `mmsearch --help` for the full syntax."
196
+ lines.join("\n")
197
+ end
198
+
199
+ # ---- translation internals -------------------------------------------
200
+
201
+ # nil = not a rewritable token (not key:value, unknown key, or a value
202
+ # with no faithful equivalent).
203
+ def translate_token(token, today, european = false)
204
+ m = token.match(/\A(?<neg>[-!])?(?<key>[A-Za-z_]+):(?<value>.+)\z/m)
205
+ return nil unless m
206
+
207
+ key = m[:key].downcase
208
+ value = unquote(m[:value])
209
+ return nil if value.empty?
210
+
211
+ if (spec = HEADER_EQUIV[key])
212
+ negated = !m[:neg].nil?
213
+ # `f !"a b"` won't tokenize (the ! detaches the quotes) — leave a
214
+ # negated multi-word value alone rather than emit a broken spec.
215
+ return nil if negated && value =~ /\s/
216
+ operand = value =~ /\s/ ? "\"#{value}\"" : value
217
+ return "#{spec} #{negated ? "!" : ""}#{operand}"
218
+ end
219
+
220
+ # Date keys: Gmail has no negated date form, so a -/! prefix here means
221
+ # the caller is inventing syntax — don't guess at intent.
222
+ return nil if m[:neg]
223
+
224
+ case key
225
+ when "date", "on", "sent", "received", "time"
226
+ translate_point_date(value, today, european)
227
+ when "after", "since"
228
+ translate_after(value, today, european)
229
+ when "before"
230
+ translate_before(value, today, "<", european)
231
+ when "until"
232
+ translate_before(value, today, "<=", european)
233
+ when "newer_than", "newer"
234
+ (rel = parse_relative(value)) && "d #{rel}"
235
+ when "older_than", "older"
236
+ (rel = parse_relative(value)) && "d !#{rel}"
237
+ end
238
+ end
239
+
240
+ def unquote(value)
241
+ case value
242
+ when /\A"(.*)"\z/m, /\A'(.*)'\z/m then Regexp.last_match(1)
243
+ else value
244
+ end
245
+ end
246
+
247
+ # A day-, month-, or year-precision point in time. `d <period>` matches
248
+ # exactly that period in the engine, so these are exact.
249
+ def translate_point_date(value, today, european = false)
250
+ v = value.downcase
251
+ return "d 1d" if v == "today"
252
+ return "d #{(today - 1).strftime("%Y-%m-%d")}" if v == "yesterday"
253
+ # `date:8/10/2026-today` (seen in real transcripts): a range whose end
254
+ # is now IS an after-window.
255
+ return translate_after(v.delete_suffix("-today"), today, european) if v.end_with?("-today")
256
+ return "d #{v}" if v =~ /\A\d+[dwmy]\z/
257
+
258
+ (period = normalize_period(v, european)) && "d #{period}"
259
+ end
260
+
261
+ # Gmail's after: includes the named day; since: likewise → >=.
262
+ def translate_after(value, today, european = false)
263
+ v = value.downcase
264
+ return "d 1d" if v == "today"
265
+ return "d 2d" if v == "yesterday"
266
+
267
+ (period = normalize_period(v, european)) && "d >=#{period}"
268
+ end
269
+
270
+ # Gmail's before: excludes the named day → <. until: includes it → <=.
271
+ def translate_before(value, today, op, european = false)
272
+ v = value.downcase
273
+ v = today.strftime("%Y-%m-%d") if v == "today"
274
+ v = (today - 1).strftime("%Y-%m-%d") if v == "yesterday"
275
+
276
+ (period = normalize_period(v, european)) && "d #{op}#{period}"
277
+ end
278
+
279
+ # "2026", "2026-05", "2026-03-05", "3/5/2026", "2026/3/5" → the
280
+ # normalized absolute period string quicksearch expects, or nil.
281
+ def normalize_period(value, european = false)
282
+ if (day = parse_day(value, european))
283
+ day.strftime("%Y-%m-%d")
284
+ elsif value =~ %r{\A(\d{4})[-/.](\d{1,2})\z}
285
+ format("%04d-%02d", Regexp.last_match(1).to_i, Regexp.last_match(2).to_i)
286
+ elsif value =~ /\A\d{4}\z/
287
+ value
288
+ end
289
+ end
290
+
291
+ # Gmail relative units (d/m/y, plus w) carry over as-is: `d N<u>` uses
292
+ # the same calendar arithmetic.
293
+ def parse_relative(value)
294
+ value =~ /\A(\d+)\s*([dwmy])\z/ ? "#{Regexp.last_match(1)}#{Regexp.last_match(2)}" : nil
295
+ end
296
+
297
+ # Y-M-D (any of - / . separators), or slash-dates with a trailing
298
+ # 4-digit year — US M/D/Y by default, D/M/Y when european. Two-digit
299
+ # years are ambiguous across dialects — refused rather than guessed.
300
+ def parse_day(value, european = false)
301
+ parts = value.split(%r{[-/.]})
302
+ return nil unless parts.size == 3 && parts.all? { |p| p =~ /\A\d+\z/ }
303
+
304
+ y, m, d =
305
+ if parts[0].length == 4
306
+ [parts[0], parts[1], parts[2]]
307
+ elsif parts[2].length == 4
308
+ european ? [parts[2], parts[1], parts[0]] : [parts[2], parts[0], parts[1]]
309
+ end
310
+ return nil unless y
311
+
312
+ y, m, d = y.to_i, m.to_i, d.to_i
313
+ Date.valid_date?(y, m, d) ? Date.new(y, m, d) : nil
314
+ end
315
+ end
316
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "1.6.0"
4
+ VERSION = "1.8.0"
5
5
  end
data/lib/mailmate.rb CHANGED
@@ -12,6 +12,8 @@
12
12
  # Mailmate::PartLookup.body_parts_of(envelope_id) → child body-part-ids of an envelope
13
13
  # Mailmate::EmlLookup.path_for(eml_id) → eml-id → absolute path
14
14
  # Mailmate::HeaderReader.header(path, name) → read one header from an .eml
15
+ # Mailmate::HeaderValue.sanitize(v) → CR/LF-safe --header value (ALL header paths use this)
16
+ # Mailmate::ReplyPrefill.build(id, mode:) → reply/reply-all/forward fields from a parent
15
17
  # Mailmate::MidUrl.for(message_id) → build a mid:%3C...%3E URL
16
18
  # Mailmate::DuplicateScanner.duplicates → Hash{Message-ID => Array<eml_id>}
17
19
  # Mailmate::AppleScriptDriver.new(...) → drive MailMate via AppleScript
@@ -35,6 +37,8 @@ require_relative "mailmate/identity"
35
37
  require_relative "mailmate/header_reader"
36
38
  require_relative "mailmate/mid_url"
37
39
  require_relative "mailmate/eml_lookup"
40
+ require_relative "mailmate/header_value"
41
+ require_relative "mailmate/reply_prefill"
38
42
  require_relative "mailmate/duplicate_scanner"
39
43
  require_relative "mailmate/applescript_driver"
40
44
  require_relative "mailmate/ast"
@@ -50,6 +54,7 @@ require_relative "mailmate/mailbox_graph"
50
54
  require_relative "mailmate/source_resolver"
51
55
  require_relative "mailmate/var_resolver"
52
56
  require_relative "mailmate/filter_classifier"
57
+ require_relative "mailmate/search_syntax"
53
58
 
54
59
  module Mailmate
55
60
  # 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.6.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Murphy-Dye
@@ -110,6 +110,7 @@ files:
110
110
  - LICENSE.txt
111
111
  - README.md
112
112
  - config.yml.example
113
+ - docs/Composing and threading.md
113
114
  - exe/mailmate-mcp
114
115
  - exe/mm-draft
115
116
  - exe/mm-mailboxes
@@ -135,6 +136,7 @@ files:
135
136
  - lib/mailmate/cli/send.rb
136
137
  - lib/mailmate/cli/tags.rb
137
138
  - lib/mailmate/cli/verify.rb
139
+ - lib/mailmate/cli/version_flag.rb
138
140
  - lib/mailmate/config.rb
139
141
  - lib/mailmate/duplicate_scanner.rb
140
142
  - lib/mailmate/eml_lookup.rb
@@ -142,6 +144,7 @@ files:
142
144
  - lib/mailmate/filter_classifier.rb
143
145
  - lib/mailmate/flag_check.rb
144
146
  - lib/mailmate/header_reader.rb
147
+ - lib/mailmate/header_value.rb
145
148
  - lib/mailmate/identity.rb
146
149
  - lib/mailmate/index_reader.rb
147
150
  - lib/mailmate/lexer.rb
@@ -153,12 +156,18 @@ files:
153
156
  - lib/mailmate/parser.rb
154
157
  - lib/mailmate/part_lookup.rb
155
158
  - lib/mailmate/platform_error.rb
159
+ - lib/mailmate/reply_prefill.rb
160
+ - lib/mailmate/search_syntax.rb
156
161
  - lib/mailmate/source_resolver.rb
157
162
  - lib/mailmate/var_resolver.rb
158
163
  - lib/mailmate/version.rb
164
+ homepage: https://github.com/brianmd/mailmate
159
165
  licenses:
160
166
  - MIT
161
- metadata: {}
167
+ metadata:
168
+ source_code_uri: https://github.com/brianmd/mailmate
169
+ bug_tracker_uri: https://github.com/brianmd/mailmate/issues
170
+ documentation_uri: https://github.com/brianmd/mailmate#readme
162
171
  rdoc_options: []
163
172
  require_paths:
164
173
  - lib