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.
@@ -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 that may be either an eml-id (all digits) or an
46
- # RFC Message-ID (anything else) to a local eml-id.
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
- # msg_id(start,end) for O(1) lookup. Construction cost ≈ 5–20 ms for
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 message isn't in this index.
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
- pair = @index[eml_id.to_i]
78
- return nil unless pair
79
- @cache_bytes[pair[0]...pair[1]]
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 records (mostly for diagnostics).
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. The value comes back as the
104
- # bare cache substring; callers that need parsed form (e.g. flag tokens)
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.each_key do |eml_id|
109
- yield eml_id, value_for(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]] = [rec[1], rec[2]]
158
+ @index[rec[0]] << [rec[1], rec[2]]
122
159
  i += 1
123
160
  end
124
161
  end
@@ -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
@@ -3,21 +3,41 @@
3
3
  module Mailmate
4
4
  # @api public
5
5
  #
6
- # Build a MailMate `mid:` URL from a Message-ID. MailMate registers the
7
- # `mid:` scheme (RFC 2392) and resolves it to a specific message. The angle
8
- # brackets that bracket the Message-ID in headers must be percent-encoded.
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
- # Returns `mid:%3C<message-id>%3E`. Accepts the Message-ID with or without
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
- "mid:%3C#{encoded}%3E"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "0.2.0"
4
+ VERSION = "1.0.0"
5
5
  end