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 +4 -4
- data/README.md +26 -13
- data/docs/Composing and threading.md +69 -0
- data/exe/mailmate-mcp +4 -0
- data/exe/mm-draft +4 -0
- data/exe/mm-mailboxes +4 -0
- data/exe/mm-modify +4 -0
- data/exe/mm-send +4 -0
- data/exe/mm-verify +4 -0
- data/exe/mmdiscover +4 -0
- data/exe/mmmessage +4 -0
- data/exe/mmopen +4 -0
- data/exe/mmsearch +4 -0
- data/exe/mmtags +4 -0
- data/lib/mailmate/cli/search.rb +74 -10
- data/lib/mailmate/cli/send.rb +130 -13
- data/lib/mailmate/cli/version_flag.rb +37 -0
- data/lib/mailmate/header_value.rb +42 -0
- data/lib/mailmate/mcp.rb +63 -23
- data/lib/mailmate/reply_prefill.rb +193 -0
- data/lib/mailmate/search_syntax.rb +6 -1
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +4 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d4531494a2e95ee32395c937a293085b055c6868327f9914702da62a7b039c6
|
|
4
|
+
data.tar.gz: 3251b2608c1ede51d85425c205a274993d51b58b9cace6f04eb53981a04345bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
369
|
+
Point `mm-send` at the parent and it derives the rest:
|
|
368
370
|
|
|
369
371
|
```bash
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
389
|
-
mm-
|
|
390
|
-
|
|
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
data/exe/mm-draft
CHANGED
data/exe/mm-mailboxes
CHANGED
data/exe/mm-modify
CHANGED
data/exe/mm-send
CHANGED
data/exe/mm-verify
CHANGED
data/exe/mmdiscover
CHANGED
data/exe/mmmessage
CHANGED
data/exe/mmopen
CHANGED
data/exe/mmsearch
CHANGED
data/exe/mmtags
CHANGED
data/lib/mailmate/cli/search.rb
CHANGED
|
@@ -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
|
|
229
|
-
o.separator "
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
data/lib/mailmate/cli/send.rb
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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 -
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
`date:today`, `after:2026-08-01`, `older_than:2w`) are
|
|
86
|
-
to quicksearch, and the rewrite is announced in the
|
|
87
|
-
announcement means your query was translated, not
|
|
88
|
-
Unrecognized keys (`
|
|
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[
|
|
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[
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
data/lib/mailmate/version.rb
CHANGED
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.
|
|
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
|