mailmate 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81aeb9b0f5abd70325c74329db580be4134b27eee50b03f6caef5958b653f288
4
- data.tar.gz: c614a5dcc2e4fb2bd6fa797e09d1e959a1ecbf9c26ae7c6a21b0e26899c6a32e
3
+ metadata.gz: 7d4531494a2e95ee32395c937a293085b055c6868327f9914702da62a7b039c6
4
+ data.tar.gz: 3251b2608c1ede51d85425c205a274993d51b58b9cace6f04eb53981a04345bb
5
5
  SHA512:
6
- metadata.gz: 9c27e9400d40047291b3486e6895b47f8e8855985bb74bfe75de2a338c5c5d59356b92d330ad93186828e3aa579f9c6273602432a77f2dbc383b3e7afdfb02aa
7
- data.tar.gz: c3d898edeb7fe251c9f816dc333014e915bcb8b1e4d63e503a52192b22ca6353bf5657109688c36a71af04060cda6fc816a95c3b680b526382f8afc47e63851c
6
+ metadata.gz: 4a162097210b4578cc6118e70607258523cbf1b0a82e50a6c50b9b10a3a30bef05c5dd394ab50204920d7967bb9103073a457259f1e4ec570920daff09b1031e
7
+ data.tar.gz: 84fa48ec99f5e9df866899d4c805df8de7c1b427fd8ba868732baf79ad9c9b6c3c55cae92e00fec928ecc3c43fca7cd4572d8f2828f4a270494310dbdd7c97f7
data/README.md CHANGED
@@ -187,6 +187,8 @@ mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
187
187
  | `m <term>` | Common headers OR body. |
188
188
  | `d <date>` | Received: `Nh` (rolling clock hours, `24h` = last 24 hours), `Nd`/`Nw`/`Nm`/`Ny` (N calendar units ending today — `1d` = today, `2d` = yesterday + today), or absolute `Y`, `Y-M`, `Y-M-D`. Slash dates are month-first American (`8/9/2026` = Aug 9); `--european` flips to day-first. Comparisons on absolute dates: `d >2026-08` (after), `d <2026-08` (before), also `>=`/`<=`. |
189
189
  | `T <tag>` | Tags / IMAP keywords (`K` is a synonym). |
190
+ | `is:<state>` | Message state: `is:unread`, `is:read`, `is:flagged`, `is:replied`, `is:draft` (Gmail synonyms `starred`/`answered` work; `-is:unread` negates). |
191
+ | `has:attachment` | Root MIME type is `multipart/mixed` — the standard attachment layout. |
190
192
  | `!<value>` | Negate, e.g. `f !smith` = From does NOT contain smith; works on dates too (`d !3d` = more than 3 days ago). |
191
193
 
192
194
  Dates match on the **display-zone day** — the same day the `date`/`time` output columns show. An impossible date term or combination (`d 2026-02-31`, `d >2026 d <2025`) is a usage error, not a silent empty result. Familiar foreign `key:value` tokens (`from:bob`, `date:today`, `after:2026-08-01`, `older_than:2w`) are auto-translated to quicksearch with each rewrite announced on stderr; unrecognized keys (`is:unread`) are searched as literal text, and an empty result says so.
@@ -360,22 +362,28 @@ EOF
360
362
  mm-send -t friend@example.com -s "Photos" /path/to/photo1.jpg /path/to/photo2.jpg <<<"See attached."
361
363
  ```
362
364
 
363
- #### Replies and threading
365
+ #### Replies, forwards, and threading
364
366
 
365
- 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).
367
+ **A `Re:` subject does not thread.** Modern clients thread on headers, so a reply without `In-Reply-To` / `References` shows up in the recipient's client as a brand-new conversation and nothing in your own view reveals it. MailMate generates the outgoing `Message-ID` itself; that part is never your job.
366
368
 
367
- To make a reply land in-thread, pass both headers:
369
+ Point `mm-send` at the parent and it derives the rest:
368
370
 
369
371
  ```bash
370
- mm-send -f you@x -t them@y -s "Re: foo" \
371
- --header "In-Reply-To: <parent-message-id@domain>" \
372
- --header "References: <root-mid> <parent-mid>" \
373
- --send-now <<<"body"
372
+ # Reply derives In-Reply-To, References, recipient and "Re:" subject.
373
+ mm-send -f you@x --reply-to "<parent-message-id@domain>" --send-now <<<"body"
374
+
375
+ # Reply-all: adds the other recipients, minus your own identities.
376
+ mm-send -f you@x --reply-all-to 12345 --send-now <<<"body"
377
+
378
+ # Forward: "Fwd:" subject and the forwarded block; you supply the recipient.
379
+ mm-send -f you@x --forward 12345 -t someone@example.com <<<"FYI"
374
380
  ```
375
381
 
376
- 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.
382
+ The parent is an eml-id or an RFC Message-ID. **Fields you pass explicitly win; fields you omit follow normal reply rules**, and overriding a visible field never drops the threading headers. `--no-quote` suppresses the quoted original. `--print-prefill` prints the derived fields as JSON and sends nothing the hook for other tools that fill their own compose form.
383
+
384
+ Hand-assembly via `--header` still works and is the escape hatch when the parent isn't in MailMate's index. The `mailmate-mcp` `send` / `draft` tools take `in_reply_to` and `references` directly.
377
385
 
378
- The same passthrough applies to the `mailmate-mcp` `send` tool see the `from`, `in_reply_to`, and `references` fields.
386
+ > **Canonical reference:** [docs/Composing and threading.md](docs/Composing%20and%20threading.md) — the References chain, the merge rule, header safety, identity selection. Everything above summarizes it; when the two disagree, that file is right.
379
387
 
380
388
  ### `mm-draft` — compose without sending
381
389
 
@@ -385,10 +393,9 @@ The same passthrough applies to the `mailmate-mcp` `send` tool — see the `from
385
393
  # Opens a draft in MailMate; never sends.
386
394
  echo "Quick **markdown** body." | mm-draft -t friend@example.com -s "Hello"
387
395
 
388
- # Threading headers and attachments work exactly as in mm-send.
389
- mm-draft -f you@x -t them@y -s "Re: foo" \
390
- --header "In-Reply-To: <parent-message-id@domain>" \
391
- --header "References: <root-mid> <parent-mid>" <<<"body"
396
+ # Reply/forward derivation, threading headers and attachments all work
397
+ # exactly as in mm-send the only difference is that this cannot send.
398
+ mm-draft -f you@x --reply-to "<parent-message-id@domain>" <<<"body"
392
399
 
393
400
  # Passing --send-now is refused (exit 2):
394
401
  mm-draft -t friend@example.com -s "nope" --send-now <<<"body"
@@ -426,6 +433,12 @@ A few rough edges to be aware of:
426
433
 
427
434
  ## Status
428
435
 
