Skip to content

Commit a463b49

Browse files
volinskeyclaude
andcommitted
feat(email): expose durable webhook deliveries (DLQ + redrive) + list direction filter
Surfaces for the run402 durable mailbox-webhook delivery change. Webhook delivery is now at-least-once with a dead-letter queue; consumers dedupe on the canonical envelope's idempotency_key (also the Run402-Webhook-Id header). - SDK: email.webhooks.listDeliveries / redriveDelivery; direction filter on email.list (EmailSummary now carries direction). scoped.ts mirrors both. - CLI: run402 email webhooks deliveries [--status] / redrive <id>; run402 email list --direction <inbound|outbound>. - MCP: new list_mailbox_webhook_deliveries + redrive_mailbox_webhook_delivery tools; direction on list_emails. Registered in src/index.ts; sync.test SURFACE + SDK_BY_CAPABILITY updated. - Docs: llms-mcp.txt, sdk/llms-sdk.txt, cli/llms-cli.txt, SKILL.md, openclaw/SKILL.md document the at-least-once + idempotency contract, the canonical envelope, the deliveries/redrive surface, and the inbound reconciliation backstop. Pairs with run402 commit (gateway + Lambdas). Lockstep publish pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed1afe0 commit a463b49

18 files changed

Lines changed: 564 additions & 24 deletions

SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@ Function authoring limits per tier: prototype 10s / 128 MB / 1 scheduled fn / 15
509509
- **`send_email`** — template (`project_invite`, `magic_link`, `notification`) or raw HTML. Single recipient. Optional `mailbox` selector.
510510
- **`list_emails`** / **`get_email`** / **`get_email_raw`** — read messages. `get_email_raw` returns RFC-822 bytes for DKIM / zk-email verification.
511511
- **`register_mailbox_webhook`** / **`list_mailbox_webhooks`** / **`get_mailbox_webhook`** / **`update_mailbox_webhook`** / **`delete_mailbox_webhook`** — email-event webhooks (delivery, bounced, complained, reply_received).
512+
- **`list_mailbox_webhook_deliveries`** / **`redrive_mailbox_webhook_delivery`** — durable-delivery visibility + replay. Delivery is at-least-once (bounded retries + exponential backoff); failures land in `failed_permanent`, 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`. `list_emails` accepts an optional `direction` (`inbound`|`outbound`); `inbound` lists received replies as the reconciliation backstop if a `reply_received` webhook is lost.
512513
- **`register_sender_domain`** / **`sender_domain_status`** / **`remove_sender_domain`** — send from your own domain (DKIM verified).
513514
- **`enable_sender_domain_inbound`** / **`disable_sender_domain_inbound`** — receive replies on your custom sender domain.
514515

cli-argv.test.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,32 @@ describe("email webhooks argv validation", () => {
291291
assert.equal(err.details.flag, "--url");
292292
assert.equal(calls.length, 0, "invalid argv must not hit the network");
293293
});
294+
295+
it("webhooks redrive rejects missing delivery_id before network", async () => {
296+
const { run } = await import("./cli/lib/webhooks.mjs");
297+
const err = await expectExit1(() => run("redrive", ["--project", "prj_test123"]));
298+
299+
assert.equal(err.code, "BAD_USAGE");
300+
assert.equal(calls.length, 0, "invalid argv must not hit the network");
301+
});
302+
303+
it("webhooks deliveries rejects unknown flag before network", async () => {
304+
const { run } = await import("./cli/lib/webhooks.mjs");
305+
const err = await expectExit1(() => run("deliveries", ["--statuz", "pending"]));
306+
307+
assert.equal(err.code, "UNKNOWN_FLAG");
308+
assert.equal(err.details.flag, "--statuz");
309+
assert.equal(calls.length, 0, "invalid argv must not hit the network");
310+
});
311+
312+
it("webhooks deliveries rejects missing --status value before network", async () => {
313+
const { run } = await import("./cli/lib/webhooks.mjs");
314+
const err = await expectExit1(() => run("deliveries", ["--status"]));
315+
316+
assert.equal(err.code, "BAD_FLAG");
317+
assert.equal(err.details.flag, "--status");
318+
assert.equal(calls.length, 0, "invalid argv must not hit the network");
319+
});
294320
});
295321

296322
describe("ai argv validation (GH-280)", () => {

cli/lib/email.mjs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ Subcommands:
1313
info [--project <id>] Show mailbox info (ID, address, slug)
1414
status [--project <id>] Alias for 'info' (prefer 'info')
1515
send --to <email> [mode flags] Send an email (template or raw HTML)
16-
list [--limit <n>] [--after <cursor>] [--project <id>]
17-
List sent/received messages (paginated)
16+
list [--limit <n>] [--after <cursor>] [--direction <inbound|outbound>] [--project <id>]
17+
List messages (paginated). Returns BOTH
18+
sent + received by default; --direction
19+
inbound is the reconciliation backstop.
1820
get <message_id> [--project <id>] Get a message with replies
1921
get-raw <message_id> --output <file> [--project <id>]
2022
Fetch raw RFC-822 bytes (inbound only).
@@ -35,6 +37,10 @@ Webhook subcommands:
3537
Update a webhook
3638
webhooks register --url <url> --events <e1,e2> [--project <id>]
3739
Register a new webhook
40+
webhooks deliveries [--status <s>] [--project <id>]
41+
List durable delivery rows (DLQ visibility)
42+
webhooks redrive <delivery_id> [--project <id>]
43+
Re-queue a dead-lettered delivery
3844
3945
Send modes:
4046
Template: --template <name> --var key=value [--var ...] OR --vars '{"k":"v",...}'
@@ -286,16 +292,18 @@ async function send(args) {
286292
}
287293

288294
async function list(args) {
289-
const valueFlags = ["--project", "--limit", "--after", "--mailbox"];
295+
const valueFlags = ["--project", "--limit", "--after", "--mailbox", "--direction"];
290296
validateArgs(args, valueFlags);
291297
const projectId = resolveProjectId(strictFlagValue(args, "--project"));
292298
const limit = strictFlagValue(args, "--limit");
293299
const after = strictFlagValue(args, "--after");
294300
const mailbox = strictFlagValue(args, "--mailbox");
301+
const direction = strictFlagValue(args, "--direction");
295302
try {
296303
const data = await getSdk().email.list(projectId, {
297304
limit: limit ? parseIntegerFlag("--limit", limit) : undefined,
298305
after: after ?? undefined,
306+
direction: direction ?? undefined,
299307
mailbox: mailbox ?? undefined,
300308
});
301309
console.log(JSON.stringify(data, null, 2));

cli/lib/webhooks.mjs

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,22 @@ Usage:
99
run402 email webhooks <action> [args...]
1010
1111
Actions:
12-
list [--mailbox <slug|id>] [--project <id>] List webhooks
13-
get <webhook_id> [--mailbox <slug|id>] [--project <id>] Get a webhook
14-
delete <webhook_id> [--mailbox <slug|id>] [--project <id>] Delete a webhook
15-
update <webhook_id> [--url <url>] [--events <e1,e2>] [--mailbox <slug|id>] Update a webhook
16-
register --url <url> --events <e1,e2> [--mailbox <slug|id>] [--project <id>] Register a new webhook
12+
list [--mailbox <slug|id>] [--project <id>] List webhooks
13+
get <webhook_id> [--mailbox <slug|id>] [--project <id>] Get a webhook
14+
delete <webhook_id> [--mailbox <slug|id>] [--project <id>] Delete a webhook
15+
update <webhook_id> [--url <url>] [--events <e1,e2>] [--mailbox <slug|id>] Update a webhook
16+
register --url <url> --events <e1,e2> [--mailbox <slug|id>] [--project <id>] Register a new webhook
17+
deliveries [--status <s>] [--mailbox <slug|id>] [--project <id>] List durable delivery rows (DLQ visibility)
18+
redrive <delivery_id> [--mailbox <slug|id>] [--project <id>] Re-queue a dead-lettered delivery
1719
1820
Valid events: delivery, bounced, complained, reply_received
21+
Delivery statuses: pending, in_flight, delivered, failed_permanent (the DLQ)
22+
23+
Webhook delivery is durable + at-least-once: failures retry with backoff, then
24+
land in failed_permanent (the dead-letter queue). The delivered body is the
25+
canonical envelope { id, type, created_at, schema_version, idempotency_key,
26+
payload } — consumers MUST dedupe on idempotency_key. Use 'deliveries' to
27+
inspect what was lost and 'redrive' to replay a dead-lettered delivery.
1928
2029
Pass --mailbox <slug|id> to target a specific mailbox when the project has more than one.
2130
@@ -24,6 +33,8 @@ Examples:
2433
run402 email webhooks register --url https://example.com/hook --events delivery,bounced
2534
run402 email webhooks update whk_123 --url https://new.example.com/hook
2635
run402 email webhooks delete whk_123
36+
run402 email webhooks deliveries --status failed_permanent
37+
run402 email webhooks redrive wd_123
2738
`;
2839

2940
const SUB_HELP = {
@@ -196,16 +207,60 @@ async function register(args) {
196207
}
197208
}
198209

