Skip to content

Commit 1752eb7

Browse files
MajorTalclaude
andcommitted
feat(email): attachments on mailbox send (SDK + CLI + MCP + docs)
Surface for run402's outbound email attachments (gateway shipped in kychee-com/run402-private #467). SDK SendEmailOptions.attachments + EmailAttachment(Meta) types + local template-mode guard; CLI 'email send --attach <path>[:content-type]' (repeatable, ext-inferred type); MCP send-email attachments param; llms-sdk/llms-cli/llms-mcp docs. Tests: SDK 28, MCP 8, CLI-argv 119 green. NOT published to npm — version bump + publish left for a deliberate release. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3badd0d commit 1752eb7

9 files changed

Lines changed: 240 additions & 7 deletions

File tree

cli-argv.test.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1628,3 +1628,57 @@ describe("CLI JSON-only output contract (v3.x cleanup)", () => {
16281628
assert.ok(Array.isArray(parsed.checks), "doctor stdout should have checks array");
16291629
});
16301630
});
1631+
1632+
describe("email --attach parsing", () => {
1633+
let attachDir;
1634+
before(() => {
1635+
attachDir = mkdtempSync(join(tmpdir(), "run402-attach-test-"));
1636+
});
1637+
after(() => {
1638+
rmSync(attachDir, { recursive: true, force: true });
1639+
});
1640+
1641+
it("parses a single --attach and infers content-type from the extension", async () => {
1642+
const { parseAttachments } = await import("./cli/lib/email.mjs");
1643+
const p = join(attachDir, "foo.pdf");
1644+
writeFileSync(p, "PDFDATA");
1645+
const out = parseAttachments(["--attach", p]);
1646+
assert.deepEqual(out, [
1647+
{ filename: "foo.pdf", content_base64: Buffer.from("PDFDATA").toString("base64"), content_type: "application/pdf" },
1648+
]);
1649+
});
1650+
1651+
it("parses repeated --attach flags into multiple entries", async () => {
1652+
const { parseAttachments } = await import("./cli/lib/email.mjs");
1653+
const a = join(attachDir, "a.csv");
1654+
const b = join(attachDir, "b.png");
1655+
writeFileSync(a, "x,y");
1656+
writeFileSync(b, "PNG");
1657+
const out = parseAttachments(["--attach", a, "--attach", b]);
1658+
assert.equal(out.length, 2);
1659+
assert.equal(out[0].content_type, "text/csv");
1660+
assert.equal(out[1].content_type, "image/png");
1661+
});
1662+
1663+
it("honors an explicit :content-type suffix over the extension", async () => {
1664+
const { parseAttachments } = await import("./cli/lib/email.mjs");
1665+
const p = join(attachDir, "data.bin");
1666+
writeFileSync(p, "raw");
1667+
const out = parseAttachments(["--attach", `${p}:text/csv`]);
1668+
assert.equal(out[0].filename, "data.bin");
1669+
assert.equal(out[0].content_type, "text/csv");
1670+
});
1671+
1672+
it("rejects an unreadable attachment path before sending", async () => {
1673+
const { parseAttachments } = await import("./cli/lib/email.mjs");
1674+
const env = await expectExit1(() => parseAttachments(["--attach", join(attachDir, "nope.pdf")]));
1675+
assert.equal(env.code, "BAD_USAGE");
1676+
assert.match(env.message, /Cannot read attachment file/);
1677+
});
1678+
1679+
it("rejects --attach with no value", async () => {
1680+
const { parseAttachments } = await import("./cli/lib/email.mjs");
1681+
const env = await expectExit1(() => parseAttachments(["--attach"]));
1682+
assert.equal(env.code, "BAD_USAGE");
1683+
});
1684+
});