436
+ 1.8.0 — Reply derivation, and message-state specs.
437
+
438
+ **Composing:** replies and forwards are now derived from the parent instead of hand-assembled. `mm-send` / `mm-draft` gain `--reply-to <id>`, `--reply-all-to <id>`, `--forward <id>` (eml-id or Message-ID), which compute `In-Reply-To`, the full `References` chain, recipients and the `Re:`/`Fwd:` subject, plus `--no-quote` and `--print-prefill` (derived fields as JSON, sends nothing). The MCP `send`/`draft` tools take the same `reply_to` / `reply_all_to` / `forward` arguments. Explicitly-passed fields win over derived ones, and overriding a visible field never drops the threading headers. A forward deliberately does *not* thread into the original conversation. The chain is built in exactly one place (`Mailmate::ReplyPrefill`), and `--header` values now route through one sanitizer (`Mailmate::HeaderValue`) — previously the MCP's own argv builder lacked the CR/LF defense that stops a parent `Message-ID` smuggling extra headers. All of it is documented once in [docs/Composing and threading.md](docs/Composing%20and%20threading.md), which the README, `--help` preambles and MCP instructions now point at rather than restate. Every `exe/` command answers `--version`, so consumers can check the installed version instead of probing for a flag (the CLIs pass unknown flags through to `emate`, which makes flag-probing unsafe). The gemspec finally carries `homepage` and `metadata`, so the rubygems page links back to the source.
439
+
440
+ **Search:** `is:unread`, `is:read`, `is:flagged`, `is:replied`, `is:draft`, and `has:attachment` are first-class quicksearch (the MailMate app has no state vocabulary in its toolbar search — its `A` modifier searches attachment *filenames* — so the familiar Gmail spellings were adopted, including the `starred`/`answered` synonyms and `-is:unread` negation). Flag states read the `#flags` index; attachment presence reads the indexed root `content-type` (`multipart/mixed`). An unknown state value (`is:snoozed`) is a usage error naming the known states, not a silent empty result.
441
+
429
442
  1.7.0 — Search-language release, driven by a study of how LLM agents actually misuse `mmsearch`. The quicksearch syntax reference is now single-sourced (`Mailmate::SearchSyntax`) into both `mmsearch --help` and the MCP `search` description, so the two can no longer drift. Foreign `key:value` dialects (Gmail/Outlook/Spotlight — `from:bob`, `date:today`, `after:2026-08-01`, `older_than:2w`) auto-translate to their exact quicksearch equivalent, loudly: each rewrite is announced on stderr, and untranslatable keys are flagged when a search returns nothing. The language itself grew: boolean `or` (AND binds tighter, no parens; a bare term after `or` inherits the modifier in force), date comparisons (`d >2026-08`, `d <2026-08`, `>=`/`<=`), rolling hour windows (`d 24h`), and American slash dates (`d 8/9/2026`; `--european` for day-first). Two semantic fixes: `d 1d` now means *today* (N calendar units ending today, matching the MailMate app; the old today−N made it span two days), and date matching converts to the display zone — the same conversion the `date`/`time` columns use — so the day a search matches is always the day shown (sender-local index days previously leaked "tomorrow's" mail into `d 1d`). Impossible date terms and combinations (`d 0d`, `d 2026-02-31`, `d >2026 d <2025`) are usage errors instead of silent empty results.
430
443
 
431
444
  1.6.0 — Distribution release. The repo is now a Claude Code plugin marketplace (`/plugin marketplace add brianmd/mailmate`), and a one-line `install.sh` provisions the MCP server into an isolated `~/.mailmate-mcp` — including a private relocatable Ruby when no Ruby ≥ 3.0 is present — without touching system Ruby, Homebrew, or shell profiles. Every MCP tool now carries a `title` plus `readOnlyHint`/`destructiveHint` annotations (Claude clients use these for permission behavior; Anthropic's directory review requires them), and the README gains a formal Privacy Policy section. No changes to CLI or library behavior.
@@ -0,0 +1,69 @@
1
+ # Composing and threading
2
+
3
+ **This is the canonical description of how mailmate sends mail.** `mm-send`, `mm-draft`, the MCP `send` / `draft` tools, and every downstream consumer behave as described here. Other surfaces (the README, `--help` preambles, MCP tool descriptions) carry deliberately-minimal summaries and point here — they should never restate a rule in their own words, because a second independent statement of the same fact is what drifts.
4
+
5
+ ## The two commands, and why there are exactly two
6
+
7
+ `mm-send` and `mm-draft` are thin wrappers around MailMate's bundled `emate mailto`, with `--markup markdown` enforced. The body is read from stdin; every other flag passes through to `emate`.
8
+
9
+ They differ on exactly one axis: **send-ability.** `mm-send` can send (with `--send-now`); `mm-draft` refuses `--send-now` with a nonzero exit and therefore *cannot*, no matter what flags it is handed. That refusal is the entire point — a "compose this but don't send it" instruction can't be silently defeated by a caller (human or model) adding a flag.
10
+
11
+ That axis, and only that axis, earns a command name. It has to be a name because a name is a guarantee you can reason about before running anything; a flag can be forgotten, mis-copied, or argued away. Every *other* variation — replying, replying-all, forwarding — is a flag, because none of them is safety-critical and because command names multiply where flags add. Cross-producting "threading mode" with "send-ability" would mean `mm-reply`, `mm-reply-all`, `mm-reply-draft`, `mm-reply-all-draft`, and then double again the day forwarding lands. The flag form covers the same matrix with two commands, permanently.
12
+
13
+ ## Threading
14
+
15
+ **MailMate generates the outgoing `Message-ID` itself.** Never construct or pass one.
16
+
17
+ `In-Reply-To` and `References` are **pure pass-through**: whatever you set ships verbatim, and *what you don't set is absent*. This is the rule that surprises people, so state it plainly: **a `Re:` subject does not thread.** Modern mail clients thread on headers. A reply with a perfect `Re: …` subject and no threading headers appears in the recipient's client as a brand-new conversation, and nothing about the sender's own view reveals this — the failure is invisible from where you're standing.
18
+
19
+ The chain is built one way:
20
+
21
+ > `References` = the parent's own `References` header (if any) + the parent's `Message-ID` appended.
22
+ > If the parent is itself a thread root with no `References`, use its `Message-ID` alone.
23
+ > `In-Reply-To` = the parent's `Message-ID`.
24
+
25
+ Message-IDs may be written with or without angle brackets on input; they are normalized to the bracketed RFC 5322 form on the wire.
26
+
27
+ ### Deriving it, rather than assembling it
28
+
29
+ Getting the chain wrong produces a message that looks correct everywhere you can see it and silently fails to thread. So prefer having it derived from the parent rather than assembling it by hand:
30
+
31
+ - **CLI:** `--reply-to <id>` / `--reply-all-to <id>` / `--forward <id>` on either command take the parent's eml-id or RFC Message-ID and derive the threading headers, recipients, and subject from it. `--no-quote` drops the quoted original.
32
+ - **MCP:** the `send` / `draft` tools take `reply_to` / `reply_all_to` / `forward` (plus `quote: false`), which hand the id to the same CLI. They also still accept `in_reply_to` / `references` directly — if you use those, set **both**.
33
+ - **Library:** `Mailmate::ReplyPrefill.build(id, mode:)` returns the derived fields without sending anything, and `mm-send --reply-to <id> --print-prefill` is the same thing as JSON for non-Ruby callers. That's the hook for a tool filling its own compose form.
34
+
35
+ Because `forward` derives no recipient, it is the one mode that still requires `to`.
36
+
37
+ Hand-assembly via `--header` remains available and is the escape hatch when the parent isn't in MailMate's index:
38
+
39
+ ```sh
40
+ mm-send -f you@x -t them@y -s "Re: foo" \
41
+ --header "In-Reply-To: <parent-message-id@domain>" \
42
+ --header "References: <root-mid> <parent-mid>" \
43
+ --send-now <<<"body"
44
+ ```
45
+
46
+ ### The merge rule
47
+
48
+ When a parent is supplied, **explicitly-passed fields always win; omitted fields follow normal reply rules** (parent's sender becomes the recipient, subject becomes `Re: <original>`, the quoted original seeds the body, reply-all additionally carries the other recipients minus your own identities).
49
+
50
+ This rule is uniform across every surface that composes from a parent — the CLI flags above and markdownr's compose popup — so a caller who learns it once can predict all of them. Overriding a visible field never drops the threading headers.
51
+
52
+ ## Header safety
53
+
54
+ `--header` values ship verbatim into the message. Any value derived from *another message* is therefore untrusted input: a `Message-ID` or `References` carrying `\r\n` could otherwise smuggle additional RFC 5322 headers into the outgoing message. All header values are collapsed to a single line before injection. If you add a new path that pushes a `--header`, route it through the same sanitization rather than formatting the flag yourself.
55
+
56
+ ## Identity
57
+
58
+ `-f <address>` picks which configured MailMate identity sends. Without it, MailMate uses its default identity — which, when the recipient is one of your own addresses, may not be the one you expect. Set it explicitly. `mmdiscover` lists the available addresses and writes them to `~/.config/mailmate/config.yml`, where `Mailmate::Identity` reads them.
59
+
60
+ ## Prerequisites for markdown bodies
61
+
62
+ Bodies are markdown; MailMate renders them to HTML on the way out. For that to reach recipients, MailMate → Preferences → Composer must have **Preview: Display = Always** and **Replying/Forwarding HTML = Always embed**. These are global, one-time settings; without them recipients get plain text.
63
+
64
+ ## Who points here
65
+
66
+ - `README.md` § `mm-send` / `mm-draft` — short usage orientation.
67
+ - `mm-send --help` / `mm-draft --help` preambles — the minimal operational recipe.
68
+ - The MCP server's `initialize` instructions and `send` / `draft` tool descriptions.
69
+ - markdownr's `.claude/instructions/nested/email.md` (compose popup + routes) and the private `email` skill (sender/signature doctrine). Those own their own layers — markdownr's UI surface and personal doctrine respectively — and defer to this file for anything below them.
data/exe/mailmate-mcp CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/mcp"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mailmate-mcp")
11
+
8
12
  exit Mailmate::MCP.run
data/exe/mm-draft CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/draft"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mm-draft")
11
+
8
12
  exit Mailmate::CLI::Draft.run(ARGV)
