Skip to content

Commit 832950c

Browse files
MajorTalclaude
andcommitted
Add project email (mailboxes) tools and CLI commands
New MCP tools: create_mailbox, send_email, list_emails, get_email New CLI commands: run402 email {create, send, list, get} New OpenClaw shim: openclaw/scripts/email.mjs Supports project-scoped mailboxes at <slug>@mail.run402.com with template-based sending (project_invite, magic_link, notification). Mailbox ID stored in keystore for automatic lookup on subsequent commands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 650e7cd commit 832950c

20 files changed

Lines changed: 1477 additions & 2 deletions

File tree

SKILL.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,59 @@ Setting an existing key overwrites it. All project functions are automatically u
241241
set_secret(project_id: "prj_...", key: "STRIPE_SECRET_KEY", value: "sk_live_...")
242242
```
243243

244+
### create_mailbox
245+
246+
Create a project-scoped email mailbox. One mailbox per project.
247+
248+
**Parameters:**
249+
- `project_id` (required) — Project ID
250+
- `slug` (required) — Mailbox slug (3-63 chars, lowercase alphanumeric + hyphens, no consecutive hyphens). Creates `<slug>@mail.run402.com`.
251+
252+
**Example:**
253+
```
254+
create_mailbox(project_id: "prj_...", slug: "my-app")
255+
```
256+
257+
### send_email
258+
259+
Send a template-based email from the project's mailbox. Single recipient only.
260+
261+
**Parameters:**
262+
- `project_id` (required) — Project ID
263+
- `template` (required) — Template name: `project_invite`, `magic_link`, or `notification`
264+
- `to` (required) — Recipient email address
265+
- `variables` (required) — Template variables object. `project_invite`: `project_name`, `invite_url`. `magic_link`: `project_name`, `link_url`, `expires_in`. `notification`: `project_name`, `message` (max 500 chars).
266+
267+
**Example:**
268+
```
269+
send_email(project_id: "prj_...", template: "project_invite", to: "user@example.com", variables: {"project_name": "My App", "invite_url": "https://..."})
270+
```
271+
272+
### list_emails
273+
274+
List sent emails from the project's mailbox.
275+
276+
**Parameters:**
277+
- `project_id` (required) — Project ID
278+
279+
**Example:**
280+
```
281+
list_emails(project_id: "prj_...")
282+
```
283+
284+
### get_email
285+
286+
Get a sent email with details and any replies.
287+
288+
**Parameters:**
289+
- `project_id` (required) — Project ID
290+
- `message_id` (required) — Message ID to retrieve
291+
292+
**Example:**
293+
```
294+
get_email(project_id: "prj_...", message_id: "msg_...")
295+
```
296+
244297
## Standard Workflow
245298

246299
Follow this sequence to go from zero to a working database:

cli/cli.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Commands:
3333
subdomains Manage custom subdomains (claim, list, delete)
3434
apps Browse and manage the app marketplace
3535
image Generate AI images via x402 or MPP micropayments
36+
email Send template-based emails from your project
3637
message Send messages to Run402 developers
3738
agent Manage agent identity (contact info)
3839
@@ -131,6 +132,11 @@ switch (cmd) {
131132
await run(sub, rest);
132133
break;
133134
}
135+
case "email": {
136+
const { run } = await import("./lib/email.mjs");
137+
await run(sub, rest);
138+
break;
139+
}
134140
case "message": {
135141
const { run } = await import("./lib/message.mjs");
136142
await run(sub, rest);

cli/lib/email.mjs

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { findProject, resolveProjectId, API, updateProject, loadKeyStore, saveKeyStore } from "./config.mjs";
2+
3+
const HELP = `run402 email — Send template-based emails from your project
4+
5+
Usage:
6+
run402 email <subcommand> [args...]
7+
8+
Subcommands:
9+
create <slug> [--project <id>] Create a mailbox (<slug>@mail.run402.com)
10+
send --template <name> --to <email> [--var key=value ...] [--project <id>]
11+
Send a template email
12+
list [--project <id>] List sent emails
13+
get <message_id> [--project <id>] Get a message with replies
14+
15+
Templates:
16+
project_invite — requires --var project_name=... --var invite_url=...
17+
magic_link — requires --var project_name=... --var link_url=... --var expires_in=...
18+
notification — requires --var project_name=... --var message=... (max 500 chars)
19+
20+
Examples:
21+
run402 email create my-app
22+
run402 email send --template project_invite --to user@example.com \\
23+
--var project_name="My App" --var invite_url="https://example.com/invite/abc"
24+
run402 email send --template notification --to admin@example.com \\
25+
--var project_name="My App" --var message="Deploy complete"
26+
run402 email list
27+
run402 email get msg_abc123
28+
29+
Notes:
30+
- One mailbox per project
31+
- Single recipient per send (no CC/BCC)
32+
- Slug: 3-63 chars, lowercase alphanumeric + hyphens, no consecutive hyphens
33+
- Rate limits vary by tier (prototype: 10/day, hobby: 50/day, team: 200/day)
34+
- --project defaults to the active project
35+
`;
36+
37+
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
38+
39+
function parseFlag(args, flag) {
40+
for (let i = 0; i < args.length; i++) {
41+
if (args[i] === flag && args[i + 1]) return args[i + 1];
42+
}
43+
return null;
44+
}
45+
46+
function parseVars(args) {
47+
const vars = {};
48+
for (let i = 0; i < args.length; i++) {
49+
if (args[i] === "--var" && args[i + 1]) {
50+
const raw = args[++i];
51+
const eq = raw.indexOf("=");
52+
if (eq > 0) {
53+
vars[raw.slice(0, eq)] = raw.slice(eq + 1);
54+
}
55+
}
56+
}
57+
return vars;
58+
}
59+
60+
async function resolveMailboxId(projectId, serviceKey) {
61+
const store = loadKeyStore();
62+
const proj = store.projects[projectId];
63+
if (proj && proj.mailbox_id) return proj.mailbox_id;
64+
65+
// Fallback: discover via API
66+
const res = await fetch(`${API}/mailboxes/v1`, {
67+
headers: { "Authorization": `Bearer ${serviceKey}` },
68+
});
69+
if (!res.ok) {
70+
const data = await res.json().catch(() => ({}));
71+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
72+
process.exit(1);
73+
}
74+
const mailboxes = await res.json();
75+
if (!Array.isArray(mailboxes) || mailboxes.length === 0) {
76+
console.error(JSON.stringify({ status: "error", message: "No mailbox found. Run: run402 email create <slug>" }));
77+
process.exit(1);
78+
}
79+
const mb = mailboxes[0];
80+
updateProject(projectId, { mailbox_id: mb.id, mailbox_address: mb.address });
81+
return mb.id;
82+
}
83+
84+
async function create(args) {
85+
const slug = args.find(a => !a.startsWith("--"));
86+
const projectId = resolveProjectId(parseFlag(args, "--project"));
87+
const p = findProject(projectId);
88+
89+
if (!slug) {
90+
console.error(JSON.stringify({ status: "error", message: "Missing slug. Usage: run402 email create <slug>" }));
91+
process.exit(1);
92+
}
93+
if (slug.length < 3 || slug.length > 63) {
94+
console.error(JSON.stringify({ status: "error", message: "Slug must be 3-63 characters." }));
95+
process.exit(1);
96+
}
97+
if (!SLUG_RE.test(slug)) {
98+
console.error(JSON.stringify({ status: "error", message: "Slug must be lowercase alphanumeric + hyphens, start/end with alphanumeric." }));
99+
process.exit(1);
100+
}
101+
if (slug.includes("--")) {
102+
console.error(JSON.stringify({ status: "error", message: "Slug must not contain consecutive hyphens." }));
103+
process.exit(1);
104+
}
105+
106+
const res = await fetch(`${API}/mailboxes/v1`, {
107+
method: "POST",
108+
headers: { "Authorization": `Bearer ${p.service_key}`, "Content-Type": "application/json" },
109+
body: JSON.stringify({ slug, project_id: projectId }),
110+
});
111+
const data = await res.json();
112+
if (!res.ok) {
113+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
114+
process.exit(1);
115+
}
116+
117+
updateProject(projectId, { mailbox_id: data.id, mailbox_address: data.address });
118+
console.log(JSON.stringify({ status: "ok", mailbox_id: data.id, address: data.address, slug: data.slug }));
119+
}
120+
121+
async function send(args) {
122+
const template = parseFlag(args, "--template");
123+
const to = parseFlag(args, "--to");
124+
const projectId = resolveProjectId(parseFlag(args, "--project"));
125+
const p = findProject(projectId);
126+
const variables = parseVars(args);
127+
128+
if (!template) {
129+
console.error(JSON.stringify({ status: "error", message: "Missing --template. Options: project_invite, magic_link, notification" }));
130+
process.exit(1);
131+
}
132+
if (!to) {
133+
console.error(JSON.stringify({ status: "error", message: "Missing --to <email>" }));
134+
process.exit(1);
135+
}
136+
137+
const mailboxId = await resolveMailboxId(projectId, p.service_key);
138+
139+
const res = await fetch(`${API}/mailboxes/v1/${mailboxId}/messages`, {
140+
method: "POST",
141+
headers: { "Authorization": `Bearer ${p.service_key}`, "Content-Type": "application/json" },
142+
body: JSON.stringify({ template, to, variables }),
143+
});
144+
const data = await res.json();
145+
if (!res.ok) {
146+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
147+
process.exit(1);
148+
}
149+
150+
console.log(JSON.stringify({ status: "ok", message_id: data.id, to: data.to, template: data.template }));
151+
}
152+
153+
async function list(args) {
154+
const projectId = resolveProjectId(parseFlag(args, "--project"));
155+
const p = findProject(projectId);
156+
const mailboxId = await resolveMailboxId(projectId, p.service_key);
157+
158+
const res = await fetch(`${API}/mailboxes/v1/${mailboxId}/messages`, {
159+
headers: { "Authorization": `Bearer ${p.service_key}` },
160+
});
161+
const data = await res.json();
162+
if (!res.ok) {
163+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
164+
process.exit(1);
165+
}
166+
167+
console.log(JSON.stringify(data, null, 2));
168+
}
169+
170+
async function get(args) {
171+
const messageId = args.find(a => !a.startsWith("--"));
172+
const projectId = resolveProjectId(parseFlag(args, "--project"));
173+
const p = findProject(projectId);
174+
175+
if (!messageId) {
176+
console.error(JSON.stringify({ status: "error", message: "Missing message_id. Usage: run402 email get <message_id>" }));
177+
process.exit(1);
178+
}
179+
180+
const mailboxId = await resolveMailboxId(projectId, p.service_key);
181+
182+
const res = await fetch(`${API}/mailboxes/v1/${mailboxId}/messages/${messageId}`, {
183+
headers: { "Authorization": `Bearer ${p.service_key}` },
184+
});
185+
const data = await res.json();
186+
if (!res.ok) {
187+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
188+
process.exit(1);
189+
}
190+
191+
console.log(JSON.stringify(data, null, 2));
192+
}
193+
194+
export async function run(sub, args) {
195+
if (!sub || sub === '--help' || sub === '-h') { console.log(HELP); process.exit(0); }
196+
switch (sub) {
197+
case "create": await create(args); break;
198+
case "send": await send(args); break;
199+
case "list": await list(args); break;
200+
case "get": await get(args); break;
201+
default:
202+
console.error(`Unknown subcommand: ${sub}\n`);
203+
console.log(HELP);
204+
process.exit(1);
205+
}
206+
}

openclaw/scripts/email.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { run } from "../../cli/lib/email.mjs";
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-03-24
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
## Context
2+
3+
Run402's `/mailboxes/v1` API is live in production, providing project-scoped email (one mailbox per project, template-based sending). The MCP server, CLI, and OpenClaw skill need tool/command support so developers can manage mailboxes and send emails from all three interfaces.
4+
5+
The existing codebase follows a strict pattern: MCP tools in `src/tools/`, CLI commands in `cli/lib/`, OpenClaw shims in `openclaw/scripts/`, all kept in sync via the `SURFACE` array in `sync.test.ts`. Email tools follow the same patterns as existing tools (secrets, functions, storage).
6+
7+
All email endpoints use `service_key` auth (same as secrets, SQL, etc.) — no paid/allowance auth needed. One admin-only endpoint (`POST /mailboxes/v1/:id/status`) is excluded from the initial tool surface since it's not developer-facing.
8+
9+
## Goals / Non-Goals
10+
11+
**Goals:**
12+
- Expose 4 MCP tools: `create_mailbox`, `send_email`, `list_emails`, `get_email`
13+
- Expose CLI command group: `run402 email {create, send, list, get}` with full `--help` documentation
14+
- Expose OpenClaw shim re-exporting CLI email module
15+
- Enforce slug validation client-side (3-63 chars, lowercase alphanumeric + hyphens, no consecutive hyphens)
16+
- Enforce single-recipient constraint client-side
17+
- Follow existing tool/test patterns exactly
18+
- Update `llms-cli.txt` in `~/dev/run402/site/` with email command reference
19+
20+
**Non-Goals:**
21+
- Admin endpoint (reactivate suspended mailbox) — not developer-facing
22+
- Webhook registration — defer to a future change
23+
- Mailbox deletion — available via API but not exposed in initial tool surface
24+
- Listing mailboxes separately — the API supports it but with 1-per-project constraint, `get_email` on a known mailbox suffices; mailbox ID is returned at creation time
25+
- Client-side rate limit tracking — the API returns 429 with clear messages
26+
27+
## Decisions
28+
29+
### 1. Four tools, not nine
30+
31+
The API has 9 endpoints but only 4 are needed for the developer workflow: create a mailbox, send an email, list sent messages, get a message. Webhooks, deletion, listing mailboxes, and admin reactivation are deferred.
32+
33+
**Why:** Keeps the tool surface small and focused. The sync test enforces parity — fewer tools means less maintenance. Webhook and delete can be added later as separate SURFACE entries.
34+
35+
### 2. Mailbox ID stored in keystore project record
36+
37+
After `create_mailbox` succeeds, store `mailbox_id` and `mailbox_address` in the project's keystore entry (via `updateProject()`). Subsequent email tools (`send_email`, `list_emails`, `get_email`) accept `project_id` and look up the mailbox ID automatically.
38+
39+
**Why:** Users shouldn't need to remember or pass mailbox IDs — there's only one per project. This mirrors how `site_url` is stored after `deploy_site`. The `StoredProject` interface in `core/src/keystore.ts` already supports arbitrary fields.
40+
41+
### 3. CLI uses `--var key=value` for template variables
42+
43+
Template variables are passed as repeatable `--var` flags: `--var project_name="My App" --var invite_url="https://..."`. The CLI parses these into a `variables` object.
44+
45+
**Why:** Follows CLI conventions for key-value pairs. The alternative (JSON string) is harder to type. The `--var` pattern is familiar from tools like `docker run -e`.
46+
47+
### 4. Slug validation on client side
48+
49+
Both MCP and CLI validate the slug format before sending the API request: 3-63 chars, `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, no consecutive hyphens. This gives faster, clearer error messages than waiting for a server 400.
50+
51+
**Why:** Better UX. The server validates too, so this is defense-in-depth, not a replacement.
52+
53+
### 5. No `mailbox_id` parameter on send/list/get
54+
55+
Since there's exactly one mailbox per project, tools look up the mailbox ID from the keystore. If no mailbox is found, the error message suggests running `create_mailbox` first.
56+
57+
**Why:** Simpler API surface. Eliminates a parameter the user would always have to look up. If multi-mailbox support is added later, we can add an optional `mailbox_id` parameter.
58+
59+
## Risks / Trade-offs
60+
61+
- **Keystore dependency for send/list/get** — If a user creates a mailbox outside the CLI/MCP (e.g., via curl), the keystore won't have the mailbox ID. Mitigation: tools can fall back to `GET /mailboxes/v1` to discover the mailbox if not in keystore.
62+
- **Template enum may grow** — Currently 3 templates. If more are added, the Zod enum needs updating. Mitigation: the server validates templates too, so a stale enum just means a less helpful error message temporarily.
63+
- **Slug validation drift** — Client and server slug rules could diverge. Mitigation: keep client validation conservative (subset of server rules). Server is authoritative.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
## Why
2+
3+
Run402 now supports project-scoped email at `<slug>@mail.run402.com`. Each project can create one mailbox and send template-based emails (invites, magic links, notifications). The backend is live in production — we need MCP tools, CLI commands, and OpenClaw shims so developers can use email from all three interfaces.
4+
5+
## What Changes
6+
7+
- Add MCP tools: `create_mailbox`, `send_email`, `list_emails`, `get_email`
8+
- Add CLI command group: `run402 email {create, send, list, get}`
9+
- Add OpenClaw shims that re-export CLI email module
10+
- Add unit tests for MCP tools (mock fetch pattern)
11+
- Update `sync.test.ts` SURFACE array with new email tools/commands
12+
- Update SKILL.md with email tool references
13+
14+
## Capabilities
15+
16+
### New Capabilities
17+
- `project-email`: Project-scoped mailbox creation, template-based email sending, and message listing via `/mailboxes/v1` API endpoints
18+
19+
### Modified Capabilities
20+
<!-- None — this is a new feature with no changes to existing spec-level behavior -->
21+
22+
## Impact
23+
24+
- **MCP server** (`src/tools/`): 4 new tool files + registration in `src/index.ts`
25+
- **CLI** (`cli/lib/`): New `email.mjs` module + registration in CLI entry point
26+
- **OpenClaw** (`openclaw/scripts/`): New `email.mjs` shim
27+
- **Core**: No core changes needed — existing `apiRequest()` and keystore are sufficient
28+
- **Tests**: New tool test files, sync test updates
29+
- **API**: Consumes 9 new `/mailboxes/v1` endpoints (service_key auth, one admin-only)

0 commit comments

Comments
 (0)