run402 1.36.1 → 1.38.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.
Files changed (2) hide show
  1. package/lib/email.mjs +263 -16
  2. package/package.json +1 -1
package/lib/email.mjs CHANGED
@@ -7,12 +7,18 @@ Usage:
7
7
 
8
8
  Subcommands:
9
9
  create <slug> [--project <id>] Create a mailbox (<slug>@mail.run402.com)
10
- status [--project <id>] Show mailbox info (ID, address, slug)
10
+ info [--project <id>] Show mailbox info (ID, address, slug)
11
+ status [--project <id>] Alias for 'info' (prefer 'info')
11
12
  send --to <email> [mode flags] Send an email (template or raw HTML)
12
- list [--project <id>] List sent emails
13
+ list [--limit <n>] [--after <cursor>] [--project <id>]
14
+ List sent/received messages (paginated)
13
15
  get <message_id> [--project <id>] Get a message with replies
14
16
  get-raw <message_id> [--project <id>] [--output <file>]
15
- Fetch raw RFC-822 bytes (inbound only)
17
+ Fetch raw RFC-822 bytes (inbound only)
18
+ reply <message_id> --html "..." [--text "..."] [--subject "..."] [--from-name "..."] [--project <id>]
19
+ Reply to an inbound message (threads via In-Reply-To)
20
+ delete [<mailbox_id>] --confirm [--project <id>]
21
+ Delete the project's mailbox (irreversible)
16
22
  webhooks <action> [args...] Manage webhooks (see below)
17
23
 
18
24
  Webhook subcommands:
@@ -25,8 +31,8 @@ Webhook subcommands:
25
31
  Register a new webhook
26
32
 
27
33
  Send modes:
28
- Template: --template <name> --var key=value [--var ...]
29
- Raw HTML: --subject "..." --html "..." [--text "..."]
34
+ Template: --template <name> --var key=value [--var ...] OR --vars '{"k":"v",...}'
35
+ Raw HTML: --subject "..." --html "..." [--text "..."] (both --subject and --html required)
30
36
  Both modes support: --from-name "Display Name" --project <id>
31
37
 
32
38
  Templates:
@@ -38,13 +44,19 @@ Examples:
38
44
  run402 email create my-app
39
45
  run402 email send --template project_invite --to user@example.com \\
40
46
  --var project_name="My App" --var invite_url="https://example.com/invite/abc"
47
+ run402 email send --template project_invite --to user@example.com \\
48
+ --vars '{"project_name":"My App","invite_url":"https://example.com/invite/abc"}'
41
49
  run402 email send --to user@example.com --subject "Welcome!" \\
42
50
  --html "<h1>Hello</h1><p>Welcome aboard.</p>" --from-name "My App"
43
51
  run402 email send --template notification --to admin@example.com \\
44
52
  --var project_name="My App" --var message="Deploy complete"
45
- run402 email list
53
+ run402 email list --limit 50
54
+ run402 email list --limit 50 --after msg_abc123
55
+ run402 email info
46
56
  run402 email get msg_abc123
47
57
  run402 email get-raw msg_abc123 --output reply.eml
58
+ run402 email reply msg_abc123 --html "<p>Thanks!</p>"
59
+ run402 email delete --confirm
48
60
  run402 email webhooks list
49
61
  run402 email webhooks register --url https://example.com/hook --events delivery,bounced
50
62
 