data/exe/mm-mailboxes CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/mailboxes"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mm-mailboxes")
11
+
8
12
  exit Mailmate::CLI::Mailboxes.run(ARGV)
data/exe/mm-modify CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/modify"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mm-modify")
11
+
8
12
  exit Mailmate::CLI::Modify.run(ARGV)
data/exe/mm-send CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/send"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mm-send")
11
+
8
12
  exit Mailmate::CLI::Send.run(ARGV)
data/exe/mm-verify CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/verify"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mm-verify")
11
+
8
12
  exit Mailmate::CLI::Verify.run(ARGV)
data/exe/mmdiscover CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/discover"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mmdiscover")
11
+
8
12
  exit Mailmate::CLI::Discover.run(ARGV)
data/exe/mmmessage CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/message"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mmmessage")
11
+
8
12
  exit Mailmate::CLI::Message.run(ARGV)
data/exe/mmopen CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/open"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mmopen")
11
+
8
12
  exit Mailmate::CLI::Open.run(ARGV)
data/exe/mmsearch CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/search"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mmsearch")
11
+
8
12
  exit Mailmate::CLI::Search.run(ARGV)
data/exe/mmtags CHANGED
@@ -5,4 +5,8 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "mailmate"
6
6
  require "mailmate/cli/tags"
7
7
 
8
+ require "mailmate/cli/version_flag"
9
+
10
+ Mailmate::CLI::VersionFlag.handle!(ARGV, "mmtags")
11
+
8
12
  exit Mailmate::CLI::Tags.run(ARGV)
@@ -225,8 +225,9 @@ module Mailmate
225
225
  "Slash dates are day-first: d 9/8/2026 = Aug 9 (default: month-first American)") { opts[:european] = true }
226
226
  o.separator ""
227
227
  o.separator "SEARCH-STRING SYNTAX"
228
- o.separator " Mirrors MailMate's toolbar quicksearch. There is no native key:value"
229
- o.separator " form but familiar foreign tokens are auto-translated (see below)."
228
+ o.separator " Mirrors MailMate's toolbar quicksearch, plus native state specs"
229
+ o.separator " (is:unread, has:attachment). Other familiar key:value tokens are"
230
+ o.separator " auto-translated (see FOREIGN SYNTAX below)."
230
231
  o.separator Mailmate::SearchSyntax.reference(indent: " ")
231
232
  o.separator " (b also takes --all to include un-indexed messages.)"
232
233
  o.separator ""
@@ -396,13 +397,23 @@ module Mailmate
396
397
  else
397
398
  negate = tok.start_with?("!")
398
399
  operand = negate ? tok[1..] : tok
399
- # A bare term opening an or-group inherits the modifier in force
400
- # (`d 2024 or 2025`). Elsewhere it is MailMate's "Common"
401
- # specifier common headers OR body matching the UI
402
- # quicksearch behavior. Pass --headers-only to skip the body scan
403
- # when speed matters.
404
- target = (i.zero? && !quoted && in_force) ? in_force : :message_or_body
405
- specs << [target, operand.downcase, negate]
400
+ if !quoted && operand =~ /\A-?(?:is|has):\S+\z/i
401
+ # First-class message-state specs (is:unread, has:attachment).
402
+ # The app has no state vocabulary to mirror (its A modifier
403
+ # searches attachment FILENAMES), so the familiar Gmail
404
+ # spellings are native syntax here. `-` negates too — the form
405
+ # Gmail callers actually write.
406
+ negate ||= operand.start_with?("-")
407
+ specs << [:state, operand.delete_prefix("-").downcase, negate]
408
+ else
409
+ # A bare term opening an or-group inherits the modifier in
410
+ # force (`d 2024 or 2025`). Elsewhere it is MailMate's
411
+ # "Common" specifier — common headers OR body — matching the
412
+ # UI quicksearch behavior. Pass --headers-only to skip the
413
+ # body scan when speed matters.
414
+ target = (i.zero? && !quoted && in_force) ? in_force : :message_or_body
415
+ specs << [target, operand.downcase, negate]
416
+ end
406
417
  i += 1
407
418
  end
408
419
  end
@@ -415,10 +426,25 @@ module Mailmate
415
426
  SPEC_COST = {
416
427
  date: 0,
417
428
  from: 1, recipients: 1, cc: 1, subject: 1, address_any: 1, any: 1,
418
- tag: 1, keyword: 1,
429
+ tag: 1, keyword: 1, state: 1,
419
430
  body: 2, message_or_body: 2,
420
431
  }.freeze
421
432
 
433
+ # Canonical state names for is:/has: specs, including the spellings
434
+ # Gmail callers actually use. Values map to a #flags IMAP flag except
435
+ # :unread (absence of \Seen) and :attachment (root MIME layout).
436
+ STATE_CANON = {
437
+ "unread" => :unread, "read" => :read,
438
+ "flagged" => :flagged, "starred" => :flagged,
439
+ "replied" => :replied, "answered" => :replied,
440
+ "draft" => :draft,
441
+ "attachment" => :attachment, "attachments" => :attachment,
442
+ }.freeze
443
+
444
+ STATE_FLAGS = {
445
+ read: "\\Seen", flagged: "\\Flagged", replied: "\\Answered", draft: "\\Draft",
446
+ }.freeze
447
+
422
448
  # Evaluate cheap, selective specs before expensive ones, within each
423
449
  # or-group. Specs in a group combine with AND (order-independent), and
424
450
  # matches? short-circuits on the first miss — so `b invoice d 7d`
@@ -559,6 +585,13 @@ module Mailmate
559
585
  def date_spec_error(specs)
560
586
  day_terms, hour_terms = [], []
561
587
  specs.each do |field, term, negate|