cli/lib/email.mjs

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,72 @@
1+
import { readFileSync } from "node:fs";
2+
import { basename } from "node:path";
13
import { resolveProjectId } from "./config.mjs";
24
import { getSdk } from "./sdk.mjs";
35
import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
46
import { assertKnownFlags, flagValue, normalizeArgv, parseIntegerFlag, positionalArgs } from "./argparse.mjs";
57

8+
// Extension → content-type for `--attach <path>` without an explicit `:type`.
9+
const ATTACH_EXT_CONTENT_TYPES = {
10+
pdf: "application/pdf",
11+
csv: "text/csv",
12+
txt: "text/plain",
13+
json: "application/json",
14+
png: "image/png",
15+
jpg: "image/jpeg",
16+
jpeg: "image/jpeg",
17+
gif: "image/gif",
18+
webp: "image/webp",
19+
zip: "application/zip",
20+
html: "text/html",
21+
xml: "application/xml",
22+
};
23+
const ATTACH_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
24+
25+
function inferAttachmentContentType(path) {
26+
const ext = (path.split(".").pop() ?? "").toLowerCase();
27+
return ATTACH_EXT_CONTENT_TYPES[ext] ?? "application/octet-stream";
28+
}
29+
30+
/**
31+
* Parse repeatable `--attach <path>[:content-type]` flags into the SDK
32+
* attachment shape. The `:content-type` suffix is recognized only when the tail
33+
* after the last `:` looks like a MIME type, so paths containing a colon (e.g. a
34+
* Windows drive) are not mis-split.
35+
*/
36+
export function parseAttachments(args) {
37+
const out = [];
38+
for (let i = 0; i < args.length; i++) {
39+
if (args[i] !== "--attach") continue;
40+
const raw = args[++i];
41+
if (typeof raw !== "string" || raw.length === 0 || raw.startsWith("--")) {
42+
fail({ code: "BAD_USAGE", message: "--attach requires a <path>[:content-type] value" });
43+
}
44+
let path = raw;
45+
let contentType;
46+
const colon = raw.lastIndexOf(":");
47+
if (colon > 0 && ATTACH_MIME_RE.test(raw.slice(colon + 1))) {
48+
path = raw.slice(0, colon);
49+
contentType = raw.slice(colon + 1).toLowerCase();
50+
}
51+
let bytes;
52+
try {
53+
bytes = readFileSync(path);
54+
} catch (err) {
55+
fail({
56+
code: "BAD_USAGE",
57+
message: `Cannot read attachment file: ${path}`,
58+
details: { error: String((err && err.message) || err) },
59+
});
60+
}
61+
out.push({
62+
filename: basename(path),
63+
content_base64: bytes.toString("base64"),
64+
content_type: contentType ?? inferAttachmentContentType(path),
65+
});
66+
}
67+
return out;
68+
}
69+
670
const HELP = `run402 email — Send emails from your project
771
872
Usage:
@@ -45,6 +109,7 @@ Webhook subcommands:
45109
Send modes:
46110
Template: --template <name> --var key=value [--var ...] OR --vars '{"k":"v",...}'
47111
Raw HTML: --subject "..." --html "..." [--text "..."] (both --subject and --html required)
112+
Raw HTML also supports: --attach <path>[:content-type] (repeatable; max 5, 7 MB total)
48113
Both modes support: --from-name "Display Name" --project <id>
49114
50115
Choosing a mailbox:
@@ -65,6 +130,8 @@ Examples:
65130
--var project_name="My App" --var invite_url="https://example.com/invite/abc"
66131
run402 email send --to user@example.com --subject "Welcome!" \\
67132
--html "<h1>Hello</h1>" --from-name "My App"
133+
run402 email send --to user@example.com --subject "Your receipt" \\
134+
--html "<p>Attached.</p>" --attach ./receipt.pdf
68135
run402 email list --limit 50
69136
run402 email info
70137
run402 email get msg_abc123
@@ -86,7 +153,7 @@ const SUB_HELP = {
86153
Usage:
87154
run402 email send --to <email> --template <name> --var key=value [--var ...]
88155
run402 email send --to <email> --template <name> --vars '{"k":"v",...}'
89-
run402 email send --to <email> --subject "..." --html "..." [--text "..."]
156+
run402 email send --to <email> --subject "..." --html "..." [--text "..."] [--attach <path> ...]
90157
91158
Options:
92159
--to <email> Recipient email address (required; single recipient)
@@ -97,6 +164,9 @@ Options:
97164
--subject "..." Subject line (raw HTML mode; required with --html)
98165
--html "..." HTML body (raw HTML mode; required with --subject)
99166
--text "..." Plain-text body (raw HTML mode; optional)
167+
--attach <path>[:content-type] Attach a file (raw HTML mode only; repeatable;
168+
max 5, ≤ 7 MB total). Content-type is inferred from the
169+
extension when the :content-type suffix is omitted.
100170
--from-name "..." Display name for the From header
101171
--mailbox <slug|id> Target mailbox (required if the project has more than one)
102172
--project <id> Project ID (defaults to the active project)
@@ -258,7 +328,7 @@ async function create(args) {
258328
}
259329

260330
async function send(args) {
261-
const valueFlags = ["--template", "--to", "--subject", "--html", "--text", "--from-name", "--project", "--vars", "--var", "--mailbox"];
331+
const valueFlags = ["--template", "--to", "--subject", "--html", "--text", "--from-name", "--project", "--vars", "--var", "--mailbox", "--attach"];
262332
validateArgs(args, valueFlags);
263333
const template = strictFlagValue(args, "--template");
264334
const to = strictFlagValue(args, "--to");
@@ -269,6 +339,7 @@ async function send(args) {
269339
const mailbox = strictFlagValue(args, "--mailbox");
270340
const projectId = resolveProjectId(strictFlagValue(args, "--project"));
271341
const variables = parseVars(args);
342+
const attachments = parseAttachments(args);
272343

273344
if (!to) {
274345
fail({ code: "BAD_USAGE", message: "Missing --to <email>" });
@@ -284,6 +355,7 @@ async function send(args) {
284355
text: text ?? undefined,
285356
from_name: fromName ?? undefined,
286357
mailbox: mailbox ?? undefined,
358+
attachments: attachments.length ? attachments : undefined,
287359
});
288360
console.log(JSON.stringify({ message_id: data.message_id, to: data.to, template: data.template, subject: data.subject, sent: true }));
289361
} catch (err) {

cli/llms-cli.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,7 +1272,7 @@ Templates: `project_invite` (project_name, invite_url), `magic_link` (project_na
12721272
- `run402 email create <slug> [--project <id>]` — create mailbox at `<slug>@mail.run402.com`. NOT idempotent: a conflict (slug already in use, address in cooldown, or the project already has 5 mailboxes) returns a 409 error rather than an existing mailbox.
12731273
- `run402 email status [--mailbox <slug|id>] [--project <id>]` — show mailbox info (ID, address, slug)
12741274
- `run402 email send --template <name> --to <email> [--var key=value ...] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]`
1275-
- `run402 email send --to <email> --subject <subject> --html <html> [--text <text>] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]`
1275+
- `run402 email send --to <email> --subject <subject> --html <html> [--text <text>] [--attach <path>[:content-type] ...] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]`
12761276
- `run402 email list [--direction <inbound|outbound>] [--mailbox <slug|id>] [--project <id>]` — lists BOTH sent + received by default; `--direction inbound` lists received replies (the reconciliation backstop if a reply_received webhook is lost)
12771277
- `run402 email get <message_id> [--mailbox <slug|id>] [--project <id>]`
12781278
- `run402 email reply <message_id> --html <html> [--text <text>] [--subject <subject>] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]` — reply to an inbound message (threads via In-Reply-To)
@@ -1286,7 +1286,7 @@ Templates: `project_invite` (project_name, invite_url), `magic_link` (project_na
12861286
- `run402 email webhooks deliveries [--status <pending|in_flight|delivered|failed_permanent>] [--mailbox <slug|id>] [--project <id>]` — list durable delivery rows. Delivery is at-least-once with bounded retries + backoff; `failed_permanent` is the dead-letter queue. The delivered body is the canonical envelope `{ id, type, created_at, schema_version, idempotency_key, payload }` — consumers MUST dedupe on `idempotency_key`.
12871287
- `run402 email webhooks redrive <delivery_id> [--mailbox <slug|id>] [--project <id>]` — re-queue a dead-lettered (failed_permanent) delivery for another attempt
12881288

1289-
Raw HTML: `--subject` (max 998 chars) + `--html` (max 1MB). Plaintext auto-generated from HTML if `--text` omitted. `--from-name` sets display name on From header (both modes): `"My App" <slug@mail.run402.com>`.
1289+
Raw HTML: `--subject` (max 998 chars) + `--html` (max 1MB). Plaintext auto-generated from HTML if `--text` omitted. `--attach <path>[:content-type]` attaches a file (raw HTML mode only; repeatable, max 5, ≤ 7 MB total; content-type inferred from the extension when the suffix is omitted). `--from-name` sets display name on From header (both modes): `"My App" <slug@mail.run402.com>`.
12901290

12911291
Slug rules: 3-63 chars, lowercase alphanumeric + hyphens, no consecutive hyphens. `--project` defaults to the active project.
12921292

llms-mcp.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ Fixed platform-managed jobs. These tools do not run arbitrary Docker images; the
388388
- `passkey_login_options` / `passkey_login_verify` — WebAuthn passkey login. Params: `project_id`, `app_origin`, `email?` then `challenge_id`, `response`.
389389
- `list_passkeys` / `delete_passkey` — list or delete the authenticated user's passkeys. Params: `project_id`, `access_token`, `passkey_id?`.
390390
- `create_mailbox` / `get_mailbox` / `delete_mailbox` — up to 5 mailboxes per project at `<slug>@mail.run402.com`. Read/send/webhook tools take an optional `mailbox` (slug or `mbx_…` id) to choose one; omitting it on a project with more than one mailbox returns an ambiguity error naming the slugs. `create_mailbox` is NOT idempotent — a 409 (slug in use / cooldown / project at its 5-mailbox limit) is surfaced as an error, not recovered. `delete_mailbox` requires `confirm: true` and takes the target via `mailbox_id` (slug or id).
391-
- `send_email` — template (`project_invite`, `magic_link`, `notification`) or raw HTML. Single recipient. Params: `project_id`, `to`, `template?` + `variables?` OR `subject?` + `html?` + `text?`, `from_name?`, `in_reply_to?`, `mailbox?`.
391+
- `send_email` — template (`project_invite`, `magic_link`, `notification`) or raw HTML. Single recipient. Params: `project_id`, `to`, `template?` + `variables?` OR `subject?` + `html?` + `text?` + `attachments?`, `from_name?`, `in_reply_to?`, `mailbox?`. `attachments?` (raw mode only): `{ filename, content_base64, content_type }[]`, max 5, ≤ 7 MB total.
392392
- `list_emails` / `get_email` — read messages. Both take an optional `mailbox`.
393393
- `get_email_raw` — return raw RFC-822 bytes for DKIM / zk-email verification (inbound only). Params: `project_id`, `message_id`, `mailbox?`.
394394
- `register_mailbox_webhook` / `list_mailbox_webhooks` / `get_mailbox_webhook` / `update_mailbox_webhook` / `delete_mailbox_webhook` — email-event webhooks (events: `delivery`, `bounced`, `complained`, `reply_received`). Each takes an optional `mailbox`.

sdk/llms-sdk.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,9 @@ getMailbox(projectId): Promise<MailboxInfo>
10221022
deleteMailbox(projectId, mailboxId?): Promise<void>
10231023

10241024
send(projectId, opts: SendEmailOptions): Promise<SendEmailResult>
1025+
// opts.attachments?: { filename, content_base64, content_type }[] — RAW MODE
1026+
// ONLY (subject + html, not template). Max 5; ≤ 7 MB total decoded. Sent as a
1027+
// multipart/mixed MIME. Sent messages echo attachments_meta (names/types/sizes).
10251028
list(projectId, opts?: { limit?, after?, direction? }): Promise<EmailSummary[]>
10261029
// direction?: "inbound" | "outbound" — omit for BOTH. direction:"inbound" lists
10271030
// received replies (each EmailSummary carries `direction`) and is the
@@ -1053,7 +1056,7 @@ info(projectId): Promise<MailboxInfo>
10531056
delete(projectId, mailboxId?): Promise<void>
10541057
```
10551058