@@ -61,6 +73,7 @@ const SUB_HELP = {
61
73
 
62
74
  Usage:
63
75
  run402 email send --to <email> --template <name> --var key=value [--var ...]
76
+ run402 email send --to <email> --template <name> --vars '{"k":"v",...}'
64
77
  run402 email send --to <email> --subject "..." --html "..." [--text "..."]
65
78
 
66
79
  Options:
@@ -69,24 +82,105 @@ Options:
69
82
  notification
70
83
  --var key=value Template variable (repeatable; required keys vary by
71
84
  template)
72
- --subject "..." Subject line (raw HTML mode)
73
- --html "..." HTML body (raw HTML mode)
85
+ --vars '<json>' All template variables as a single JSON object
86
+ (alternative to multiple --var). Later --var overrides.
87
+ --subject "..." Subject line (raw HTML mode; required with --html)
88
+ --html "..." HTML body (raw HTML mode; required with --subject)
74
89
  --text "..." Plain-text body (raw HTML mode; optional)
75
90
  --from-name "..." Display name for the From header
76
91
  --project <id> Project ID (defaults to the active project)
77
92
 
78
93
  Templates:
79
- project_invite --var project_name=... --var invite_url=...
80
- magic_link --var project_name=... --var link_url=... --var expires_in=...
81
- notification --var project_name=... --var message=... (max 500 chars)
94
+ project_invite project_name, invite_url
95
+ magic_link project_name, link_url, expires_in
96
+ notification project_name, message (max 500 chars)
82
97
 
83
98
  Examples:
84
99
  run402 email send --template project_invite --to user@example.com \\
85
100
  --var project_name="My App" --var invite_url="https://example.com/invite/abc"
101
+ run402 email send --template project_invite --to user@example.com \\
102
+ --vars '{"project_name":"My App","invite_url":"https://example.com/invite/abc"}'
86
103
  run402 email send --to user@example.com --subject "Welcome!" \\
87
104
  --html "<h1>Hello</h1><p>Welcome aboard.</p>" --from-name "My App"
88
- run402 email send --template notification --to admin@example.com \\
89
- --var project_name="My App" --var message="Deploy complete"
105
+ `,
106
+ list: `run402 email list List messages in the mailbox
107
+
108
+ Usage:
109
+ run402 email list [--limit <n>] [--after <cursor>] [--project <id>]
110
+
111
+ Options:
112
+ --limit <n> Max messages to return (server caps at 200)
113
+ --after <cursor> Pagination cursor (message id from prior page)
114
+ --project <id> Project ID (defaults to the active project)
115
+
116
+ Examples:
117
+ run402 email list
118
+ run402 email list --limit 50
119
+ run402 email list --limit 50 --after msg_abc123
120
+ `,
121
+ reply: `run402 email reply — Reply to an inbound message (threaded via In-Reply-To)
122
+
123
+ Usage:
124
+ run402 email reply <message_id> --html "..." [--text "..."] [options]
125
+
126
+ Arguments:
127
+ <message_id> Inbound message ID to reply to
128
+
129
+ Options:
130
+ --html "..." HTML reply body (required unless --text is given)
131
+ --text "..." Plain-text reply body (required unless --html is given)
132
+ --subject "..." Override the reply subject (default: "Re: <original>")
133
+ --from-name "..." Display name for the From header
134
+ --project <id> Project ID (defaults to the active project)
135
+
136
+ Notes:
137
+ The CLI fetches the original message to derive the reply-to address and
138
+ subject, then POSTs a new message with in_reply_to = <message_id> so the
139
+ server can wire the RFC-822 In-Reply-To / References headers.
140
+
141
+ Examples:
142
+ run402 email reply msg_abc123 --html "<p>Thanks, here's the info you asked for.</p>"
143
+ run402 email reply msg_abc123 --subject "Re: invoice #42" --text "Paid, thanks."
144
+ `,
145
+ delete: `run402 email delete — Delete the project's mailbox (irreversible)
146
+
147
+ Usage:
148
+ run402 email delete [<mailbox_id>] --confirm [--project <id>]
149
+
150
+ Arguments:
151
+ <mailbox_id> Mailbox ID to delete (defaults to the project's mailbox)
152
+
153
+ Options:
154
+ --confirm Required: explicit confirmation flag
155
+ --project <id> Project ID (defaults to the active project)
156
+
157
+ Notes:
158
+ Destructive. Drops all messages and webhook subscriptions. Cached
159
+ mailbox_id in the local keystore is cleared on success.
160
+
161
+ Examples:
162
+ run402 email delete --confirm
163
+ run402 email delete mbx_abc123 --confirm
164
+ `,
165
+ info: `run402 email info — Show mailbox info (ID, address, slug)
166
+
167
+ Usage:
168
+ run402 email info [--project <id>]
169
+
170
+ Options:
171
+ --project <id> Project ID (defaults to the active project)
172
+
173
+ Notes:
174
+ Same output as 'run402 email status' (kept as an alias for backward
175
+ compatibility). 'info' is the preferred name.
176
+ `,
177
+ status: `run402 email status — Alias for 'run402 email info' (prefer 'info')
178
+
179
+ Usage:
180
+ run402 email status [--project <id>]
181
+
182
+ See 'run402 email info --help' for details. 'status' is kept for backward
183
+ compatibility; new code should use 'info'.
90
184
  `,
91
185
  "get-raw": `run402 email get-raw — Fetch raw RFC-822 bytes for an inbound message
92
186
 
@@ -117,6 +211,23 @@ function parseFlag(args, flag) {
117
211
 
118
212
  function parseVars(args) {
119
213
  const vars = {};
214
+ // Apply --vars '<json>' first so later --var can override on key collision.
215
+ for (let i = 0; i < args.length; i++) {
216
+ if (args[i] === "--vars" && args[i + 1]) {
217
+ const raw = args[++i];
218
+ let parsed;
219
+ try { parsed = JSON.parse(raw); } catch {
220
+ console.error(JSON.stringify({ status: "error", message: "Invalid JSON for --vars. Expected a JSON object, e.g. '{\"key\":\"value\"}'" }));
221
+ process.exit(1);
222
+ }
223
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
224
+ console.error(JSON.stringify({ status: "error", message: "--vars must be a JSON object, e.g. '{\"key\":\"value\"}'" }));
225
+ process.exit(1);
226
+ }
227
+ for (const [k, v] of Object.entries(parsed)) vars[k] = typeof v === "string" ? v : String(v);
228
+ }
229
+ }
230
+ // Then --var key=value (later wins).
120
231
  for (let i = 0; i < args.length; i++) {
121
232
  if (args[i] === "--var" && args[i + 1]) {
122
233
  const raw = args[++i];
@@ -231,10 +342,21 @@ async function send(args) {
231
342
  process.exit(1);
232
343
  }
233
344
 
234
- const isRaw = !!(subject || html);
345
+ const hasSubject = !!subject;
346
+ const hasHtml = !!html;
347
+ const isRaw = hasSubject || hasHtml;
235
348
  const isTemplate = !!template;
236
349
  if (!isRaw && !isTemplate) {
237
- console.error(JSON.stringify({ status: "error", message: "Provide --template (template mode) or --subject + --html (raw HTML mode)" }));
350
+ console.error(JSON.stringify({ status: "error", message: "Provide --template (template mode) or both --subject and --html (raw HTML mode)" }));
351
+ process.exit(1);
352
+ }
353
+ if (isRaw && isTemplate) {
354
+ console.error(JSON.stringify({ status: "error", message: "Provide --template OR raw mode (--subject + --html), not both" }));
355
+ process.exit(1);
356
+ }
357
+ if (isRaw && !(hasSubject && hasHtml)) {
358
+ const missing = hasSubject ? "--html" : "--subject";
359
+ console.error(JSON.stringify({ status: "error", message: `Raw mode requires both --subject and --html (missing ${missing})` }));
238
360
  process.exit(1);
239
361
  }
240
362
 
@@ -268,9 +390,16 @@ async function send(args) {
268
390
  async function list(args) {
269
391
  const projectId = resolveProjectId(parseFlag(args, "--project"));
270
392
  const p = findProject(projectId);
393
+ const limit = parseFlag(args, "--limit");
394
+ const after = parseFlag(args, "--after");
271
395
  const mailboxId = await requireMailboxId(projectId, p.service_key);
272
396
 
273
- const res = await fetch(`${API}/mailboxes/v1/${mailboxId}/messages`, {
397
+ const qs = new URLSearchParams();
398
+ if (limit) qs.set("limit", limit);
399
+ if (after) qs.set("after", after);
400
+ const url = `${API}/mailboxes/v1/${mailboxId}/messages${qs.toString() ? "?" + qs.toString() : ""}`;
401
+
402
+ const res = await fetch(url, {
274
403
  headers: { "Authorization": `Bearer ${p.service_key}` },
275
404
  });
276
405
  const data = await res.json();
@@ -352,6 +481,121 @@ async function getRaw(args) {
352
481
  }
353
482
  }
354
483
 
484
+ async function reply(args) {
485
+ let messageId = null;
486
+ let projectOpt = null;
487
+ let outputFile = null;
488
+ void outputFile;
489
+ for (let i = 0; i < args.length; i++) {
490
+ const a = args[i];
491
+ if (a === "--project" && args[i + 1]) { projectOpt = args[++i]; }
492
+ else if (a === "--html" || a === "--text" || a === "--subject" || a === "--from-name") { i++; }
493
+ else if (!a.startsWith("--") && !messageId) { messageId = a; }
494
+ }
495
+ const html = parseFlag(args, "--html");
496
+ const text = parseFlag(args, "--text");
497
+ const subjectOverride = parseFlag(args, "--subject");
498
+ const fromName = parseFlag(args, "--from-name");
499
+ const projectId = resolveProjectId(projectOpt);
500
+ const p = findProject(projectId);
501
+
502
+ if (!messageId) {
503
+ console.error(JSON.stringify({ status: "error", message: "Missing message_id. Usage: run402 email reply <message_id> --html \"...\"" }));
504
+ process.exit(1);
505
+ }
506
+ if (!html && !text) {
507
+ console.error(JSON.stringify({ status: "error", message: "Provide --html and/or --text for the reply body" }));
508
+ process.exit(1);
509
+ }
510
+
511
+ const mailboxId = await requireMailboxId(projectId, p.service_key);
512
+
513
+ const getRes = await fetch(`${API}/mailboxes/v1/${mailboxId}/messages/${messageId}`, {
514
+ headers: { "Authorization": `Bearer ${p.service_key}` },
515
+ });
516
+ const original = await getRes.json().catch(() => ({}));
517
+ if (!getRes.ok) {
518
+ console.error(JSON.stringify({ status: "error", http: getRes.status, message: "Failed to fetch original message", ...original }));
519
+ process.exit(1);
520
+ }
521
+
522
+ const replyTo = original.from || original.from_address || original.sender || null;
523
+ if (!replyTo) {
524
+ console.error(JSON.stringify({ status: "error", message: "Original message has no from address to reply to", original_keys: Object.keys(original) }));
525
+ process.exit(1);
526
+ }
527
+ const origSubject = typeof original.subject === "string" ? original.subject : "";
528
+ const defaultSubject = origSubject && origSubject.toLowerCase().startsWith("re:")
529
+ ? origSubject
530
+ : `Re: ${origSubject || "(no subject)"}`;
531
+ const replySubject = subjectOverride || defaultSubject;
532
+
533
+ const body = { to: replyTo, subject: replySubject, in_reply_to: messageId };
534
+ if (html) body.html = html;
535
+ if (text) body.text = text;
536
+ if (fromName) body.from_name = fromName;
537
+
538
+ const res = await fetch(`${API}/mailboxes/v1/${mailboxId}/messages`, {
539
+ method: "POST",
540
+ headers: { "Authorization": `Bearer ${p.service_key}`, "Content-Type": "application/json" },
541
+ body: JSON.stringify(body),
542
+ });
543
+ const data = await res.json().catch(() => ({}));
544
+ if (!res.ok) {
545
+ console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
546
+ process.exit(1);
547
+ }
548
+
549
+ console.log(JSON.stringify({ status: "ok", message_id: data.id, to: data.to, subject: replySubject, in_reply_to: messageId }));
550
+ }
551
+
552
+ async function deleteMailbox(args) {
553
+ let positional = null;
554
+ let projectOpt = null;
555
+ for (let i = 0; i < args.length; i++) {
556
+ const a = args[i];
557
+ if (a === "--project" && args[i + 1]) { projectOpt = args[++i]; }
558
+ else if (a === "--confirm") { /* flag */ }
559
+ else if (!a.startsWith("--") && !positional) { positional = a; }
560
+ }
561
+ const projectId = resolveProjectId(projectOpt);
562
+ const p = findProject(projectId);
563
+ const confirmed = args.includes("--confirm");
564
+
565
+ if (!confirmed) {
566
+ console.error(JSON.stringify({
567
+ status: "error",
568
+ message: "Destructive: deleting a mailbox is irreversible (drops all messages and webhook subscriptions). Re-run with --confirm to proceed.",
569
+ }));
570
+ process.exit(1);
571
+ }
572
+
573
+ const mailboxId = positional || await requireMailboxId(projectId, p.service_key);
574
+
575
+ const res = await fetch(`${API}/mailboxes/v1/${mailboxId}`, {
576
+ method: "DELETE",
577
+ headers: { "Authorization": `Bearer ${p.service_key}` },
578
+ });
579
+ if (res.status !== 204 && !res.ok) {
580
+ let errBody;
581
+ try { errBody = await res.json(); } catch { errBody = {}; }
582
+ console.error(JSON.stringify({ status: "error", http: res.status, ...errBody }));
583
+ process.exit(1);
584
+ }
585
+
586
+ // Clear the cached mailbox_id/address from the local keystore so future
587
+ // email commands re-discover (or fail-fast with "no mailbox found").
588
+ const store = loadKeyStore();
589
+ const proj = store.projects[projectId];
590
+ if (proj) {
591
+ delete proj.mailbox_id;
592
+ delete proj.mailbox_address;
593
+ saveKeyStore(store);
594
+ }
595
+
596
+ console.log(JSON.stringify({ status: "ok", mailbox_id: mailboxId, deleted: true }));
597
+ }
598
+
355
599
  async function status(args) {
356
600
  const projectId = resolveProjectId(parseFlag(args, "--project"));
357
601
  const p = findProject(projectId);
@@ -380,11 +624,14 @@ export async function run(sub, args) {
380
624
  if (Array.isArray(args) && (args.includes("--help") || args.includes("-h")) && sub !== "webhooks") { console.log(SUB_HELP[sub] || HELP); process.exit(0); }
381
625
  switch (sub) {
382
626
  case "create": await create(args); break;
627
+ case "info": // fall through — 'info' is the preferred name; 'status' is a backward-compat alias
383
628
  case "status": await status(args); break;
384
629
  case "send": await send(args); break;
385
630
  case "list": await list(args); break;
386
631
  case "get": await get(args); break;
387
632
  case "get-raw": await getRaw(args); break;
633
+ case "reply": await reply(args); break;
634
+ case "delete": await deleteMailbox(args); break;
388
635
  case "webhooks": {
389
636
  const { run: runWebhooks } = await import("./webhooks.mjs");
390
637
  await runWebhooks(args[0], args.slice(1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "1.36.1",
3
+ "version": "1.38.0",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
5
5
  "type": "module",
6
6
  "bin": {