588
+ # State specs validate here too (same pre-pass, same
589
+ # silent-nothing failure being prevented): an unknown state value
590
+ # would otherwise quietly match no message ever.
591
+ if field == :state && !STATE_CANON.key?(term.split(":", 2).last)
592
+ return "state term cannot match anything: #{term} " \
593
+ "(known: is:unread is:read is:flagged is:replied is:draft has:attachment)"
594
+ end
562
595
  next unless field == :date
563
596
  range = hour_range_for(term) || date_range_for(term)
564
597
  if range.nil? || range[0] > range[1]
@@ -751,6 +784,35 @@ module Mailmate
751
784
  flags.reject { |f| f.start_with?("\\", "$") }.join(" ").downcase
752
785
  end
753
786
 
787
+ # term is the full lowercased token ("is:unread", "has:attachment").
788
+ # Flag states read the #flags index; attachment presence reads the
789
+ # indexed root content-type — multipart/mixed is the standard
790
+ # attachment layout (a Mail fallback checks real attachments when the
791
+ # message is already loaded). Unknown state values never reach here:
792
+ # date_spec_error rejects them up front.
793
+ def state_matches?(eml_id, mail, term)
794
+ state = STATE_CANON[term.split(":", 2).last]
795
+ return false unless state
796
+
797
+ case state
798
+ when :unread
799
+ eml_id ? !message_flags(eml_id).include?("\\Seen") : false
800
+ when :attachment
801
+ ct = eml_id ? (reader_for("content-type")&.value_for(eml_id.to_i) rescue nil).to_s : ""
802
+ return ct.downcase.include?("multipart/mixed") unless ct.empty?
803
+ mail ? mail.attachments.any? : false
804
+ else
805
+ message_flags(eml_id).include?(STATE_FLAGS[state])
806
+ end
807
+ end
808
+
809
+ def message_flags(eml_id)
810
+ return [] unless eml_id
811
+ reader_for("#flags")&.flags_for(eml_id.to_i) || []
812
+ rescue StandardError
813
+ []
814
+ end
815
+
754
816
  def text_body(mail)
755
817
  (mail.text_part&.decoded || mail.body.decoded).to_s.force_encoding("UTF-8").scrub.downcase
756
818
  rescue StandardError
@@ -843,6 +905,8 @@ module Mailmate
843
905
  common || (!headers_only && body_matches?(eml_id, mail, path, term, term_b, index_only: index_only, exclude_quoted: exclude_quoted))
844
906
  when :date
845
907
  date_matches?(mail, eml_id, term)
908
+ when :state
909
+ state_matches?(eml_id, mail, term)
846
910
  when :any
847
911
  %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
848
912
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "../reply_prefill"
4
5
 
5
6
  module Mailmate
6
7
  module CLI
@@ -19,26 +20,25 @@ module Mailmate
19
20
  Body is read from stdin. All other flags pass through to emate (its help follows).
20
21
 
21
22
  Replies and threading
22
- MailMate auto-generates the outgoing Message-IDnever your job.
23
- In-Reply-To and References are PURE PASS-THROUGH: whatever you set via
24
- `--header` ships verbatim; what you don't set is absent (and recipients'
25
- clients will see the message as a fresh thread, no matter how `Re:` the
26
- subject looks). To make a reply land in-thread, pass both:
23
+ A `Re:` subject alone does NOT thread modern clients thread on headers.
24
+ MailMate generates the outgoing Message-ID; never your job.
27
25
 
28
- mm-send -f you@x -t them@y -s "Re: foo" \\
29
- --header "In-Reply-To: <parent-message-id@domain>" \\
30
- --header "References: <root-mid> <parent-mid>" \\
31
- --send-now <<<"body"
26
+ mm-send -f you@x --reply-to "<parent-mid@domain>" --send-now <<<"body"
32
27
 
33
- References is constructed as the source message's References header (if
34
- any) with the source's Message-ID appended. If the source is a thread
35
- root with no References, just use its Message-ID alone.
28
+ derives In-Reply-To, References, recipients and subject from the parent.
29
+ --reply-all-to replies to all; --forward forwards. Fields you pass
30
+ explicitly win; fields you omit follow normal reply rules. --header
31
+ stays available as the escape hatch when the parent isn't indexed.
36
32
 
37
33
  Identity selection
38
34
  `-f <address>` picks which of MailMate's configured identities sends.
39
35
  Without `-f`, MailMate uses its default identity. See `mmdiscover` to
40
36
  list available addresses.
41
37
 
38
+ Full rules — threading chain, merge rule, header safety:
39
+ docs/Composing and threading.md (shipped with the gem), or
40
+ https://github.com/brianmd/mailmate/blob/main/docs/
41
+
42
42
  ──────────────────────────── emate help follows ────────────────────────────
43
43
 
44
44
  PREAMBLE
@@ -58,16 +58,29 @@ module Mailmate
58
58
  # through the `$stdout`/`$stderr` globals so the MCP's capture sees it
59
59
  # instead of the protocol stream getting corrupted.
60
60
  def run(argv)
61
+ help = argv.include?("--help") || argv.include?("-h")
62
+
63
+ # Our own flags are peeled off BEFORE the platform/emate checks so
64
+ # `--print-prefill` works as a pure query — markdownr calls it to fill
65
+ # a form and has no business requiring a launchable MailMate.
66
+ begin
67
+ argv, derived = apply_parent!(argv, help: help)
68
+ rescue Mailmate::ReplyPrefill::NotFound, ArgumentError => e
69
+ warn "mm-send: #{e.message}"
70
+ return 1
71
+ end
72
+ return print_prefill(derived) if derived && derived[:print_only]
73
+
61
74
  Mailmate::PlatformError.check_darwin!(component: "mm-send")
62
75
  unless File.executable?(EMATE_PATH)
63
76
  warn "mm-send: emate not found at #{EMATE_PATH}. Is MailMate installed?"
64
77
  return 1
65
78
  end
66
- help = argv.include?("--help") || argv.include?("-h")
67
79
  warn PREAMBLE if help
68
80
  # --help never reads a body; consuming stdin here would hang an
69
81
  # interactive `mm-send --help` waiting for Ctrl-D.
70
82
  body = help ? "" : $stdin.read.to_s
83
+ body = append_quote(body, derived) if derived
71
84
  out, err, status = Open3.capture3(EMATE_PATH, "mailto", "--markup", "markdown", *argv, stdin_data: body)
72
85
  $stdout.write(out)
73
86
  $stderr.write(err)
@@ -75,6 +88,110 @@ module Mailmate
75
88
  # `exit run(ARGV)`, which needs an Integer.
76
89
  status.exitstatus || 1
77
90
  end
