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.
@@ -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
- MailMate auto-generates the outgoing Message-IDnever your job.
21
- In-Reply-To and References are PURE PASS-THROUGH: whatever you set via
22
- `--header` ships verbatim; what you don't set is absent (and recipients'
23
- clients will see the message as a fresh thread, no matter how `Re:` the
24
- subject looks). To make a reply land in-thread, pass both:
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 -t them@y -s "Re: foo" \\
27
- --header "In-Reply-To: <parent-message-id@domain>" \\
28
- --header "References: <root-mid> <parent-mid>" \\
29
- --send-now <<<"body"
26
+ mm-send -f you@x --reply-to "<parent-mid@domain>" --send-now <<<"body"
30
27
 
31
- References is constructed as the source message's References header (if
32
- any) with the source's Message-ID appended. If the source is a thread
33
- root with no References, just use its Message-ID alone.
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. Uses
45
- # `system` (not `exec`) so the caller — and the test suite — can
46
- # actually observe the result.
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 argv.include?("--help") || argv.include?("-h")
54
- system(EMATE_PATH, "mailto", "--markup", "markdown", *argv)
55
- $?.exitstatus
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
- - Threading: set BOTH `in_reply_to` and `references`. A "Re:" subject
55
- alone does not thread in modern clients. MailMate generates the
56
- outgoing Message-ID itself.
54
+ - Replying: pass `reply_to` (the parent's eml-id or Message-ID) and the
55
+ threading headers, recipient and "Re:" subject are derived for you.
56
+ `reply_all_to` replies to all; `forward` forwards (supply `to`).
57
+ Fields you also pass explicitly win; ones you omit follow normal
58
+ reply rules. Prefer this over hand-setting in_reply_to/references —
59
+ a mis-built References chain sends fine and simply doesn't thread,
60
+ and nothing in your own view reveals it. A "Re:" subject alone never
61
+ threads. MailMate generates the outgoing Message-ID itself.
62
+ Full rules — the References chain, the merge rule, header safety —
63
+ are in the gem's docs/Composing and threading.md
64
+ (github.com/brianmd/mailmate), which is canonical; this summary
65
+ exists only so you need not follow a link mid-call.
57
66
 
58
67
  Modifying (modify)
59
68
  - Drives MailMate's UI via AppleScript: it briefly takes focus, calls are
@@ -80,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
- Query = space-separated specs combined with AND (no or/parens yet).
84
- Quote multi-word terms ("invoice due"). Modifiers:
85
- (bare term) From/To/Cc/Subject OR body contains
86
- f <t> From t <t> To/Cc c <t> Cc s <t> Subject
87
- a <t> any address header b <t> body m <t> headers OR body
88
- d <date> received: Y, Y-M, Y-M-D, or relative 1d/2w/3m/1y
89
- T <tag> tag / IMAP keyword (K is a synonym)
90
- !<value> negate, e.g. f !smith (From does NOT contain smith)
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[to subject body],
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[to subject body],
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
- argv.push("--header", "In-Reply-To: #{bracket_mid(args["in_reply_to"])}") if args["in_reply_to"]
481
- argv.push("--header", "References: #{args["references"]}") if args["references"]
512
+ # Both values come from ANOTHER message, so both go through the shared
513
+ # sanitizer see Mailmate::HeaderValue for why this is not open-coded.
514
+ argv.push("--header", "In-Reply-To: #{Mailmate::HeaderValue.bracket_message_id(args["in_reply_to"])}") if args["in_reply_to"]
515
+ argv.push("--header", "References: #{Mailmate::HeaderValue.sanitize(args["references"])}") if args["references"]
516
+ # Parent-derived compose: hand the id to the CLI rather than deriving
517
+ # here. `reply_to` is what a caller should reach for over hand-setting
518
+ # in_reply_to/references — it builds the References chain from the
519
+ # parent, the step that is easy to get subtly wrong and impossible to
520
+ # notice afterwards (a mis-built chain sends fine and simply doesn't
521
+ # thread).
522
+ if (parent = args["reply_to"] || args["reply_all_to"] || args["forward"])
523
+ flag = if args["forward"] then "--forward"
524
+ elsif args["reply_all_to"] then "--reply-all-to"
525
+ else "--reply-to"
526
+ end
527
+ argv.push(flag, parent.to_s)
528
+ argv << "--no-quote" if args["quote"] == false
529
+ end
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