210+
async function deliveries(args) {
211+
const valueFlags = ["--project", "--mailbox", "--status", "--limit", "--after"];
212+
validateArgs(args, valueFlags);
213+
const projectId = resolveProjectId(strictFlagValue(args, "--project"));
214+
const mailbox = strictFlagValue(args, "--mailbox");
215+
const status = strictFlagValue(args, "--status");
216+
const limitRaw = strictFlagValue(args, "--limit");
217+
const after = strictFlagValue(args, "--after");
218+
try {
219+
const data = await getSdk().email.webhooks.listDeliveries(projectId, {
220+
status: status ?? undefined,
221+
limit: limitRaw ? Number(limitRaw) : undefined,
222+
after: after ?? undefined,
223+
mailbox: mailbox ?? undefined,
224+
});
225+
console.log(JSON.stringify(data, null, 2));
226+
} catch (err) {
227+
reportSdkError(err);
228+
}
229+
}
230+
231+
async function redrive(args) {
232+
const valueFlags = ["--project", "--mailbox"];
233+
validateArgs(args, valueFlags);
234+
const deliveryId = positionalArgs(args, valueFlags)[0] ?? null;
235+
const projectId = resolveProjectId(strictFlagValue(args, "--project"));
236+
const mailbox = strictFlagValue(args, "--mailbox");
237+
if (!deliveryId) {
238+
fail({
239+
code: "BAD_USAGE",
240+
message: "Missing delivery_id.",
241+
hint: "run402 email webhooks redrive <delivery_id>",
242+
});
243+
}
244+
try {
245+
const data = await getSdk().email.webhooks.redriveDelivery(projectId, deliveryId, { mailbox: mailbox ?? undefined });
246+
console.log(JSON.stringify(data, null, 2));
247+
} catch (err) {
248+
reportSdkError(err);
249+
}
250+
}
251+
199252
export async function run(sub, args) {
200253
args = normalizeArgv(args);
201254
if (!sub || sub === '--help' || sub === '-h') { console.log(HELP); process.exit(0); }
202255
if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) { console.log(SUB_HELP[sub] || HELP); process.exit(0); }
203256
switch (sub) {
204-
case "list": await list(args); break;
205-
case "get": await get(args); break;
206-
case "delete": await del(args); break;
207-
case "update": await update(args); break;
208-
case "register": await register(args); break;
257+
case "list": await list(args); break;
258+
case "get": await get(args); break;
259+
case "delete": await del(args); break;
260+
case "update": await update(args); break;
261+
case "register": await register(args); break;
262+
case "deliveries": await deliveries(args); break;
263+
case "redrive": await redrive(args); break;
209264
default:
210265
console.error(`Unknown webhooks action: ${sub}\n`);
211266
console.log(HELP);

cli/llms-cli.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1226,7 +1226,7 @@ Templates: `project_invite` (project_name, invite_url), `magic_link` (project_na
12261226
- `run402 email status [--mailbox <slug|id>] [--project <id>]` — show mailbox info (ID, address, slug)
12271227
- `run402 email send --template <name> --to <email> [--var key=value ...] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]`
12281228
- `run402 email send --to <email> --subject <subject> --html <html> [--text <text>] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]`
1229-
- `run402 email list [--mailbox <slug|id>] [--project <id>]`
1229+
- `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)
12301230
- `run402 email get <message_id> [--mailbox <slug|id>] [--project <id>]`
12311231
- `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)
12321232
- `run402 email get-raw <message_id> --output <file> [--mailbox <slug|id>] [--project <id>]` — fetch raw RFC-822 bytes of an inbound message (for DKIM/zk-email verification). `--output` is required: bytes are written to the file; stdout receives a JSON envelope `{ message_id, bytes, output }` so the CLI stays pipeable. Inbound only; outbound returns 404.
@@ -1236,6 +1236,8 @@ Templates: `project_invite` (project_name, invite_url), `magic_link` (project_na
12361236
- `run402 email webhooks delete <webhook_id> [--mailbox <slug|id>] [--project <id>]` — delete a webhook
12371237
- `run402 email webhooks update <webhook_id> [--url <url>] [--events <e1,e2>] [--mailbox <slug|id>] [--project <id>]` — update webhook URL and/or events
12381238
- `run402 email webhooks register --url <url> --events <e1,e2> [--mailbox <slug|id>] [--project <id>]` — register a new webhook. Valid events: delivery, bounced, complained, reply_received
1239+
- `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`.
1240+
- `run402 email webhooks redrive <delivery_id> [--mailbox <slug|id>] [--project <id>]` — re-queue a dead-lettered (failed_permanent) delivery for another attempt
12391241

12401242
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>`.
12411243

llms-mcp.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,8 @@ Fixed platform-managed jobs. These tools do not run arbitrary Docker images; the
391391
- `list_emails` / `get_email` — read messages. Both take an optional `mailbox`.
392392
- `get_email_raw` — return raw RFC-822 bytes for DKIM / zk-email verification (inbound only). Params: `project_id`, `message_id`, `mailbox?`.
393393
- `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`.
394+
- `list_mailbox_webhook_deliveries` / `redrive_mailbox_webhook_delivery` — durable-delivery visibility + replay. Webhook delivery is **at-least-once** with bounded retries + exponential backoff; failures that exhaust the budget (or fail permanently) land in `failed_permanent` — the dead-letter queue. `list_mailbox_webhook_deliveries` (optional `status` filter) inspects pending/delivered/dead-lettered rows; `redrive_mailbox_webhook_delivery` re-queues a dead-lettered delivery after you fix the consumer. The delivered body is the canonical envelope `{ id, type, created_at, schema_version, idempotency_key, payload }` — **consumers MUST dedupe on `idempotency_key`** (also sent as the `Run402-Webhook-Id` header). Mailbox webhooks are unsigned.
395+
- `list_emails` also takes an optional `direction` (`inbound` | `outbound`); omit for both. `direction: inbound` lists received replies — the reconciliation backstop if a `reply_received` webhook is ever lost.
394396
- `register_sender_domain` / `sender_domain_status` / `remove_sender_domain` — send from your own DKIM-verified domain.
395397
- `enable_sender_domain_inbound` / `disable_sender_domain_inbound` — receive replies on your custom sender domain. Returns the MX record to add.
396398

openclaw/SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,16 @@ run402 email webhooks register --url https://… --events delivery,bounced,reply
734734
run402 email webhooks list
735735
run402 email webhooks update <id> --events delivery,bounced,complained,reply_received
736736
run402 email webhooks delete <id>
737+
738+
# Durable delivery is at-least-once (bounded retries + exponential backoff);
739+
# failures land in failed_permanent — the dead-letter queue. The delivered body
740+
# is the canonical envelope { id, type, created_at, schema_version,
741+
# idempotency_key, payload } — dedupe on idempotency_key.
742+
run402 email webhooks deliveries --status failed_permanent # inspect the DLQ
743+
run402 email webhooks redrive <delivery_id> # replay a dead-lettered delivery
744+
745+
# Reconciliation backstop if a reply_received webhook is ever lost:
746+
run402 email list --direction inbound
737747
```
738748

739749
### Custom sender domain

sdk/llms-sdk.txt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -987,7 +987,10 @@ getMailbox(projectId): Promise<MailboxInfo>
987987
deleteMailbox(projectId, mailboxId?): Promise<void>
988988

989989
send(projectId, opts: SendEmailOptions): Promise<SendEmailResult>
990-
list(projectId, opts?: { limit?, after? }): Promise<EmailSummary[]>
990+
list(projectId, opts?: { limit?, after?, direction? }): Promise<EmailSummary[]>
991+
// direction?: "inbound" | "outbound" — omit for BOTH. direction:"inbound" lists
992+
// received replies (each EmailSummary carries `direction`) and is the
993+
// reconciliation backstop if a reply_received webhook is ever lost.
991994
get(projectId, messageId): Promise<EmailDetail>
992995
getRaw(projectId, messageId): Promise<RawEmailResult> // bytes + content_type
993996

@@ -997,6 +1000,16 @@ webhooks.list(projectId): Promise<MailboxWebhooksResult>
9971000
webhooks.get(projectId, webhookId): Promise<MailboxWebhookSummary>
9981001
webhooks.update(projectId, webhookId, opts: { url?, events? }): Promise<MailboxWebhookSummary>
9991002
webhooks.delete(projectId, webhookId): Promise<void>
1003+
webhooks.listDeliveries(projectId, opts?: { status?, limit?, after? }): Promise<WebhookDeliveriesResult>
1004+
// Durable delivery is AT-LEAST-ONCE with bounded retries + exponential backoff.
1005+
// Failures that exhaust the budget (or fail permanently) become status
1006+
// "failed_permanent" — the dead-letter queue. status?: pending | in_flight |
1007+
// delivered | failed_permanent. The delivered body is the canonical envelope
1008+
// { id, type, created_at, schema_version, idempotency_key, payload }; consumers
1009+
// MUST dedupe on idempotency_key (also the Run402-Webhook-Id header). Mailbox
1010+
// webhooks are unsigned (verifyWebhook is for operator notifications only).
1011+
webhooks.redriveDelivery(projectId, deliveryId): Promise<RedriveDeliveryResult>
1012+
// Re-queue a dead-lettered delivery for another attempt (after fixing the consumer).
10001013

10011014
// CLI-style aliases:
10021015
create(projectId, slug): Promise<CreateMailboxResult | MailboxInfo>

0 commit comments

Comments
 (0)