91
+
92
+ # Flags this wrapper consumes itself. Everything else in argv is emate's
93
+ # and passes through untouched — that pass-through is the design, so the
94
+ # scan below is deliberately literal rather than an OptionParser (which
95
+ # would have to be taught every emate flag in order to ignore them).
96
+ PARENT_FLAGS = { "--reply-to" => "reply", "--reply-all-to" => "reply-all", "--forward" => "forward" }.freeze
97
+
98
+ # Returns [argv_for_emate, derived_or_nil]. When a parent flag is
99
+ # present, derives the reply fields and splices them in as emate flags —
100
+ # but only for fields the caller did NOT pass. Explicit always wins; see
101
+ # the merge rule in docs/Composing and threading.md.
102
+ def apply_parent!(argv, help: false)
103
+ rest, parent, mode, print_only, quote = extract_flags(argv)
104
+ return [rest, nil] if parent.nil?
105
+
106
+ # --print-prefill is a query, so it answers even under --help; the
107
+ # send path would otherwise be unreachable for a caller inspecting it.
108
+ prefill = Mailmate::ReplyPrefill.build(parent, mode: mode)
109
+ derived = { prefill: prefill, print_only: print_only, quote: quote }
110
+ return [rest, derived] if print_only || help
111
+
112
+ [splice(rest, prefill), derived]
113
+ end
114
+
115
+ def extract_flags(argv)
116
+ rest = []
117
+ parent = mode = nil
118
+ print_only = false
119
+ quote = true
120
+ i = 0
121
+ while i < argv.length
122
+ arg = argv[i]
123
+ if PARENT_FLAGS.key?(arg)
124
+ raise ArgumentError, "#{arg} needs a message id" if argv[i + 1].nil?
125
+ raise ArgumentError, "pass only one of #{PARENT_FLAGS.keys.join(', ')}" if parent
126
+
127
+ mode = PARENT_FLAGS[arg]
128
+ parent = argv[i + 1]
129
+ i += 2
130
+ elsif arg == "--print-prefill"
131
+ print_only = true
132
+ i += 1
133
+ elsif arg == "--no-quote"
134
+ quote = false
135
+ i += 1
136
+ else
137
+ rest << arg
138
+ i += 1
139
+ end
140
+ end
141
+ raise ArgumentError, "--print-prefill needs one of #{PARENT_FLAGS.keys.join(', ')}" if print_only && parent.nil?
142
+
143
+ [rest, parent, mode, print_only, quote]
144
+ end
145
+
146
+ # Add derived values ONLY where the caller was silent. `passed?` looks
147
+ # for the flag itself, so `-t a@x --reply-to <id>` keeps a@x and still
148
+ # threads — overriding a visible field must never drop the headers.
149
+ def splice(argv, prefill)
150
+ out = argv.dup
151
+ out.push("-f", prefill.from) if prefill.from && !passed?(argv, %w[-f --from])
152
+ unless passed?(argv, %w[-t --to])
153
+ prefill.to.each { |a| out.push("-t", a) }
154
+ end
155
+ unless passed?(argv, %w[-c --cc])
156
+ prefill.cc.each { |a| out.push("-c", a) }
157
+ end
158
+ out.push("-s", prefill.subject) if prefill.subject && !passed?(argv, %w[-s --subject])
159
+ # Threading headers are NOT subject to the merge rule's "explicit
160
+ # wins" in the usual sense — a caller who passes their own
161
+ # --header "In-Reply-To: …" alongside --reply-to gets both, which is
162
+ # a duplicate header. Skip ours when they've hand-set either one.
163
+ out.push("--header", "In-Reply-To: #{prefill.in_reply_to}") if prefill.in_reply_to && !header_passed?(argv, "in-reply-to")
164
+ out.push("--header", "References: #{prefill.references}") if prefill.references && !header_passed?(argv, "references")
165
+ out
166
+ end
167
+
168
+ def passed?(argv, flags)
169
+ argv.any? { |a| flags.include?(a) || flags.any? { |f| f.start_with?("--") && a.start_with?("#{f}=") } }
170
+ end
171
+
172
+ def header_passed?(argv, name)
173
+ argv.each_with_index.any? do |a, i|
174
+ (a == "--header" && argv[i + 1].to_s.downcase.start_with?("#{name}:")) ||
175
+ (a.start_with?("--header=") && a.split("=", 2).last.to_s.downcase.start_with?("#{name}:"))
176
+ end
177
+ end
178
+
179
+ # Reply rules seed the body with the quoted original BELOW whatever the
180
+ # caller wrote, matching what a mail client's Reply button produces.
181
+ def append_quote(body, derived)
182
+ return body unless derived[:quote]
183
+
184
+ quote = derived[:prefill].quoted_body.to_s
185
+ return body if quote.strip.empty?
186
+
187
+ "#{body.to_s.sub(/\n+\z/, '')}\n\n#{quote}"
188
+ end
189
+
190
+ def print_prefill(derived)
191
+ require "json"
192
+ $stdout.puts JSON.pretty_generate(derived[:prefill].to_h)
193
+ 0
194
+ end
78
195
  end
79
196
  end
80
197
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../version"
4
+
5
+ module Mailmate
6
+ module CLI
7
+ # `--version` / `-V`, handled uniformly by every exe shim.
8
+ #
9
+ # This exists so a CONSUMER can tell how old an installed mailmate is
10
+ # without parsing help text or probing for a flag's side effects. That
11
+ # matters because the CLIs are deliberately pass-through: an older
12
+ # `mm-send` handed a flag it doesn't know forwards it to `emate` rather
13
+ # than rejecting it, so "did this flag work?" is not a safe capability
14
+ # probe — it can open a composer window instead of erroring. A version
15
+ # string is the honest check.
16
+ #
17
+ # Every shim calls this before dispatching, so the answer is available
18
+ # even from commands whose real work needs macOS or a running MailMate.
19
+ # test_exe_shims.rb asserts the coverage is total; a new shim that skips
20
+ # the call fails that test rather than silently becoming the one command
21
+ # that can't be version-probed.
22
+ module VersionFlag
23
+ extend self
24
+
25
+ FLAGS = %w[--version -V].freeze
26
+
27
+ # Prints "<name> (mailmate X.Y.Z)" and exits 0 when the flag is present.
28
+ # Returns nil otherwise, so shims can call it unconditionally.
29
+ def handle!(argv, name)
30
+ return unless argv.any? { |a| FLAGS.include?(a) }
31
+
32
+ $stdout.puts "#{name} (mailmate #{Mailmate::VERSION})"
33
+ exit 0
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailmate
4
+ # @api public
5
+ #
6
+ # Sanitize a value destined for an `emate --header "Name: value"` flag.
7
+ #
8
+ # Header values ship VERBATIM into the outgoing message, and the values we
9
+ # inject most often (`In-Reply-To`, `References`) are derived from ANOTHER
10
+ # message — i.e. from input nobody in this process authored. A value
11
+ # carrying CR/LF would end the header and begin a new one, smuggling
12
+ # arbitrary RFC 5322 headers (a `Bcc:`, say) into mail the caller believes
13
+ # they fully specified.
14
+ #
15
+ # Every path that builds a `--header` flag must run its value through here.
16
+ # There is deliberately ONE implementation: this logic previously existed in
17
+ # two places (the MCP server's argv builder and markdownr's), and only one of
18
+ # them had the defense — which is exactly the failure mode a shared helper
19
+ # exists to prevent.
20
+ module HeaderValue
21
+ extend self
22
+
23
+ # Collapse any CR/LF (and the whitespace that follows it, so an unfolded
24
+ # continuation doesn't leave a ragged double space) to a single space,
25
+ # then trim. Returns a String; nil/empty in → "" out.
26
+ def sanitize(value)
27
+ value.to_s.gsub(/[\r\n]+\s*/, " ").strip
28
+ end
29
+
30
+ # Wrap a Message-ID in angle brackets unless it already has them. Both
31
+ # forms are valid input; the on-wire form is bracketed per RFC 5322.
32
+ # Sanitizes first, so a smuggled newline can't survive by hiding inside
33
+ # what looks like an already-bracketed id.
34
+ def bracket_message_id(id)
35
+ s = sanitize(id)
36
+ return s if s.empty?
37
+ return s if s.start_with?("<") && s.end_with?(">")
38
+
39
+ "<#{s}>"
40
+ end
41
+ end
42
+ end
data/lib/mailmate/mcp.rb CHANGED
@@ -51,9 +51,18 @@ module Mailmate
51
51
  - Prefer `draft` over `send` whenever the user said "don't send" / "just
52
52
  draft it" — `draft` physically cannot send, so it's the safe choice.
53
53
  `send` also opens a draft and waits unless you pass `send_now: true`.