1056-
Templates: `project_invite`, `magic_link`, `notification`. Or pass `subject` + `html` for raw mode. Tier rate limits: prototype 10/day, hobby 50/day, team 500/day.
1059+
Templates: `project_invite`, `magic_link`, `notification`. Or pass `subject` + `html` for raw mode. Raw mode also accepts `attachments` (max 5, ≤ 7 MB total) — a multipart/mixed MIME is sent. Tier rate limits: prototype 10/day, hobby 50/day, team 500/day.
10571060

10581061
### `r.senderDomain`
10591062

sdk/src/namespaces/email.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,42 @@ describe("email.send", () => {
251251
);
252252
assert.equal(calls.length, 0);
253253
});
254+
255+
it("includes attachments in the raw-mode send body", async () => {
256+
const { fetch, calls } = mockFetch(() =>
257+
jsonResponse({ message_id: "msg_1", to: "user@example.invalid", template: null, subject: "test", status: "sent", sent_at: "2026-05-01T18:30:14.904Z" }),
258+
);
259+
const sdk = makeSdk(makeCreds(), fetch);
260+
const attachments = [{ filename: "a.pdf", content_base64: "JVBERi0=", content_type: "application/pdf" }];
261+
await sdk.email.send("prj_known", {
262+
to: "user@example.invalid",
263+
subject: "test",
264+
html: "<p>hi</p>",
265+
attachments,
266+
mailbox: "mbx_known",
267+
});
268+
assert.deepEqual(JSON.parse(calls[0]!.body as string).attachments, attachments);
269+
});
270+
271+
it("rejects attachments in template mode before requesting", async () => {
272+
const { fetch, calls } = mockFetch(() => {
273+
throw new Error("unexpected fetch for template+attachments");
274+
});
275+
const sdk = makeSdk(makeCreds(), fetch);
276+
await assert.rejects(
277+
sdk.email.send("prj_known", {
278+
to: "user@example.invalid",
279+
template: "notification",
280+
variables: { project_name: "X", message: "hi" },
281+
attachments: [{ filename: "a.pdf", content_base64: "JVBERi0=", content_type: "application/pdf" }],
282+
}),
283+
(err: unknown) =>
284+
err instanceof LocalError &&
285+
err.context === "sending email" &&
286+
/raw mode/i.test(err.message),
287+
);
288+
assert.equal(calls.length, 0);
289+
});
254290
});
255291

