mailmate 0.2.0 → 1.0.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 +76 -9
- data/exe/mailmate-mcp +8 -0
- data/exe/mm-mailboxes +8 -0
- data/exe/mmopen +8 -0
- data/exe/mmtags +8 -0
- data/lib/mailmate/cli/mailboxes.rb +142 -0
- data/lib/mailmate/cli/message.rb +63 -12
- data/lib/mailmate/cli/modify.rb +122 -4
- data/lib/mailmate/cli/open.rb +71 -0
- data/lib/mailmate/cli/search.rb +222 -76
- data/lib/mailmate/cli/tags.rb +93 -0
- data/lib/mailmate/config.rb +4 -1
- data/lib/mailmate/eml_lookup.rb +23 -3
- data/lib/mailmate/index_reader.rb +49 -12
- data/lib/mailmate/mcp.rb +394 -0
- data/lib/mailmate/mid_url.rb +30 -10
- data/lib/mailmate/part_lookup.rb +62 -0
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +24 -0
- metadata +28 -1
data/lib/mailmate/eml_lookup.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module Mailmate
|
|
4
6
|
# @api public
|
|
5
7
|
#
|
|
@@ -42,19 +44,37 @@ module Mailmate
|
|
|
42
44
|
nil
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
# Resolve an identifier
|
|
46
|
-
#
|
|
47
|
+
# Resolve an identifier to a local eml-id. Accepts:
|
|
48
|
+
# - eml-id (all digits) e.g. "183715"
|
|
49
|
+
# - RFC Message-ID, brackets optional e.g. "<abc@example.com>"
|
|
50
|
+
# - message://… URL e.g. "message://%3Cabc%40example.com%3E"
|
|
51
|
+
# or "message://183715"
|
|
52
|
+
# - mid:… URL e.g. "mid:%3Cabc%40example.com%3E"
|
|
53
|
+
# or "mid:183715"
|
|
54
|
+
# Strips the URL wrapper first (message:// or mid:) so the digit check
|
|
55
|
+
# below catches the local-eml-id payload too — same leniency MailMate's
|
|
56
|
+
# own URL handler offers. The %3C…%3E payload form is portable across
|
|
57
|
+
# machines; the bare-integer form is local-only.
|
|
47
58
|
def self.resolve_id(input)
|
|
48
59
|
s = input.to_s.strip
|
|
60
|
+
s = URI.decode_www_form_component(s.sub(%r{\A(?:message://|mid:)}, "")) if s.match?(%r{\A(?:message://|mid:)})
|
|
49
61
|
return s.to_i if s =~ /\A\d+\z/
|
|
50
62
|
eml_id_for_message_id(s)
|
|
51
63
|
end
|
|
52
64
|
|
|
53
65
|
# Force the index path (useful for tests and benchmarking).
|
|
66
|
+
#
|
|
67
|
+
# Returns nil — not just on missing-index — when the indexed path no
|
|
68
|
+
# longer points at an existing file. That happens whenever MailMate's
|
|
69
|
+
# `#source` index lags the filesystem; the common case is right after
|
|
70
|
+
# `mm-modify`'s fast-path move renames the .eml on disk and MailMate
|
|
71
|
+
# hasn't rescanned yet. Treating stale entries as misses lets `path_for`
|
|
72
|
+
# fall through to the glob walker and recover the file at its new home.
|
|
54
73
|
def self.via_index(eml_id)
|
|
55
74
|
url = source_url_for(eml_id)
|
|
56
75
|
return nil if url.nil?
|
|
57
|
-
url_to_path(url, eml_id)
|
|
76
|
+
path = url_to_path(url, eml_id)
|
|
77
|
+
path if path && File.exist?(path)
|
|
58
78
|
end
|
|
59
79
|
|
|
60
80
|
# Force the glob fallback.
|
|
@@ -13,13 +13,29 @@
|
|
|
13
13
|
# value = cache[start...end] (start == end → empty / "no value")
|
|
14
14
|
# .plist Old-style plist with offsetsFileSize / stringsFileSize sentinels.
|
|
15
15
|
#
|
|
16
|
+
# Most indexes have multiple records per id even when they're conceptually
|
|
17
|
+
# 1:1 (header indexes accumulate stale records as messages change; e.g.
|
|
18
|
+
# `#flags` for a single id can have dozens of records, one per flag change,
|
|
19
|
+
# with the latest at the end). Body indexes (`#unquoted#lc`, `#quoted#lc`)
|
|
20
|
+
# are intentionally multi-record: one record per text segment of each body
|
|
21
|
+
# part. The accessor pair handles both cases:
|
|
22
|
+
#
|
|
23
|
+
# value_for(id) → LAST record for the id (matches the on-disk
|
|
24
|
+
# "latest-version" semantics for accumulator-style
|
|
25
|
+
# indexes; for genuinely multi-record indexes like body
|
|
26
|
+
# content, the last record alone is meaningless — use
|
|
27
|
+
# values_for there).
|
|
28
|
+
# values_for(id) → all records for the id, in offsets-file order.
|
|
29
|
+
# each_record → yields (id, value) once per on-disk record. Multi-record
|
|
30
|
+
# ids yield multiple times; 1:1 ids yield once.
|
|
31
|
+
#
|
|
16
32
|
# Specific accessors:
|
|
17
33
|
# `flags.flag(eml_id)` → Array<String> of IMAP keywords (`\Seen`,
|
|
18
34
|
# `\Flagged`, `$Forwarded`, `$Muted`, custom tags…)
|
|
19
35
|
# or [] if the message has no flags / isn't indexed.
|
|
20
36
|
#
|
|
21
37
|
# IndexReader instances cache both files in memory and build a hash from
|
|
22
|
-
#
|
|
38
|
+
# id → [[start,end], …] for O(1) lookup. Construction cost ≈ 5–20 ms for
|
|
23
39
|
# 50–200k records; memory ≈ a few MB. For a CLI invocation that's fine; the
|
|
24
40
|
# evaluator instantiates one lazily when first needed.
|
|
25
41
|
|
|
@@ -72,11 +88,25 @@ module Mailmate
|
|
|
72
88
|
end
|
|
73
89
|
|
|
74
90
|
# Returns the raw cached value for a given .eml body-part ID, or nil if
|
|
75
|
-
# the
|
|
91
|
+
# the id isn't in this index. Returns the LAST record for the id — for
|
|
92
|
+
# accumulator-style header indexes (`#flags`, `#source`, `subject`, etc.)
|
|
93
|
+
# that's the latest state; the older records are stale versions. For
|
|
94
|
+
# body indexes (`#unquoted#lc`, `#quoted#lc`) last-alone is meaningless
|
|
95
|
+
# — use values_for to read every segment.
|
|
76
96
|
def value_for(eml_id)
|
|
77
|
-
|
|
78
|
-
return nil
|
|
79
|
-
|
|
97
|
+
pairs = @index[eml_id.to_i]
|
|
98
|
+
return nil if pairs.nil? || pairs.empty?
|
|
99
|
+
s, e = pairs[-1]
|
|
100
|
+
@cache_bytes[s...e]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Returns every recorded value for an id, in offsets-file order. Returns
|
|
104
|
+
# [] if the id isn't in the index. Use this for body indexes
|
|
105
|
+
# (#unquoted#lc, #quoted#lc), which store one record per text segment.
|
|
106
|
+
def values_for(eml_id)
|
|
107
|
+
pairs = @index[eml_id.to_i]
|
|
108
|
+
return [] if pairs.nil?
|
|
109
|
+
pairs.map { |(s, e)| @cache_bytes[s...e] }
|
|
80
110
|
end
|
|
81
111
|
|
|
82
112
|
# `#flags.flag` semantics: the cache stores a space-separated list of IMAP
|
|
@@ -87,11 +117,17 @@ module Mailmate
|
|
|
87
117
|
v.split(/\s+/).reject(&:empty?)
|
|
88
118
|
end
|
|
89
119
|
|
|
90
|
-
# Number of
|
|
120
|
+
# Number of distinct ids in the index. For multi-record indexes this is
|
|
121
|
+
# smaller than the on-disk record count (use record_count for that).
|
|
91
122
|
def size
|
|
92
123
|
@index.size
|
|
93
124
|
end
|
|
94
125
|
|
|
126
|
+
# Total number of on-disk records (sum across all ids). Diagnostics.
|
|
127
|
+
def record_count
|
|
128
|
+
@index.values.sum(&:size)
|
|
129
|
+
end
|
|
130
|
+
|
|
95
131
|
# Iterate every recorded eml-id. Yields just the id; callers that also
|
|
96
132
|
# want the value should pair this with `value_for`. Exists so other gem
|
|
97
133
|
# modules don't have to reach into `@index` directly.
|
|
@@ -100,25 +136,26 @@ module Mailmate
|
|
|
100
136
|
@index.each_key(&block)
|
|
101
137
|
end
|
|
102
138
|
|
|
103
|
-
# Iterate every (eml_id, raw_value) pair
|
|
104
|
-
#
|
|
139
|
+
# Iterate every (eml_id, raw_value) pair, once per on-disk record.
|
|
140
|
+
# Multi-record ids yield multiple times. The value comes back as the bare
|
|
141
|
+
# cache substring; callers that need parsed form (e.g. flag tokens)
|
|
105
142
|
# should massage it themselves.
|
|
106
143
|
def each_record
|
|
107
144
|
return enum_for(:each_record) unless block_given?
|
|
108
|
-
@index.
|
|
109
|
-
yield eml_id,
|
|
145
|
+
@index.each do |eml_id, pairs|
|
|
146
|
+
pairs.each { |(s, e)| yield eml_id, @cache_bytes[s...e] }
|
|
110
147
|
end
|
|
111
148
|
end
|
|
112
149
|
|
|
113
150
|
private
|
|
114
151
|
|
|
115
152
|
def build_index!
|
|
116
|
-
@index = {}
|
|
153
|
+
@index = Hash.new { |h, k| h[k] = [] }
|
|
117
154
|
n = @offsets_bytes.bytesize / RECORD_SIZE
|
|
118
155
|
i = 0
|
|
119
156
|
while i < n
|
|
120
157
|
rec = @offsets_bytes[i * RECORD_SIZE, RECORD_SIZE].unpack("V3")
|
|
121
|
-
@index[rec[0]]
|
|
158
|
+
@index[rec[0]] << [rec[1], rec[2]]
|
|
122
159
|
i += 1
|
|
123
160
|
end
|
|
124
161
|
end
|
data/lib/mailmate/mcp.rb
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
require "mailmate"
|
|
7
|
+
require "mailmate/cli/search"
|
|
8
|
+
require "mailmate/cli/message"
|
|
9
|
+
require "mailmate/cli/modify"
|
|
10
|
+
require "mailmate/cli/send"
|
|
11
|
+
require "mailmate/cli/open"
|
|
12
|
+
require "mailmate/cli/mailboxes"
|
|
13
|
+
require "mailmate/cli/tags"
|
|
14
|
+
require "mailmate/eml_lookup"
|
|
15
|
+
require "mailmate/header_reader"
|
|
16
|
+
require "mailmate/mid_url"
|
|
17
|
+
|
|
18
|
+
module Mailmate
|
|
19
|
+
# Stdio MCP server (JSON-RPC 2.0, line-delimited). Exposes the gem's CLIs —
|
|
20
|
+
# search, message, modify, send — plus a resolve_id helper that round-trips
|
|
21
|
+
# between local eml-id, RFC Message-ID, and the cross-machine message:// URL.
|
|
22
|
+
#
|
|
23
|
+
# In-process: each tool call runs the corresponding `Mailmate::CLI::*.run`
|
|
24
|
+
# method with a synthesized argv, capturing stdout/stderr from the existing
|
|
25
|
+
# CLI rather than re-implementing each command.
|
|
26
|
+
module MCP
|
|
27
|
+
extend self
|
|
28
|
+
|
|
29
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
30
|
+
SERVER_NAME = "mailmate"
|
|
31
|
+
|
|
32
|
+
TOOLS = [
|
|
33
|
+
{
|
|
34
|
+
name: "search",
|
|
35
|
+
description: <<~DESC.strip,
|
|
36
|
+
Search MailMate's .eml files using MailMate's quicksearch syntax.
|
|
37
|
+
Returns column-aligned CSV. Same engine as the `mmsearch` CLI.
|
|
38
|
+
|
|
39
|
+
Common patterns:
|
|
40
|
+
query="f medium d 7d" from Medium in the last 7 days
|
|
41
|
+
query="T robot" tagged "robot" (reads MailMate's #flags index)
|
|
42
|
+
query="s 'rent due' !draft" subject has 'rent due', not 'draft'
|
|
43
|
+
query="d 2026-05" received in May 2026
|
|
44
|
+
|
|
45
|
+
Fields default to: flags date time direction party subject
|
|
46
|
+
Prefix fields with "+" to add to defaults (e.g. "+tags +mailbox").
|
|
47
|
+
Bare fields list replaces defaults (id is always first).
|
|
48
|
+
DESC
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
query: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "Quicksearch expression. Empty string disables filtering. Default: 'd 1d' (today).",
|
|
55
|
+
},
|
|
56
|
+
fields: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "Space-separated columns. Prefix with '+' to add to defaults. Available: id path mailbox from to cc bcc reply-to subject date time message-id references in-reply-to direction party flags read archive tags keywords.",
|
|
59
|
+
},
|
|
60
|
+
mailbox: {
|
|
61
|
+
type: "string",
|
|
62
|
+
description: "Account, mailbox path, or smart-mailbox name. Default: all.",
|
|
63
|
+
},
|
|
64
|
+
limit: { type: "integer", description: "Stop after N matches." },
|
|
65
|
+
headers_only: { type: "boolean", description: "Skip body matching (much faster on text searches)." },
|
|
66
|
+
sort: { type: "string", enum: %w[asc desc none], description: "Sort by date+time. Default: asc." },
|
|
67
|
+
},
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "message",
|
|
73
|
+
description: "Read one MailMate message. Accepts either local eml-id (digits) or RFC Message-ID (with or without angle brackets). Default output: headers block + plain-text body.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {
|
|
77
|
+
id: { type: "string", description: "eml-id (e.g. '183715') or RFC Message-ID (e.g. '<abc@example.com>')." },
|
|
78
|
+
raw: { type: "boolean", description: "Return raw .eml bytes." },
|
|
79
|
+
text_only: { type: "boolean", description: "Body only, no headers block." },
|
|
80
|
+
},
|
|
81
|
+
required: ["id"],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "modify",
|
|
87
|
+
description: <<~DESC.strip,
|
|
88
|
+
Apply state-change actions to a message via MailMate.
|
|
89
|
+
NOTE: drives MailMate's UI via AppleScript — it briefly takes focus,
|
|
90
|
+
and calls are serial per app.
|
|
91
|
+
|
|
92
|
+
actions is a flat array; arg-taking actions consume the next item:
|
|
93
|
+
["read"] mark read
|
|
94
|
+
["read", "flag", "archive"] three actions, one open/wait cycle
|
|
95
|
+
["tag", "urgent"] add tag
|
|
96
|
+
["untag", "todo"] remove tag
|
|
97
|
+
["move", "Archive.mailbox"] move
|
|
98
|
+
|
|
99
|
+
Valid actions: read unread flag unflag tag untag clear-tags archive
|
|
100
|
+
junk not-junk mute delete move
|
|
101
|
+
DESC
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
id: { type: "string", description: "eml-id or RFC Message-ID." },
|
|
106
|
+
actions: {
|
|
107
|
+
type: "array",
|
|
108
|
+
items: { type: "string" },
|
|
109
|
+
description: "Flat list of action tokens; arg-taking actions consume the following item.",
|
|
110
|
+
},
|
|
111
|
+
dry_run: { type: "boolean", description: "Print plan, don't execute." },
|
|
112
|
+
verify: { type: "boolean", description: "Re-read flags after acting to confirm." },
|
|
113
|
+
keep_window: { type: "boolean", description: "Skip the close-window keystroke at the end." },
|
|
114
|
+
},
|
|
115
|
+
required: %w[id actions],
|
|
116
|
+
additionalProperties: false,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "send",
|
|
121
|
+
description: "Send mail via MailMate's `emate` (markdown body). Recipients and subject via fields; body is the markdown source.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
to: { type: "string", description: "Recipient(s), comma-separated." },
|
|
126
|
+
cc: { type: "string", description: "CC recipient(s), comma-separated." },
|
|
127
|
+
bcc: { type: "string", description: "BCC recipient(s), comma-separated." },
|
|
128
|
+
subject: { type: "string", description: "Subject line." },
|
|
129
|
+
body: { type: "string", description: "Markdown body." },
|
|
130
|
+
attachments: { type: "array", items: { type: "string" }, description: "Absolute paths to files to attach." },
|
|
131
|
+
send_now: { type: "boolean", description: "Send immediately (skip the Drafts pause)." },
|
|
132
|
+
},
|
|
133
|
+
required: %w[to subject body],
|
|
134
|
+
additionalProperties: false,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "open",
|
|
139
|
+
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.",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
id: { type: "string", description: "eml-id, RFC Message-ID, message://… URL, or mid:… URL." },
|
|
144
|
+
print_only: { type: "boolean", description: "Return the mid: URL without invoking `open`." },
|
|
145
|
+
},
|
|
146
|
+
required: ["id"],
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "list_mailboxes",
|
|
152
|
+
description: "Enumerate accounts, IMAP mailboxes (with optional message counts), and smart mailboxes MailMate has defined. Account names are decoded for display (`%40` → `@`).",
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
count: { type: "boolean", description: "Include .eml counts per IMAP mailbox (default true; pass false to skip for speed)." },
|
|
157
|
+
csv: { type: "boolean", description: "Flat CSV output (one row per mailbox); default is grouped by account." },
|
|
158
|
+
},
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "list_tags",
|
|
164
|
+
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).",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
defined: { type: "boolean", description: "Read from Tags.plist (defined tags) instead of scanning #flags (used tags)." },
|
|
169
|
+
},
|
|
170
|
+
additionalProperties: false,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "resolve_id",
|
|
175
|
+
description: <<~DESC.strip,
|
|
176
|
+
Look up a message and return all its identifiers:
|
|
177
|
+
- eml_id local body-part id (changes per machine)
|
|
178
|
+
- message_id RFC Message-ID header (portable across machines)
|
|
179
|
+
- message_url message://%3C<MID>%3E — cross-machine reference
|
|
180
|
+
- mid_url mid:%3C<MID>%3E — drives MailMate locally
|
|
181
|
+
|
|
182
|
+
Accepts: eml-id, Message-ID (with/without angle brackets), or message:// URL.
|
|
183
|
+
Use to mint a portable reference from a local eml-id, or to find the
|
|
184
|
+
local eml-id given a Message-ID copied from another machine.
|
|
185
|
+
DESC
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: "object",
|
|
188
|
+
properties: {
|
|
189
|
+
id: { type: "string", description: "eml-id, Message-ID, or message:// URL." },
|
|
190
|
+
},
|
|
191
|
+
required: ["id"],
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
].freeze
|
|
196
|
+
|
|
197
|
+
def run(stdin: $stdin, stdout: $stdout)
|
|
198
|
+
stdin.binmode
|
|
199
|
+
stdout.binmode
|
|
200
|
+
stdout.sync = true
|
|
201
|
+
loop do
|
|
202
|
+
line = stdin.gets
|
|
203
|
+
break if line.nil?
|
|
204
|
+
line = line.strip
|
|
205
|
+
next if line.empty?
|
|
206
|
+
begin
|
|
207
|
+
msg = JSON.parse(line)
|
|
208
|
+
rescue JSON::ParserError => e
|
|
209
|
+
write(stdout, jsonrpc_error(nil, -32700, "Parse error: #{e.message}"))
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
handle(msg, stdout)
|
|
213
|
+
end
|
|
214
|
+
0
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def handle(msg, stdout)
|
|
218
|
+
method = msg["method"]
|
|
219
|
+
id = msg["id"]
|
|
220
|
+
case method
|
|
221
|
+
when "initialize"
|
|
222
|
+
write(stdout, jsonrpc_result(id, {
|
|
223
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
224
|
+
capabilities: { tools: {} },
|
|
225
|
+
serverInfo: { name: SERVER_NAME, version: Mailmate::VERSION },
|
|
226
|
+
}))
|
|
227
|
+
when "notifications/initialized", "notifications/cancelled"
|
|
228
|
+
# notifications — no response
|
|
229
|
+
when "tools/list"
|
|
230
|
+
write(stdout, jsonrpc_result(id, { tools: TOOLS }))
|
|
231
|
+
when "tools/call"
|
|
232
|
+
params = msg["params"] || {}
|
|
233
|
+
result = dispatch(params["name"], params["arguments"] || {})
|
|
234
|
+
write(stdout, jsonrpc_result(id, result))
|
|
235
|
+
when "ping"
|
|
236
|
+
write(stdout, jsonrpc_result(id, {}))
|
|
237
|
+
else
|
|
238
|
+
# Unknown method — error if it has an id (request), drop if not.
|
|
239
|
+
write(stdout, jsonrpc_error(id, -32601, "Method not found: #{method}")) unless id.nil?
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def dispatch(name, args)
|
|
244
|
+
case name
|
|
245
|
+
when "search" then call_search(args)
|
|
246
|
+
when "message" then call_message(args)
|
|
247
|
+
when "modify" then call_modify(args)
|
|
248
|
+
when "send" then call_send(args)
|
|
249
|
+
when "open" then call_open(args)
|
|
250
|
+
when "list_mailboxes" then call_list_mailboxes(args)
|
|
251
|
+
when "list_tags" then call_list_tags(args)
|
|
252
|
+
when "resolve_id" then call_resolve(args)
|
|
253
|
+
else text_error("Unknown tool: #{name}")
|
|
254
|
+
end
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
text_error("#{e.class}: #{e.message}\n#{e.backtrace.first(8).join("\n")}")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ---- tool handlers ----------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def call_search(args)
|
|
262
|
+
argv = []
|
|
263
|
+
argv.push("--mailbox", args["mailbox"].to_s) if args["mailbox"]
|
|
264
|
+
argv.push("--limit", args["limit"].to_i.to_s) if args["limit"]
|
|
265
|
+
argv.push("--headers-only") if args["headers_only"]
|
|
266
|
+
argv.push("--sort", args["sort"].to_s) if args["sort"]
|
|
267
|
+
# Positionals: search-string then fields. Only include if the caller
|
|
268
|
+
# gave us either — otherwise let the CLI apply its defaults.
|
|
269
|
+
if args.key?("query") || args["fields"]
|
|
270
|
+
argv << (args["query"] || "")
|
|
271
|
+
argv << args["fields"].to_s if args["fields"]
|
|
272
|
+
end
|
|
273
|
+
run_cli(Mailmate::CLI::Search, argv)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def call_message(args)
|
|
277
|
+
argv = [args["id"].to_s]
|
|
278
|
+
argv << "--raw" if args["raw"]
|
|
279
|
+
argv << "--text-only" if args["text_only"]
|
|
280
|
+
run_cli(Mailmate::CLI::Message, argv)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def call_modify(args)
|
|
284
|
+
argv = [args["id"].to_s] + Array(args["actions"]).map(&:to_s)
|
|
285
|
+
argv << "--dry-run" if args["dry_run"]
|
|
286
|
+
argv << "--verify" if args["verify"]
|
|
287
|
+
argv << "--keep-window" if args["keep_window"]
|
|
288
|
+
run_cli(Mailmate::CLI::Modify, argv)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def call_send(args)
|
|
292
|
+
argv = []
|
|
293
|
+
argv.push("-t", args["to"].to_s) if args["to"]
|
|
294
|
+
argv.push("-c", args["cc"].to_s) if args["cc"]
|
|
295
|
+
argv.push("-b", args["bcc"].to_s) if args["bcc"]
|
|
296
|
+
argv.push("-s", args["subject"].to_s) if args["subject"]
|
|
297
|
+
argv << "--send-now" if args["send_now"]
|
|
298
|
+
Array(args["attachments"]).each { |p| argv << p.to_s }
|
|
299
|
+
with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Send, argv) }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def call_open(args)
|
|
303
|
+
argv = [args["id"].to_s]
|
|
304
|
+
argv << "--print" if args["print_only"]
|
|
305
|
+
run_cli(Mailmate::CLI::Open, argv)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def call_list_mailboxes(args)
|
|
309
|
+
argv = []
|
|
310
|
+
argv << "--no-count" if args.key?("count") && !args["count"]
|
|
311
|
+
argv << "--csv" if args["csv"]
|
|
312
|
+
run_cli(Mailmate::CLI::Mailboxes, argv)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def call_list_tags(args)
|
|
316
|
+
argv = []
|
|
317
|
+
argv << "--defined" if args["defined"]
|
|
318
|
+
run_cli(Mailmate::CLI::Tags, argv)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def call_resolve(args)
|
|
322
|
+
eml_id = Mailmate::EmlLookup.resolve_id(args["id"].to_s)
|
|
323
|
+
return text_error("Not found: #{args["id"].inspect}") if eml_id.nil? || eml_id.zero?
|
|
324
|
+
|
|
325
|
+
path = Mailmate::EmlLookup.path_for(eml_id)
|
|
326
|
+
return text_error("Not found: #{eml_id}.eml") unless path
|
|
327
|
+
|
|
328
|
+
message_id = Mailmate::HeaderReader.message_id(path)
|
|
329
|
+
mailbox = path.sub("#{Mailmate.config.imap_root}/", "")
|
|
330
|
+
.sub(%r{/Messages/[^/]+\.eml\z}, "")
|
|
331
|
+
|
|
332
|
+
payload = {
|
|
333
|
+
eml_id: eml_id,
|
|
334
|
+
message_id: message_id,
|
|
335
|
+
message_url: message_id ? Mailmate::MidUrl.message_url_for(message_id) : nil,
|
|
336
|
+
mid_url: message_id ? Mailmate::MidUrl.for(message_id) : nil,
|
|
337
|
+
path: path,
|
|
338
|
+
mailbox: mailbox,
|
|
339
|
+
}
|
|
340
|
+
{ content: [{ type: "text", text: JSON.pretty_generate(payload) }] }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# ---- protocol helpers -------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def run_cli(mod, argv)
|
|
346
|
+
out, err, code = with_captured_io { mod.run(argv) }
|
|
347
|
+
text = +""
|
|
348
|
+
text << out unless out.empty?
|
|
349
|
+
unless err.empty?
|
|
350
|
+
text << "\n" unless text.empty?
|
|
351
|
+
text << "[stderr]\n" << err
|
|
352
|
+
end
|
|
353
|
+
text = "(no output)" if text.empty?
|
|
354
|
+
{ content: [{ type: "text", text: text }], isError: code != 0 }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def with_captured_io
|
|
358
|
+
old_out, old_err = $stdout, $stderr
|
|
359
|
+
$stdout = StringIO.new
|
|
360
|
+
$stderr = StringIO.new
|
|
361
|
+
code = yield
|
|
362
|
+
[$stdout.string, $stderr.string, code.is_a?(Integer) ? code : 0]
|
|
363
|
+
ensure
|
|
364
|
+
$stdout = old_out
|
|
365
|
+
$stderr = old_err
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def with_stdin(text)
|
|
369
|
+
old = $stdin
|
|
370
|
+
$stdin = StringIO.new(text)
|
|
371
|
+
yield
|
|
372
|
+
ensure
|
|
373
|
+
$stdin = old
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def text_error(msg)
|
|
377
|
+
{ content: [{ type: "text", text: msg }], isError: true }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def jsonrpc_result(id, result)
|
|
381
|
+
{ jsonrpc: "2.0", id: id, result: result }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def jsonrpc_error(id, code, message)
|
|
385
|
+
{ jsonrpc: "2.0", id: id, error: { code: code, message: message } }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def write(stdout, obj)
|
|
389
|
+
stdout.write(JSON.generate(obj) + "\n")
|
|
390
|
+
stdout.flush
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
end
|
|
394
|
+
end
|
data/lib/mailmate/mid_url.rb
CHANGED
|
@@ -3,21 +3,41 @@
|
|
|
3
3
|
module Mailmate
|
|
4
4
|
# @api public
|
|
5
5
|
#
|
|
6
|
-
# Build
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# Build URLs that point to a single MailMate message:
|
|
7
|
+
#
|
|
8
|
+
# - `mid:%3C<id>%3E` — local MailMate driver URL (RFC 2392). Selects
|
|
9
|
+
# the message in the local store; what
|
|
10
|
+
# `mm-modify` uses to drive the UI.
|
|
11
|
+
# - `message://%3C<id>%3E` — portable, cross-machine reference. Same
|
|
12
|
+
# encoding, different scheme. Resolves to the
|
|
13
|
+
# same physical email on any MailMate install
|
|
14
|
+
# because the RFC `Message-ID` header is
|
|
15
|
+
# globally unique.
|
|
16
|
+
#
|
|
17
|
+
# Both schemes need the angle brackets that wrap a Message-ID to be
|
|
18
|
+
# percent-encoded; characters that would break URL parsers (`[`, `]`,
|
|
19
|
+
# whitespace) are also encoded. Other URL-reserved characters (`@`, `.`,
|
|
20
|
+
# `-`, `_`) are preserved — MailMate's parser accepts them as-is.
|
|
9
21
|
module MidUrl
|
|
10
|
-
#
|
|
11
|
-
# surrounding angle brackets — strips them either way before encoding.
|
|
12
|
-
# URL-encodes characters that would break the URL parser, notably `[` and
|
|
13
|
-
# `]` (which appear in Message-IDs containing IPv4 literals, e.g.
|
|
14
|
-
# `<id@[169.254.16.253]>`). Other URL-reserved characters (`@`, `.`, `-`,
|
|
15
|
-
# `_`, etc.) are preserved — MailMate's parser accepts them as-is.
|
|
22
|
+
# `mid:%3C<message-id>%3E` — used by MailMate to select a message locally.
|
|
16
23
|
def self.for(message_id)
|
|
24
|
+
"mid:#{encoded_with_brackets(message_id)}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# `message://%3C<message-id>%3E` — portable cross-machine reference. Pass
|
|
28
|
+
# back to `EmlLookup.resolve_id` (or any CLI that takes an id) to look up
|
|
29
|
+
# the same physical email on another machine.
|
|
30
|
+
def self.message_url_for(message_id)
|
|
31
|
+
"message://#{encoded_with_brackets(message_id)}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Percent-encoded `%3C…%3E` envelope shared by both schemes. Exposed in
|
|
35
|
+
# case a caller wants the brackets-only form without a scheme prefix.
|
|
36
|
+
def self.encoded_with_brackets(message_id)
|
|
17
37
|
raise ArgumentError, "Message-ID required" if message_id.nil? || message_id.to_s.empty?
|
|
18
38
|
id = message_id.to_s.sub(/\A</, "").sub(/>\z/, "")
|
|
19
39
|
encoded = id.gsub(/[\[\]<>\s]/) { |c| "%%%02X" % c.ord }
|
|
20
|
-
"
|
|
40
|
+
"%3C#{encoded}%3E"
|
|
21
41
|
end
|
|
22
42
|
end
|
|
23
43
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# PartLookup — given a `.eml` envelope id, return the body-part-ids of its
|
|
4
|
+
# child parts.
|
|
5
|
+
#
|
|
6
|
+
# MailMate stores each message as a tree of parts: the envelope (which becomes
|
|
7
|
+
# the on-disk `.eml` filename) is the root, with text/plain, text/html, and
|
|
8
|
+
# attachments as children with their own part-ids. Body content indexes
|
|
9
|
+
# (`#unquoted#lc`, `#quoted#lc`, etc.) are keyed by body-part-id, not
|
|
10
|
+
# envelope-id — so to read a message's body content from those indexes we
|
|
11
|
+
# first have to walk from envelope-id to its children.
|
|
12
|
+
#
|
|
13
|
+
# Data source: `#root-body-part.{cache,offsets}`, which stores the root
|
|
14
|
+
# (envelope) id as a decimal-string value for each non-envelope part-id. We
|
|
15
|
+
# invert that mapping once per process: envelope_id → [part_ids].
|
|
16
|
+
#
|
|
17
|
+
# Memory: ~5 MB for 100k part records. Negligible. Build cost: one IndexReader
|
|
18
|
+
# pass, ≈10–30 ms.
|
|
19
|
+
|
|
20
|
+
module Mailmate
|
|
21
|
+
# @api public
|
|
22
|
+
module PartLookup
|
|
23
|
+
class << self
|
|
24
|
+
# Returns the body-part-ids that descend from `envelope_id`. Returns []
|
|
25
|
+
# for envelopes with no recorded children (single-part messages where
|
|
26
|
+
# envelope-id == body-part-id, and messages MailMate hasn't yet
|
|
27
|
+
# indexed). Order matches `#root-body-part`'s id-iteration order, not
|
|
28
|
+
# MIME-tree order.
|
|
29
|
+
def body_parts_of(envelope_id)
|
|
30
|
+
inversion[envelope_id.to_i] || []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Drop the cached inversion. Tests use this with `with_config` swaps;
|
|
34
|
+
# production callers use it when MailMate's index has been rewritten on
|
|
35
|
+
# disk. Cheap — next call rebuilds lazily.
|
|
36
|
+
def reset!
|
|
37
|
+
@inversions = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def inversion
|
|
43
|
+
@inversions ||= {}
|
|
44
|
+
@inversions[Mailmate.config.db_headers] ||= build_inversion
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_inversion
|
|
48
|
+
inv = Hash.new { |h, k| h[k] = [] }
|
|
49
|
+
reader = Mailmate::IndexReader.for("#root-body-part")
|
|
50
|
+
reader.each_eml_id do |part_id|
|
|
51
|
+
root_str = reader.value_for(part_id)
|
|
52
|
+
# Skip deleted parts: MailMate appends an empty trailing record to
|
|
53
|
+
# `#root-body-part` when a part is removed. value_for returns that
|
|
54
|
+
# latest record, so empty == "this part is gone."
|
|
55
|
+
next if root_str.nil? || root_str.empty?
|
|
56
|
+
inv[root_str.to_i] << part_id
|
|
57
|
+
end
|
|
58
|
+
inv
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/mailmate/version.rb
CHANGED