54
- - Threading: set BOTH `in_reply_to` and `references`. A "Re:" subject
55
- alone does not thread in modern clients. MailMate generates the
56
- outgoing Message-ID itself.
54
+ - Replying: pass `reply_to` (the parent's eml-id or Message-ID) and the
55
+ threading headers, recipient and "Re:" subject are derived for you.
56
+ `reply_all_to` replies to all; `forward` forwards (supply `to`).
57
+ Fields you also pass explicitly win; ones you omit follow normal
58
+ reply rules. Prefer this over hand-setting in_reply_to/references —
59
+ a mis-built References chain sends fine and simply doesn't thread,
60
+ and nothing in your own view reveals it. A "Re:" subject alone never
61
+ threads. MailMate generates the outgoing Message-ID itself.
62
+ Full rules — the References chain, the merge rule, header safety —
63
+ are in the gem's docs/Composing and threading.md
64
+ (github.com/brianmd/mailmate), which is canonical; this summary
65
+ exists only so you need not follow a link mid-call.
57
66
 
58
67
  Modifying (modify)
59
68
  - Drives MailMate's UI via AppleScript: it briefly takes focus, calls are
@@ -80,13 +89,13 @@ module Mailmate
80
89
  Search MailMate's .eml files using MailMate's quicksearch syntax.
81
90
  Returns column-aligned CSV. Same engine as the `mmsearch` CLI.
82
91
 
83
- There is NO native key:value form write quicksearch directly using
84
- the modifiers below. Familiar foreign tokens (`from:bob`,
85
- `date:today`, `after:2026-08-01`, `older_than:2w`) are auto-translated
86
- to quicksearch, and the rewrite is announced in the result — that
87
- announcement means your query was translated, not that it failed.
88
- Unrecognized keys (`is:unread`, `has:attachment`) are searched as
89
- literal text and match nothing, silently.
92
+ The only native key:value specs are the state forms below
93
+ (is:unread, has:attachment). Other familiar foreign tokens
94
+ (`from:bob`, `date:today`, `after:2026-08-01`, `older_than:2w`) are
95
+ auto-translated to quicksearch, and the rewrite is announced in the
96
+ result — that announcement means your query was translated, not
97
+ that it failed. Unrecognized keys (`in:inbox`, `filename:pdf`) are
98
+ searched as literal text and match nothing, silently.
90
99
 
91
100
  #{Mailmate::SearchSyntax.reference(indent: " ")}
92
101
 
@@ -237,11 +246,15 @@ module Mailmate
237
246
  subject: { type: "string", description: "Subject line." },
238
247
  body: { type: "string", description: "Markdown body." },
239
248
  attachments: { type: "array", items: { type: "string" }, description: "Absolute paths to files to attach." },
249
+ reply_to: { type: "string", description: "Parent message to reply to — eml-id or RFC Message-ID. PREFER THIS over setting in_reply_to/references by hand: it derives In-Reply-To, the full References chain, the recipient and a \"Re:\" subject from the parent. Fields you also pass explicitly win; ones you omit follow normal reply rules." },
250
+ reply_all_to: { type: "string", description: "Same as reply_to but replies to all — adds the other recipients, minus the user own identities." },
251
+ forward: { type: "string", description: "Parent message to forward — eml-id or RFC Message-ID. Derives a \"Fwd:\" subject and the forwarded block; you supply `to`. A forward deliberately does NOT thread into the original conversation." },
252
+ quote: { type: "boolean", description: "Include the quoted original when replying/forwarding (default true). Set false to send only your own text." },
240
253
  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." },
241
254
  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." },
242
255
  send_now: { type: "boolean", description: "Send immediately (skip the Drafts pause)." },
243
256
  },
244
- required: %w[to subject body],
257
+ required: %w[body],
245
258
  additionalProperties: false,
246
259
  },
247
260
  },
@@ -260,10 +273,14 @@ module Mailmate
260
273
  subject: { type: "string", description: "Subject line." },
261
274
  body: { type: "string", description: "Markdown body." },
262
275
  attachments: { type: "array", items: { type: "string" }, description: "Absolute paths to files to attach." },
276
+ reply_to: { type: "string", description: "Parent message to reply to — eml-id or RFC Message-ID. PREFER THIS over setting in_reply_to/references by hand: it derives In-Reply-To, the full References chain, the recipient and a \"Re:\" subject from the parent. Fields you also pass explicitly win; ones you omit follow normal reply rules." },
277
+ reply_all_to: { type: "string", description: "Same as reply_to but replies to all — adds the other recipients, minus the user own identities." },
278
+ forward: { type: "string", description: "Parent message to forward — eml-id or RFC Message-ID. Derives a \"Fwd:\" subject and the forwarded block; you supply `to`. A forward deliberately does NOT thread into the original conversation." },
279
+ quote: { type: "boolean", description: "Include the quoted original when replying/forwarding (default true). Set false to send only your own text." },
263
280
  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." },
264
281
  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." },
265
282
  },
266
- required: %w[to subject body],
283
+ required: %w[body],
267
284
  additionalProperties: false,
268
285
  },
269
286
  },
@@ -454,7 +471,21 @@ module Mailmate
454
471
  with_stdin(payload) { run_cli(Mailmate::CLI::Verify, argv) }
455
472
  end
456
473
 
474
+ # `to` and `subject` used to be schema-required, which stopped working the
475
+ # moment a parent could supply them. JSON Schema can't say "required
476
+ # unless another field is present", so the check moved here — dropping it
477
+ # entirely would let a `to`-less call through to open an empty composer.
478
+ def recipient_check(args)
479
+ return nil if args["to"] || args["reply_to"] || args["reply_all_to"]
480
+ return nil if args["forward"] && args["to"]
481
+
482
+ text_error("no recipient: pass `to`, or `reply_to`/`reply_all_to` to derive it from the parent. " \
483
+ "(`forward` derives the subject and body but not the recipient — pass `to` with it.)")
484
+ end
485
+
457
486
  def call_send(args)
487
+ (err = recipient_check(args)) and return err
488
+
458
489
  argv = compose_argv(args)
459
490
  argv << "--send-now" if args["send_now"]
460
491
  with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Send, argv) }
@@ -463,6 +494,8 @@ module Mailmate
463
494
  # `draft` mirrors `send` but never sends — it has no send_now option and
464
495
  # routes through CLI::Draft, which refuses `--send-now` outright.
465
496
  def call_draft(args)
497
+ (err = recipient_check(args)) and return err
498
+
466
499
  argv = compose_argv(args)
467
500
  with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Draft, argv) }
468
501
  end
@@ -476,21 +509,28 @@ module Mailmate
476
509
  argv.push("-c", args["cc"].to_s) if args["cc"]
477
510
  argv.push("-b", args["bcc"].to_s) if args["bcc"]
478
511
  argv.push("-s", args["subject"].to_s) if args["subject"]
479
- argv.push("--header", "In-Reply-To: #{bracket_mid(args["in_reply_to"])}") if args["in_reply_to"]
480
- argv.push("--header", "References: #{args["references"]}") if args["references"]
512
+ # Both values come from ANOTHER message, so both go through the shared
513
+ # sanitizer see Mailmate::HeaderValue for why this is not open-coded.
514
+ argv.push("--header", "In-Reply-To: #{Mailmate::HeaderValue.bracket_message_id(args["in_reply_to"])}") if args["in_reply_to"]
515
+ argv.push("--header", "References: #{Mailmate::HeaderValue.sanitize(args["references"])}") if args["references"]
516
+ # Parent-derived compose: hand the id to the CLI rather than deriving
517
+ # here. `reply_to` is what a caller should reach for over hand-setting
518
+ # in_reply_to/references — it builds the References chain from the
519
+ # parent, the step that is easy to get subtly wrong and impossible to
520
+ # notice afterwards (a mis-built chain sends fine and simply doesn't
521
+ # thread).
522
+ if (parent = args["reply_to"] || args["reply_all_to"] || args["forward"])
523
+ flag = if args["forward"] then "--forward"
524
+ elsif args["reply_all_to"] then "--reply-all-to"
525
+ else "--reply-to"
526
+ end
527
+ argv.push(flag, parent.to_s)
528
+ argv << "--no-quote" if args["quote"] == false
529
+ end
481
530
  Array(args["attachments"]).each { |p| argv << p.to_s }
