Module: Mailmate::CLI::Send Private

Extended by:
Send
Included in:
Send
Defined in:
lib/mailmate/cli/send.rb

Overview

This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.

mm-send — send mail through MailMate's emate CLI with a markdown body. Replaces the 6-line bash wrapper that previously lived at ~/.claude/skills/email/send-email. All flags pass through to emate mailto; --markup markdown is enforced.

Constant Summary collapse

EMATE_PATH =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

"/Applications/MailMate.app/Contents/Resources/emate"
PREAMBLE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

<<~PREAMBLE
  mm-send — thin wrapper around `emate mailto` with `--markup markdown` enforced.
  Body is read from stdin. All other flags pass through to emate (its help follows).

  Replies and threading
    A `Re:` subject alone does NOT thread — modern clients thread on headers.
    MailMate generates the outgoing Message-ID; never your job.

      mm-send -f you@x --reply-to "<parent-mid@domain>" --send-now <<<"body"

    derives In-Reply-To, References, recipients and subject from the parent.
    --reply-all-to replies to all; --forward forwards. Fields you pass
    explicitly win; fields you omit follow normal reply rules. --header
    stays available as the escape hatch when the parent isn't indexed.

  Identity selection
    `-f <address>` picks which of MailMate's configured identities sends.
    Without `-f`, MailMate uses its default identity. See `mmdiscover` to
    list available addresses.

  Full rules — threading chain, merge rule, header safety:
    docs/Composing and threading.md (shipped with the gem), or
    https://github.com/brianmd/mailmate/blob/main/docs/

  ──────────────────────────── emate help follows ────────────────────────────

PREAMBLE
PARENT_FLAGS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Flags this wrapper consumes itself. Everything else in argv is emate's and passes through untouched — that pass-through is the design, so the scan below is deliberately literal rather than an OptionParser (which would have to be taught every emate flag in order to ignore them).

{ "--reply-to" => "reply", "--reply-all-to" => "reply-all", "--forward" => "forward" }.freeze

Instance Method Summary collapse

Instance Method Details

#append_quote(body, derived) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Reply rules seed the body with the quoted original BELOW whatever the caller wrote, matching what a mail client's Reply button produces.



181
182
183
184
185
186
187
188
# File 'lib/mailmate/cli/send.rb', line 181

def append_quote(body, derived)
  return body unless derived[:quote]

  quote = derived[:prefill].quoted_body.to_s
  return body if quote.strip.empty?

  "#{body.to_s.sub(/\n+\z/, '')}\n\n#{quote}"
end

#apply_parent!(argv, help: false) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns [argv_for_emate, derived_or_nil]. When a parent flag is present, derives the reply fields and splices them in as emate flags — but only for fields the caller did NOT pass. Explicit always wins; see the merge rule in docs/Composing and threading.md.



102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/mailmate/cli/send.rb', line 102

def apply_parent!(argv, help: false)
  rest, parent, mode, print_only, quote = extract_flags(argv)
  return [rest, nil] if parent.nil?

  # --print-prefill is a query, so it answers even under --help; the
  # send path would otherwise be unreachable for a caller inspecting it.
  prefill = Mailmate::ReplyPrefill.build(parent, mode: mode)
  derived = { prefill: prefill, print_only: print_only, quote: quote }
  return [rest, derived] if print_only || help

  [splice(rest, prefill), derived]
end

#extract_flags(argv) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Raises:

  • (ArgumentError)


115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/mailmate/cli/send.rb', line 115

def extract_flags(argv)
  rest = []
  parent = mode = nil
  print_only = false
  quote = true
  i = 0
  while i < argv.length
    arg = argv[i]
    if PARENT_FLAGS.key?(arg)
      raise ArgumentError, "#{arg} needs a message id" if argv[i + 1].nil?
      raise ArgumentError, "pass only one of #{PARENT_FLAGS.keys.join(', ')}" if parent

      mode = PARENT_FLAGS[arg]
      parent = argv[i + 1]
      i += 2
    elsif arg == "--print-prefill"
      print_only = true
      i += 1
    elsif arg == "--no-quote"
      quote = false
      i += 1
    else
      rest << arg
      i += 1
    end
  end
  raise ArgumentError, "--print-prefill needs one of #{PARENT_FLAGS.keys.join(', ')}" if print_only && parent.nil?

  [rest, parent, mode, print_only, quote]
