mailmate 1.4.0 → 1.6.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 +182 -95
- data/exe/mm-verify +8 -0
- data/lib/mailmate/cli/message.rb +22 -3
- data/lib/mailmate/cli/modify.rb +151 -12
- data/lib/mailmate/cli/search.rb +268 -50
- data/lib/mailmate/cli/verify.rb +146 -0
- data/lib/mailmate/eml_lookup.rb +22 -5
- data/lib/mailmate/flag_check.rb +51 -0
- data/lib/mailmate/index_reader.rb +160 -29
- data/lib/mailmate/mcp.rb +93 -8
- data/lib/mailmate/part_lookup.rb +12 -3
- data/lib/mailmate/version.rb +1 -1
- metadata +5 -1
|
@@ -35,23 +35,34 @@
|
|
|
35
35
|
# or [] if the message has no flags / isn't indexed.
|
|
36
36
|
#
|
|
37
37
|
# IndexReader instances cache both files in memory and build a hash from
|
|
38
|
-
# id → [
|
|
39
|
-
#
|
|
40
|
-
#
|
|
38
|
+
# id → [packed_range, …] (start << 32 | end, one Integer per record) for
|
|
39
|
+
# O(1) lookup. Construction cost ≈ 5–20 ms for 50–200k records (one bulk
|
|
40
|
+
# unpack("V*") pass); memory ≈ a few MB. For a CLI invocation that's fine;
|
|
41
|
+
# the evaluator instantiates one lazily when first needed.
|
|
41
42
|
|
|
42
43
|
module Mailmate
|
|
43
44
|
# @api public
|
|
44
45
|
class IndexReader
|
|
45
46
|
RECORD_SIZE = 12
|
|
46
47
|
|
|
48
|
+
# Re-stat the underlying files at most this often per reader (seconds).
|
|
49
|
+
# Short-lived CLI processes never hit the recheck; the persistent MCP
|
|
50
|
+
# server picks up MailMate's continuous index rewrites within this window
|
|
51
|
+
# instead of serving a snapshot from its first request forever.
|
|
52
|
+
FRESHNESS_INTERVAL = 1.0
|
|
53
|
+
|
|
47
54
|
class << self
|
|
48
55
|
# Per-process cache of readers keyed by [name, db_headers]. Including
|
|
49
56
|
# db_headers means a Mailmate.config swap (e.g. a test pointing at a
|
|
50
57
|
# different tmpdir) doesn't return stale readers built from the old
|
|
51
|
-
# path.
|
|
58
|
+
# path. Cached readers are re-validated against the on-disk files'
|
|
59
|
+
# mtime+size (throttled; see FRESHNESS_INTERVAL) so long-lived
|
|
60
|
+
# processes don't serve stale data after MailMate rewrites an index.
|
|
52
61
|
def for(name)
|
|
53
62
|
@cache ||= {}
|
|
54
|
-
|
|
63
|
+
key = cache_key(name)
|
|
64
|
+
@cache.delete(key) if @cache[key]&.stale?
|
|
65
|
+
@cache[key] ||= new(name)
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
# Invalidate cached readers. With no argument, drops the entire cache
|
|
@@ -78,13 +89,33 @@ module Mailmate
|
|
|
78
89
|
|
|
79
90
|
def initialize(name)
|
|
80
91
|
@name = name
|
|
81
|
-
base = "#{Mailmate.config.db_headers}/#{name}"
|
|
82
|
-
raise ArgumentError, "Index not found: #{name} (looked at #{base}.{cache,offsets})" \
|
|
83
|
-
unless File.exist?("#{base}.cache") && File.exist?("#{base}.offsets")
|
|
92
|
+
@base = "#{Mailmate.config.db_headers}/#{name}"
|
|
93
|
+
raise ArgumentError, "Index not found: #{name} (looked at #{@base}.{cache,offsets})" \
|
|
94
|
+
unless File.exist?("#{@base}.cache") && File.exist?("#{@base}.offsets")
|
|
95
|
+
|
|
96
|
+
@cache_bytes = File.binread("#{@base}.cache")
|
|
97
|
+
@offsets_bytes = File.binread("#{@base}.offsets")
|
|
98
|
+
@cache_sig = file_sig("#{@base}.cache")
|
|
99
|
+
@offsets_sig = file_sig("#{@base}.offsets")
|
|
100
|
+
@checked_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
101
|
+
# The id→ranges hash builds lazily (see index): ids_matching-only
|
|
102
|
+
# consumers (the inverted body search) never need it, and skipping it
|
|
103
|
+
# saves ~250 ms of construction on the big body indexes.
|
|
104
|
+
@index = nil
|
|
105
|
+
end
|
|
84
106
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
# True when the on-disk files no longer match what this reader was built
|
|
108
|
+
# from. Throttled to one stat-pair per FRESHNESS_INTERVAL; a vanished
|
|
109
|
+
# file (mid-swap while MailMate rewrites) counts as not-stale so we keep
|
|
110
|
+
# serving the last good snapshot rather than racing the writer.
|
|
111
|
+
def stale?
|
|
112
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
+
return false if now - @checked_at < FRESHNESS_INTERVAL
|
|
114
|
+
@checked_at = now
|
|
115
|
+
cache_sig = file_sig("#{@base}.cache")
|
|
116
|
+
offsets_sig = file_sig("#{@base}.offsets")
|
|
117
|
+
return false if cache_sig.nil? || offsets_sig.nil?
|
|
118
|
+
cache_sig != @cache_sig || offsets_sig != @offsets_sig
|
|
88
119
|
end
|
|
89
120
|
|
|
90
121
|
# Returns the raw cached value for a given .eml body-part ID, or nil if
|
|
@@ -94,19 +125,19 @@ module Mailmate
|
|
|
94
125
|
# body indexes (`#unquoted#lc`, `#quoted#lc`) last-alone is meaningless
|
|
95
126
|
# — use values_for to read every segment.
|
|
96
127
|
def value_for(eml_id)
|
|
97
|
-
|
|
98
|
-
return nil if
|
|
99
|
-
|
|
100
|
-
@cache_bytes[
|
|
128
|
+
packs = index[eml_id.to_i]
|
|
129
|
+
return nil if packs.nil? || packs.empty?
|
|
130
|
+
v = packs[-1]
|
|
131
|
+
@cache_bytes[(v >> 32)...(v & 0xFFFFFFFF)]
|
|
101
132
|
end
|
|
102
133
|
|
|
103
134
|
# Returns every recorded value for an id, in offsets-file order. Returns
|
|
104
135
|
# [] if the id isn't in the index. Use this for body indexes
|
|
105
136
|
# (#unquoted#lc, #quoted#lc), which store one record per text segment.
|
|
106
137
|
def values_for(eml_id)
|
|
107
|
-
|
|
108
|
-
return [] if
|
|
109
|
-
|
|
138
|
+
packs = index[eml_id.to_i]
|
|
139
|
+
return [] if packs.nil?
|
|
140
|
+
packs.map { |v| @cache_bytes[(v >> 32)...(v & 0xFFFFFFFF)] }
|
|
110
141
|
end
|
|
111
142
|
|
|
112
143
|
# `#flags.flag` semantics: the cache stores a space-separated list of IMAP
|
|
@@ -117,15 +148,45 @@ module Mailmate
|
|
|
117
148
|
v.split(/\s+/).reject(&:empty?)
|
|
118
149
|
end
|
|
119
150
|
|
|
151
|
+
# True when the index has at least one record for this id. Cheaper than
|
|
152
|
+
# values_for(id).empty? — no substring slicing.
|
|
153
|
+
def key?(eml_id)
|
|
154
|
+
index.key?(eml_id.to_i)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Inverted substring search: returns a Hash whose keys are every id with
|
|
158
|
+
# at least one record containing `needle` (byte-wise; pass pre-downcased
|
|
159
|
+
# bytes when querying an #lc index). One memchr-fast String#index scan
|
|
160
|
+
# of the whole cache instead of one substring test per record — for a
|
|
161
|
+
# 77 MB body cache that's ~75 ms versus seconds of per-message lookups.
|
|
162
|
+
#
|
|
163
|
+
# A raw cache hit can span two adjacent records' ranges; interval
|
|
164
|
+
# stabbing keeps only hits that fall entirely inside a single record
|
|
165
|
+
# (per-segment semantics, matching MailMate's own body search). Records
|
|
166
|
+
# sharing a byte range (deduped values) all report their ids.
|
|
167
|
+
def ids_matching(needle)
|
|
168
|
+
needle = needle.b
|
|
169
|
+
found = {}
|
|
170
|
+
return found if needle.empty? || @cache_bytes.empty?
|
|
171
|
+
ensure_stab_table!
|
|
172
|
+
nlen = needle.bytesize
|
|
173
|
+
pos = 0
|
|
174
|
+
while (pos = @cache_bytes.index(needle, pos))
|
|
175
|
+
stab(pos, pos + nlen) { |id| found[id] = true }
|
|
176
|
+
pos += 1
|
|
177
|
+
end
|
|
178
|
+
found
|
|
179
|
+
end
|
|
180
|
+
|
|
120
181
|
# Number of distinct ids in the index. For multi-record indexes this is
|
|
121
182
|
# smaller than the on-disk record count (use record_count for that).
|
|
122
183
|
def size
|
|
123
|
-
|
|
184
|
+
index.size
|
|
124
185
|
end
|
|
125
186
|
|
|
126
187
|
# Total number of on-disk records (sum across all ids). Diagnostics.
|
|
127
188
|
def record_count
|
|
128
|
-
|
|
189
|
+
index.values.sum(&:size)
|
|
129
190
|
end
|
|
130
191
|
|
|
131
192
|
# Iterate every recorded eml-id. Yields just the id; callers that also
|
|
@@ -133,7 +194,7 @@ module Mailmate
|
|
|
133
194
|
# modules don't have to reach into `@index` directly.
|
|
134
195
|
def each_eml_id(&block)
|
|
135
196
|
return enum_for(:each_eml_id) unless block
|
|
136
|
-
|
|
197
|
+
index.each_key(&block)
|
|
137
198
|
end
|
|
138
199
|
|
|
139
200
|
# Iterate every (eml_id, raw_value) pair, once per on-disk record.
|
|
@@ -142,22 +203,92 @@ module Mailmate
|
|
|
142
203
|
# should massage it themselves.
|
|
143
204
|
def each_record
|
|
144
205
|
return enum_for(:each_record) unless block_given?
|
|
145
|
-
|
|
146
|
-
|
|
206
|
+
index.each do |eml_id, packs|
|
|
207
|
+
packs.each { |v| yield eml_id, @cache_bytes[(v >> 32)...(v & 0xFFFFFFFF)] }
|
|
147
208
|
end
|
|
148
209
|
end
|
|
149
210
|
|
|
150
211
|
private
|
|
151
212
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
213
|
+
# Lazy tables for ids_matching's interval stabbing, built once per
|
|
214
|
+
# reader snapshot (a rebuilt reader starts fresh, so staleness handling
|
|
215
|
+
# comes for free). @stab_flat is the raw [id, start, end, …] triple
|
|
216
|
+
# stream; @stab_order holds record numbers sorted by start;
|
|
217
|
+
# @stab_prefix_max_end[i] is the max end among @stab_order[0..i], which
|
|
218
|
+
# lets stab() stop walking left as soon as no earlier-starting record
|
|
219
|
+
# could still reach the queried range. Integers only — no per-record
|
|
220
|
+
# object allocations.
|
|
221
|
+
def ensure_stab_table!
|
|
222
|
+
return if @stab_order
|
|
223
|
+
flat = @offsets_bytes.unpack("V*")
|
|
224
|
+
recs = (flat.size - (flat.size % 3)) / 3
|
|
225
|
+
# Pack (start, recnum) into one Integer and sort! with native compare —
|
|
226
|
+
# a sort_by block is ~3× slower at this record count. start sorts as
|
|
227
|
+
# the high bits; recnum keeps the low bits unique.
|
|
228
|
+
packed = Array.new(recs) { |k| (flat[k * 3 + 1] << 32) | k }
|
|
229
|
+
packed.sort!
|
|
230
|
+
order = packed
|
|
231
|
+
order.map! { |p| p & 0xFFFFFFFF }
|
|
232
|
+
prefix = Array.new(recs)
|
|
233
|
+
max_end = -1
|
|
234
|
+
order.each_with_index do |k, i|
|
|
235
|
+
e = flat[k * 3 + 2]
|
|
236
|
+
max_end = e if e > max_end
|
|
237
|
+
prefix[i] = max_end
|
|
238
|
+
end
|
|
239
|
+
@stab_flat = flat
|
|
240
|
+
@stab_order = order
|
|
241
|
+
@stab_prefix_max_end = prefix
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Yields the id of every record whose [start, end) range fully contains
|
|
245
|
+
# [lo, hi). Classic stabbing query over ranges sorted by start: binary
|
|
246
|
+
# search to the last range starting at or before lo, then walk left
|
|
247
|
+
# while the prefix-max end says a covering range is still possible —
|
|
248
|
+
# O(log n + overlap depth), and body-index ranges rarely overlap.
|
|
249
|
+
def stab(lo, hi)
|
|
250
|
+
order = @stab_order
|
|
251
|
+
flat = @stab_flat
|
|
252
|
+
i = order.bsearch_index { |k| flat[k * 3 + 1] > lo }
|
|
253
|
+
i = i.nil? ? order.size - 1 : i - 1
|
|
254
|
+
while i >= 0 && @stab_prefix_max_end[i] >= hi
|
|
255
|
+
k = order[i]
|
|
256
|
+
yield flat[k * 3] if flat[k * 3 + 1] <= lo && flat[k * 3 + 2] >= hi
|
|
257
|
+
i -= 1
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def file_sig(path)
|
|
262
|
+
st = File.stat(path)
|
|
263
|
+
[st.mtime, st.size]
|
|
264
|
+
rescue SystemCallError
|
|
265
|
+
nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Decode the offsets file in one bulk unpack — one C call instead of one
|
|
269
|
+
# String#[] + unpack per record (2× faster on the 730k-record body
|
|
270
|
+
# indexes). Each (start, end) range is packed into a single Integer
|
|
271
|
+
# (start << 32 | end) so a record costs an immediate value, not a
|
|
272
|
+
# two-element Array; accessors decode with shift/mask. Caches are tens
|
|
273
|
+
# of MB, so both halves fit 32 bits with room to spare. unpack("V*")
|
|
274
|
+
# silently drops trailing bytes that don't fill a uint32; the % 3 guard
|
|
275
|
+
# drops a trailing partial record.
|
|
276
|
+
def build_index
|
|
277
|
+
h = {}
|
|
278
|
+
flat = @offsets_bytes.unpack("V*")
|
|
279
|
+
n = flat.size - (flat.size % 3)
|
|
155
280
|
i = 0
|
|
156
281
|
while i < n
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
i += 1
|
|
282
|
+
(h[flat[i]] ||= []) << ((flat[i + 1] << 32) | flat[i + 2])
|
|
283
|
+
i += 3
|
|
160
284
|
end
|
|
285
|
+
h
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Lazy id→ranges hash: built on first keyed access, skipped entirely by
|
|
289
|
+
# ids_matching-only consumers (the inverted body search).
|
|
290
|
+
def index
|
|
291
|
+
@index ||= build_index
|
|
161
292
|
end
|
|
162
293
|
end
|
|
163
294
|
end
|
data/lib/mailmate/mcp.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "mailmate"
|
|
|
7
7
|
require "mailmate/cli/search"
|
|
8
8
|
require "mailmate/cli/message"
|
|
9
9
|
require "mailmate/cli/modify"
|
|
10
|
+
require "mailmate/cli/verify"
|
|
10
11
|
require "mailmate/cli/send"
|
|
11
12
|
require "mailmate/cli/draft"
|
|
12
13
|
require "mailmate/cli/open"
|
|
@@ -73,25 +74,27 @@ module Mailmate
|
|
|
73
74
|
TOOLS = [
|
|
74
75
|
{
|
|
75
76
|
name: "search",
|
|
77
|
+
title: "Search mail",
|
|
78
|
+
annotations: { title: "Search mail", readOnlyHint: true },
|
|
76
79
|
description: <<~DESC.strip,
|
|
77
80
|
Search MailMate's .eml files using MailMate's quicksearch syntax.
|
|
78
81
|
Returns column-aligned CSV. Same engine as the `mmsearch` CLI.
|
|
79
82
|
|
|
80
83
|
Query = space-separated specs combined with AND (no or/parens yet).
|
|
81
|
-
Quote multi-word terms ("
|
|
84
|
+
Quote multi-word terms ("invoice due"). Modifiers:
|
|
82
85
|
(bare term) From/To/Cc/Subject OR body contains
|
|
83
86
|
f <t> From t <t> To/Cc c <t> Cc s <t> Subject
|
|
84
87
|
a <t> any address header b <t> body m <t> headers OR body
|
|
85
88
|
d <date> received: Y, Y-M, Y-M-D, or relative 1d/2w/3m/1y
|
|
86
89
|
T <tag> tag / IMAP keyword (K is a synonym)
|
|
87
90
|
!<value> negate, e.g. f !smith (From does NOT contain smith)
|
|
88
|
-
The `mailbox` arg also accepts a smart-mailbox name (e.g.
|
|
89
|
-
|
|
91
|
+
The `mailbox` arg also accepts a smart-mailbox name (e.g. Newsletters,
|
|
92
|
+
Receipts, Priority) whose filter is ANDed into the search.
|
|
90
93
|
|
|
91
94
|
Examples:
|
|
92
|
-
query="f
|
|
93
|
-
query="T
|
|
94
|
-
query="s '
|
|
95
|
+
query="f substack d 7d" from Substack in the last 7 days
|
|
96
|
+
query="T urgent" tagged "urgent"
|
|
97
|
+
query="s 'invoice due' !draft" subject has 'invoice due', not 'draft'
|
|
95
98
|
|
|
96
99
|
Fields default to: flags date time direction party subject.
|
|
97
100
|
Prefix with "+" to add to the defaults ("+tags +mailbox"); a bare
|
|
@@ -127,13 +130,16 @@ module Mailmate
|
|
|
127
130
|
},
|
|
128
131
|
{
|
|
129
132
|
name: "message",
|
|
130
|
-
|
|
133
|
+
title: "Read message",
|
|
134
|
+
annotations: { title: "Read message", readOnlyHint: true },
|
|
135
|
+
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.",
|
|
131
136
|
inputSchema: {
|
|
132
137
|
type: "object",
|
|
133
138
|
properties: {
|
|
134
139
|
id: { type: "string", description: "eml-id (e.g. '183715') or RFC Message-ID (e.g. '<abc@example.com>')." },
|
|
135
140
|
raw: { type: "boolean", description: "Return raw .eml bytes." },
|
|
136
141
|
text_only: { type: "boolean", description: "Body only, no headers block." },
|
|
142
|
+
markdown: { type: "boolean", description: "Render an HTML-only body as clean markdown (drops <style>/<script>, strips newsletter spacer chars). No-op for plain-text messages." },
|
|
137
143
|
},
|
|
138
144
|
required: ["id"],
|
|
139
145
|
additionalProperties: false,
|
|
@@ -141,6 +147,8 @@ module Mailmate
|
|
|
141
147
|
},
|
|
142
148
|
{
|
|
143
149
|
name: "modify",
|
|
150
|
+
title: "Modify message state",
|
|
151
|
+
annotations: { title: "Modify message state", readOnlyHint: false, destructiveHint: true },
|
|
144
152
|
description: <<~DESC.strip,
|
|
145
153
|
Apply state-change actions to a message via MailMate.
|
|
146
154
|
NOTE: drives MailMate's UI via AppleScript — it briefly takes focus,
|
|
@@ -155,6 +163,20 @@ module Mailmate
|
|
|
155
163
|
|
|
156
164
|
Valid actions: read unread flag unflag tag untag clear-tags archive
|
|
157
165
|
junk not-junk mute delete move
|
|
166
|
+
|
|
167
|
+
Verifying the action landed (it can't be read back from MailMate, so
|
|
168
|
+
this re-reads the target eml-id's #flags index — the only way to catch
|
|
169
|
+
a Message-ID that resolved to a different duplicate copy):
|
|
170
|
+
check:"inline" confirm now, before returning (failed → isError).
|
|
171
|
+
Costs a few seconds — MailMate flushes #flags ~5s
|
|
172
|
+
after acting. Use for a high-stakes single mutation.
|
|
173
|
+
check:"defer" don't wait; return a JSON check-ticket instead.
|
|
174
|
+
Collect tickets across a batch, then pass them to the
|
|
175
|
+
`verify` tool to confirm them ALL with one flush-wait
|
|
176
|
+
(the efficient choice for bulk work — 50 modifies pay
|
|
177
|
+
the ~5s latency once, not 50 times).
|
|
178
|
+
check:"none" (default) fire-and-forget.
|
|
179
|
+
Location-changing chains (move/archive/delete) aren't flag-verifiable.
|
|
158
180
|
DESC
|
|
159
181
|
inputSchema: {
|
|
160
182
|
type: "object",
|
|
@@ -166,15 +188,46 @@ module Mailmate
|
|
|
166
188
|
description: "Flat list of action tokens; arg-taking actions consume the following item.",
|
|
167
189
|
},
|
|
168
190
|
dry_run: { type: "boolean", description: "Print plan, don't execute." },
|
|
169
|
-
verify: { type: "boolean", description: "
|
|
191
|
+
verify: { type: "boolean", description: "Print the message's current flags after acting (raw probe)." },
|
|
192
|
+
check: { type: "string", enum: %w[none inline defer], description: "Effect-verification mode (default none). 'inline' confirms now (slow, returns isError on failure); 'defer' returns a JSON ticket for batched verification via the `verify` tool." },
|
|
170
193
|
keep_window: { type: "boolean", description: "Skip the close-window keystroke at the end." },
|
|
171
194
|
},
|
|
172
195
|
required: %w[id actions],
|
|
173
196
|
additionalProperties: false,
|
|
174
197
|
},
|
|
175
198
|
},
|
|
199
|
+
{
|
|
200
|
+
name: "verify",
|
|
201
|
+
title: "Verify modify results",
|
|
202
|
+
annotations: { title: "Verify modify results", readOnlyHint: true },
|
|
203
|
+
description: <<~DESC.strip,
|
|
204
|
+
Batch-confirm `modify` check-tickets (from check:"defer") against the
|
|
205
|
+
#flags index in ONE flush-wait. Pass the tickets you collected from a
|
|
206
|
+
run of deferred modifies; this polls the index until every ticket's
|
|
207
|
+
expected flag/tag/read state holds (or check_timeout elapses) and
|
|
208
|
+
returns a JSON summary {checked, passed, failed, waited_seconds,
|
|
209
|
+
results:[{eml_id, ok, flags, unmet}]}. isError if any ticket failed —
|
|
210
|
+
a failure means that action didn't land on that eml-id (wrong
|
|
211
|
+
duplicate copy, or it never registered).
|
|
212
|
+
DESC
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: {
|
|
216
|
+
tickets: {
|
|
217
|
+
type: "array",
|
|
218
|
+
items: { type: "object" },
|
|
219
|
+
description: "The check-ticket objects returned by modify calls made with check:\"defer\".",
|
|
220
|
+
},
|
|
221
|
+
check_timeout: { type: "number", description: "Max seconds to wait for #flags to reflect the batch (default 8)." },
|
|
222
|
+
},
|
|
223
|
+
required: ["tickets"],
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
176
227
|
{
|
|
177
228
|
name: "send",
|
|
229
|
+
title: "Send mail",
|
|
230
|
+
annotations: { title: "Send mail", readOnlyHint: false, destructiveHint: true },
|
|
178
231
|
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.",
|
|
179
232
|
inputSchema: {
|
|
180
233
|
type: "object",
|
|
@@ -196,6 +249,8 @@ module Mailmate
|
|
|
196
249
|
},
|
|
197
250
|
{
|
|
198
251
|
name: "draft",
|
|
252
|
+
title: "Compose draft",
|
|
253
|
+
annotations: { title: "Compose draft", readOnlyHint: false, destructiveHint: false },
|
|
199
254
|
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.",
|
|
200
255
|
inputSchema: {
|
|
201
256
|
type: "object",
|
|
@@ -216,6 +271,8 @@ module Mailmate
|
|
|
216
271
|
},
|
|
217
272
|
{
|
|
218
273
|
name: "open",
|
|
274
|
+
title: "Open in MailMate",
|
|
275
|
+
annotations: { title: "Open in MailMate", readOnlyHint: false, destructiveHint: false },
|
|
219
276
|
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.",
|
|
220
277
|
inputSchema: {
|
|
221
278
|
type: "object",
|
|
@@ -229,6 +286,8 @@ module Mailmate
|
|
|
229
286
|
},
|
|
230
287
|
{
|
|
231
288
|
name: "list_mailboxes",
|
|
289
|
+
title: "List mailboxes",
|
|
290
|
+
annotations: { title: "List mailboxes", readOnlyHint: true },
|
|
232
291
|
description: "Enumerate accounts, IMAP mailboxes (with optional message counts), and smart mailboxes MailMate has defined. Account names are decoded for display (`%40` → `@`).",
|
|
233
292
|
inputSchema: {
|
|
234
293
|
type: "object",
|
|
@@ -241,6 +300,8 @@ module Mailmate
|
|
|
241
300
|
},
|
|
242
301
|
{
|
|
243
302
|
name: "list_tags",
|
|
303
|
+
title: "List tags",
|
|
304
|
+
annotations: { title: "List tags", readOnlyHint: true },
|
|
244
305
|
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).",
|
|
245
306
|
inputSchema: {
|
|
246
307
|
type: "object",
|
|
@@ -252,6 +313,8 @@ module Mailmate
|
|
|
252
313
|
},
|
|
253
314
|
{
|
|
254
315
|
name: "resolve_id",
|
|
316
|
+
title: "Resolve message identifiers",
|
|
317
|
+
annotations: { title: "Resolve message identifiers", readOnlyHint: true },
|
|
255
318
|
description: <<~DESC.strip,
|
|
256
319
|
Look up a message and return all its identifiers:
|
|
257
320
|
- eml_id local body-part id (changes per machine)
|
|
@@ -326,6 +389,7 @@ module Mailmate
|
|
|
326
389
|
when "search" then call_search(args)
|
|
327
390
|
when "message" then call_message(args)
|
|
328
391
|
when "modify" then call_modify(args)
|
|
392
|
+
when "verify" then call_verify(args)
|
|
329
393
|
when "send" then call_send(args)
|
|
330
394
|
when "draft" then call_draft(args)
|
|
331
395
|
when "open" then call_open(args)
|
|
@@ -336,6 +400,12 @@ module Mailmate
|
|
|
336
400
|
end
|
|
337
401
|
rescue StandardError => e
|
|
338
402
|
text_error("#{e.class}: #{e.message}\n#{e.backtrace.first(8).join("\n")}")
|
|
403
|
+
rescue SystemExit => e
|
|
404
|
+
# A CLI path that calls exit/abort (e.g. a missing optional gem) must
|
|
405
|
+
# not take down the persistent server — surface it as a tool error and
|
|
406
|
+
# keep the loop alive. SystemExit isn't a StandardError, so it needs
|
|
407
|
+
# its own clause.
|
|
408
|
+
text_error("Tool '#{name}' called exit(#{e.status}) — treated as failure, server still running.")
|
|
339
409
|
end
|
|
340
410
|
|
|
341
411
|
# ---- tool handlers ----------------------------------------------------
|
|
@@ -359,6 +429,7 @@ module Mailmate
|
|
|
359
429
|
argv = [args["id"].to_s]
|
|
360
430
|
argv << "--raw" if args["raw"]
|
|
361
431
|
argv << "--text-only" if args["text_only"]
|
|
432
|
+
argv << "--markdown" if args["markdown"]
|
|
362
433
|
run_cli(Mailmate::CLI::Message, argv)
|
|
363
434
|
end
|
|
364
435
|
|
|
@@ -366,10 +437,24 @@ module Mailmate
|
|
|
366
437
|
argv = [args["id"].to_s] + Array(args["actions"]).map(&:to_s)
|
|
367
438
|
argv << "--dry-run" if args["dry_run"]
|
|
368
439
|
argv << "--verify" if args["verify"]
|
|
440
|
+
case args["check"]
|
|
441
|
+
when "inline" then argv << "--check"
|
|
442
|
+
when "defer" then argv << "--emit-check"
|
|
443
|
+
end
|
|
369
444
|
argv << "--keep-window" if args["keep_window"]
|
|
370
445
|
run_cli(Mailmate::CLI::Modify, argv)
|
|
371
446
|
end
|
|
372
447
|
|
|
448
|
+
# Batch-verify deferred check-tickets. Tickets arrive as JSON objects;
|
|
449
|
+
# pipe them to mm-verify on stdin (the same array-or-NDJSON it reads
|
|
450
|
+
# from the CLI).
|
|
451
|
+
def call_verify(args)
|
|
452
|
+
argv = []
|
|
453
|
+
argv.push("--check-timeout", args["check_timeout"].to_s) if args["check_timeout"]
|
|
454
|
+
payload = JSON.generate(Array(args["tickets"]))
|
|
455
|
+
with_stdin(payload) { run_cli(Mailmate::CLI::Verify, argv) }
|
|
456
|
+
end
|
|
457
|
+
|
|
373
458
|
def call_send(args)
|
|
374
459
|
argv = compose_argv(args)
|
|
375
460
|
argv << "--send-now" if args["send_now"]
|
data/lib/mailmate/part_lookup.rb
CHANGED
|
@@ -39,14 +39,23 @@ module Mailmate
|
|
|
39
39
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
|
+
# The cached inversion is keyed by db_headers AND pinned to the exact
|
|
43
|
+
# IndexReader object it was built from — when IndexReader.for returns a
|
|
44
|
+
# rebuilt reader (file changed on disk; see IndexReader#stale?), the
|
|
45
|
+
# identity check fails and the inversion rebuilds with it. Keeps the
|
|
46
|
+
# persistent MCP server's part map in sync without explicit resets.
|
|
42
47
|
def inversion
|
|
48
|
+
reader = Mailmate::IndexReader.for("#root-body-part")
|
|
43
49
|
@inversions ||= {}
|
|
44
|
-
@inversions[Mailmate.config.db_headers]
|
|
50
|
+
entry = @inversions[Mailmate.config.db_headers]
|
|
51
|
+
return entry[:inv] if entry && entry[:reader].equal?(reader)
|
|
52
|
+
inv = build_inversion(reader)
|
|
53
|
+
@inversions[Mailmate.config.db_headers] = { reader: reader, inv: inv }
|
|
54
|
+
inv
|
|
45
55
|
end
|
|
46
56
|
|
|
47
|
-
def build_inversion
|
|
57
|
+
def build_inversion(reader)
|
|
48
58
|
inv = Hash.new { |h, k| h[k] = [] }
|
|
49
|
-
reader = Mailmate::IndexReader.for("#root-body-part")
|
|
50
59
|
reader.each_eml_id do |part_id|
|
|
51
60
|
root_str = reader.value_for(part_id)
|
|
52
61
|
# Skip deleted parts: MailMate appends an empty trailing record to
|
data/lib/mailmate/version.rb
CHANGED
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Murphy-Dye
|
|
@@ -98,6 +98,7 @@ executables:
|
|
|
98
98
|
- mm-mailboxes
|
|
99
99
|
- mm-modify
|
|
100
100
|
- mm-send
|
|
101
|
+
- mm-verify
|
|
101
102
|
- mmdiscover
|
|
102
103
|
- mmmessage
|
|
103
104
|
- mmopen
|
|
@@ -114,6 +115,7 @@ files:
|
|
|
114
115
|
- exe/mm-mailboxes
|
|
115
116
|
- exe/mm-modify
|
|
116
117
|
- exe/mm-send
|
|
118
|
+
- exe/mm-verify
|
|
117
119
|
- exe/mmdiscover
|
|
118
120
|
- exe/mmmessage
|
|
119
121
|
- exe/mmopen
|
|
@@ -132,11 +134,13 @@ files:
|
|
|
132
134
|
- lib/mailmate/cli/search.rb
|
|
133
135
|
- lib/mailmate/cli/send.rb
|
|
134
136
|
- lib/mailmate/cli/tags.rb
|
|
137
|
+
- lib/mailmate/cli/verify.rb
|
|
135
138
|
- lib/mailmate/config.rb
|
|
136
139
|
- lib/mailmate/duplicate_scanner.rb
|
|
137
140
|
- lib/mailmate/eml_lookup.rb
|
|
138
141
|
- lib/mailmate/evaluator.rb
|
|
139
142
|
- lib/mailmate/filter_classifier.rb
|
|
143
|
+
- lib/mailmate/flag_check.rb
|
|
140
144
|
- lib/mailmate/header_reader.rb
|
|
141
145
|
- lib/mailmate/identity.rb
|
|
142
146
|
- lib/mailmate/index_reader.rb
|