482
531
  argv
483
532
  end
484
533
 
485
- # Wrap a bare Message-ID in `<…>` if it doesn't already have them. Both
486
- # forms are valid input to the MCP for ergonomics; the header value
487
- # going on the wire needs the brackets per RFC 5322.
488
- def bracket_mid(id)
489
- s = id.to_s.strip
490
- return s if s.start_with?("<") && s.end_with?(">")
491
- "<#{s}>"
492
- end
493
-
494
534
  def call_open(args)
495
535
  argv = [args["id"].to_s]
496
536
  argv << "--print" if args["print_only"]
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "header_value"
4
+
5
+ module Mailmate
6
+ # @api public
7
+ #
8
+ # Derive the fields of a reply / reply-all / forward from a parent message:
9
+ # recipients, subject, threading headers, and the quoted original.
10
+ #
11
+ # This is the ONE place the References chain is constructed. It is exposed as
12
+ # a library call (not just a CLI behavior) because consumers need the pieces
13
+ # WITHOUT sending — markdownr's compose popup fills a form from them, and
14
+ # `mm-send --reply-to` turns them into emate flags. Two implementations of
15
+ # "parent's References + parent's Message-ID" is how one of them silently
16
+ # stops threading; there is only this one.
17
+ #
18
+ # Rules it encodes (canonical prose: docs/Composing and threading.md):
19
+ # * In-Reply-To = the parent's Message-ID.
20
+ # * References = the parent's References (if any) + the parent's
21
+ # Message-ID appended; the bare Message-ID when the parent is a root.
22
+ # * A forward does NOT thread. It is a new conversation sent to someone who
23
+ # was not party to the original, so injecting the original's chain would
24
+ # graft a stranger into a thread they can't see. Forward derives the
25
+ # subject and the quoted original only.
26
+ module ReplyPrefill
27
+ extend self
28
+
29
+ MODES = %w[reply reply-all forward].freeze
30
+
31
+ class NotFound < StandardError; end
32
+
33
+ Prefill = Struct.new(
34
+ :mode, :from, :to, :cc, :subject, :in_reply_to, :references, :quoted_body,
35
+ :parent_message_id, :parent_eml_id,
36
+ keyword_init: true
37
+ ) do
38
+ def to_h
39
+ super.transform_keys(&:to_s)
40
+ end
41
+ end
42
+
43
+ # `input` is an eml-id or an RFC Message-ID (bracketed or not) — anything
44
+ # EmlLookup.resolve_id accepts. `identities` defaults to the configured
45
+ # list; pass an explicit array to override (markdownr passes what
46
+ # mmdiscover reported, which may be broader than config.yml).
47
+ def build(input, mode: "reply", identities: nil)
48
+ mode = mode.to_s
49
+ raise ArgumentError, "mode must be one of: #{MODES.join(', ')}" unless MODES.include?(mode)
50
+
51
+ mail, eml_id = load_parent(input)
52
+ idents = normalize_identities(identities)
53
+
54
+ message_id = HeaderValue.bracket_message_id(mail.message_id)
55
+ threading = mode == "forward" ? { in_reply_to: nil, references: nil } : {
56
+ in_reply_to: presence(message_id),
57
+ references: build_references(mail, message_id)
58
+ }
59
+
60
+ Prefill.new(
61
+ mode: mode,
62
+ from: pick_from_identity(mail, idents),
63
+ to: derive_to(mail, mode),
64
+ cc: derive_cc(mail, mode, idents),
65
+ subject: derive_subject(mail, mode),
66
+ quoted_body: derive_quoted_body(mail, mode),
67
+ parent_message_id: presence(message_id),
68
+ parent_eml_id: eml_id,
69
+ **threading
70
+ )
71
+ end
72
+
73
+ private
74
+
75
+ def load_parent(input)
76
+ eml_id = Mailmate::EmlLookup.resolve_id(input)
77
+ raise NotFound, "couldn't resolve #{input.inspect} as an eml-id or Message-ID" if eml_id.nil? || eml_id.zero?
78
+
79
+ path = Mailmate::EmlLookup.path_for(eml_id)
80
+ raise NotFound, "no .eml on disk for eml-id #{eml_id}" unless path
81
+
82
+ require "mail"
83
+ [Mail.read(path), eml_id]
84
+ end
85
+
86
+ # References = parent's own chain + parent's Message-ID. A parent that is
87
+ # itself a thread root has no References, so the chain is just its id.
88
+ def build_references(mail, message_id)
89
+ old = HeaderValue.sanitize(mail["references"]&.value)
90
+ presence(old.empty? ? message_id : "#{old} #{message_id}".strip)
91
+ end
92
+
93
+ # Reply goes to Reply-To when the sender set one, else From. A forward has
94
+ # no derivable recipient — that's the caller's whole reason for forwarding.
95
+ def derive_to(mail, mode)
96
+ return [] if mode == "forward"
97
+
98
+ reply_to = addresses(mail.reply_to).first
99
+ [reply_to || addresses(mail.from).first].compact
100
+ end
101
+
102
+ # Reply-all carries the other recipients, minus every address of ours (so
103
+ # switching identity can't leave us Cc'ing ourselves) and minus whoever
104
+ # already landed in To.
105
+ def derive_cc(mail, mode, idents)
106
+ return [] unless mode == "reply-all"
107
+
108
+ to_lc = derive_to(mail, mode).map(&:downcase)
109
+ (addresses(mail.to) + addresses(mail.cc))
110
+ .reject { |a| idents.include?(a.downcase) || to_lc.include?(a.downcase) }
111
+ .uniq
112
+ end
113
+
114
+ def derive_subject(mail, mode)
115
+ subject = mail.subject.to_s.strip
116
+ mode == "forward" ? ensure_prefix(subject, "Fwd", /\Afwd?\s*:/i) : ensure_prefix(subject, "Re", /\Are\s*(?:\[\d+\])?\s*:/i)
117
+ end
118
+
119
+ # Conservative: never double-prefix, and leave an existing prefix in
120
+ # whatever case/shape the sender used (`RE:`, `Re[2]:`) alone.
121
+ def ensure_prefix(subject, word, already)
122
+ return "#{word}: " if subject.empty?
123
+ return subject if subject.match?(already)
124
+
125
+ "#{word}: #{subject}"
126
+ end
127
+
128
+ # Whichever of OUR addresses the parent was addressed to — so a reply goes
129
+ # out from the identity that received it, not from whatever MailMate
130
+ # defaults to. nil when we can't tell; the caller decides the fallback.
131
+ def pick_from_identity(mail, idents)
132
+ return nil if idents.empty?
133
+
134
+ candidates = addresses(mail.to) + addresses(mail.cc) + addresses(mail.bcc)
135
+ candidates.find { |a| idents.include?(a.downcase) }
136
+ end
137
+
138
+ # Reply: email-classic "On <date>, <sender> wrote:" + a `> `-prefixed body.
139
+ # Forward: the conventional un-prefixed forwarded-message block with its
140
+ # own header summary, since the recipient has never seen the original.
141
+ def derive_quoted_body(mail, mode)
142
+ body = plain_body(mail)
143
+ from = presence(mail["from"]&.value.to_s.strip) || "(unknown sender)"
144
+ date = mail["date"]&.value.to_s.strip
145
+
146
+ if mode == "forward"
147
+ header = ["---------- Forwarded message ----------",
148
+ "From: #{from}",
149
+ ("Date: #{date}" unless date.empty?),
150
+ "Subject: #{mail.subject.to_s.strip}",
151
+ ("To: #{mail['to'].value}" if mail["to"])].compact.join("\n")
152
+ return "#{header}\n\n#{body}"
153
+ end
154
+
155
+ attribution = date.empty? ? "#{from} wrote:" : "On #{date}, #{from} wrote:"
156
+ return "#{attribution}\n> [no plain-text alternative — paste the original manually]\n" if body.strip.empty?
157
+
158
+ quoted = body.sub(/\n+\z/, "").split("\n", -1).map { |l| "> #{l}".rstrip }.join("\n")
159
+ "#{attribution}\n#{quoted}\n"
160
+ end
161
+
162
+ # The text/plain alternative, or "" when the message is HTML-only. We do
163
+ # NOT synthesize text from the HTML part here: a lossy auto-conversion
164
+ # quoted back to the original sender is worse than an honest placeholder.
165
+ def plain_body(mail)
166
+ part = mail.multipart? ? mail.text_part : mail
167
+ return "" if part.nil?
168
+ return "" if part.respond_to?(:mime_type) && part.mime_type && part.mime_type != "text/plain"
169
+
170
+ part.body.decoded.to_s
171
+ rescue StandardError
172
+ ""
173
+ end
174
+
175
+ # Mail's address fields raise on malformed input often enough that a reply
176
+ # to a slightly-broken message shouldn't blow up the whole derivation.
177
+ def addresses(field)
178
+ Array(field).map { |a| a.to_s.strip }.reject(&:empty?)
179
+ rescue StandardError
180
+ []
181
+ end
182
+
183
+ def normalize_identities(identities)
184
+ list = identities.nil? ? Mailmate::Identity.list : Array(identities)
185
+ list.map { |a| a.to_s.downcase.strip }.reject(&:empty?)
186
+ end
187
+
188
+ def presence(str)
189
+ s = str.to_s.strip
190
+ s.empty? ? nil : s
191
+ end
192
+ end
193
+ end
@@ -26,6 +26,8 @@ module Mailmate
26
26
  ["m <term>", "common headers OR body (same as a bare term)"],
