mailmate 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b986acdac48e1c98c6cc80ae0cc1686af00161a5de3cc17b0946ea09c43b17e1
4
- data.tar.gz: 6867d4736f7c4118ea775c60b108a36dc76a5234312b20a36cb5c60070233712
3
+ metadata.gz: '048aab794be796fae6993d06f902be520dd044a3e09b65ea8f673a127c670de0'
4
+ data.tar.gz: 17f3dcd902490b4077676f61c50e5c600b14382a6b753046d75e5e4b7d76f611
5
5
  SHA512:
6
- metadata.gz: 45d6fc1c66f4549fdf14a899bdba83d1b7d0510b5a20e9469e75f44d92e8b5285bd4cba548a2524d9561831cd34ee64cf3fe3239829afe8b4dc19aa27e4cdbda
7
- data.tar.gz: 7b88d5bb8668614368c969d0e6ba10f14a444594b4fe55fdf6dc14bf536e35b29c8845818bff44b65f880c4c5a724d551b245c23c4f3ee56d78247043da1af9c
6
+ metadata.gz: a05405be8bf33235663c439d96af6afab59c9dbdd6a1a238d3746affe964c1bb4cbc9e7865a1a5c1f69276fe92598605f5814bc3cd3b31e3607c7e89d5957791
7
+ data.tar.gz: d722a40a671b8d44b5106a6a094cbf78d7886c7d85d1a048dabe8767453e235d3272ead4361c4655b2b2aef562a26659a77a810863d14760fe3f85b41f176859
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # mailmate
2
2
 