end

#header_passed?(argv, name) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


172
173
174
175
176
177
# File 'lib/mailmate/cli/send.rb', line 172

def header_passed?(argv, name)
  argv.each_with_index.any? do |a, i|
    (a == "--header" && argv[i + 1].to_s.downcase.start_with?("#{name}:")) ||
      (a.start_with?("--header=") && a.split("=", 2).last.to_s.downcase.start_with?("#{name}:"))
  end
end

#passed?(argv, flags) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


168
169
170
# File 'lib/mailmate/cli/send.rb', line 168

def passed?(argv, flags)
  argv.any? { |a| flags.include?(a) || flags.any? { |f| f.start_with?("--") && a.start_with?("#{f}=") } }
end

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



190
191
192
193
194
# File 'lib/mailmate/cli/send.rb', line 190

def print_prefill(derived)
  require "json"
  $stdout.puts JSON.pretty_generate(derived[:prefill].to_h)
  0
end

#run(argv) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the exit status of the spawned emate invocation.

emate must NEVER inherit the caller's real stdin/stdout. Inside the MCP server, fd 0/1 are the JSON-RPC transport, and the previous system(...) handed both to emate: it blocked reading the protocol pipe for a body and consumed the next frame as one (a cancelled turn produced a MailMate draft whose entire body was a notifications/cancelled frame — the composed body, swapped in via the Ruby-level $stdin global, was silently discarded). So: read the body through $stdin (honors the MCP's StringIO swap AND a shell pipe), hand it to emate on a private pipe that capture3 EOFs (no more hanging until the server dies), and re-emit emate's output through the $stdout/$stderr globals so the MCP's capture sees it instead of the protocol stream getting corrupted.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/mailmate/cli/send.rb', line 60

def run(argv)
  help = argv.include?("--help") || argv.include?("-h")

  # Our own flags are peeled off BEFORE the platform/emate checks so
  # `--print-prefill` works as a pure query — markdownr calls it to fill
  # a form and has no business requiring a launchable MailMate.
  begin
    argv, derived = apply_parent!(argv, help: help)
  rescue Mailmate::ReplyPrefill::NotFound, ArgumentError => e
    warn "mm-send: #{e.message}"
    return 1
  end
  return print_prefill(derived) if derived && derived[:print_only]

  Mailmate::PlatformError.check_darwin!(component: "mm-send")
  unless File.executable?(EMATE_PATH)
    warn "mm-send: emate not found at #{EMATE_PATH}. Is MailMate installed?"
    return 1
  end
  warn PREAMBLE if help
  # --help never reads a body; consuming stdin here would hang an
  # interactive `mm-send --help` waiting for Ctrl-D.
  body = help ? "" : $stdin.read.to_s
  body = append_quote(body, derived) if derived
  out, err, status = Open3.capture3(EMATE_PATH, "mailto", "--markup", "markdown", *argv, stdin_data: body)
  $stdout.write(out)
  $stderr.write(err)
  # exitstatus is nil for a signal-killed child; the exe shims do
  # `exit run(ARGV)`, which needs an Integer.
  status.exitstatus || 1
end

#splice(argv, prefill) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Add derived values ONLY where the caller was silent. passed? looks for the flag itself, so -t a@x --reply-to <id> keeps a@x and still threads — overriding a visible field must never drop the headers.



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/mailmate/cli/send.rb', line 149

def splice(argv, prefill)
  out = argv.dup
  out.push("-f", prefill.from) if prefill.from && !passed?(argv, %w[-f --from])
  unless passed?(argv, %w[-t --to])
    prefill.to.each { |a| out.push("-t", a) }
  end
  unless passed?(argv, %w[-c --cc])
    prefill.cc.each { |a| out.push("-c", a) }
  end
  out.push("-s", prefill.subject) if prefill.subject && !passed?(argv, %w[-s --subject])
  # Threading headers are NOT subject to the merge rule's "explicit
  # wins" in the usual sense — a caller who passes their own
  # --header "In-Reply-To: …" alongside --reply-to gets both, which is
  # a duplicate header. Skip ours when they've hand-set either one.
  out.push("--header", "In-Reply-To: #{prefill.in_reply_to}") if prefill.in_reply_to && !header_passed?(argv, "in-reply-to")
  out.push("--header", "References: #{prefill.references}")   if prefill.references && !header_passed?(argv, "references")
  out
end