27
27
  ["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (N calendar units ending today; 1d = today), or Y, Y-M, Y-M-D"],
28
28
  ["T <tag>", "tag / IMAP keyword contains (K is a synonym)"],
29
+ ["is:<state>", "message state: unread, read, flagged, replied, draft"],
30
+ ["has:attachment", "root MIME is multipart/mixed (the standard attachment layout)"],
29
31
  ].freeze
30
32
 
31
33
  EXAMPLES = [
@@ -37,6 +39,7 @@ module Mailmate
37
39
  ["d 24h", "received in the last 24 hours (rolling, not calendar)"],
38
40
  ["d >=2026-05 d <2026-08", "received May through July 2026"],
39
41
  ["d 1h or 2026-08-09", "last hour, plus everything from Aug 9"],
42
+ ["is:unread d 7d", "unread, received in the last 7 days"],
40
43
  ["T urgent", "tagged 'urgent'"],
41
44
  ].freeze
42
45
 
@@ -59,11 +62,13 @@ module Mailmate
59
62
  # matches nothing. That silence is the whole problem this list exists to
60
63
  # break: an agent or a person gets an empty result set that is
61
64
  # indistinguishable from "your mail really has nothing", and believes it.
65
+ # NOTE: is/has are absent — they are first-class quicksearch now
66
+ # (is:unread, has:attachment parse as native state specs).
62
67
  FOREIGN_KEYS = %w[
63
68
  after before older newer older_than newer_than on since until
64
69
  date sent received time
65
70
  from to cc bcc subject body
66
- is has in label folder mailbox category filename
71
+ in label folder mailbox category filename
67
72
  ].freeze
68
73
 
69
74
  # Spec placeholders for the zero-result hint, for foreign keys whose value
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "1.7.0"
4
+ VERSION = "1.8.0"
5
5
  end
data/lib/mailmate.rb CHANGED
@@ -12,6 +12,8 @@
12
12
  # Mailmate::PartLookup.body_parts_of(envelope_id) → child body-part-ids of an envelope
13
13
  # Mailmate::EmlLookup.path_for(eml_id) → eml-id → absolute path
14
14
  # Mailmate::HeaderReader.header(path, name) → read one header from an .eml
15
+ # Mailmate::HeaderValue.sanitize(v) → CR/LF-safe --header value (ALL header paths use this)
16
+ # Mailmate::ReplyPrefill.build(id, mode:) → reply/reply-all/forward fields from a parent
15
17
  # Mailmate::MidUrl.for(message_id) → build a mid:%3C...%3E URL
16
18
  # Mailmate::DuplicateScanner.duplicates → Hash{Message-ID => Array<eml_id>}
17
19
  # Mailmate::AppleScriptDriver.new(...) → drive MailMate via AppleScript
@@ -35,6 +37,8 @@ require_relative "mailmate/identity"
35
37
  require_relative "mailmate/header_reader"
36
38
  require_relative "mailmate/mid_url"
37
39
  require_relative "mailmate/eml_lookup"
40
+ require_relative "mailmate/header_value"
41
+ require_relative "mailmate/reply_prefill"
38
42
  require_relative "mailmate/duplicate_scanner"
39
43
  require_relative "mailmate/applescript_driver"
40
44
  require_relative "mailmate/ast"
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.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Murphy-Dye
@@ -110,6 +110,7 @@ files:
110
110
  - LICENSE.txt
111
111
  - README.md
112
112
  - config.yml.example
113
+ - docs/Composing and threading.md
113
114
  - exe/mailmate-mcp
114
115
  - exe/mm-draft
115
116
  - exe/mm-mailboxes
@@ -135,6 +136,7 @@ files:
135
136
  - lib/mailmate/cli/send.rb
136
137
  - lib/mailmate/cli/tags.rb
137
138
  - lib/mailmate/cli/verify.rb
139
+ - lib/mailmate/cli/version_flag.rb
138
140
  - lib/mailmate/config.rb
139
141
  - lib/mailmate/duplicate_scanner.rb
140
142
  - lib/mailmate/eml_lookup.rb
@@ -142,6 +144,7 @@ files:
142
144
  - lib/mailmate/filter_classifier.rb
143
145
  - lib/mailmate/flag_check.rb
144
146
  - lib/mailmate/header_reader.rb
147
+ - lib/mailmate/header_value.rb
145
148
  - lib/mailmate/identity.rb
146
149
  - lib/mailmate/index_reader.rb
147
150
  - lib/mailmate/lexer.rb
@@ -153,13 +156,18 @@ files:
153
156
  - lib/mailmate/parser.rb
154
157
  - lib/mailmate/part_lookup.rb
155
158
  - lib/mailmate/platform_error.rb
159
+ - lib/mailmate/reply_prefill.rb
156
160
  - lib/mailmate/search_syntax.rb
157
161
  - lib/mailmate/source_resolver.rb
158
162
  - lib/mailmate/var_resolver.rb
159
163
  - lib/mailmate/version.rb
164
+ homepage: https://github.com/brianmd/mailmate
160
165
  licenses:
161
166
  - MIT
162
- metadata: {}
167
+ metadata:
168
+ source_code_uri: https://github.com/brianmd/mailmate
169
+ bug_tracker_uri: https://github.com/brianmd/mailmate/issues
170
+ documentation_uri: https://github.com/brianmd/mailmate#readme
163
171
  rdoc_options: []
164
172
  require_paths:
165
173
  - lib