3
- Ruby toolkit for [MailMate](https://freron.com) on macOS — a smart-mailbox filter engine, on-disk index readers, and CLI tools (`mmsearch`, `mmmessage`, `mmopen`, `mm-mailboxes`, `mmtags`, `mm-modify`, `mm-send`, `mmdiscover`) for searching, reading, modifying, and sending mail.
3
+ Ruby toolkit for [MailMate](https://freron.com) on macOS — a smart-mailbox filter engine, on-disk index readers, and CLI tools (`mmsearch`, `mmmessage`, `mmopen`, `mm-mailboxes`, `mmtags`, `mm-modify`, `mm-send`, `mm-draft`, `mmdiscover`) for searching, reading, modifying, and sending mail.
4
4
 
5
5
  **Requires macOS with MailMate installed.** The library code (filter parser, evaluator) works anywhere, but the integration with MailMate itself — AppleScript, on-disk index reads, the `emate` binary — is macOS-only by way of MailMate being macOS-only.
6
6
 
@@ -146,12 +146,49 @@ EOF
146
146
  mm-send -t friend@example.com -s "Photos" /path/to/photo1.jpg /path/to/photo2.jpg <<<"See attached."
147
147
  ```
148
148
 
149
+ #### Replies and threading
150
+
151
+ MailMate auto-generates the outgoing `Message-ID` for every send — never the caller's job. `In-Reply-To` and `References` are pure pass-through: whatever you set via `--header` ships verbatim, and **what you don't set is absent**. A reply with a `Re: …` subject but no threading headers shows up as a brand-new conversation in modern mail clients (they thread on headers, not subjects).
152
+
153
+ To make a reply land in-thread, pass both headers:
154
+
155
+ ```bash
156
+ mm-send -f you@x -t them@y -s "Re: foo" \
157
+ --header "In-Reply-To: <parent-message-id@domain>" \
158
+ --header "References: <root-mid> <parent-mid>" \
159
+ --send-now <<<"body"
160
+ ```
161
+
162
+ Construct `References` as the source message's `References` header (if any) with the source's `Message-ID` appended. If the source is itself a thread root with no `References`, just use its `Message-ID` alone.
163
+
164
+ The same passthrough applies to the `mailmate-mcp` `send` tool — see the `from`, `in_reply_to`, and `references` fields.
165
+
166
+ ### `mm-draft` — compose without sending
167
+
168
+ `mm-draft` is identical to `mm-send` in every way except one: it **never sends**. It refuses the `--send-now` flag with a nonzero exit, so a "compose this but don't send it" instruction can't be silently defeated by passing the flag that flips `emate mailto` from draft-pause to send. Without `--send-now` the two commands already behave identically (open a draft window in MailMate and wait) — `mm-draft` just removes the ability to send at all.
169
+
170
+ ```bash
171
+ # Opens a draft in MailMate; never sends.
172
+ echo "Quick **markdown** body." | mm-draft -t friend@example.com -s "Hello"
173
+
174
+ # Threading headers and attachments work exactly as in mm-send.
175
+ mm-draft -f you@x -t them@y -s "Re: foo" \
176
+ --header "In-Reply-To: <parent-message-id@domain>" \
177
+ --header "References: <root-mid> <parent-mid>" <<<"body"
178
+
179
+ # Passing --send-now is refused (exit 2):
180
+ mm-draft -t friend@example.com -s "nope" --send-now <<<"body"
181
+ # → mm-draft: refusing --send-now — mm-draft only ever creates drafts. Use mm-send to send.
182
+ ```
183
+
184
+ Reach for `mm-draft` (or the `mailmate-mcp` `draft` tool) whenever the instruction is "write/compose but don't send" — it's the unambiguous, can't-misfire choice. The `draft` MCP tool mirrors `send` minus the `send_now` field.
185
+
149
186
  ### Why the names
150
187
 
151
188
  The `mm` prefix is for tab completion: typing `mm<tab>` in a shell lists every command in the toolkit. The dash matters:
152
189
 
153
190
  - **`mm<name>`** (no dash) — **read** operations. `mmsearch`, `mmmessage`, `mmopen`, `mmtags`, `mmdiscover` observe MailMate's on-disk state without changing it. (`mmopen` activates MailMate's UI but doesn't modify any message.)
154
- - **`mm-<name>`** (with dash) — **write** operations. `mm-modify`, `mm-send` change state (or send mail). `mm-mailboxes` is an exception: read-only, but uses the dash to keep `mmm<tab>` free for the daily-driver `mmmessage`. Typing `mm-<tab>` filters to the write-leaning commands.
191
+ - **`mm-<name>`** (with dash) — **write** operations. `mm-modify`, `mm-send`, `mm-draft` change state (or compose/send mail). `mm-mailboxes` is an exception: read-only, but uses the dash to keep `mmm<tab>` free for the daily-driver `mmmessage`. Typing `mm-<tab>` filters to the write-leaning commands. (`mm-draft` only ever opens a draft — it never sends.)
155
192
 
156
193
  ## Limitations
157
194
 
@@ -278,6 +315,7 @@ Restart Claude Desktop after any change to server code or config.
278
315
  | `mmtags` | List user tags applied to messages (with counts) or defined in Preferences. |
279
316
  | `mm-modify` | Mark read/flag/tag/archive a message via AppleScript; same-account `move` uses a fast `.eml`-rename path with no UI takeover. |
280
317
  | `mm-send` | Send mail through `emate` with a markdown body on stdin. |
318
+ | `mm-draft` | Like `mm-send`, but only ever opens a draft — refuses `--send-now` (exit 2). |
281
319
  | `mmdiscover` | First-run bootstrap; (re-)writes the user config from MailMate's plists. |
282
320
 
283
321
  Each command takes `--help` for usage. Tab-completion: `mm<tab>` lists every command; `mms<tab>` → `mmsearch`; `mmm<tab>` → `mmmessage`; `mm-<tab>` lists the write-side commands.
data/exe/mm-draft ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "mailmate"
6
+ require "mailmate/cli/draft"
7
+
8
+ exit Mailmate::CLI::Draft.run(ARGV)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailmate/cli/send"
4
+
5
+ module Mailmate
6
+ module CLI
7
+ # `mm-draft` — identical to `mm-send` but guaranteed never to actually
8
+ # send. It refuses `--send-now` with a nonzero exit, so a "compose this
9
+ # but don't send it" instruction can't be silently defeated by the flag
10
+ # that flips `emate mailto` from draft-pause to send. Without `--send-now`
11
+ # both commands behave identically (open a draft window in MailMate); the
12
+ # only difference is that mm-draft *cannot* be talked into sending.
13
+ # @api private
14
+ module Draft
15
+ extend self
16
+
17
+ NOTE = <<~NOTE
18
+ mm-draft — same as `mm-send`, but it only ever opens a draft and refuses
19
+ `--send-now`. Use `mm-send` when you actually want to send. All other
20
+ flags pass through to `emate mailto` exactly as in mm-send (its help
21
+ follows below).
22
+
23
+ NOTE
24
+
25
+ # Returns the exit status. Refuses `--send-now` (exit 2); otherwise
26
+ # delegates verbatim to `Mailmate::CLI::Send`.
27
+ def run(argv)
28
+ if argv.include?("--send-now")
29
+ warn "mm-draft: refusing --send-now — mm-draft only ever creates drafts. Use mm-send to send."
30
+ return 2
31
+ end
32
+ warn NOTE if argv.include?("--help") || argv.include?("-h")
33
+ Send.run(argv)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -89,6 +89,8 @@ module Mailmate
89
89
  $stdout.puts "subject: #{mail.subject}"
90
90
  $stdout.puts "date: #{Mailmate.localize(mail.date)&.iso8601}"
91
91
  $stdout.puts "message-id: #{mail.message_id}"
92
+ thread_id = Mailmate::Attributes.thread_id_for(mail)
93
+ $stdout.puts "thread-id: #{thread_id}" if thread_id
92
94
  if mail.attachments.any?
93
95
  $stdout.puts "attachments:"
94
96
  mail.attachments.each do |a|
@@ -256,6 +256,13 @@ module Mailmate
256
256
  driver = Mailmate::AppleScriptDriver.new(dry_run: opts[:dry_run])
257
257
  mid_url = Mailmate::MidUrl.for(message_id)
258
258
 
259
+ # Opening the `mid:` URL displays the message in a viewer, which
260
+ # causes MailMate to mark it `\Seen`. If the caller's action chain
261
+ # neither sets nor clears the read state, snapshot it first and
262
+ # restore after the open so this side-effect doesn't silently
263
+ # change read state. Skip in dry-run — no real open happens.
264
+ preserve_unread = should_preserve_unread?(eml_id, actions, opts)
265
+
259
266
  windows_before = driver.window_ids
260
267
  driver.open_url(mid_url)
261
268
  # Active wait: poll for the new viewer window instead of sleeping a
@@ -264,6 +271,15 @@ module Mailmate
264
271
  # the timeout for this wait rather than the sleep duration.
265
272
  new_windows = opts[:dry_run] ? [] : wait_for_new_window(driver, windows_before, timeout: opts[:settle])
266
273
 
274
+ # Restore unread BEFORE user actions: a subsequent move / archive /
275
+ # delete moves the message out of the viewer's selection, after
276
+ # which a `markAsUnread:` would land on whatever MailMate selected
277
+ # next (or be a silent no-op).
278
+ if preserve_unread
279
+ $stdout.puts "preserve-read-state: re-marking #{eml_id}.eml unread (opening the mid: URL marks it read)"
280
+ driver.perform("markAsUnread:")
281
+ end
282
+
267
283
  # Send each action's selector without sleeping between them.
268
284
  # AppleEvents queue per-app and process in order, so a subsequent
269
285
  # `close window id N` will execute only after `perform` is committed.
@@ -312,6 +328,17 @@ module Mailmate
312
328
  []
313
329
  end
314
330
 
331
+ # True iff we should re-mark the message unread after opening it.
332
+ # - Skip when any user action already touches read state (read/unread)
333
+ # — the user's chain wins.
334
+ # - Skip in dry-run — no real open, so no real read-flip to undo.
335
+ # - Otherwise: read the index; preserve only if currently unread.
336
+ def should_preserve_unread?(eml_id, actions, opts)
337
+ return false if opts[:dry_run]
338
+ return false if actions.any? { |name, _, _| name == "read" || name == "unread" }
339
+ !current_flags(eml_id).include?("\\Seen")
340
+ end
341
+
315
342
  def current_flags(eml_id)
316
343
  # AppleScript actions write the index asynchronously — bust just the
317
344
  # #flags cache to pick up the latest values without throwing away
@@ -12,6 +12,35 @@ module Mailmate
12
12
 
13
13
  EMATE_PATH = "/Applications/MailMate.app/Contents/Resources/emate"
14
14
 
15
+ PREAMBLE = <<~PREAMBLE
16
+ mm-send — thin wrapper around `emate mailto` with `--markup markdown` enforced.
17
+ Body is read from stdin. All other flags pass through to emate (its help follows).
18
+
19
+ Replies and threading
20
+ MailMate auto-generates the outgoing Message-ID — never your job.
21
+ In-Reply-To and References are PURE PASS-THROUGH: whatever you set via
22
+ `--header` ships verbatim; what you don't set is absent (and recipients'
23
+ clients will see the message as a fresh thread, no matter how `Re:` the
24
+ subject looks). To make a reply land in-thread, pass both:
25
+
26
+ mm-send -f you@x -t them@y -s "Re: foo" \\
27
+ --header "In-Reply-To: <parent-message-id@domain>" \\
28
+ --header "References: <root-mid> <parent-mid>" \\
29
+ --send-now <<<"body"
30
+
31
+ References is constructed as the source message's References header (if
32
+ any) with the source's Message-ID appended. If the source is a thread
33
+ root with no References, just use its Message-ID alone.
34
+
35
+ Identity selection
36
+ `-f <address>` picks which of MailMate's configured identities sends.
37
+ Without `-f`, MailMate uses its default identity. See `mmdiscover` to
38
+ list available addresses.
39
+
40
+ ──────────────────────────── emate help follows ────────────────────────────
41
+
42
+ PREAMBLE
43
+
15
44
  # Returns the exit status of the spawned `emate` invocation. Uses
16
45
  # `system` (not `exec`) so the caller — and the test suite — can
17
46
  # actually observe the result.
@@ -21,6 +50,7 @@ module Mailmate
21
50
  warn "mm-send: emate not found at #{EMATE_PATH}. Is MailMate installed?"
22
51
  return 1
23
52
  end
53
+ warn PREAMBLE if argv.include?("--help") || argv.include?("-h")
24
54
  system(EMATE_PATH, "mailto", "--markup", "markdown", *argv)
25
55
  $?.exitstatus
26
56
  end
data/lib/mailmate/mcp.rb CHANGED
@@ -8,6 +8,7 @@ require "mailmate/cli/search"
8
8
  require "mailmate/cli/message"
9
9
  require "mailmate/cli/modify"
10
10
  require "mailmate/cli/send"
11
+ require "mailmate/cli/draft"
11
12
  require "mailmate/cli/open"
12
13
  require "mailmate/cli/mailboxes"
13
14
  require "mailmate/cli/tags"
@@ -17,7 +18,7 @@ require "mailmate/mid_url"
17
18
 
18
19
  module Mailmate
19
20
  # 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
+ # search, message, modify, send, draft — plus a resolve_id helper that round-trips
21
22
  # between local eml-id, RFC Message-ID, and the cross-machine message:// URL.
22
23
  #
23
24
  # In-process: each tool call runs the corresponding `Mailmate::CLI::*.run`
@@ -118,22 +119,45 @@ module Mailmate
118
119
  },
119
120
  {
120
121
  name: "send",
121
- description: "Send mail via MailMate's `emate` (markdown body). Recipients and subject via fields; body is the markdown source.",
122
+ 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.",
122
123
  inputSchema: {
123
124
  type: "object",
124
125
  properties: {
126
+ from: { type: "string", description: "Sender identity (one of MailMate's configured addresses; see mmdiscover). If omitted, MailMate uses its default identity." },
125
127
  to: { type: "string", description: "Recipient(s), comma-separated." },
126
128
  cc: { type: "string", description: "CC recipient(s), comma-separated." },
127
129
  bcc: { type: "string", description: "BCC recipient(s), comma-separated." },
128
130
  subject: { type: "string", description: "Subject line." },
129
131
  body: { type: "string", description: "Markdown body." },
130
132
  attachments: { type: "array", items: { type: "string" }, description: "Absolute paths to files to attach." },
133
+ in_reply_to: { type: "string", description: "Message-ID of the parent message (with or without angle brackets). Sets the In-Reply-To header on the outgoing message so recipients' clients thread it correctly." },
134
+ references: { type: "string", description: "Space-separated chain of Message-IDs (with angle brackets). Conventionally: parent's References header + parent's Message-ID. Required alongside in_reply_to for clean threading in deep chains." },
131
135
  send_now: { type: "boolean", description: "Send immediately (skip the Drafts pause)." },
132
136
  },
133
137
  required: %w[to subject body],
134
138
  additionalProperties: false,
135
139
  },
136
140
  },
141
+ {
142
+ name: "draft",
143
+ 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.",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ from: { type: "string", description: "Sender identity (one of MailMate's configured addresses; see mmdiscover). If omitted, MailMate uses its default identity." },
148
+ to: { type: "string", description: "Recipient(s), comma-separated." },
149
+ cc: { type: "string", description: "CC recipient(s), comma-separated." },
150
+ bcc: { type: "string", description: "BCC recipient(s), comma-separated." },
151
+ subject: { type: "string", description: "Subject line." },
152
+ body: { type: "string", description: "Markdown body." },
153
+ attachments: { type: "array", items: { type: "string" }, description: "Absolute paths to files to attach." },
154
+ in_reply_to: { type: "string", description: "Message-ID of the parent message (with or without angle brackets). Sets the In-Reply-To header so recipients' clients thread it correctly." },
155
+ references: { type: "string", description: "Space-separated chain of Message-IDs (with angle brackets). Conventionally: parent's References header + parent's Message-ID. Required alongside in_reply_to for clean threading in deep chains." },
156
+ },
157
+ required: %w[to subject body],
158
+ additionalProperties: false,
159
+ },
160
+ },
137
161
  {
138
162
  name: "open",
139
163
  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.",
@@ -246,6 +270,7 @@ module Mailmate
246
270
  when "message" then call_message(args)
247
271
  when "modify" then call_modify(args)
248
272
  when "send" then call_send(args)
273
+ when "draft" then call_draft(args)
249
274
  when "open" then call_open(args)
250
275
  when "list_mailboxes" then call_list_mailboxes(args)
251
276
  when "list_tags" then call_list_tags(args)
@@ -289,14 +314,40 @@ module Mailmate
289
314
  end
290
315
 
291
316
  def call_send(args)
317
+ argv = compose_argv(args)
318
+ argv << "--send-now" if args["send_now"]
319
+ with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Send, argv) }
320
+ end
321
+
322
+ # `draft` mirrors `send` but never sends — it has no send_now option and
323
+ # routes through CLI::Draft, which refuses `--send-now` outright.
324
+ def call_draft(args)
325
+ argv = compose_argv(args)
326
+ with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Draft, argv) }
327
+ end
328
+
329
+ # Shared message-composition argv for both send and draft (everything bar
330
+ # the body, which is piped on stdin, and the send-only `--send-now`).
331
+ def compose_argv(args)
292
332
  argv = []
333
+ argv.push("-f", args["from"].to_s) if args["from"]
293
334
  argv.push("-t", args["to"].to_s) if args["to"]
294
335
  argv.push("-c", args["cc"].to_s) if args["cc"]
295
336
  argv.push("-b", args["bcc"].to_s) if args["bcc"]
296
337
  argv.push("-s", args["subject"].to_s) if args["subject"]
297
- argv << "--send-now" if args["send_now"]
338
+ argv.push("--header", "In-Reply-To: #{bracket_mid(args["in_reply_to"])}") if args["in_reply_to"]
339
+ argv.push("--header", "References: #{args["references"]}") if args["references"]
298
340
  Array(args["attachments"]).each { |p| argv << p.to_s }
299
- with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Send, argv) }
341
+ argv
342
+ end
343
+
344
+ # Wrap a bare Message-ID in `<…>` if it doesn't already have them. Both
345
+ # forms are valid input to the MCP for ergonomics; the header value
346
+ # going on the wire needs the brackets per RFC 5322.
347
+ def bracket_mid(id)
348
+ s = id.to_s.strip
349
+ return s if s.start_with?("<") && s.end_with?(">")
350
+ "<#{s}>"
300
351
  end
301
352
 
302
353
  def call_open(args)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
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.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Murphy-Dye
@@ -94,6 +94,7 @@ email:
94
94
  - brian@murphydye.com
95
95
  executables:
96
96
  - mailmate-mcp
97
+ - mm-draft
97
98
  - mm-mailboxes
98
99
  - mm-modify
99
100
  - mm-send
@@ -109,6 +110,7 @@ files:
109
110
  - README.md
110
111
  - config.yml.example
111
112
  - exe/mailmate-mcp
113
+ - exe/mm-draft
112
114
  - exe/mm-mailboxes
113
115
  - exe/mm-modify
114
116
  - exe/mm-send
@@ -122,6 +124,7 @@ files:
122
124
  - lib/mailmate/ast.rb
123
125
  - lib/mailmate/attributes.rb
124
126
  - lib/mailmate/cli/discover.rb
127
+ - lib/mailmate/cli/draft.rb
125
128
  - lib/mailmate/cli/mailboxes.rb
126
129
  - lib/mailmate/cli/message.rb
127
130
  - lib/mailmate/cli/modify.rb
@@ -166,7 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
166
169
  - !ruby/object:Gem::Version
167
170
  version: '0'
168
171
  requirements: []
169
- rubygems_version: 3.6.9
172
+ rubygems_version: 4.0.12
170
173
  specification_version: 4
171
174
  summary: Ruby toolkit for MailMate on macOS — search, read, modify, send, and smart-mailbox
172
175
  evaluation