mailmate 1.6.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 +43 -16
- 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 +361 -96
- data/lib/mailmate/cli/send.rb +156 -18
- data/lib/mailmate/cli/version_flag.rb +37 -0
- data/lib/mailmate/header_value.rb +42 -0
- data/lib/mailmate/mcp.rb +68 -29
- data/lib/mailmate/reply_prefill.rb +193 -0
- data/lib/mailmate/search_syntax.rb +316 -0
- data/lib/mailmate/version.rb +1 -1
- data/lib/mailmate.rb +5 -0
- metadata +11 -2
data/lib/mailmate/cli/send.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../reply_prefill"
|
|
5
|
+
|
|
3
6
|
module Mailmate
|
|
4
7
|
module CLI
|
|
5
8
|
# `mm-send` — send mail through MailMate's `emate` CLI with a markdown body.
|
|
@@ -17,42 +20,177 @@ module Mailmate
|
|
|
17
20
|
Body is read from stdin. All other flags pass through to emate (its help follows).
|
|
18
21
|
|
|
19
22
|
Replies and threading
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
`--header` ships verbatim; what you don't set is absent (and recipients'
|
|
23
|
-
clients will see the message as a fresh thread, no matter how `Re:` the
|
|
24
|
-
subject looks). To make a reply land in-thread, pass both:
|
|
23
|
+
A `Re:` subject alone does NOT thread — modern clients thread on headers.
|
|
24
|
+
MailMate generates the outgoing Message-ID; never your job.
|
|
25
25
|
|
|
26
|
-
mm-send -f you@x -
|
|
27
|
-
--header "In-Reply-To: <parent-message-id@domain>" \\
|
|
28
|
-
--header "References: <root-mid> <parent-mid>" \\
|
|
29
|
-
--send-now <<<"body"
|
|
26
|
+
mm-send -f you@x --reply-to "<parent-mid@domain>" --send-now <<<"body"
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
34
32
|
|
|
35
33
|
Identity selection
|
|
36
34
|
`-f <address>` picks which of MailMate's configured identities sends.
|
|
37
35
|
Without `-f`, MailMate uses its default identity. See `mmdiscover` to
|
|
38
36
|
list available addresses.
|
|
39
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
|
+
|
|
40
42
|
──────────────────────────── emate help follows ────────────────────────────
|
|
41
43
|
|
|
42
44
|
PREAMBLE
|
|
43
45
|
|
|
44
|
-
# Returns the exit status of the spawned `emate` invocation.
|
|
45
|
-
#
|
|
46
|
-
#
|
|
46
|
+
# Returns the exit status of the spawned `emate` invocation.
|
|
47
|
+
#
|
|
48
|
+
# emate must NEVER inherit the caller's real stdin/stdout. Inside the
|
|
49
|
+
# MCP server, fd 0/1 are the JSON-RPC transport, and the previous
|
|
50
|
+
# `system(...)` handed both to emate: it blocked reading the protocol
|
|
51
|
+
# pipe for a body and consumed the next frame as one (a cancelled turn
|
|
52
|
+
# produced a MailMate draft whose entire body was a
|
|
53
|
+
# `notifications/cancelled` frame — the composed body, swapped in via
|
|
54
|
+
# the Ruby-level `$stdin` global, was silently discarded). So: read the
|
|
55
|
+
# body through `$stdin` (honors the MCP's StringIO swap AND a shell
|
|
56
|
+
# pipe), hand it to emate on a private pipe that capture3 EOFs (no
|
|
57
|
+
# more hanging until the server dies), and re-emit emate's output
|
|
58
|
+
# through the `$stdout`/`$stderr` globals so the MCP's capture sees it
|
|
59
|
+
# instead of the protocol stream getting corrupted.
|
|
47
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
|
+
|
|
48
74
|
Mailmate::PlatformError.check_darwin!(component: "mm-send")
|
|
49
75
|
unless File.executable?(EMATE_PATH)
|
|
50
76
|
warn "mm-send: emate not found at #{EMATE_PATH}. Is MailMate installed?"
|
|
51
77
|
return 1
|
|
52
78
|
end
|
|
53
|
-
warn PREAMBLE if
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
warn PREAMBLE if help
|
|
80
|
+
# --help never reads a body; consuming stdin here would hang an
|
|
81
|
+
# interactive `mm-send --help` waiting for Ctrl-D.
|
|
82
|
+
body = help ? "" : $stdin.read.to_s
|
|
83
|
+
body = append_quote(body, derived) if derived
|
|
84
|
+
out, err, status = Open3.capture3(EMATE_PATH, "mailto", "--markup", "markdown", *argv, stdin_data: body)
|
|
85
|
+
$stdout.write(out)
|
|
86
|
+
$stderr.write(err)
|
|
87
|
+
# exitstatus is nil for a signal-killed child; the exe shims do
|
|
88
|
+
# `exit run(ARGV)`, which needs an Integer.
|
|
89
|
+
status.exitstatus || 1
|
|
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
|
|
56
194
|
end
|
|
57
195
|
end
|
|
58
196
|
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,22 +89,19 @@ 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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
99
|
+
|
|
100
|
+
#{Mailmate::SearchSyntax.reference(indent: " ")}
|
|
101
|
+
|
|
91
102
|
The `mailbox` arg also accepts a smart-mailbox name (e.g. Newsletters,
|
|
92
103
|
Receipts, Priority) whose filter is ANDed into the search.
|
|
93
104
|
|
|
94
|
-
Examples:
|
|
95
|
-
query="f substack d 7d" from Substack in the last 7 days
|
|
96
|
-
query="T urgent" tagged "urgent"
|
|
97
|
-
query="s 'invoice due' !draft" subject has 'invoice due', not 'draft'
|
|
98
|
-
|
|
99
105
|
Fields default to: flags date time direction party subject.
|
|
100
106
|
Prefix with "+" to add to the defaults ("+tags +mailbox"); a bare
|
|
101
107
|
list replaces them (id is always the first column). Meanings:
|
|
@@ -124,6 +130,7 @@ module Mailmate
|
|
|
124
130
|
limit: { type: "integer", description: "Stop after N matches." },
|
|
125
131
|
headers_only: { type: "boolean", description: "Skip body matching (much faster on text searches)." },
|
|
126
132
|
sort: { type: "string", enum: %w[asc desc none], description: "Sort by date+time. Default: asc." },
|
|
133
|
+
european: { type: "boolean", description: "Slash dates in the query are day-first (d 9/8/2026 = Aug 9). Default: month-first American." },
|
|
127
134
|
},
|
|
128
135
|
additionalProperties: false,
|
|
129
136
|
},
|
|
@@ -239,11 +246,15 @@ module Mailmate
|
|
|
239
246
|
subject: { type: "string", description: "Subject line." },
|
|
240
247
|
body: { type: "string", description: "Markdown body." },
|
|
241
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." },
|
|
242
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." },
|
|
243
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." },
|
|
244
255
|
send_now: { type: "boolean", description: "Send immediately (skip the Drafts pause)." },
|
|
245
256
|
},
|
|
246
|
-
required: %w[
|
|
257
|
+
required: %w[body],
|
|
247
258
|
additionalProperties: false,
|
|
248
259
|
},
|
|
249
260
|
},
|
|
@@ -262,10 +273,14 @@ module Mailmate
|
|
|
262
273
|
subject: { type: "string", description: "Subject line." },
|
|
263
274
|
body: { type: "string", description: "Markdown body." },
|
|
264
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." },
|
|
265
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." },
|
|
266
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." },
|
|
267
282
|
},
|
|
268
|
-
required: %w[
|
|
283
|
+
required: %w[body],
|
|
269
284
|
additionalProperties: false,
|
|
270
285
|
},
|
|
271
286
|
},
|
|
@@ -416,6 +431,7 @@ module Mailmate
|
|
|
416
431
|
argv.push("--limit", args["limit"].to_i.to_s) if args["limit"]
|
|
417
432
|
argv.push("--headers-only") if args["headers_only"]
|
|
418
433
|
argv.push("--sort", args["sort"].to_s) if args["sort"]
|
|
434
|
+
argv.push("--european") if args["european"]
|
|
419
435
|
# Positionals: search-string then fields. Only include if the caller
|
|
420
436
|
# gave us either — otherwise let the CLI apply its defaults.
|
|
421
437
|
if args.key?("query") || args["fields"]
|
|
@@ -455,7 +471,21 @@ module Mailmate
|
|
|
455
471
|
with_stdin(payload) { run_cli(Mailmate::CLI::Verify, argv) }
|
|
456
472
|
end
|
|
457
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
|
+
|
|
458
486
|
def call_send(args)
|
|
487
|
+
(err = recipient_check(args)) and return err
|
|
488
|
+
|
|
459
489
|
argv = compose_argv(args)
|
|
460
490
|
argv << "--send-now" if args["send_now"]
|
|
461
491
|
with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Send, argv) }
|
|
@@ -464,6 +494,8 @@ module Mailmate
|
|
|
464
494
|
# `draft` mirrors `send` but never sends — it has no send_now option and
|
|
465
495
|
# routes through CLI::Draft, which refuses `--send-now` outright.
|
|
466
496
|
def call_draft(args)
|
|
497
|
+
(err = recipient_check(args)) and return err
|
|
498
|
+
|
|
467
499
|
argv = compose_argv(args)
|
|
468
500
|
with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Draft, argv) }
|
|
469
501
|
end
|
|
@@ -477,21 +509,28 @@ module Mailmate
|
|
|
477
509
|
argv.push("-c", args["cc"].to_s) if args["cc"]
|
|
478
510
|
argv.push("-b", args["bcc"].to_s) if args["bcc"]
|
|
479
511
|
argv.push("-s", args["subject"].to_s) if args["subject"]
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
482
530
|
Array(args["attachments"]).each { |p| argv << p.to_s }
|
|
483
531
|
argv
|
|
484
532
|
end
|
|
485
533
|
|
|
486
|
-
# Wrap a bare Message-ID in `<…>` if it doesn't already have them. Both
|
|
487
|
-
# forms are valid input to the MCP for ergonomics; the header value
|
|
488
|
-
# going on the wire needs the brackets per RFC 5322.
|
|
489
|
-
def bracket_mid(id)
|
|
490
|
-
s = id.to_s.strip
|
|
491
|
-
return s if s.start_with?("<") && s.end_with?(">")
|
|
492
|
-
"<#{s}>"
|
|
493
|
-
end
|
|
494
|
-
|
|
495
534
|
def call_open(args)
|
|
496
535
|
argv = [args["id"].to_s]
|
|
497
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
|