256292
describe("email message ids", () => {

sdk/src/namespaces/email.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ export type EmailTemplate = "project_invite" | "magic_link" | "notification";
5454
/** Selects a target mailbox on a multi-mailbox project: a mailbox id (`mbx_…`) or a slug. */
5555
export type MailboxSelector = string;
5656

57+
/** A single binary attachment (raw mode only). `content_base64` is the file's bytes, base64-encoded. */
58+
export interface EmailAttachment {
59+
filename: string;
60+
content_base64: string;
61+
content_type: string;
62+
}
63+
64+
/** Attachment metadata recorded on a sent message (names/types/sizes — never the bytes). */
65+
export interface EmailAttachmentMeta {
66+
filename: string;
67+
content_type: string;
68+
size_bytes: number;
69+
}
70+
5771
export interface SendEmailOptions {
5872
to: string;
5973
template?: EmailTemplate;
@@ -62,6 +76,12 @@ export interface SendEmailOptions {
6276
html?: string;
6377
text?: string;
6478
from_name?: string;
79+
/**
80+
* Binary attachments — RAW MODE ONLY (with `subject` + `html`, not `template`).
81+
* At most 5; each ≤ 7 MB and ≤ 7 MB total (decoded). The platform sends a
82+
* multipart/mixed MIME when present.
83+
*/
84+
attachments?: EmailAttachment[];
6585
in_reply_to?: string;
6686
/**
6787
* Target mailbox (slug or `mbx_…` id) on a project that has more than one.
@@ -93,6 +113,8 @@ export interface EmailSummary {
93113
to: string;
94114
status: string;
95115
created_at: string;
116+
/** Present (non-null) when the send carried attachments; names/types/sizes only. */
117+
attachments_meta?: EmailAttachmentMeta[] | null;
96118
}
97119

98120
export interface EmailDetail {
@@ -102,6 +124,8 @@ export interface EmailDetail {
102124
status: string;
103125
variables: Record<string, string>;
104126
created_at: string;
127+
/** Present (non-null) when the send carried attachments; names/types/sizes only. */
128+
attachments_meta?: EmailAttachmentMeta[] | null;
105129
replies?: Array<{
106130
id: string;
107131
from: string;
@@ -504,6 +528,13 @@ export class Email {
504528
"sending email",
505529
);
506530
}
531+
const hasAttachments = Array.isArray(opts.attachments) && opts.attachments.length > 0;
532+
if (hasAttachments && isTemplate) {
533+
throw new LocalError(
534+
"Attachments are only supported in raw mode (`subject` + `html`), not with `template`.",
535+
"sending email",
536+
);
537+
}
507538

508539
const { id, serviceKey } = await this.resolveMailbox(projectId, opts.mailbox);
509540
const body: Record<string, unknown> = { to: opts.to };
@@ -514,6 +545,7 @@ export class Email {
514545
body.subject = opts.subject;
515546
body.html = opts.html;
516547
if (opts.text !== undefined) body.text = opts.text;
548+
if (hasAttachments) body.attachments = opts.attachments;
517549
}
518550
if (opts.from_name !== undefined) body.from_name = opts.from_name;
519551
if (opts.in_reply_to !== undefined) body.in_reply_to = opts.in_reply_to;

src/tools/send-email.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,30 @@ describe("send_email tool", () => {
171171
assert.equal(parsed.from_name, "My App");
172172
});
173173

174+
it("forwards attachments in the raw-mode send body", async () => {
175+
let capturedBody: string | undefined;
176+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
177+
if (isMailboxListGet(url, init)) return mailboxListResponse();
178+
capturedBody = init?.body as string;
179+
return new Response(
180+
JSON.stringify({ message_id: "msg-004", status: "sent", to: "user@example.com", subject: "Receipt", template: null, sent_at: "2026-05-01T00:00:00.000Z" }),
181+
{ status: 200, headers: { "Content-Type": "application/json" } },
182+
);
183+
}) as typeof fetch;
184+
185+
const attachments = [{ filename: "receipt.pdf", content_base64: "JVBERi0=", content_type: "application/pdf" }];
186+
const result = await handleSendEmail({
187+
project_id: "proj-001",
188+
to: "user@example.com",
189+
subject: "Receipt",
190+
html: "<p>Attached.</p>",
191+
attachments,
192+
});
193+
194+
assert.equal(result.isError, undefined);
195+
assert.deepEqual(JSON.parse(capturedBody!).attachments, attachments);
196+
});
197+
174198
it("returns error when neither template nor subject/html provided", async () => {
175199
const result = await handleSendEmail({
176200
project_id: "proj-001",

0 commit comments

Comments
 (0)