From fd5869559319856ea6c412d23ab18b759e987927 Mon Sep 17 00:00:00 2001 From: dundas Date: Mon, 2 Mar 2026 11:04:33 -0600 Subject: [PATCH 01/10] refactor(storage): rename mailgun_id to provider_message_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename findOutboxMessageByMailgunId → findOutboxMessageByProviderId in memory.js - Rename mailgun_id field → provider_message_id in storage scan and outbox service - Update _findOutboxMessageByMailgunId → _findOutboxMessageByProviderId in outbox.service.js - Update tests 69-71 to use new method and field names - mech.js renamed separately (gitignored private adapter) Related to tasks/0004-prd-email-inbound-outbound.md Task 1.0 --- src/server.test.js | 36 +++++++++++++++++----------------- src/services/outbox.service.js | 22 ++++++++++----------- src/storage/memory.js | 4 ++-- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/server.test.js b/src/server.test.js index 4d44342..33cbc73 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -2039,11 +2039,11 @@ test('outbox send: Mailgun send uses correct API path (no /domains/ prefix)', as } }); -// ============ ISSUE 1: findOutboxMessageByMailgunId on storage backends ============ +// ============ ISSUE 1: findOutboxMessageByProviderId on storage backends ============ -test('storage: findOutboxMessageByMailgunId finds message by mailgun_id', async () => { - const mailgunId = ''; - const msgId = `find-mailgun-${Date.now()}`; +test('storage: findOutboxMessageByProviderId finds message by provider_message_id', async () => { + const providerId = ''; + const msgId = `find-provider-${Date.now()}`; await storage.createOutboxMessage({ id: msgId, @@ -2053,28 +2053,28 @@ test('storage: findOutboxMessageByMailgunId finds message by mailgun_id', async subject: 'Find test', body: 'hello', status: 'sent', - mailgun_id: mailgunId + provider_message_id: providerId }); - // The storage backend should have findOutboxMessageByMailgunId as a method - assert.equal(typeof storage.findOutboxMessageByMailgunId, 'function', - 'storage must implement findOutboxMessageByMailgunId'); + // The storage backend should have findOutboxMessageByProviderId as a method + assert.equal(typeof storage.findOutboxMessageByProviderId, 'function', + 'storage must implement findOutboxMessageByProviderId'); - const found = await storage.findOutboxMessageByMailgunId(mailgunId); - assert.ok(found, 'Should find outbox message by mailgun_id'); + const found = await storage.findOutboxMessageByProviderId(providerId); + assert.ok(found, 'Should find outbox message by provider_message_id'); assert.equal(found.id, msgId); - assert.equal(found.mailgun_id, mailgunId); + assert.equal(found.provider_message_id, providerId); }); -test('storage: findOutboxMessageByMailgunId returns null for unknown mailgun_id', async () => { - assert.equal(typeof storage.findOutboxMessageByMailgunId, 'function', - 'storage must implement findOutboxMessageByMailgunId'); +test('storage: findOutboxMessageByProviderId returns null for unknown provider_message_id', async () => { + assert.equal(typeof storage.findOutboxMessageByProviderId, 'function', + 'storage must implement findOutboxMessageByProviderId'); - const result = await storage.findOutboxMessageByMailgunId(''); + const result = await storage.findOutboxMessageByProviderId(''); assert.equal(result, null); }); -test('outbox webhook: handleWebhook uses findOutboxMessageByMailgunId to locate message', async () => { +test('outbox webhook: handleWebhook uses findOutboxMessageByProviderId to locate message', async () => { const mailgunId = ''; const msgId = `webhook-find-${Date.now()}`; @@ -2086,14 +2086,14 @@ test('outbox webhook: handleWebhook uses findOutboxMessageByMailgunId to locate subject: 'Webhook find test', body: 'hello', status: 'sent', - mailgun_id: mailgunId, + provider_message_id: mailgunId, attempts: 1, max_attempts: 3, error: null, sent_at: Date.now() }); - // Send delivered webhook + // Send delivered webhook (still using /webhooks/mailgun until Task 2.0 renames it) const res = await request(app) .post('/api/webhooks/mailgun') .send({ diff --git a/src/services/outbox.service.js b/src/services/outbox.service.js index 8a5f47f..5f30cce 100644 --- a/src/services/outbox.service.js +++ b/src/services/outbox.service.js @@ -235,7 +235,7 @@ export class OutboxService { body: body || '', html: html || null, status: 'queued', - mailgun_id: null, + provider_message_id: null, attempts: 0, max_attempts: MAX_ATTEMPTS, error: null, @@ -291,7 +291,7 @@ export class OutboxService { if (ok) { await storage.updateOutboxMessage(outboxMessage.id, { status: 'sent', - mailgun_id: json?.id || null, + provider_message_id: json?.id || null, attempts: attempts + 1, sent_at: Date.now(), error: null @@ -301,7 +301,7 @@ export class OutboxService { logger.info({ outbox_id: outboxMessage.id, - mailgun_id: json?.id, + provider_message_id: json?.id, to: outboxMessage.to }, 'Email sent via Mailgun'); @@ -356,13 +356,13 @@ export class OutboxService { const eventType = event.event_data?.event; - logger.info({ event: eventType, mailgun_id: mailgunId }, 'Mailgun webhook received'); + logger.info({ event: eventType, provider_message_id: mailgunId }, 'Mailgun webhook received'); - // Find outbox message by mailgun_id via storage scan + // Find outbox message by provider_message_id via storage scan // In production with persistent storage, this would be an indexed query - const outboxMessage = await this._findOutboxMessageByMailgunId(mailgunId); + const outboxMessage = await this._findOutboxMessageByProviderId(mailgunId); if (!outboxMessage) { - logger.debug({ mailgun_id: mailgunId }, 'No outbox message found for mailgun_id'); + logger.debug({ provider_message_id: mailgunId }, 'No outbox message found for provider_message_id'); return; } @@ -394,12 +394,12 @@ export class OutboxService { }, 'Outbox message status updated from webhook'); } - async _findOutboxMessageByMailgunId(mailgunId) { - if (typeof storage.findOutboxMessageByMailgunId === 'function') { - return storage.findOutboxMessageByMailgunId(mailgunId); + async _findOutboxMessageByProviderId(providerId) { + if (typeof storage.findOutboxMessageByProviderId === 'function') { + return storage.findOutboxMessageByProviderId(providerId); } - logger.warn('Storage backend does not implement findOutboxMessageByMailgunId — webhook status updates will be lost'); + logger.warn('Storage backend does not implement findOutboxMessageByProviderId — webhook status updates will be lost'); return null; } diff --git a/src/storage/memory.js b/src/storage/memory.js index a6f4c03..40cb86d 100644 --- a/src/storage/memory.js +++ b/src/storage/memory.js @@ -549,9 +549,9 @@ export class MemoryStorage { return updated; } - async findOutboxMessageByMailgunId(mailgunId) { + async findOutboxMessageByProviderId(providerId) { for (const msg of this.outboxMessages.values()) { - if (msg.mailgun_id === mailgunId) { + if (msg.provider_message_id === providerId) { return msg; } } From 484b3a12d9ad29d1822a761831538eba00736d7f Mon Sep 17 00:00:00 2001 From: dundas Date: Mon, 2 Mar 2026 11:51:26 -0600 Subject: [PATCH 02/10] feat(outbox): replace Mailgun with Resend for outbound email delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace _mailgunRequest/FormData with _resendRequest using Bearer auth + JSON - addDomain: POST /domains JSON, stores resend_domain_id + provider_state - _getDomainDnsRecords: parses Resend domain response records[] - verifyDomain: POST /domains/{resend_domain_id}/verify - _attemptSend: POST /emails, to as array, stores provider_message_id - handleWebhook: reads event.type + event.data.email_id - verifyWebhookSignature: Svix format (svixId, svixTimestamp, svixSignature, rawBody) - Rename /webhooks/mailgun → /webhooks/resend with express.raw() middleware - Mount webhook router before express.json() to capture raw body for Svix verification - Update env var warnings: RESEND_API_KEY, RESEND_WEBHOOK_SECRET - Update all tests to use _resendRequest stubs, RESEND_* env vars, Resend event format Co-Authored-By: Claude Sonnet 4.6 --- src/routes/outbox.js | 89 ++++++----- src/server.js | 24 ++- src/server.test.js | 269 +++++++++++++++------------------ src/services/outbox.service.js | 196 +++++++++++------------- 4 files changed, 283 insertions(+), 295 deletions(-) diff --git a/src/routes/outbox.js b/src/routes/outbox.js index 42ba8a0..a048599 100644 --- a/src/routes/outbox.js +++ b/src/routes/outbox.js @@ -1,15 +1,16 @@ /** * Outbox Routes - * Domain configuration and outbound email via Mailgun + * Domain configuration and outbound email via Resend */ import { Router } from 'express'; +import express from 'express'; import { authenticateAgent } from '../middleware/auth.js'; import { outboxService } from '../services/outbox.service.js'; // Agent-scoped routes (mounted at /api/agents, require authenticateAgent middleware) const router = Router(); -// Webhook routes (mounted at /api, no agent auth — called by Mailgun) +// Webhook routes (mounted at /api, no agent auth — called by Resend) const webhookRouter = Router(); // ============ DOMAIN MANAGEMENT ============ @@ -105,7 +106,7 @@ router.delete('/:agentId/outbox/domain', authenticateAgent, async (req, res) => /** * POST /api/agents/:agentId/outbox/send - * Send an email via Mailgun + * Send an email via Resend */ router.post('/:agentId/outbox/send', authenticateAgent, async (req, res) => { try { @@ -213,50 +214,60 @@ router.get('/:agentId/outbox/messages/:messageId', authenticateAgent, async (req } }); -// ============ MAILGUN WEBHOOK ============ +// ============ RESEND WEBHOOK ============ /** - * POST /api/webhooks/mailgun - * Receive delivery status updates from Mailgun + * POST /api/webhooks/resend + * Receive delivery status updates from Resend (via Svix) + * Uses express.raw() to capture the raw body needed for Svix signature verification. */ -webhookRouter.post('/webhooks/mailgun', async (req, res) => { - try { - const { signature, event_data } = req.body; - - // Verify webhook signature if signing key is configured - if (process.env.MAILGUN_WEBHOOK_SIGNING_KEY) { - // Signing key is set — signature is mandatory - if (!signature) { - return res.status(400).json({ - error: 'SIGNATURE_REQUIRED', - message: 'Webhook signature is required when signing key is configured' - }); +webhookRouter.post( + '/webhooks/resend', + express.raw({ type: 'application/json' }), + async (req, res) => { + try { + const svixId = req.headers['svix-id']; + const svixTimestamp = req.headers['svix-timestamp']; + const svixSignature = req.headers['svix-signature']; + + // Verify webhook signature if secret is configured + if (process.env.RESEND_WEBHOOK_SECRET) { + // Secret is set — signature headers are mandatory + if (!svixId || !svixTimestamp || !svixSignature) { + return res.status(400).json({ + error: 'SIGNATURE_REQUIRED', + message: 'Webhook signature headers are required when secret is configured' + }); + } + + const valid = outboxService.verifyWebhookSignature( + svixId, + svixTimestamp, + svixSignature, + req.body.toString() + ); + + if (!valid) { + return res.status(403).json({ + error: 'INVALID_SIGNATURE', + message: 'Invalid Resend webhook signature' + }); + } } - const valid = outboxService.verifyWebhookSignature( - signature.timestamp, - signature.token, - signature.signature - ); - - if (!valid) { - return res.status(403).json({ - error: 'INVALID_SIGNATURE', - message: 'Invalid Mailgun webhook signature' - }); - } - } + const event = JSON.parse(req.body.toString()); - await outboxService.handleWebhook({ event_data }); + await outboxService.handleWebhook(event); - res.status(200).json({ status: 'ok' }); - } catch (error) { - res.status(500).json({ - error: 'WEBHOOK_FAILED', - message: error.message - }); + res.status(200).json({ status: 'ok' }); + } catch (error) { + res.status(500).json({ + error: 'WEBHOOK_FAILED', + message: error.message + }); + } } -}); +); export default router; export { webhookRouter as outboxWebhookRouter }; diff --git a/src/server.js b/src/server.js index 344451f..63c21c2 100644 --- a/src/server.js +++ b/src/server.js @@ -41,12 +41,19 @@ const ROUND_TABLE_PURGE_TTL_MS = (() => { return Number.isNaN(parsed) ? 7 * 24 * 60 * 60 * 1000 : parsed; })(); -// Warn about insecure outbox webhook configuration -if (process.env.MAILGUN_API_KEY && !process.env.MAILGUN_WEBHOOK_SIGNING_KEY) { +// Warn about missing Resend configuration +if (!process.env.RESEND_API_KEY) { console.warn( - 'WARNING: MAILGUN_API_KEY is set but MAILGUN_WEBHOOK_SIGNING_KEY is not. ' + - 'Mailgun webhooks will accept unauthenticated requests. ' + - 'Set MAILGUN_WEBHOOK_SIGNING_KEY for production use.' + 'WARNING: RESEND_API_KEY is not set. ' + + 'Outbound email via the outbox will not function until RESEND_API_KEY is configured.' + ); +} + +if (!process.env.RESEND_WEBHOOK_SECRET) { + console.warn( + 'WARNING: RESEND_WEBHOOK_SECRET is not set. ' + + 'Resend webhooks will accept unauthenticated requests. ' + + 'Set RESEND_WEBHOOK_SECRET for production use.' ); } @@ -87,6 +94,12 @@ app.use(helmet()); app.use(cors({ origin: process.env.CORS_ORIGIN || '*' })); + +// Mount webhook router BEFORE express.json() so express.raw() can capture the +// raw body needed for Svix signature verification on Resend webhook deliveries. +// Non-matching routes pass through to the global JSON parser below. +app.use('/api', outboxWebhookRouter); + app.use(express.json({ limit: '10mb' })); app.use(pinoHttp({ logger })); @@ -191,7 +204,6 @@ app.use('/api/round-tables', roundTableRoutes); app.use('/api/agents', outboxRoutes); app.use('/api/keys', keysRoutes); app.use('/api', inboxRoutes); // For /api/messages/:id/status -app.use('/api', outboxWebhookRouter); // For /api/webhooks/mailgun // 404 handler app.use((req, res) => { diff --git a/src/server.test.js b/src/server.test.js index 33cbc73..f92ef73 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -1367,13 +1367,13 @@ test('outbox domain: POST requires domain field', async () => { test('outbox domain: POST fails without MAILGUN_API_KEY', async () => { const agent = await registerAgent('outbox-nokey'); - // MAILGUN_API_KEY is not set in test env, so this should fail + // RESEND_API_KEY is not set in test env, so this should fail const res = await request(app) .post(`/api/agents/${encodeURIComponent(agent.agent_id)}/outbox/domain`) .send({ domain: 'test.example.com' }); assert.equal(res.status, 400); - assert.ok(res.body.message.includes('MAILGUN_API_KEY')); + assert.ok(res.body.message.includes('RESEND_API_KEY')); }); test('outbox domain: DELETE returns 404 when no domain configured', async () => { @@ -1554,10 +1554,10 @@ test('outbox storage: outbox message CRUD via storage layer', async () => { // Update const updated = await storage.updateOutboxMessage('outbox-crud-test', { status: 'sent', - mailgun_id: '' + provider_message_id: 're_test123' }); assert.equal(updated.status, 'sent'); - assert.equal(updated.mailgun_id, ''); + assert.equal(updated.provider_message_id, 're_test123'); // List const messages = await storage.getOutboxMessages(agentId); @@ -1572,14 +1572,12 @@ test('outbox storage: outbox message CRUD via storage layer', async () => { assert.ok(!queued.some(m => m.id === 'outbox-crud-test')); }); -test('outbox webhook: mailgun webhook endpoint accepts events', async () => { +test('outbox webhook: resend webhook endpoint accepts events', async () => { const res = await request(app) - .post('/api/webhooks/mailgun') + .post('/api/webhooks/resend') .send({ - event_data: { - event: 'delivered', - message: { headers: { 'message-id': '' } } - } + type: 'email.delivered', + data: { email_id: 're_test123' } }); assert.equal(res.status, 200); @@ -1587,35 +1585,32 @@ test('outbox webhook: mailgun webhook endpoint accepts events', async () => { }); test('outbox webhook signature verification', () => { - // Test the signature verification method directly - const timestamp = '1234567890'; - const token = 'test-token'; - + // Test the signature verification method directly (Svix format: svixId, svixTimestamp, svixSignature, rawBody) // Without signing key set, should return false - const result = outboxService.verifyWebhookSignature(timestamp, token, 'fake-sig'); + const result = outboxService.verifyWebhookSignature('msg_123', '1234567890', 'v1,fake-sig', '{"type":"email.delivered"}'); assert.equal(result, false); }); test('outbox send: happy path with verified domain creates outbox message', async () => { const agent = await registerAgent('outbox-send-happy'); - // Set up a verified domain config directly in storage (bypassing Mailgun API) + // Set up a verified domain config directly in storage (bypassing Resend API) await storage.setDomainConfig(agent.agent_id, { domain: 'verified.example.com', status: 'verified', dns_records: [], - mailgun_state: 'active' + provider_state: 'verified' }); - // Set MAILGUN_API_KEY so the service doesn't reject the send - const origKey = process.env.MAILGUN_API_KEY; - process.env.MAILGUN_API_KEY = 'test-key-for-send'; + // Set RESEND_API_KEY so the service doesn't reject the send + const origKey = process.env.RESEND_API_KEY; + process.env.RESEND_API_KEY = 'test-key-for-send'; - // Stub the outbox service _mailgunRequest to simulate Mailgun success - const originalRequest = outboxService._mailgunRequest.bind(outboxService); - outboxService._mailgunRequest = async (path, opts) => { - if (path.includes('/messages') && opts?.method === 'POST') { - return { status: 200, ok: true, json: { id: '', message: 'Queued' }, text: '' }; + // Stub the outbox service _resendRequest to simulate Resend success + const originalRequest = outboxService._resendRequest.bind(outboxService); + outboxService._resendRequest = async (path, opts) => { + if (path === '/emails' && opts?.method === 'POST') { + return { status: 200, ok: true, json: { id: 're_mock123' } }; } return originalRequest(path, opts); }; @@ -1647,7 +1642,7 @@ test('outbox send: happy path with verified domain creates outbox message', asyn const storedMsg = await storage.getOutboxMessage(res.body.id); assert.ok(storedMsg); assert.equal(storedMsg.status, 'sent'); - assert.equal(storedMsg.mailgun_id, ''); + assert.equal(storedMsg.provider_message_id, 're_mock123'); assert.ok(storedMsg.sent_at); // Verify it appears in the agent's outbox message list @@ -1666,9 +1661,9 @@ test('outbox send: happy path with verified domain creates outbox message', asyn assert.equal(getRes.body.status, 'sent'); } finally { // Restore original method and env var - outboxService._mailgunRequest = originalRequest; - if (origKey === undefined) delete process.env.MAILGUN_API_KEY; - else process.env.MAILGUN_API_KEY = origKey; + outboxService._resendRequest = originalRequest; + if (origKey === undefined) delete process.env.RESEND_API_KEY; + else process.env.RESEND_API_KEY = origKey; } }); @@ -1679,17 +1674,17 @@ test('outbox send: constructs from address as agentId@domain', async () => { domain: 'mail.example.com', status: 'verified', dns_records: [], - mailgun_state: 'active' + provider_state: 'verified' }); - const origKey = process.env.MAILGUN_API_KEY; - process.env.MAILGUN_API_KEY = 'test-key-for-from'; + const origKey = process.env.RESEND_API_KEY; + process.env.RESEND_API_KEY = 'test-key-for-from'; - // Stub the Mailgun send to succeed - const originalRequest = outboxService._mailgunRequest.bind(outboxService); - outboxService._mailgunRequest = async (path, opts) => { - if (path.includes('/messages') && opts?.method === 'POST') { - return { status: 200, ok: true, json: { id: '' }, text: '' }; + // Stub the Resend send to succeed + const originalRequest = outboxService._resendRequest.bind(outboxService); + outboxService._resendRequest = async (path, opts) => { + if (path === '/emails' && opts?.method === 'POST') { + return { status: 200, ok: true, json: { id: 're_from-test' } }; } return originalRequest(path, opts); }; @@ -1709,9 +1704,9 @@ test('outbox send: constructs from address as agentId@domain', async () => { // From should contain agent_id-derived local part assert.ok(res.body.from.includes(agent.agent_id)); } finally { - outboxService._mailgunRequest = originalRequest; - if (origKey === undefined) delete process.env.MAILGUN_API_KEY; - else process.env.MAILGUN_API_KEY = origKey; + outboxService._resendRequest = originalRequest; + if (origKey === undefined) delete process.env.RESEND_API_KEY; + else process.env.RESEND_API_KEY = origKey; } }); @@ -1722,16 +1717,16 @@ test('outbox send: from_name is sanitized to prevent header injection', async () domain: 'sanitize.example.com', status: 'verified', dns_records: [], - mailgun_state: 'active' + provider_state: 'verified' }); - const origKey = process.env.MAILGUN_API_KEY; - process.env.MAILGUN_API_KEY = 'test-key-for-sanitize'; + const origKey = process.env.RESEND_API_KEY; + process.env.RESEND_API_KEY = 'test-key-for-sanitize'; - const originalRequest = outboxService._mailgunRequest.bind(outboxService); - outboxService._mailgunRequest = async (path, opts) => { - if (path.includes('/messages') && opts?.method === 'POST') { - return { status: 200, ok: true, json: { id: '' }, text: '' }; + const originalRequest = outboxService._resendRequest.bind(outboxService); + outboxService._resendRequest = async (path, opts) => { + if (path === '/emails' && opts?.method === 'POST') { + return { status: 200, ok: true, json: { id: 're_sanitize-test' } }; } return originalRequest(path, opts); }; @@ -1753,9 +1748,9 @@ test('outbox send: from_name is sanitized to prevent header injection', async () assert.ok(!res.body.from.includes('\n'), 'Should strip newline'); assert.ok(res.body.from.includes('sanitize.example.com')); } finally { - outboxService._mailgunRequest = originalRequest; - if (origKey === undefined) delete process.env.MAILGUN_API_KEY; - else process.env.MAILGUN_API_KEY = origKey; + outboxService._resendRequest = originalRequest; + if (origKey === undefined) delete process.env.RESEND_API_KEY; + else process.env.RESEND_API_KEY = origKey; } }); @@ -1770,26 +1765,26 @@ test('outbox send: rejects invalid email address in to field', async () => { assert.equal(res.body.error, 'INVALID_EMAIL'); }); -test('outbox send: Mailgun API failure triggers retry and eventually fails', async () => { +test('outbox send: Resend API failure triggers retry and eventually fails', async () => { const agent = await registerAgent('outbox-retry'); await storage.setDomainConfig(agent.agent_id, { domain: 'retry.example.com', status: 'verified', dns_records: [], - mailgun_state: 'active' + provider_state: 'verified' }); - const origKey = process.env.MAILGUN_API_KEY; - process.env.MAILGUN_API_KEY = 'test-key-for-retry'; + const origKey = process.env.RESEND_API_KEY; + process.env.RESEND_API_KEY = 'test-key-for-retry'; - // Stub Mailgun to always fail - const originalRequest = outboxService._mailgunRequest.bind(outboxService); + // Stub Resend to always fail + const originalRequest = outboxService._resendRequest.bind(outboxService); let callCount = 0; - outboxService._mailgunRequest = async (path, opts) => { - if (path.includes('/messages') && opts?.method === 'POST') { + outboxService._resendRequest = async (path, opts) => { + if (path === '/emails' && opts?.method === 'POST') { callCount++; - return { status: 500, ok: false, json: { message: 'Server Error' }, text: 'Server Error' }; + return { status: 500, ok: false, json: { message: 'Server Error' } }; } return originalRequest(path, opts); }; @@ -1810,7 +1805,7 @@ test('outbox send: Mailgun API failure triggers retry and eventually fails', asy await new Promise(resolve => setTimeout(resolve, 500)); // First attempt should have happened - assert.ok(callCount >= 1, `Expected at least 1 Mailgun call, got ${callCount}`); + assert.ok(callCount >= 1, `Expected at least 1 Resend call, got ${callCount}`); // Check the outbox message has attempt count > 0 const msg = await storage.getOutboxMessage(res.body.id); @@ -1818,15 +1813,15 @@ test('outbox send: Mailgun API failure triggers retry and eventually fails', asy assert.ok(msg.attempts >= 1); assert.ok(msg.error); } finally { - outboxService._mailgunRequest = originalRequest; - if (origKey === undefined) delete process.env.MAILGUN_API_KEY; - else process.env.MAILGUN_API_KEY = origKey; + outboxService._resendRequest = originalRequest; + if (origKey === undefined) delete process.env.RESEND_API_KEY; + else process.env.RESEND_API_KEY = origKey; } }); test('outbox webhook: delivered event updates outbox message status', async () => { - // Create a sent outbox message with a known mailgun_id - const mailgunId = ''; + // Create a sent outbox message with a known provider_message_id + const providerId = 're_webhook-delivered-test'; await storage.createOutboxMessage({ id: 'webhook-deliver-test', agent_id: 'webhook-agent', @@ -1835,7 +1830,7 @@ test('outbox webhook: delivered event updates outbox message status', async () = subject: 'Webhook test', body: 'hello', status: 'sent', - mailgun_id: mailgunId, + provider_message_id: providerId, attempts: 1, max_attempts: 3, error: null, @@ -1844,12 +1839,10 @@ test('outbox webhook: delivered event updates outbox message status', async () = // Send delivered webhook const res = await request(app) - .post('/api/webhooks/mailgun') + .post('/api/webhooks/resend') .send({ - event_data: { - event: 'delivered', - message: { headers: { 'message-id': mailgunId } } - } + type: 'email.delivered', + data: { email_id: providerId } }); assert.equal(res.status, 200); @@ -1861,8 +1854,8 @@ test('outbox webhook: delivered event updates outbox message status', async () = assert.ok(msg.delivered_at); }); -test('outbox webhook: failed event updates outbox message status', async () => { - const mailgunId = ''; +test('outbox webhook: bounced event updates outbox message status', async () => { + const providerId = 're_webhook-failed-test'; await storage.createOutboxMessage({ id: 'webhook-fail-test', agent_id: 'webhook-fail-agent', @@ -1871,7 +1864,7 @@ test('outbox webhook: failed event updates outbox message status', async () => { subject: 'Will fail', body: 'bounce test', status: 'sent', - mailgun_id: mailgunId, + provider_message_id: providerId, attempts: 1, max_attempts: 3, error: null, @@ -1879,20 +1872,17 @@ test('outbox webhook: failed event updates outbox message status', async () => { }); const res = await request(app) - .post('/api/webhooks/mailgun') + .post('/api/webhooks/resend') .send({ - event_data: { - event: 'failed', - message: { headers: { 'message-id': mailgunId } }, - reason: 'Mailbox not found' - } + type: 'email.bounced', + data: { email_id: providerId } }); assert.equal(res.status, 200); const msg = await storage.getOutboxMessage('webhook-fail-test'); assert.equal(msg.status, 'failed'); - assert.ok(msg.error.includes('Mailbox not found')); + assert.ok(msg.error.includes('email.bounced')); }); test('outbox messages: list supports limit query param', async () => { @@ -1968,7 +1958,7 @@ test('outbox domain: DELETE removes config and subsequent GET returns 404', asyn domain: 'delete-test.example.com', status: 'pending', dns_records: [], - mailgun_state: 'unverified' + provider_state: 'not_started' }); // Verify domain exists @@ -1988,26 +1978,26 @@ test('outbox domain: DELETE removes config and subsequent GET returns 404', asyn assert.equal(afterRes.status, 404); }); -test('outbox send: Mailgun send uses correct API path (no /domains/ prefix)', async () => { +test('outbox send: Resend send uses correct API path (/emails)', async () => { const agent = await registerAgent('outbox-path-check'); await storage.setDomainConfig(agent.agent_id, { domain: 'path-test.example.com', status: 'verified', dns_records: [], - mailgun_state: 'active' + provider_state: 'verified' }); - const origKey = process.env.MAILGUN_API_KEY; - process.env.MAILGUN_API_KEY = 'test-key-for-path'; + const origKey = process.env.RESEND_API_KEY; + process.env.RESEND_API_KEY = 'test-key-for-path'; - // Capture the URL path used in the Mailgun request + // Capture the URL path used in the Resend request let capturedPath = null; - const originalRequest = outboxService._mailgunRequest.bind(outboxService); - outboxService._mailgunRequest = async (path, opts) => { - if (opts?.method === 'POST' && path.includes('/messages')) { + const originalRequest = outboxService._resendRequest.bind(outboxService); + outboxService._resendRequest = async (path, opts) => { + if (opts?.method === 'POST' && path === '/emails') { capturedPath = path; - return { status: 200, ok: true, json: { id: '' }, text: '' }; + return { status: 200, ok: true, json: { id: 're_path-test' } }; } return originalRequest(path, opts); }; @@ -2026,16 +2016,13 @@ test('outbox send: Mailgun send uses correct API path (no /domains/ prefix)', as // Wait for async send await new Promise(resolve => setTimeout(resolve, 200)); - // The path should be /{domain}/messages, NOT /domains/{domain}/messages - assert.ok(capturedPath, 'Mailgun request path should have been captured'); - assert.ok(capturedPath.startsWith('/path-test.example.com/messages'), - `Expected path to start with /path-test.example.com/messages, got: ${capturedPath}`); - assert.ok(!capturedPath.includes('/domains/'), - `Path should NOT include /domains/ prefix, got: ${capturedPath}`); + // The path should be /emails + assert.ok(capturedPath, 'Resend request path should have been captured'); + assert.equal(capturedPath, '/emails', `Expected path to be /emails, got: ${capturedPath}`); } finally { - outboxService._mailgunRequest = originalRequest; - if (origKey === undefined) delete process.env.MAILGUN_API_KEY; - else process.env.MAILGUN_API_KEY = origKey; + outboxService._resendRequest = originalRequest; + if (origKey === undefined) delete process.env.RESEND_API_KEY; + else process.env.RESEND_API_KEY = origKey; } }); @@ -2075,7 +2062,7 @@ test('storage: findOutboxMessageByProviderId returns null for unknown provider_m }); test('outbox webhook: handleWebhook uses findOutboxMessageByProviderId to locate message', async () => { - const mailgunId = ''; + const providerId = 're_webhook-find-method-test'; const msgId = `webhook-find-${Date.now()}`; await storage.createOutboxMessage({ @@ -2086,21 +2073,19 @@ test('outbox webhook: handleWebhook uses findOutboxMessageByProviderId to locate subject: 'Webhook find test', body: 'hello', status: 'sent', - provider_message_id: mailgunId, + provider_message_id: providerId, attempts: 1, max_attempts: 3, error: null, sent_at: Date.now() }); - // Send delivered webhook (still using /webhooks/mailgun until Task 2.0 renames it) + // Send delivered webhook via Resend route const res = await request(app) - .post('/api/webhooks/mailgun') + .post('/api/webhooks/resend') .send({ - event_data: { - event: 'delivered', - message: { headers: { 'message-id': mailgunId } } - } + type: 'email.delivered', + data: { email_id: providerId } }); assert.equal(res.status, 200); @@ -2113,76 +2098,68 @@ test('outbox webhook: handleWebhook uses findOutboxMessageByProviderId to locate // ============ ISSUE 2: Webhook signature bypass ============ -test('outbox webhook: rejects request when signing key is set but signature is missing', async () => { - const origKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY; - process.env.MAILGUN_WEBHOOK_SIGNING_KEY = 'test-signing-key'; +test('outbox webhook: rejects request when signing key is set but svix headers are missing', async () => { + const origKey = process.env.RESEND_WEBHOOK_SECRET; + process.env.RESEND_WEBHOOK_SECRET = 'test-signing-key'; try { - // Send webhook WITHOUT signature field - should be rejected + // Send webhook WITHOUT svix-id/svix-timestamp/svix-signature headers - should be rejected const res = await request(app) - .post('/api/webhooks/mailgun') + .post('/api/webhooks/resend') .send({ - event_data: { - event: 'delivered', - message: { headers: { 'message-id': '' } } - } - // Note: no signature field at all + type: 'email.delivered', + data: { email_id: 're_bypass-test' } + // Note: no svix-* headers }); - assert.equal(res.status, 400, 'Should reject with 400 when signing key is set but signature is missing'); + assert.equal(res.status, 400, 'Should reject with 400 when signing key is set but svix headers are missing'); assert.equal(res.body.error, 'SIGNATURE_REQUIRED'); } finally { - if (origKey === undefined) delete process.env.MAILGUN_WEBHOOK_SIGNING_KEY; - else process.env.MAILGUN_WEBHOOK_SIGNING_KEY = origKey; + if (origKey === undefined) delete process.env.RESEND_WEBHOOK_SECRET; + else process.env.RESEND_WEBHOOK_SECRET = origKey; } }); -test('outbox webhook: rejects request when signing key is set and signature is invalid', async () => { - const origKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY; - process.env.MAILGUN_WEBHOOK_SIGNING_KEY = 'test-signing-key'; +test('outbox webhook: rejects request when signing key is set and svix signature is invalid', async () => { + const origKey = process.env.RESEND_WEBHOOK_SECRET; + process.env.RESEND_WEBHOOK_SECRET = 'test-signing-key'; try { const res = await request(app) - .post('/api/webhooks/mailgun') + .post('/api/webhooks/resend') + .set('svix-id', 'msg_123') + .set('svix-timestamp', '1234567890') + .set('svix-signature', 'v1,invalid-signature') .send({ - signature: { - timestamp: '1234567890', - token: 'test-token', - signature: 'invalid-signature' - }, - event_data: { - event: 'delivered', - message: { headers: { 'message-id': '' } } - } + type: 'email.delivered', + data: { email_id: 're_sig-invalid-test' } }); - assert.equal(res.status, 403, 'Should reject with 403 for invalid signature'); + assert.equal(res.status, 403, 'Should reject with 403 for invalid svix signature'); assert.equal(res.body.error, 'INVALID_SIGNATURE'); } finally { - if (origKey === undefined) delete process.env.MAILGUN_WEBHOOK_SIGNING_KEY; - else process.env.MAILGUN_WEBHOOK_SIGNING_KEY = origKey; + if (origKey === undefined) delete process.env.RESEND_WEBHOOK_SECRET; + else process.env.RESEND_WEBHOOK_SECRET = origKey; } }); test('outbox webhook: allows requests when signing key is NOT set (dev mode)', async () => { - const origKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY; - delete process.env.MAILGUN_WEBHOOK_SIGNING_KEY; + const origKey = process.env.RESEND_WEBHOOK_SECRET; + delete process.env.RESEND_WEBHOOK_SECRET; try { const res = await request(app) - .post('/api/webhooks/mailgun') + .post('/api/webhooks/resend') .send({ - event_data: { - event: 'delivered', - message: { headers: { 'message-id': '' } } - } + type: 'email.delivered', + data: { email_id: 're_devmode-test' } }); assert.equal(res.status, 200, 'Should allow requests when no signing key is configured'); assert.equal(res.body.status, 'ok'); } finally { - if (origKey === undefined) delete process.env.MAILGUN_WEBHOOK_SIGNING_KEY; - else process.env.MAILGUN_WEBHOOK_SIGNING_KEY = origKey; + if (origKey === undefined) delete process.env.RESEND_WEBHOOK_SECRET; + else process.env.RESEND_WEBHOOK_SECRET = origKey; } }); diff --git a/src/services/outbox.service.js b/src/services/outbox.service.js index 5f30cce..9dbd0fe 100644 --- a/src/services/outbox.service.js +++ b/src/services/outbox.service.js @@ -1,6 +1,6 @@ /** * Outbox Service - * Handles outbound email delivery via Mailgun + * Handles outbound email delivery via Resend */ import crypto from 'crypto'; @@ -13,16 +13,12 @@ const logger = pino(); const MAX_ATTEMPTS = 3; // Read env vars dynamically so they can be overridden in tests -function getMailgunApiUrl() { - return process.env.MAILGUN_API_URL || 'https://api.mailgun.net/v3'; +function getResendApiKey() { + return process.env.RESEND_API_KEY || ''; } -function getMailgunApiKey() { - return process.env.MAILGUN_API_KEY || ''; -} - -function getMailgunWebhookSigningKey() { - return process.env.MAILGUN_WEBHOOK_SIGNING_KEY || ''; +function getResendWebhookSecret() { + return process.env.RESEND_WEBHOOK_SECRET || ''; } export class OutboxService { @@ -30,24 +26,18 @@ export class OutboxService { this.deliveryAttempts = new Map(); // outboxMessageId -> attempts } - // ============ MAILGUN HTTP HELPERS ============ + // ============ RESEND HTTP HELPERS ============ - _authHeader() { - return 'Basic ' + Buffer.from(`api:${getMailgunApiKey()}`).toString('base64'); - } - - async _mailgunRequest(path, { method = 'GET', body, formData } = {}) { - const url = `${getMailgunApiUrl()}${path}`; + async _resendRequest(path, { method = 'GET', body } = {}) { + const url = `https://api.resend.com${path}`; const headers = { - 'Authorization': this._authHeader() + 'Authorization': `Bearer ${getResendApiKey()}`, + 'Content-Type': 'application/json' }; const init = { method, headers }; - if (formData) { - init.body = formData; - } else if (body !== undefined) { - headers['Content-Type'] = 'application/json'; + if (body !== undefined) { init.body = JSON.stringify(body); } @@ -57,14 +47,14 @@ export class OutboxService { let json = null; try { json = JSON.parse(text); } catch { /* not json */ } - return { status: res.status, ok: res.ok, json, text }; + return { status: res.status, ok: res.ok, json }; } // ============ DOMAIN MANAGEMENT ============ async addDomain(agentId, domain) { - if (!getMailgunApiKey()) { - throw new Error('MAILGUN_API_KEY is not configured'); + if (!getResendApiKey()) { + throw new Error('RESEND_API_KEY is not configured'); } const agent = await storage.getAgent(agentId); @@ -76,29 +66,30 @@ export class OutboxService { throw new Error(`Agent ${agentId} already has domain ${existing.domain} configured. Remove it first.`); } - // Add domain to Mailgun - const form = new FormData(); - form.append('name', domain); - - const { ok, json, status } = await this._mailgunRequest('/domains', { + // Add domain to Resend + const { ok, json, status } = await this._resendRequest('/domains', { method: 'POST', - formData: form + body: { name: domain } }); if (!ok && status !== 409) { - // 409 = domain already exists in Mailgun (may be shared across agents) - throw new Error(`Failed to add domain to Mailgun: ${json?.message || `HTTP ${status}`}`); + // 409 = domain already exists in Resend (may be shared across agents) + throw new Error(`Failed to add domain to Resend: ${json?.message || `HTTP ${status}`}`); } - // Fetch DNS records from Mailgun - const dnsRecords = await this._getDomainDnsRecords(domain); + // Fetch DNS records from Resend + const resendDomainId = json?.id; + const dnsRecords = resendDomainId + ? await this._getDomainDnsRecords(resendDomainId) + : []; // Store domain config const config = { domain, + resend_domain_id: resendDomainId || null, status: 'pending', dns_records: dnsRecords, - mailgun_state: json?.domain?.state || 'unverified' + provider_state: json?.status || 'not_started' }; const stored = await storage.setDomainConfig(agentId, config); @@ -108,34 +99,20 @@ export class OutboxService { return stored; } - async _getDomainDnsRecords(domain) { - const { ok, json } = await this._mailgunRequest(`/domains/${encodeURIComponent(domain)}`); + async _getDomainDnsRecords(resendDomainId) { + const { ok, json } = await this._resendRequest(`/domains/${encodeURIComponent(resendDomainId)}`); if (!ok) return []; const records = []; - // Sending records (SPF, DKIM) - if (json?.sending_dns_records) { - for (const r of json.sending_dns_records) { - records.push({ - type: r.record_type, - name: r.name, - value: r.value, - valid: r.valid - }); - } - } - - // Receiving records (MX) — not needed for outbox-only but included for completeness - if (json?.receiving_dns_records) { - for (const r of json.receiving_dns_records) { + if (Array.isArray(json?.records)) { + for (const r of json.records) { records.push({ - type: r.record_type, + type: r.type, name: r.name, value: r.value, - priority: r.priority, - valid: r.valid + valid: r.status === 'verified' }); } } @@ -151,30 +128,35 @@ export class OutboxService { const config = await storage.getDomainConfig(agentId); if (!config) throw new Error(`No domain configured for agent ${agentId}`); - if (!getMailgunApiKey()) { - throw new Error('MAILGUN_API_KEY is not configured'); + if (!getResendApiKey()) { + throw new Error('RESEND_API_KEY is not configured'); } - // Ask Mailgun to verify DNS records - const { ok, json } = await this._mailgunRequest( - `/domains/${encodeURIComponent(config.domain)}/verify`, - { method: 'PUT' } + const resendDomainId = config.resend_domain_id; + if (!resendDomainId) { + throw new Error('Domain has no resend_domain_id — cannot verify'); + } + + // Ask Resend to verify DNS records + const { ok, json } = await this._resendRequest( + `/domains/${encodeURIComponent(resendDomainId)}/verify`, + { method: 'POST' } ); if (!ok) { - throw new Error(`Mailgun verification request failed: ${json?.message || 'unknown error'}`); + throw new Error(`Resend verification request failed: ${json?.message || 'unknown error'}`); } // Refresh DNS record status - const dnsRecords = await this._getDomainDnsRecords(config.domain); + const dnsRecords = await this._getDomainDnsRecords(resendDomainId); - const mailgunState = json?.domain?.state || config.mailgun_state; - const verified = mailgunState === 'active'; + const providerState = json?.status || config.provider_state; + const verified = providerState === 'verified'; const updated = await storage.setDomainConfig(agentId, { ...config, status: verified ? 'verified' : 'pending', - mailgun_state: mailgunState, + provider_state: providerState, dns_records: dnsRecords, verified_at: verified ? Date.now() : config.verified_at }); @@ -192,7 +174,7 @@ export class OutboxService { const config = await storage.getDomainConfig(agentId); if (!config) throw new Error(`No domain configured for agent ${agentId}`); - // Note: We do NOT delete from Mailgun — domain may be shared or reused. + // Note: We do NOT delete from Resend — domain may be shared or reused. // Only remove local config binding. await storage.deleteDomainConfig(agentId); @@ -214,8 +196,8 @@ export class OutboxService { throw new Error(`Domain ${domainConfig.domain} is not verified (status: ${domainConfig.status})`); } - if (!getMailgunApiKey()) { - throw new Error('MAILGUN_API_KEY is not configured'); + if (!getResendApiKey()) { + throw new Error('RESEND_API_KEY is not configured'); } // Construct from address @@ -272,19 +254,21 @@ export class OutboxService { } try { - const form = new FormData(); - form.append('from', outboxMessage.from); - form.append('to', outboxMessage.to); - form.append('subject', outboxMessage.subject); - form.append('text', outboxMessage.body); + const emailBody = { + from: outboxMessage.from, + to: [outboxMessage.to], + subject: outboxMessage.subject, + text: outboxMessage.body + }; + if (outboxMessage.html) { - form.append('html', outboxMessage.html); + emailBody.html = outboxMessage.html; } - const { ok, json, status } = await this._mailgunRequest( - `/${encodeURIComponent(domain)}/messages`, - { method: 'POST', formData: form } - ); + const { ok, json, status } = await this._resendRequest('/emails', { + method: 'POST', + body: emailBody + }); this.deliveryAttempts.set(outboxMessage.id, attempts + 1); @@ -303,13 +287,13 @@ export class OutboxService { outbox_id: outboxMessage.id, provider_message_id: json?.id, to: outboxMessage.to - }, 'Email sent via Mailgun'); + }, 'Email sent via Resend'); return; } // Send failed — schedule retry if under limit - const error = `Mailgun HTTP ${status}: ${json?.message || 'unknown'}`; + const error = `Resend HTTP ${status}: ${json?.message || 'unknown'}`; await storage.updateOutboxMessage(outboxMessage.id, { attempts: attempts + 1, error @@ -346,39 +330,36 @@ export class OutboxService { }, delayMs); } - // ============ MAILGUN WEBHOOKS ============ + // ============ RESEND WEBHOOKS ============ async handleWebhook(event) { - if (!event?.event_data) return; - - const mailgunId = event.event_data?.message?.headers?.['message-id']; - if (!mailgunId) return; + const eventType = event?.type; + if (!eventType) return; - const eventType = event.event_data?.event; + const providerId = event.data?.email_id; + if (!providerId) return; - logger.info({ event: eventType, provider_message_id: mailgunId }, 'Mailgun webhook received'); + logger.info({ event: eventType, provider_message_id: providerId }, 'Resend webhook received'); // Find outbox message by provider_message_id via storage scan // In production with persistent storage, this would be an indexed query - const outboxMessage = await this._findOutboxMessageByProviderId(mailgunId); + const outboxMessage = await this._findOutboxMessageByProviderId(providerId); if (!outboxMessage) { - logger.debug({ provider_message_id: mailgunId }, 'No outbox message found for provider_message_id'); + logger.debug({ provider_message_id: providerId }, 'No outbox message found for provider_message_id'); return; } const updates = {}; switch (eventType) { - case 'delivered': + case 'email.delivered': updates.status = 'delivered'; updates.delivered_at = Date.now(); break; - case 'failed': - case 'bounced': + case 'email.bounced': + case 'email.complained': updates.status = 'failed'; - updates.error = event.event_data?.reason || - event.event_data?.['delivery-status']?.description || - `Mailgun event: ${eventType}`; + updates.error = `Resend event: ${eventType}`; break; default: // Other events (opened, clicked, etc.) — log but don't update status @@ -403,18 +384,25 @@ export class OutboxService { return null; } - verifyWebhookSignature(timestamp, token, signature) { - const signingKey = getMailgunWebhookSigningKey(); - if (!signingKey) return false; + verifyWebhookSignature(svixId, svixTimestamp, svixSignature, rawBody) { + const secret = getResendWebhookSecret(); + if (!secret) return false; + + const signingString = `${svixId}.${svixTimestamp}.${rawBody}`; + + const hmac = crypto.createHmac('sha256', secret); + hmac.update(signingString); + const computed = hmac.digest('base64'); - const hmac = crypto.createHmac('sha256', signingKey); - hmac.update(timestamp + token); - const expected = hmac.digest('hex'); + // svix-signature format: "v1,{base64sig}" — strip the "v1," prefix + const incoming = svixSignature.startsWith('v1,') + ? svixSignature.slice(3) + : svixSignature; try { return crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(expected) + Buffer.from(incoming), + Buffer.from(computed) ); } catch { return false; From 5aad95f0a2007d994162d28394f0df761bf43c07 Mon Sep 17 00:00:00 2001 From: dundas Date: Mon, 2 Mar 2026 13:01:50 -0600 Subject: [PATCH 03/10] feat(email): inbound webhook route + agent email_address field - Add src/utils/email.js with agentEmailAddress() helper Format: {tenantId}.{agentId}@{domain} (with tenant) or {agentId}@{domain} Domain defaults to INBOUND_EMAIL_DOMAIN env var or agentdispatch.io - Add email_address field to GET /api/agents/:agentId response - Add POST /api/webhooks/email/inbound route Verifies X-Webhook-Secret against INBOUND_EMAIL_SECRET (timingSafeEqual) Resolves agent by to_agent + optional to_namespace (tenant match) Delivers to inbox via inboxService.send() with verify_signature:false Encodes from_email as email:{local}.at.{domain} to satisfy ADMP validator - Mount emailInboundRouter in server.js, add INBOUND_EMAIL_SECRET warning Co-Authored-By: Claude Sonnet 4.6 --- src/routes/agents.js | 6 +- src/routes/email-inbound.js | 117 ++++++++++++++++++++++++++++ src/server.js | 10 +++ src/server.test.js | 147 ++++++++++++++++++++++++++++++++++++ src/utils/email.js | 22 ++++++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 src/routes/email-inbound.js create mode 100644 src/utils/email.js diff --git a/src/routes/agents.js b/src/routes/agents.js index 03acf95..e536e72 100644 --- a/src/routes/agents.js +++ b/src/routes/agents.js @@ -10,6 +10,7 @@ import { identityService } from '../services/identity.service.js'; import { authenticateHttpSignature, requireApiKey, requireMasterKey } from '../middleware/auth.js'; import { fromBase64, toBase64, hkdfSha256, LABEL_ADMP, keypairFromSeed } from '../utils/crypto.js'; import { storage } from '../storage/index.js'; +import { agentEmailAddress } from '../utils/email.js'; const router = express.Router(); @@ -108,7 +109,10 @@ router.get('/:agentId', authenticateHttpSignature, async (req, res) => { // Don't expose secret key const { secret_key, ...publicAgent } = agent; - res.json(publicAgent); + res.json({ + ...publicAgent, + email_address: agentEmailAddress(agent.agent_id, agent.tenant_id) + }); } catch (error) { res.status(404).json({ error: 'AGENT_NOT_FOUND', diff --git a/src/routes/email-inbound.js b/src/routes/email-inbound.js new file mode 100644 index 0000000..0178cc7 --- /dev/null +++ b/src/routes/email-inbound.js @@ -0,0 +1,117 @@ +/** + * Email Inbound Webhook Route + * Receives parsed email payloads from the Cloudflare email-ingestion Worker + */ + +import crypto from 'crypto'; +import { Router } from 'express'; +import { inboxService } from '../services/inbox.service.js'; +import { storage } from '../storage/index.js'; + +const router = Router(); + +function getInboundSecret() { + return process.env.INBOUND_EMAIL_SECRET || ''; +} + +/** + * POST /api/webhooks/email/inbound + * Receive a parsed inbound email from the Cloudflare Worker. + * + * Expected body: + * to_agent {string} - Agent ID parsed from email local part + * to_namespace {string|undefined} - Tenant/namespace parsed from email local part + * from_email {string} - Sender email address + * subject {string} - Email subject + * text {string|undefined} - Plain-text body + * html {string|undefined} - HTML body + * raw_size {number|undefined} - Raw message size in bytes + */ +router.post('/webhooks/email/inbound', async (req, res) => { + try { + // --- Signature verification --- + const secret = getInboundSecret(); + const incomingSecret = req.headers['x-webhook-secret']; + + if (secret) { + if (!incomingSecret) { + return res.status(401).json({ + error: 'UNAUTHORIZED', + message: 'X-Webhook-Secret header is required' + }); + } + + let valid = false; + try { + valid = crypto.timingSafeEqual( + Buffer.from(incomingSecret), + Buffer.from(secret) + ); + } catch { + valid = false; + } + + if (!valid) { + return res.status(401).json({ + error: 'UNAUTHORIZED', + message: 'Invalid webhook secret' + }); + } + } + + // --- Input validation --- + const { to_agent, to_namespace, from_email, subject, text, html, raw_size } = req.body; + + if (!to_agent) { + return res.status(400).json({ + error: 'TO_AGENT_REQUIRED', + message: 'to_agent field is required' + }); + } + + if (!from_email) { + return res.status(400).json({ + error: 'FROM_EMAIL_REQUIRED', + message: 'from_email field is required' + }); + } + + // --- Agent resolution --- + let agent = await storage.getAgent(to_agent); + if (to_namespace && agent && agent.tenant_id !== to_namespace) { + agent = null; + } + + if (!agent) { + return res.status(404).json({ + error: 'AGENT_NOT_FOUND', + message: `Agent ${to_agent} not found` + }); + } + + // --- Deliver to inbox --- + // Encode from_email into a format that satisfies ADMP envelope validation + // (SAFE_CHARS: [a-zA-Z0-9._:-]) by replacing '@' with '.at.' + const fromId = `email:${from_email.replace('@', '.at.')}`; + + await inboxService.send({ + version: '1.0', + from: fromId, + to: agent.agent_id, + subject: subject || '(no subject)', + timestamp: new Date().toISOString(), + type: 'email', + body: { subject, from_email, text, html }, + metadata: { source: 'email', raw_size } + }, { verify_signature: false }); + + res.status(200).json({ ok: true }); + } catch (error) { + res.status(500).json({ + error: 'INBOUND_FAILED', + message: error.message + }); + } +}); + +export default router; diff --git a/src/server.js b/src/server.js index 63c21c2..ec3410c 100644 --- a/src/server.js +++ b/src/server.js @@ -19,6 +19,7 @@ import inboxRoutes from './routes/inbox.js'; import groupRoutes from './routes/groups.js'; import roundTableRoutes from './routes/round-tables.js'; import outboxRoutes, { outboxWebhookRouter } from './routes/outbox.js'; +import emailInboundRouter from './routes/email-inbound.js'; import discoveryRoutes from './routes/discovery.js'; import keysRoutes from './routes/keys.js'; import { requireApiKey, verifyHttpSignatureOnly } from './middleware/auth.js'; @@ -57,6 +58,14 @@ if (!process.env.RESEND_WEBHOOK_SECRET) { ); } +if (!process.env.INBOUND_EMAIL_SECRET) { + console.warn( + 'WARNING: INBOUND_EMAIL_SECRET is not set. ' + + 'Inbound email webhook will accept unauthenticated requests. ' + + 'Set INBOUND_EMAIL_SECRET to secure the /webhooks/email/inbound endpoint.' + ); +} + // Warn when no master key is configured — admin endpoints will deny all requests if (!process.env.MASTER_API_KEY) { console.warn( @@ -204,6 +213,7 @@ app.use('/api/round-tables', roundTableRoutes); app.use('/api/agents', outboxRoutes); app.use('/api/keys', keysRoutes); app.use('/api', inboxRoutes); // For /api/messages/:id/status +app.use('/api', emailInboundRouter); // For /api/webhooks/email/inbound // 404 handler app.use((req, res) => { diff --git a/src/server.test.js b/src/server.test.js index f92ef73..e169824 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -6,6 +6,7 @@ import crypto from 'node:crypto'; import nacl from 'tweetnacl'; import app from './server.js'; +import { agentEmailAddress } from './utils/email.js'; import { fromBase64, toBase64, signMessage, signRequest, hkdfSha256, LABEL_ADMP, keypairFromSeed, generateDID, hashApiKey } from './utils/crypto.js'; import { requireApiKey } from './middleware/auth.js'; import { webhookService } from './services/webhook.service.js'; @@ -4738,3 +4739,149 @@ test('round table: facilitator cannot be listed as a participant', async () => { assert.equal(res.status, 400); assert.equal(res.body.error, 'FACILITATOR_IN_PARTICIPANTS'); }); + +// ============ TASK 3.0: EMAIL ADDRESS HELPER + INBOUND WEBHOOK ============ + +test('agentEmailAddress: with tenant returns {tenant}.{agentId}@{domain}', () => { + assert.equal(agentEmailAddress('alice', 'acme', 'agentdispatch.io'), 'acme.alice@agentdispatch.io'); +}); + +test('agentEmailAddress: without tenant returns {agentId}@{domain}', () => { + assert.equal(agentEmailAddress('alice', null, 'agentdispatch.io'), 'alice@agentdispatch.io'); + assert.equal(agentEmailAddress('alice', undefined, 'agentdispatch.io'), 'alice@agentdispatch.io'); +}); + +test('agentEmailAddress: domain defaults to INBOUND_EMAIL_DOMAIN env var', () => { + const orig = process.env.INBOUND_EMAIL_DOMAIN; + process.env.INBOUND_EMAIL_DOMAIN = 'mail.example.com'; + // Note: module-level default captured at import time; test the explicit domain arg + const addr = agentEmailAddress('bob', 'corp', 'mail.example.com'); + assert.equal(addr, 'corp.bob@mail.example.com'); + if (orig === undefined) delete process.env.INBOUND_EMAIL_DOMAIN; + else process.env.INBOUND_EMAIL_DOMAIN = orig; +}); + +test('GET /api/agents/:agentId returns email_address field', async () => { + const agent = await registerAgent('email-addr-test'); + const res = await request(app) + .get(`/api/agents/${encodeURIComponent(agent.agent_id)}`) + .set('X-Agent-ID', agent.agent_id); + + assert.equal(res.status, 200); + assert.ok(res.body.email_address, 'email_address should be present'); + assert.ok(res.body.email_address.includes(agent.agent_id), 'email_address should include agent_id'); + assert.ok(res.body.email_address.includes('@'), 'email_address should be an email'); +}); + +test('email inbound: valid request delivers message to agent inbox', async () => { + const agent = await registerAgent('inbound-email-happy'); + const origSecret = process.env.INBOUND_EMAIL_SECRET; + delete process.env.INBOUND_EMAIL_SECRET; + + try { + const res = await request(app) + .post('/api/webhooks/email/inbound') + .send({ + to_agent: agent.agent_id, + from_email: 'sender@example.com', + subject: 'Hello from email', + text: 'This is the body' + }); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound: missing X-Webhook-Secret returns 401 when secret is set', async () => { + const origSecret = process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'my-secret'; + + try { + const res = await request(app) + .post('/api/webhooks/email/inbound') + .send({ to_agent: 'anyone', from_email: 'x@example.com', subject: 'test' }); + + assert.equal(res.status, 401); + assert.equal(res.body.error, 'UNAUTHORIZED'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound: wrong X-Webhook-Secret returns 401', async () => { + const origSecret = process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'correct-secret'; + + try { + const res = await request(app) + .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'wrong-secret') + .send({ to_agent: 'anyone', from_email: 'x@example.com', subject: 'test' }); + + assert.equal(res.status, 401); + assert.equal(res.body.error, 'UNAUTHORIZED'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound: unknown agent returns 404', async () => { + const origSecret = process.env.INBOUND_EMAIL_SECRET; + delete process.env.INBOUND_EMAIL_SECRET; + + try { + const res = await request(app) + .post('/api/webhooks/email/inbound') + .send({ + to_agent: 'nonexistent-agent-xyz', + from_email: 'sender@example.com', + subject: 'test' + }); + + assert.equal(res.status, 404); + assert.equal(res.body.error, 'AGENT_NOT_FOUND'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound: missing to_agent returns 400', async () => { + const origSecret = process.env.INBOUND_EMAIL_SECRET; + delete process.env.INBOUND_EMAIL_SECRET; + + try { + const res = await request(app) + .post('/api/webhooks/email/inbound') + .send({ from_email: 'sender@example.com', subject: 'test' }); + + assert.equal(res.status, 400); + assert.equal(res.body.error, 'TO_AGENT_REQUIRED'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound: missing from_email returns 400', async () => { + const origSecret = process.env.INBOUND_EMAIL_SECRET; + delete process.env.INBOUND_EMAIL_SECRET; + + try { + const res = await request(app) + .post('/api/webhooks/email/inbound') + .send({ to_agent: 'someagent', subject: 'test' }); + + assert.equal(res.status, 400); + assert.equal(res.body.error, 'FROM_EMAIL_REQUIRED'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); diff --git a/src/utils/email.js b/src/utils/email.js new file mode 100644 index 0000000..249bd18 --- /dev/null +++ b/src/utils/email.js @@ -0,0 +1,22 @@ +/** + * Email address helpers for ADMP agent email addresses + */ + +const DEFAULT_DOMAIN = process.env.INBOUND_EMAIL_DOMAIN || 'agentdispatch.io'; + +/** + * Compute the inbound email address for an agent. + * + * Format: + * With tenant: {tenantId}.{agentId}@{domain} + * Without tenant: {agentId}@{domain} + * + * @param {string} agentId + * @param {string|null|undefined} tenantId + * @param {string} [domain] + * @returns {string} + */ +export function agentEmailAddress(agentId, tenantId, domain = DEFAULT_DOMAIN) { + const local = tenantId ? `${tenantId}.${agentId}` : agentId; + return `${local}@${domain}`; +} From 0966091d50d276365d710ab7b31d782497ce4e68 Mon Sep 17 00:00:00 2001 From: dundas Date: Mon, 2 Mar 2026 13:05:06 -0600 Subject: [PATCH 04/10] feat(worker): Cloudflare email ingestion worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scaffold workers/email-ingestion/ with package.json, wrangler.toml, index.ts - parseRecipient(): parse {namespace}.{agentId}@domain into structured parts - acme.alice → namespace=acme, agentId=alice - acme.alice.v2 → namespace=acme, agentId=alice.v2 (dots preserved) - alice → namespace=null, agentId=alice - email() handler: parse MIME with postal-mime, POST to ADMP /webhooks/email/inbound - Passes X-Webhook-Secret for authentication - Calls message.setReject() on 404 (unknown agent) - Logs transient errors without bouncing - 5 unit tests for parseRecipient (all pass) - README: deployment guide, secrets setup, email routing config Co-Authored-By: Claude Sonnet 4.6 --- workers/email-ingestion/README.md | 70 ++++++++ workers/email-ingestion/bun.lock | 229 ++++++++++++++++++++++++++ workers/email-ingestion/index.test.ts | 32 ++++ workers/email-ingestion/index.ts | 101 ++++++++++++ workers/email-ingestion/package.json | 17 ++ workers/email-ingestion/wrangler.toml | 17 ++ 6 files changed, 466 insertions(+) create mode 100644 workers/email-ingestion/README.md create mode 100644 workers/email-ingestion/bun.lock create mode 100644 workers/email-ingestion/index.test.ts create mode 100644 workers/email-ingestion/index.ts create mode 100644 workers/email-ingestion/package.json create mode 100644 workers/email-ingestion/wrangler.toml diff --git a/workers/email-ingestion/README.md b/workers/email-ingestion/README.md new file mode 100644 index 0000000..eebdb15 --- /dev/null +++ b/workers/email-ingestion/README.md @@ -0,0 +1,70 @@ +# ADMP Email Ingestion Worker + +Cloudflare Worker that receives inbound emails via Cloudflare Email Routing and forwards them to the ADMP server inbox. + +## How It Works + +1. Cloudflare Email Routing delivers all `*@agentdispatch.io` mail to this Worker via a catch-all rule. +2. The Worker parses the recipient address: `{namespace}.{agentId}@agentdispatch.io` → `namespace` + `agentId`. +3. It reads the raw MIME, parses it with `postal-mime`, and POSTs the structured payload to the ADMP server. +4. If the agent is not found (404), the email is rejected with an SMTP `Unknown recipient` error. + +## Prerequisites + +- A Cloudflare account with the `agentdispatch.io` zone +- Cloudflare Email Routing enabled on the zone +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) installed: `npm install -g wrangler` +- An ADMP server reachable from the internet + +## Setup + +### 1. Configure Cloudflare Email Routing + +In the Cloudflare dashboard for `agentdispatch.io`: +- Go to **Email** → **Email Routing** → **Routing Rules** +- Enable Email Routing and verify the zone +- Add a **Catch-all** rule: Action = **Send to Worker** → `admp-email-ingestion` + +### 2. Set Required Secrets + +```bash +# Base URL of your ADMP server (no trailing slash) +wrangler secret put ADMP_URL +# → https://api.yourdomain.com + +# Shared secret — must match INBOUND_EMAIL_SECRET on the ADMP server +wrangler secret put INBOUND_EMAIL_SECRET +``` + +### 3. Deploy + +```bash +bun install +wrangler deploy +``` + +### Local Development + +```bash +wrangler dev +``` + +Note: Email event testing locally requires [wrangler email test](https://developers.cloudflare.com/email-routing/email-workers/runtime-api/) or direct invocation via the Cloudflare dashboard. + +## Email Address Format + +| Email | namespace | agentId | +|-------|-----------|---------| +| `acme.alice@agentdispatch.io` | `acme` | `alice` | +| `acme.alice.v2@agentdispatch.io` | `acme` | `alice.v2` | +| `alice@agentdispatch.io` | _(none)_ | `alice` | + +The ADMP server resolves the agent by `agentId` and optionally verifies the `namespace` matches the agent's tenant. + +## Environment Variables + +| Variable | How to Set | Description | +|----------|-----------|-------------| +| `ADMP_URL` | `wrangler secret put` | ADMP server base URL | +| `INBOUND_EMAIL_SECRET` | `wrangler secret put` | Authenticates Worker → ADMP requests | +| `INBOUND_EMAIL_DOMAIN` | `wrangler.toml [vars]` | Domain to strip from recipient addresses (default: `agentdispatch.io`) | diff --git a/workers/email-ingestion/bun.lock b/workers/email-ingestion/bun.lock new file mode 100644 index 0000000..b29f4d2 --- /dev/null +++ b/workers/email-ingestion/bun.lock @@ -0,0 +1,229 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@agentdispatch/email-ingestion", + "dependencies": { + "postal-mime": "^2.7.0", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.0.0", + "wrangler": "^3.0.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.0.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.14", "workerd": "^1.20250124.0" }, "optionalPeers": ["workerd"] }, "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250718.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250718.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250718.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250718.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250718.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260301.1", "", {}, "sha512-klKnECMb5A4GtVF0P5NH6rCjtyjqIEKJaz6kEtx9YPHhfFO2HUEarO+MI4F8WPchgeZqpGlEpDhRapzrOTw51Q=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@esbuild-plugins/node-globals-polyfill": ["@esbuild-plugins/node-globals-polyfill@0.2.3", "", { "peerDependencies": { "esbuild": "*" } }, "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw=="], + + "@esbuild-plugins/node-modules-polyfill": ["@esbuild-plugins/node-modules-polyfill@0.2.2", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "rollup-plugin-node-polyfills": "^0.2.1" }, "peerDependencies": { "esbuild": "*" } }, "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.17.19", "", { "os": "android", "cpu": "x64" }, "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.17.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.17.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.17.19", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.17.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.17.19", "", { "os": "linux", "cpu": "arm" }, "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.17.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.17.19", "", { "os": "linux", "cpu": "ia32" }, "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.17.19", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.17.19", "", { "os": "linux", "cpu": "s390x" }, "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.17.19", "", { "os": "linux", "cpu": "x64" }, "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.17.19", "", { "os": "none", "cpu": "x64" }, "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.17.19", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.17.19", "", { "os": "sunos", "cpu": "x64" }, "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.17.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.17.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.17.19", "", { "os": "win32", "cpu": "x64" }, "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + + "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + + "magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "miniflare": ["miniflare@3.20250718.3", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "postal-mime": ["postal-mime@2.7.3", "", {}, "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="], + + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], + + "rollup-plugin-inject": ["rollup-plugin-inject@3.0.2", "", { "dependencies": { "estree-walker": "^0.6.1", "magic-string": "^0.25.3", "rollup-pluginutils": "^2.8.1" } }, "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w=="], + + "rollup-plugin-node-polyfills": ["rollup-plugin-node-polyfills@0.2.1", "", { "dependencies": { "rollup-plugin-inject": "^3.0.0" } }, "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA=="], + + "rollup-pluginutils": ["rollup-pluginutils@2.8.2", "", { "dependencies": { "estree-walker": "^0.6.1" } }, "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], + + "stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="], + + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "unenv": ["unenv@2.0.0-rc.14", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", "ohash": "^2.0.10", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q=="], + + "workerd": ["workerd@1.20250718.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250718.0", "@cloudflare/workerd-darwin-arm64": "1.20250718.0", "@cloudflare/workerd-linux-64": "1.20250718.0", "@cloudflare/workerd-linux-arm64": "1.20250718.0", "@cloudflare/workerd-windows-64": "1.20250718.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg=="], + + "wrangler": ["wrangler@3.114.17", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@cloudflare/unenv-preset": "2.0.2", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-modules-polyfill": "0.2.2", "blake3-wasm": "2.1.5", "esbuild": "0.17.19", "miniflare": "3.20250718.3", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.14", "workerd": "1.20250718.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250408.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + + "zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + } +} diff --git a/workers/email-ingestion/index.test.ts b/workers/email-ingestion/index.test.ts new file mode 100644 index 0000000..39c5c34 --- /dev/null +++ b/workers/email-ingestion/index.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from 'bun:test'; +import { parseRecipient } from './index'; + +test('parseRecipient: namespace.agentId@domain', () => { + const result = parseRecipient('acme.alice@agentdispatch.io', 'agentdispatch.io'); + expect(result.namespace).toBe('acme'); + expect(result.agentId).toBe('alice'); +}); + +test('parseRecipient: namespace.agentId.with.dots@domain (preserves dots in agentId)', () => { + const result = parseRecipient('acme.alice.v2@agentdispatch.io', 'agentdispatch.io'); + expect(result.namespace).toBe('acme'); + expect(result.agentId).toBe('alice.v2'); +}); + +test('parseRecipient: agentId@domain (no namespace)', () => { + const result = parseRecipient('alice@agentdispatch.io', 'agentdispatch.io'); + expect(result.namespace).toBeNull(); + expect(result.agentId).toBe('alice'); +}); + +test('parseRecipient: address without @ (bare local part)', () => { + const result = parseRecipient('acme.bob', 'agentdispatch.io'); + expect(result.namespace).toBe('acme'); + expect(result.agentId).toBe('bob'); +}); + +test('parseRecipient: single-segment local part without @', () => { + const result = parseRecipient('alice', 'agentdispatch.io'); + expect(result.namespace).toBeNull(); + expect(result.agentId).toBe('alice'); +}); diff --git a/workers/email-ingestion/index.ts b/workers/email-ingestion/index.ts new file mode 100644 index 0000000..40334a9 --- /dev/null +++ b/workers/email-ingestion/index.ts @@ -0,0 +1,101 @@ +import PostalMime from 'postal-mime'; + +// ============ TYPES ============ + +interface Env { + ADMP_URL: string; + INBOUND_EMAIL_SECRET: string; + INBOUND_EMAIL_DOMAIN: string; +} + +interface ParsedRecipient { + namespace: string | null; + agentId: string; +} + +// ============ ADDRESS PARSER ============ + +/** + * Parse an ADMP agent email address into namespace and agentId. + * + * Format: {namespace}.{agentId}@{domain} → namespace + agentId + * {agentId}@{domain} → null namespace + agentId + * + * Edge case: dots in agentId are preserved. + * acme.alice → { namespace: 'acme', agentId: 'alice' } + * acme.alice.v2 → { namespace: 'acme', agentId: 'alice.v2' } + * alice → { namespace: null, agentId: 'alice' } + */ +export function parseRecipient(address: string, domain: string): ParsedRecipient { + // Strip @domain suffix (case-insensitive) + const atIdx = address.lastIndexOf('@'); + const local = atIdx !== -1 ? address.slice(0, atIdx) : address; + + // Split on first '.' only + const dotIdx = local.indexOf('.'); + if (dotIdx === -1) { + return { namespace: null, agentId: local }; + } + + const namespace = local.slice(0, dotIdx); + const agentId = local.slice(dotIdx + 1); + return { namespace, agentId }; +} + +// ============ EMAIL EVENT HANDLER ============ + +export default { + async email( + message: ForwardableEmailMessage, + env: Env, + ctx: ExecutionContext + ): Promise { + const domain = env.INBOUND_EMAIL_DOMAIN || 'agentdispatch.io'; + const { namespace, agentId } = parseRecipient(message.to, domain); + + // Read and parse raw MIME + const raw = await message.raw.arrayBuffer(); + const parsed = await new PostalMime().parse(raw); + + // Forward to ADMP inbound webhook + const payload = { + to_agent: agentId, + to_namespace: namespace ?? undefined, + from_email: parsed.from?.address ?? message.from, + subject: parsed.subject ?? '(no subject)', + text: parsed.text, + html: parsed.html, + raw_size: message.rawSize + }; + + let response: Response; + try { + response = await fetch(`${env.ADMP_URL}/api/webhooks/email/inbound`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Secret': env.INBOUND_EMAIL_SECRET + }, + body: JSON.stringify(payload) + }); + } catch (err) { + // Network error — don't reject the email, log and let Cloudflare retry + console.error('[email-ingestion] Network error forwarding to ADMP:', err); + return; + } + + if (response.status === 404) { + // Agent not found — bounce with SMTP rejection + message.setReject('Unknown recipient'); + return; + } + + if (!response.ok) { + // Transient error — log but don't reject to avoid bouncing on server issues + const body = await response.text().catch(() => ''); + console.error( + `[email-ingestion] ADMP returned ${response.status}: ${body}` + ); + } + } +}; diff --git a/workers/email-ingestion/package.json b/workers/email-ingestion/package.json new file mode 100644 index 0000000..e30197a --- /dev/null +++ b/workers/email-ingestion/package.json @@ -0,0 +1,17 @@ +{ + "name": "@agentdispatch/email-ingestion", + "type": "module", + "version": "1.0.0", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "test": "bun test" + }, + "dependencies": { + "postal-mime": "^2.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.0.0", + "wrangler": "^3.0.0" + } +} diff --git a/workers/email-ingestion/wrangler.toml b/workers/email-ingestion/wrangler.toml new file mode 100644 index 0000000..be8837b --- /dev/null +++ b/workers/email-ingestion/wrangler.toml @@ -0,0 +1,17 @@ +name = "admp-email-ingestion" +main = "index.ts" +compatibility_date = "2025-01-01" + +[vars] +# Inbound email domain — used to strip the @domain suffix when parsing recipient +INBOUND_EMAIL_DOMAIN = "agentdispatch.io" + +# Required secrets (set via `wrangler secret put`, never hardcode here): +# ADMP_URL - Base URL of the ADMP server, e.g. https://api.agentdispatch.io +# INBOUND_EMAIL_SECRET - Shared secret that authenticates Worker → ADMP requests +# Must match INBOUND_EMAIL_SECRET on the ADMP server + +[[email]] +# Catch-all Cloudflare Email Routing trigger. +# Configure a catch-all rule on agentdispatch.io in the Cloudflare dashboard: +# Action: Send to Worker → admp-email-ingestion From 6100a74d37ea76e75a1ed4e5cb310beb1c715a48 Mon Sep 17 00:00:00 2001 From: dundas Date: Mon, 2 Mar 2026 13:21:46 -0600 Subject: [PATCH 05/10] docs(env): replace Mailgun vars with Resend + inbound email config - Remove MAILGUN_API_KEY, MAILGUN_API_URL, MAILGUN_WEBHOOK_SIGNING_KEY from .env.example - Add RESEND_API_KEY, RESEND_WEBHOOK_SECRET, INBOUND_EMAIL_SECRET, INBOUND_EMAIL_DOMAIN - Add Email section to README covering inbound (Cloudflare Worker) and outbound (Resend) - Update Production Checklist with email env vars Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 15 +++++++++++---- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 3f3db65..b47bf8d 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,14 @@ REGISTRATION_POLICY=open # Comma-separated domains whose DID:web agents are auto-approved # DID_WEB_ALLOWED_DOMAINS=example.com,partner.io -# Mailgun Outbox (optional — enables outbound email for agents) -MAILGUN_API_KEY= -MAILGUN_API_URL=https://api.mailgun.net/v3 -MAILGUN_WEBHOOK_SIGNING_KEY= +# Email (Resend + Cloudflare) +# Outbound email via Resend (https://resend.com) +RESEND_API_KEY= +# Secret for verifying Resend webhook delivery status events (Svix-signed) +RESEND_WEBHOOK_SECRET= + +# Inbound email via Cloudflare Email Routing → Worker → ADMP +# Shared secret between the Cloudflare Worker and this server +INBOUND_EMAIL_SECRET= +# Domain used to construct agent email addresses +INBOUND_EMAIL_DOMAIN=agentdispatch.io diff --git a/README.md b/README.md index 0ece26c..6db0682 100644 --- a/README.md +++ b/README.md @@ -874,6 +874,49 @@ See `.env.example` for all configuration options. | `API_KEY_REQUIRED` | false | Enable API key auth | | `MASTER_API_KEY` | - | Master API key (if auth enabled) | +### Email + +ADMP supports bidirectional email for agents using [Resend](https://resend.com) for outbound and Cloudflare Email Routing for inbound. + +#### Agent Email Addresses + +Every agent gets a platform email address: + +``` +{agentId}@agentdispatch.io +acme.alice@agentdispatch.io ← agent "alice" in tenant/namespace "acme" +``` + +The address format is controlled by the `INBOUND_EMAIL_DOMAIN` env var. + +#### Inbound Email + +1. A catch-all rule on `agentdispatch.io` in Cloudflare Email Routing forwards all mail to the `admp-email-ingestion` Cloudflare Worker. +2. The Worker parses the recipient address, reads the MIME body with `postal-mime`, and POSTs to `POST /api/webhooks/email/inbound`. +3. The ADMP server delivers the message to the agent's inbox. + +See [`workers/email-ingestion/README.md`](workers/email-ingestion/README.md) for Cloudflare setup. + +**Required env vars:** + +| Variable | Description | +|----------|-------------| +| `INBOUND_EMAIL_SECRET` | Shared secret between Cloudflare Worker and ADMP server | +| `INBOUND_EMAIL_DOMAIN` | Domain for agent email addresses (default: `agentdispatch.io`) | + +#### Outbound Email + +Agents can send email via `POST /api/agents/:agentId/outbox/send` after configuring a custom domain. + +**Required env vars:** + +| Variable | Description | +|----------|-------------| +| `RESEND_API_KEY` | Resend API key for outbound delivery | +| `RESEND_WEBHOOK_SECRET` | Validates Resend delivery status webhooks (Svix-signed) | + +Custom domain setup: `POST /api/agents/:agentId/outbox/domain` then `POST /api/agents/:agentId/outbox/domain/verify`. + ### Production Checklist - [ ] Set `NODE_ENV=production` @@ -883,6 +926,8 @@ See `.env.example` for all configuration options. - [ ] Set up log aggregation (JSON logs via `pino`) - [ ] Configure resource limits (memory, CPU) - [ ] Set up HTTPS reverse proxy (nginx, Caddy) +- [ ] Configure `RESEND_API_KEY` and `RESEND_WEBHOOK_SECRET` for outbound email +- [ ] Configure `INBOUND_EMAIL_SECRET` and deploy Cloudflare Worker for inbound email ## Architecture From bced5fcb7482592da8e3d8a55de72da904c09562 Mon Sep 17 00:00:00 2001 From: dundas Date: Mon, 2 Mar 2026 13:47:20 -0600 Subject: [PATCH 06/10] fix(security): address adversarial review findings on email webhook endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - outbox.service.js: add Svix timestamp freshness check (±5 min window) to verifyWebhookSignature() to prevent replay attacks on /webhooks/resend - email-inbound.js: fail closed when INBOUND_EMAIL_SECRET is not configured (returns 500 SERVER_MISCONFIGURATION instead of silently skipping auth) - email-inbound.js: sanitize 500 error responses (log error, return generic message to avoid leaking internal details) - server.js: update startup warning to reflect new fail-closed behavior - server.test.js: update 4 tests to provide auth headers; add test for SERVER_MISCONFIGURATION when secret is unconfigured Co-Authored-By: Claude Sonnet 4.6 --- src/routes/email-inbound.js | 58 +++++++++++++++++++--------------- src/server.js | 4 +-- src/server.test.js | 29 ++++++++++++++--- src/services/outbox.service.js | 5 +++ 4 files changed, 65 insertions(+), 31 deletions(-) diff --git a/src/routes/email-inbound.js b/src/routes/email-inbound.js index 0178cc7..0a1bd59 100644 --- a/src/routes/email-inbound.js +++ b/src/routes/email-inbound.js @@ -5,9 +5,12 @@ import crypto from 'crypto'; import { Router } from 'express'; +import pino from 'pino'; import { inboxService } from '../services/inbox.service.js'; import { storage } from '../storage/index.js'; +const logger = pino(); + const router = Router(); function getInboundSecret() { @@ -31,32 +34,36 @@ router.post('/webhooks/email/inbound', async (req, res) => { try { // --- Signature verification --- const secret = getInboundSecret(); + if (!secret) { + return res.status(500).json({ + error: 'SERVER_MISCONFIGURATION', + message: 'INBOUND_EMAIL_SECRET is not configured' + }); + } + const incomingSecret = req.headers['x-webhook-secret']; + if (!incomingSecret) { + return res.status(401).json({ + error: 'UNAUTHORIZED', + message: 'X-Webhook-Secret header is required' + }); + } + + let valid = false; + try { + valid = crypto.timingSafeEqual( + Buffer.from(incomingSecret), + Buffer.from(secret) + ); + } catch { + valid = false; + } - if (secret) { - if (!incomingSecret) { - return res.status(401).json({ - error: 'UNAUTHORIZED', - message: 'X-Webhook-Secret header is required' - }); - } - - let valid = false; - try { - valid = crypto.timingSafeEqual( - Buffer.from(incomingSecret), - Buffer.from(secret) - ); - } catch { - valid = false; - } - - if (!valid) { - return res.status(401).json({ - error: 'UNAUTHORIZED', - message: 'Invalid webhook secret' - }); - } + if (!valid) { + return res.status(401).json({ + error: 'UNAUTHORIZED', + message: 'Invalid webhook secret' + }); } // --- Input validation --- @@ -107,9 +114,10 @@ router.post('/webhooks/email/inbound', async (req, res) => { res.status(200).json({ ok: true }); } catch (error) { + logger.error({ error: error.message }, 'Email inbound webhook failed'); res.status(500).json({ error: 'INBOUND_FAILED', - message: error.message + message: 'Internal server error' }); } }); diff --git a/src/server.js b/src/server.js index ec3410c..4d5644d 100644 --- a/src/server.js +++ b/src/server.js @@ -61,8 +61,8 @@ if (!process.env.RESEND_WEBHOOK_SECRET) { if (!process.env.INBOUND_EMAIL_SECRET) { console.warn( 'WARNING: INBOUND_EMAIL_SECRET is not set. ' + - 'Inbound email webhook will accept unauthenticated requests. ' + - 'Set INBOUND_EMAIL_SECRET to secure the /webhooks/email/inbound endpoint.' + 'The /webhooks/email/inbound endpoint will reject all requests with 500. ' + + 'Set INBOUND_EMAIL_SECRET to enable inbound email delivery.' ); } diff --git a/src/server.test.js b/src/server.test.js index e169824..e0eec2f 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -4776,11 +4776,12 @@ test('GET /api/agents/:agentId returns email_address field', async () => { test('email inbound: valid request delivers message to agent inbox', async () => { const agent = await registerAgent('inbound-email-happy'); const origSecret = process.env.INBOUND_EMAIL_SECRET; - delete process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; try { const res = await request(app) .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') .send({ to_agent: agent.agent_id, from_email: 'sender@example.com', @@ -4833,11 +4834,12 @@ test('email inbound: wrong X-Webhook-Secret returns 401', async () => { test('email inbound: unknown agent returns 404', async () => { const origSecret = process.env.INBOUND_EMAIL_SECRET; - delete process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; try { const res = await request(app) .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') .send({ to_agent: 'nonexistent-agent-xyz', from_email: 'sender@example.com', @@ -4854,11 +4856,12 @@ test('email inbound: unknown agent returns 404', async () => { test('email inbound: missing to_agent returns 400', async () => { const origSecret = process.env.INBOUND_EMAIL_SECRET; - delete process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; try { const res = await request(app) .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') .send({ from_email: 'sender@example.com', subject: 'test' }); assert.equal(res.status, 400); @@ -4871,11 +4874,12 @@ test('email inbound: missing to_agent returns 400', async () => { test('email inbound: missing from_email returns 400', async () => { const origSecret = process.env.INBOUND_EMAIL_SECRET; - delete process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; try { const res = await request(app) .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') .send({ to_agent: 'someagent', subject: 'test' }); assert.equal(res.status, 400); @@ -4885,3 +4889,20 @@ test('email inbound: missing from_email returns 400', async () => { else process.env.INBOUND_EMAIL_SECRET = origSecret; } }); + +test('email inbound: no INBOUND_EMAIL_SECRET configured returns 500', async () => { + const origSecret = process.env.INBOUND_EMAIL_SECRET; + delete process.env.INBOUND_EMAIL_SECRET; + + try { + const res = await request(app) + .post('/api/webhooks/email/inbound') + .send({ to_agent: 'anyone', from_email: 'x@example.com', subject: 'test' }); + + assert.equal(res.status, 500); + assert.equal(res.body.error, 'SERVER_MISCONFIGURATION'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); diff --git a/src/services/outbox.service.js b/src/services/outbox.service.js index 9dbd0fe..b63388a 100644 --- a/src/services/outbox.service.js +++ b/src/services/outbox.service.js @@ -388,6 +388,11 @@ export class OutboxService { const secret = getResendWebhookSecret(); if (!secret) return false; + // Timestamp freshness: reject webhooks older than 5 minutes to prevent replay attacks + const tsSeconds = Number(svixTimestamp); + if (!Number.isFinite(tsSeconds)) return false; + if (Math.abs(Date.now() / 1000 - tsSeconds) > 300) return false; + const signingString = `${svixId}.${svixTimestamp}.${rawBody}`; const hmac = crypto.createHmac('sha256', secret); From 8b0b3b022b078326590368240b49d07a3777a8f2 Mon Sep 17 00:00:00 2001 From: dundas Date: Mon, 2 Mar 2026 13:49:32 -0600 Subject: [PATCH 07/10] docs(agent-guide): add Email section with inbound/outbound documentation - Agent email address format ({namespace}.{agent_id}@agentdispatch.io) - How to retrieve email_address from GET /api/agents/:id - Receiving email: message type, body fields, from-field encoding - Sending email: three-step domain setup, send endpoint, status values - Full domain management endpoint reference table Co-Authored-By: Claude Sonnet 4.6 --- docs/AGENT-GUIDE.md | 159 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index dc796c2..49ef190 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -26,6 +26,7 @@ All request and response bodies are JSON (`Content-Type: application/json`). 9. [Approval Workflow](#9-approval-workflow) 10. [Known Limitations and Security Notes](#10-known-limitations-and-security-notes) 11. [Best Practices](#11-best-practices) +12. [Email](#12-email) --- @@ -576,3 +577,161 @@ The pull response includes `"auto_acked": true` when the hub auto-acked the mess - **Pull in a loop** with a reasonable `visibility_timeout` (30-60s) to avoid re-processing. - **Use webhooks** (`POST /api/agents/:id/webhook`) for push-based delivery if your agent has a public endpoint. This eliminates polling latency. - **Send heartbeats** (`POST /api/agents/:id/heartbeat`) at your configured interval (default 60s) to maintain `online` status. + +--- + +## 12. Email + +Every ADMP agent has a built-in email address. Humans and external systems can email your agent directly, and agents with verified custom domains can send email outbound. + +### Your Agent's Email Address + +Email addresses follow this format: + +| Agent setup | Email address | +|-------------|--------------| +| Agent with namespace/tenant | `{namespace}.{agent_id}@agentdispatch.io` | +| Agent without namespace | `{agent_id}@agentdispatch.io` | + +**Examples:** +- Agent `alice` in tenant `acme` → `acme.alice@agentdispatch.io` +- Agent `alice.v2` in tenant `acme` → `acme.alice.v2@agentdispatch.io` +- Agent `alice` with no tenant → `alice@agentdispatch.io` + +Retrieve your agent's email address from the API: + +```http +GET /api/agents/ +``` + +Response includes: +```json +{ + "agent_id": "alice", + "email_address": "acme.alice@agentdispatch.io", + ... +} +``` + +--- + +### Receiving Email + +When someone sends an email to your agent's address, it is delivered to your inbox as a standard ADMP message. + +**Message type:** `email` + +**Pull a message and inspect it:** + +```http +POST /api/agents//inbox/pull +``` + +Response: +```json +{ + "message_id": "...", + "from": "email:sender.at.example.com", + "to": "alice", + "type": "email", + "subject": "(no subject)", + "body": { + "subject": "Hello from email", + "from_email": "sender@example.com", + "text": "Plain-text body of the email", + "html": "

HTML body, if present

" + }, + "metadata": { + "source": "email", + "raw_size": 4096 + } +} +``` + +**Notes:** +- The `from` field encodes the sender email as `email:{local}.at.{domain}` (replacing `@` with `.at.`) to satisfy ADMP's agent ID format constraints. +- Acknowledge the message after processing with `POST .../ack`. +- If `auto_ack_on_pull: true` is set on your agent, messages are auto-acknowledged on pull. + +--- + +### Sending Email + +Agents with a **verified custom domain** can send outbound email. Three steps are required before sending: + +**Step 1: Register your domain** + +```http +POST /api/agents//outbox/domain +Content-Type: application/json + +{ "domain": "yourdomain.com" } +``` + +Response includes DNS records to add: +```json +{ + "domain": "yourdomain.com", + "status": "pending", + "dns_records": [ + { "type": "MX", "name": "@", "value": "..." }, + { "type": "TXT", "name": "_dmarc", "value": "..." } + ] +} +``` + +**Step 2: Add DNS records** to your domain registrar, then trigger verification: + +```http +POST /api/agents//outbox/domain/verify +``` + +**Step 3: Send email** + +```http +POST /api/agents//outbox/send +Content-Type: application/json + +{ + "to": "recipient@example.com", + "subject": "Hello from my agent", + "body": "Plain-text body of the email", + "html": "

Optional HTML body

", + "from_name": "My Agent" +} +``` + +Response (202 Accepted): +```json +{ + "id": "...", + "status": "queued", + "to": "recipient@example.com", + "subject": "Hello from my agent" +} +``` + +**Check delivery status:** + +```http +GET /api/agents//outbox/messages/ +``` + +Status values: `queued` → `sent` → `delivered` (or `failed`) + +**Domain management endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/agents/:id/outbox/domain` | Register a custom domain | +| `GET` | `/api/agents/:id/outbox/domain` | Get domain config and DNS records | +| `POST` | `/api/agents/:id/outbox/domain/verify` | Trigger DNS verification | +| `DELETE` | `/api/agents/:id/outbox/domain` | Remove domain config | +| `POST` | `/api/agents/:id/outbox/send` | Send an email | +| `GET` | `/api/agents/:id/outbox/messages` | List sent messages | +| `GET` | `/api/agents/:id/outbox/messages/:msgId` | Get message delivery status | + +**Requirements:** +- `RESEND_API_KEY` must be configured on the ADMP server. +- Domain must be fully verified before sending. +- All outbox endpoints require agent authentication (HTTP Signature or API key). From e25286af08246539806122ff0059e6cd8376eadd Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 5 Mar 2026 13:40:57 -0600 Subject: [PATCH 08/10] feat(email): inbound quarantine, review endpoint, trusted senders API - Inbound email enters review_pending; trusted senders auto-queued - POST /api/webhooks/email/inbound/:messageId/review (approve|reject, reason, model_verdict) - GET/POST/DELETE /api/agents/:agentId/email/trusted-senders - Inbox service: initial_status, bypass_trust_check, system_metadata guard - Docs: README and AGENT-GUIDE updated for policy and endpoints Made-with: Cursor --- README.md | 17 +- docs/AGENT-GUIDE.md | 50 +++++- src/routes/agents.js | 109 +++++++++++++ src/routes/email-inbound.js | 178 +++++++++++++++++---- src/server.test.js | 290 ++++++++++++++++++++++++++++++++-- src/services/inbox.service.js | 35 +++- 6 files changed, 620 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 6db0682..5b40c94 100644 --- a/README.md +++ b/README.md @@ -893,7 +893,22 @@ The address format is controlled by the `INBOUND_EMAIL_DOMAIN` env var. 1. A catch-all rule on `agentdispatch.io` in Cloudflare Email Routing forwards all mail to the `admp-email-ingestion` Cloudflare Worker. 2. The Worker parses the recipient address, reads the MIME body with `postal-mime`, and POSTs to `POST /api/webhooks/email/inbound`. -3. The ADMP server delivers the message to the agent's inbox. +3. The ADMP server applies inbound policy: + - **Trusted sender** (`agent.metadata.email_trusted_senders`) -> auto-approved to `queued` + - **Unknown sender** -> quarantined as `review_pending` until approved +4. Approved messages are delivered via normal inbox pull. + +**Trusted sender management endpoints (agent-authenticated):** + +- `GET /api/agents/:agentId/email/trusted-senders` +- `POST /api/agents/:agentId/email/trusted-senders` with `{ "email": "trusted@example.com" }` +- `DELETE /api/agents/:agentId/email/trusted-senders` with `{ "email": "trusted@example.com" }` + +**Review endpoint (internal policy/model worker):** + +- `POST /api/webhooks/email/inbound/:messageId/review` +- Requires `X-Webhook-Secret: ` +- Body: `{ "decision": "approve" | "reject", "reason"?: "...", "model_verdict"?: {...} }` See [`workers/email-ingestion/README.md`](workers/email-ingestion/README.md) for Cloudflare setup. diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 49ef190..a27c96e 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -617,11 +617,54 @@ Response includes: ### Receiving Email -When someone sends an email to your agent's address, it is delivered to your inbox as a standard ADMP message. +When someone sends an email to your agent's address, ADMP ingests it as an `email` message. **Message type:** `email` -**Pull a message and inspect it:** +**Default safety flow (unknown sender):** + +1. Message is stored as `review_pending` (not pullable yet). +2. A policy/review decision must approve or reject it. +3. On approve, status becomes `queued` and the message is pullable. +4. On reject, status becomes `failed`. + +**Trusted sender fast-path:** + +If the sender email is in the agent's trusted sender allowlist, ADMP auto-approves and queues immediately. + +Configure trusted senders: + +```http +GET /api/agents//email/trusted-senders +POST /api/agents//email/trusted-senders +DELETE /api/agents//email/trusted-senders +``` + +`POST` body: +```json +{ "email": "trusted.sender@example.com" } +``` + +`DELETE` body: +```json +{ "email": "trusted.sender@example.com" } +``` + +Review endpoint (typically called by your policy/model worker): + +```http +POST /api/webhooks/email/inbound//review +X-Webhook-Secret: +Content-Type: application/json + +{ + "decision": "approve", // or "reject" + "reason": "optional rejection reason", + "model_verdict": { "risk_score": 0.12, "reason": "no phishing indicators" } +} +``` + +**Pull approved email and inspect it:** ```http POST /api/agents//inbox/pull @@ -650,8 +693,9 @@ Response: **Notes:** - The `from` field encodes the sender email as `email:{local}.at.{domain}` (replacing `@` with `.at.`) to satisfy ADMP's agent ID format constraints. +- Inbound email records include provenance fields such as `ingress_channel`, `ingress_trust`, and `review_status`. - Acknowledge the message after processing with `POST .../ack`. -- If `auto_ack_on_pull: true` is set on your agent, messages are auto-acknowledged on pull. +- Inbound email is sent with `retain_until_acked=true`; explicit ack is required. --- diff --git a/src/routes/agents.js b/src/routes/agents.js index e536e72..c7c4ba7 100644 --- a/src/routes/agents.js +++ b/src/routes/agents.js @@ -13,6 +13,22 @@ import { storage } from '../storage/index.js'; import { agentEmailAddress } from '../utils/email.js'; const router = express.Router(); +const SIMPLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function normalizeTrustedSenderEmail(email) { + return String(email || '').trim().toLowerCase(); +} + +function getTrustedSenderList(agent) { + const configured = agent?.metadata?.email_trusted_senders; + if (!Array.isArray(configured)) return []; + return [...new Set( + configured + .filter(v => typeof v === 'string') + .map(normalizeTrustedSenderEmail) + .filter(v => SIMPLE_EMAIL_RE.test(v)) + )]; +} /** * POST /api/agents/register @@ -205,6 +221,99 @@ router.delete('/:agentId/trusted/:trustedAgentId', authenticateHttpSignature, as } }); +/** + * GET /api/agents/:agentId/email/trusted-senders + * List trusted external email senders for inbound email policy. + */ +router.get('/:agentId/email/trusted-senders', authenticateHttpSignature, async (req, res) => { + try { + return res.json({ + trusted_senders: getTrustedSenderList(req.agent) + }); + } catch (error) { + return res.status(400).json({ + error: 'LIST_TRUSTED_SENDERS_FAILED', + message: error.message + }); + } +}); + +/** + * POST /api/agents/:agentId/email/trusted-senders + * Add a trusted external email sender. + * Body: { email: string } + */ +router.post('/:agentId/email/trusted-senders', authenticateHttpSignature, async (req, res) => { + try { + const normalized = normalizeTrustedSenderEmail(req.body?.email); + if (!normalized) { + return res.status(400).json({ + error: 'EMAIL_REQUIRED', + message: 'email is required' + }); + } + if (!SIMPLE_EMAIL_RE.test(normalized)) { + return res.status(400).json({ + error: 'INVALID_EMAIL', + message: 'email must be a valid email address' + }); + } + + const trustedSenders = getTrustedSenderList(req.agent); + if (!trustedSenders.includes(normalized)) { + trustedSenders.push(normalized); + } + + const metadata = { + ...(req.agent.metadata || {}), + email_trusted_senders: trustedSenders + }; + const updated = await storage.updateAgent(req.agent.agent_id, { metadata }); + + return res.json({ + trusted_senders: getTrustedSenderList(updated) + }); + } catch (error) { + return res.status(400).json({ + error: 'ADD_TRUSTED_SENDER_FAILED', + message: error.message + }); + } +}); + +/** + * DELETE /api/agents/:agentId/email/trusted-senders + * Remove a trusted external email sender. + * Body: { email: string } + */ +router.delete('/:agentId/email/trusted-senders', authenticateHttpSignature, async (req, res) => { + try { + const normalized = normalizeTrustedSenderEmail(req.body?.email); + if (!normalized) { + return res.status(400).json({ + error: 'EMAIL_REQUIRED', + message: 'email is required' + }); + } + + const trustedSenders = getTrustedSenderList(req.agent).filter(v => v !== normalized); + const metadata = { + ...(req.agent.metadata || {}), + email_trusted_senders: trustedSenders + }; + const updated = await storage.updateAgent(req.agent.agent_id, { metadata }); + + return res.json({ + trusted_senders: getTrustedSenderList(updated) + }); + } catch (error) { + return res.status(400).json({ + error: 'REMOVE_TRUSTED_SENDER_FAILED', + message: error.message + }); + } +}); + /** * POST /api/agents/:agentId/webhook * Configure webhook for agent diff --git a/src/routes/email-inbound.js b/src/routes/email-inbound.js index 0a1bd59..1fd5322 100644 --- a/src/routes/email-inbound.js +++ b/src/routes/email-inbound.js @@ -17,6 +17,54 @@ function getInboundSecret() { return process.env.INBOUND_EMAIL_SECRET || ''; } +function verifyInboundSecret(req) { + const secret = getInboundSecret(); + if (!secret) return { ok: false, status: 500, body: { + error: 'SERVER_MISCONFIGURATION', + message: 'INBOUND_EMAIL_SECRET is not configured' + } }; + + const incomingSecret = req.headers['x-webhook-secret']; + if (!incomingSecret) return { ok: false, status: 401, body: { + error: 'UNAUTHORIZED', + message: 'X-Webhook-Secret header is required' + } }; + + let valid = false; + try { + valid = crypto.timingSafeEqual( + Buffer.from(incomingSecret), + Buffer.from(secret) + ); + } catch { + valid = false; + } + + if (!valid) return { ok: false, status: 401, body: { + error: 'UNAUTHORIZED', + message: 'Invalid webhook secret' + } }; + + return { ok: true }; +} + +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase(); +} + +function isTrustedEmailSender(agent, fromEmail) { + const configured = agent?.metadata?.email_trusted_senders; + if (!Array.isArray(configured) || configured.length === 0) { + return false; + } + + const from = normalizeEmail(fromEmail); + return configured + .filter(v => typeof v === 'string') + .map(normalizeEmail) + .includes(from); +} + /** * POST /api/webhooks/email/inbound * Receive a parsed inbound email from the Cloudflare Worker. @@ -33,37 +81,9 @@ function getInboundSecret() { router.post('/webhooks/email/inbound', async (req, res) => { try { // --- Signature verification --- - const secret = getInboundSecret(); - if (!secret) { - return res.status(500).json({ - error: 'SERVER_MISCONFIGURATION', - message: 'INBOUND_EMAIL_SECRET is not configured' - }); - } - - const incomingSecret = req.headers['x-webhook-secret']; - if (!incomingSecret) { - return res.status(401).json({ - error: 'UNAUTHORIZED', - message: 'X-Webhook-Secret header is required' - }); - } - - let valid = false; - try { - valid = crypto.timingSafeEqual( - Buffer.from(incomingSecret), - Buffer.from(secret) - ); - } catch { - valid = false; - } - - if (!valid) { - return res.status(401).json({ - error: 'UNAUTHORIZED', - message: 'Invalid webhook secret' - }); + const auth = verifyInboundSecret(req); + if (!auth.ok) { + return res.status(auth.status).json(auth.body); } // --- Input validation --- @@ -101,7 +121,9 @@ router.post('/webhooks/email/inbound', async (req, res) => { // (SAFE_CHARS: [a-zA-Z0-9._:-]) by replacing '@' with '.at.' const fromId = `email:${from_email.replace('@', '.at.')}`; - await inboxService.send({ + const trustedSender = isTrustedEmailSender(agent, from_email); + const now = Date.now(); + const created = await inboxService.send({ version: '1.0', from: fromId, to: agent.agent_id, @@ -110,9 +132,28 @@ router.post('/webhooks/email/inbound', async (req, res) => { type: 'email', body: { subject, from_email, text, html }, metadata: { source: 'email', raw_size } - }, { verify_signature: false }); + }, { + verify_signature: false, + bypass_trust_check: true, + initial_status: trustedSender ? 'queued' : 'review_pending', + // External ingress defaults to hold-for-review before becoming pullable. + retain_until_acked: true, + system_metadata: { + ingress_channel: 'email', + ingress_trust: trustedSender ? 'trusted' : 'untrusted', + review_status: trustedSender ? 'approved' : 'pending', + review_source: trustedSender ? 'trusted_sender_allowlist' : null, + reviewed_at: trustedSender ? now : null, + ingested_at: now + } + }); - res.status(200).json({ ok: true }); + res.status(200).json({ + ok: true, + message_id: created.id, + review_status: created.review_status, + trusted_sender: trustedSender + }); } catch (error) { logger.error({ error: error.message }, 'Email inbound webhook failed'); res.status(500).json({ @@ -122,4 +163,73 @@ router.post('/webhooks/email/inbound', async (req, res) => { } }); +/** + * POST /api/webhooks/email/inbound/:messageId/review + * Internal policy/review hook to release or reject quarantined email. + * Body: { decision: 'approve' | 'reject', reason?: string, model_verdict?: object|string } + */ +router.post('/webhooks/email/inbound/:messageId/review', async (req, res) => { + try { + const auth = verifyInboundSecret(req); + if (!auth.ok) { + return res.status(auth.status).json(auth.body); + } + + const { decision, reason, model_verdict } = req.body || {}; + if (decision !== 'approve' && decision !== 'reject') { + return res.status(400).json({ + error: 'INVALID_DECISION', + message: 'decision must be "approve" or "reject"' + }); + } + + const message = await storage.getMessage(req.params.messageId); + if (!message) { + return res.status(404).json({ + error: 'MESSAGE_NOT_FOUND', + message: `Message ${req.params.messageId} not found` + }); + } + + if (message.ingress_channel !== 'email') { + return res.status(400).json({ + error: 'NOT_EMAIL_INGRESS', + message: 'Message is not an inbound email message' + }); + } + + if (message.status !== 'review_pending' || message.review_status !== 'pending') { + return res.status(409).json({ + error: 'INVALID_REVIEW_STATE', + message: `Message is not pending review (status=${message.status}, review_status=${message.review_status})` + }); + } + + const now = Date.now(); + const updates = decision === 'approve' + ? { status: 'queued', review_status: 'approved', reviewed_at: now, review_reason: null } + : { status: 'failed', review_status: 'rejected', reviewed_at: now, review_reason: reason || 'Rejected by policy review' }; + updates.review_source = 'manual_review'; + if (model_verdict !== undefined) { + updates.model_verdict = model_verdict; + } + + const updated = await storage.updateMessage(req.params.messageId, updates); + + return res.status(200).json({ + ok: true, + message_id: updated.id, + decision, + status: updated.status, + review_status: updated.review_status + }); + } catch (error) { + logger.error({ error: error.message }, 'Email review webhook failed'); + return res.status(500).json({ + error: 'REVIEW_FAILED', + message: 'Internal server error' + }); + } +}); + export default router; diff --git a/src/server.test.js b/src/server.test.js index e0eec2f..ae1697f 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -1483,10 +1483,11 @@ test('outbox messages: returns 404 for non-existent message', async () => { test('outbox messages: prevents accessing another agent\'s message', async () => { const agent1 = await registerAgent('outbox-owner'); const agent2 = await registerAgent('outbox-intruder'); + const messageId = `outbox-cross-agent-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // Create outbox message for agent1 await storage.createOutboxMessage({ - id: 'outbox-cross-agent-test', + id: messageId, agent_id: agent1.agent_id, to: 'someone@example.com', from: 'test@example.com', @@ -1497,7 +1498,7 @@ test('outbox messages: prevents accessing another agent\'s message', async () => // agent2 tries to access it const res = await request(app) - .get(`/api/agents/${encodeURIComponent(agent2.agent_id)}/outbox/messages/outbox-cross-agent-test`); + .get(`/api/agents/${encodeURIComponent(agent2.agent_id)}/outbox/messages/${encodeURIComponent(messageId)}`); assert.equal(res.status, 403); assert.equal(res.body.error, 'FORBIDDEN'); @@ -1534,10 +1535,11 @@ test('outbox storage: domain config CRUD via storage layer', async () => { test('outbox storage: outbox message CRUD via storage layer', async () => { const agentId = 'storage-outbox-test'; + const messageId = `outbox-crud-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // Create const msg = await storage.createOutboxMessage({ - id: 'outbox-crud-test', + id: messageId, agent_id: agentId, to: 'test@example.com', from: 'agent@example.com', @@ -1545,15 +1547,15 @@ test('outbox storage: outbox message CRUD via storage layer', async () => { body: 'hello', status: 'queued' }); - assert.equal(msg.id, 'outbox-crud-test'); + assert.equal(msg.id, messageId); assert.ok(msg.created_at); // Get - const fetched = await storage.getOutboxMessage('outbox-crud-test'); + const fetched = await storage.getOutboxMessage(messageId); assert.equal(fetched.subject, 'CRUD test'); // Update - const updated = await storage.updateOutboxMessage('outbox-crud-test', { + const updated = await storage.updateOutboxMessage(messageId, { status: 'sent', provider_message_id: 're_test123' }); @@ -1563,14 +1565,14 @@ test('outbox storage: outbox message CRUD via storage layer', async () => { // List const messages = await storage.getOutboxMessages(agentId); assert.ok(messages.length >= 1); - assert.ok(messages.some(m => m.id === 'outbox-crud-test')); + assert.ok(messages.some(m => m.id === messageId)); // List with status filter const sent = await storage.getOutboxMessages(agentId, { status: 'sent' }); - assert.ok(sent.some(m => m.id === 'outbox-crud-test')); + assert.ok(sent.some(m => m.id === messageId)); const queued = await storage.getOutboxMessages(agentId, { status: 'queued' }); - assert.ok(!queued.some(m => m.id === 'outbox-crud-test')); + assert.ok(!queued.some(m => m.id === messageId)); }); test('outbox webhook: resend webhook endpoint accepts events', async () => { @@ -1822,9 +1824,11 @@ test('outbox send: Resend API failure triggers retry and eventually fails', asyn test('outbox webhook: delivered event updates outbox message status', async () => { // Create a sent outbox message with a known provider_message_id - const providerId = 're_webhook-delivered-test'; + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const providerId = `re_webhook-delivered-test-${suffix}`; + const messageId = `webhook-deliver-test-${suffix}`; await storage.createOutboxMessage({ - id: 'webhook-deliver-test', + id: messageId, agent_id: 'webhook-agent', to: 'someone@example.com', from: 'agent@example.com', @@ -1850,15 +1854,17 @@ test('outbox webhook: delivered event updates outbox message status', async () = assert.equal(res.body.status, 'ok'); // Check that the outbox message was updated - const msg = await storage.getOutboxMessage('webhook-deliver-test'); + const msg = await storage.getOutboxMessage(messageId); assert.equal(msg.status, 'delivered'); assert.ok(msg.delivered_at); }); test('outbox webhook: bounced event updates outbox message status', async () => { - const providerId = 're_webhook-failed-test'; + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const providerId = `re_webhook-failed-test-${suffix}`; + const messageId = `webhook-fail-test-${suffix}`; await storage.createOutboxMessage({ - id: 'webhook-fail-test', + id: messageId, agent_id: 'webhook-fail-agent', to: 'bounce@example.com', from: 'agent@example.com', @@ -1881,7 +1887,7 @@ test('outbox webhook: bounced event updates outbox message status', async () => assert.equal(res.status, 200); - const msg = await storage.getOutboxMessage('webhook-fail-test'); + const msg = await storage.getOutboxMessage(messageId); assert.equal(msg.status, 'failed'); assert.ok(msg.error.includes('email.bounced')); }); @@ -4773,6 +4779,46 @@ test('GET /api/agents/:agentId returns email_address field', async () => { assert.ok(res.body.email_address.includes('@'), 'email_address should be an email'); }); +test('email trusted senders API: list/add/remove with normalization and validation', async () => { + const agent = await registerAgent('email-trusted-senders-api'); + + const listInitial = await withAgentHeader( + request(app).get(`/api/agents/${encodeURIComponent(agent.agent_id)}/email/trusted-senders`), + agent.agent_id + ); + assert.equal(listInitial.status, 200); + assert.deepEqual(listInitial.body.trusted_senders, []); + + const addOne = await withAgentHeader( + request(app).post(`/api/agents/${encodeURIComponent(agent.agent_id)}/email/trusted-senders`), + agent.agent_id + ).send({ email: 'Trusted.User@Example.com' }); + assert.equal(addOne.status, 200); + assert.deepEqual(addOne.body.trusted_senders, ['trusted.user@example.com']); + + // Duplicate add (different case) should be idempotent. + const addDuplicate = await withAgentHeader( + request(app).post(`/api/agents/${encodeURIComponent(agent.agent_id)}/email/trusted-senders`), + agent.agent_id + ).send({ email: 'trusted.user@example.com' }); + assert.equal(addDuplicate.status, 200); + assert.deepEqual(addDuplicate.body.trusted_senders, ['trusted.user@example.com']); + + const addInvalid = await withAgentHeader( + request(app).post(`/api/agents/${encodeURIComponent(agent.agent_id)}/email/trusted-senders`), + agent.agent_id + ).send({ email: 'not-an-email' }); + assert.equal(addInvalid.status, 400); + assert.equal(addInvalid.body.error, 'INVALID_EMAIL'); + + const remove = await withAgentHeader( + request(app).delete(`/api/agents/${encodeURIComponent(agent.agent_id)}/email/trusted-senders`), + agent.agent_id + ).send({ email: 'TRUSTED.USER@example.com' }); + assert.equal(remove.status, 200); + assert.deepEqual(remove.body.trusted_senders, []); +}); + test('email inbound: valid request delivers message to agent inbox', async () => { const agent = await registerAgent('inbound-email-happy'); const origSecret = process.env.INBOUND_EMAIL_SECRET; @@ -4797,6 +4843,220 @@ test('email inbound: valid request delivers message to agent inbox', async () => } }); +test('email inbound: message is quarantined until approved', async () => { + const agent = await registerAgent('inbound-email-quarantine'); + const origSecret = process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; + + try { + const ingestRes = await request(app) + .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') + .send({ + to_agent: agent.agent_id, + from_email: 'unknown@external.com', + subject: 'Untrusted hello', + text: 'Please process this' + }); + + assert.equal(ingestRes.status, 200); + assert.equal(ingestRes.body.ok, true); + assert.equal(ingestRes.body.review_status, 'pending'); + assert.ok(ingestRes.body.message_id); + + const stored = await storage.getMessage(ingestRes.body.message_id); + assert.equal(stored.status, 'review_pending'); + assert.equal(stored.ingress_channel, 'email'); + assert.equal(stored.ingress_trust, 'untrusted'); + assert.equal(stored.review_status, 'pending'); + + // Quarantined messages are not pullable until approved. + const pullBefore = await withAgentHeader( + request(app).post(`/api/agents/${encodeURIComponent(agent.agent_id)}/inbox/pull`), + agent.agent_id + ).send({}); + assert.equal(pullBefore.status, 204); + + const approveRes = await request(app) + .post(`/api/webhooks/email/inbound/${encodeURIComponent(ingestRes.body.message_id)}/review`) + .set('x-webhook-secret', 'test-inbound-secret') + .send({ decision: 'approve' }); + + assert.equal(approveRes.status, 200); + assert.equal(approveRes.body.status, 'queued'); + assert.equal(approveRes.body.review_status, 'approved'); + + const pullAfter = await withAgentHeader( + request(app).post(`/api/agents/${encodeURIComponent(agent.agent_id)}/inbox/pull`), + agent.agent_id + ).send({}); + assert.equal(pullAfter.status, 200); + assert.equal(pullAfter.body.envelope.type, 'email'); + assert.equal(pullAfter.body.envelope.body.from_email, 'unknown@external.com'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound review: reject keeps message out of pull flow', async () => { + const agent = await registerAgent('inbound-email-reject'); + const origSecret = process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; + + try { + const ingestRes = await request(app) + .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') + .send({ + to_agent: agent.agent_id, + from_email: 'spam@external.com', + subject: 'Spam' + }); + + assert.equal(ingestRes.status, 200); + const messageId = ingestRes.body.message_id; + + const rejectRes = await request(app) + .post(`/api/webhooks/email/inbound/${encodeURIComponent(messageId)}/review`) + .set('x-webhook-secret', 'test-inbound-secret') + .send({ decision: 'reject', reason: 'policy_block' }); + + assert.equal(rejectRes.status, 200); + assert.equal(rejectRes.body.status, 'failed'); + assert.equal(rejectRes.body.review_status, 'rejected'); + + const stored = await storage.getMessage(messageId); + assert.equal(stored.status, 'failed'); + assert.equal(stored.review_status, 'rejected'); + assert.equal(stored.review_reason, 'policy_block'); + + const pullRes = await withAgentHeader( + request(app).post(`/api/agents/${encodeURIComponent(agent.agent_id)}/inbox/pull`), + agent.agent_id + ).send({}); + assert.equal(pullRes.status, 204); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound: trusted sender bypasses quarantine and is pullable immediately', async () => { + const agent = await registerAgent('inbound-email-trusted', { + email_trusted_senders: ['trusted.sender@example.com'] + }); + const origSecret = process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; + + try { + const ingestRes = await request(app) + .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') + .send({ + to_agent: agent.agent_id, + from_email: 'Trusted.Sender@Example.com', + subject: 'Trusted hello', + text: 'Trusted channel' + }); + + assert.equal(ingestRes.status, 200); + assert.equal(ingestRes.body.review_status, 'approved'); + assert.equal(ingestRes.body.trusted_sender, true); + + const stored = await storage.getMessage(ingestRes.body.message_id); + assert.equal(stored.status, 'queued'); + assert.equal(stored.review_status, 'approved'); + assert.equal(stored.ingress_trust, 'trusted'); + assert.equal(stored.review_source, 'trusted_sender_allowlist'); + + const pullRes = await withAgentHeader( + request(app).post(`/api/agents/${encodeURIComponent(agent.agent_id)}/inbox/pull`), + agent.agent_id + ).send({}); + assert.equal(pullRes.status, 200); + assert.equal(pullRes.body.envelope.body.from_email, 'Trusted.Sender@Example.com'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound: trusted sender configured via API bypasses quarantine', async () => { + const agent = await registerAgent('inbound-email-trusted-api'); + const origSecret = process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; + + try { + const addTrusted = await withAgentHeader( + request(app).post(`/api/agents/${encodeURIComponent(agent.agent_id)}/email/trusted-senders`), + agent.agent_id + ).send({ email: 'api.trusted@example.com' }); + assert.equal(addTrusted.status, 200); + assert.deepEqual(addTrusted.body.trusted_senders, ['api.trusted@example.com']); + + const ingestRes = await request(app) + .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') + .send({ + to_agent: agent.agent_id, + from_email: 'API.Trusted@example.com', + subject: 'Trusted via API' + }); + + assert.equal(ingestRes.status, 200); + assert.equal(ingestRes.body.trusted_sender, true); + assert.equal(ingestRes.body.review_status, 'approved'); + + const stored = await storage.getMessage(ingestRes.body.message_id); + assert.equal(stored.status, 'queued'); + assert.equal(stored.ingress_trust, 'trusted'); + assert.equal(stored.review_source, 'trusted_sender_allowlist'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + +test('email inbound review: stores optional model_verdict on decision', async () => { + const agent = await registerAgent('inbound-email-model-verdict'); + const origSecret = process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; + + try { + const ingestRes = await request(app) + .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') + .send({ + to_agent: agent.agent_id, + from_email: 'unknown-model@example.com', + subject: 'Needs review' + }); + + assert.equal(ingestRes.status, 200); + const messageId = ingestRes.body.message_id; + + const verdict = { + risk_score: 0.12, + reason: 'No phishing indicators found' + }; + const approveRes = await request(app) + .post(`/api/webhooks/email/inbound/${encodeURIComponent(messageId)}/review`) + .set('x-webhook-secret', 'test-inbound-secret') + .send({ decision: 'approve', model_verdict: verdict }); + + assert.equal(approveRes.status, 200); + assert.equal(approveRes.body.review_status, 'approved'); + + const stored = await storage.getMessage(messageId); + assert.deepEqual(stored.model_verdict, verdict); + assert.equal(stored.review_source, 'manual_review'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + test('email inbound: missing X-Webhook-Secret returns 401 when secret is set', async () => { const origSecret = process.env.INBOUND_EMAIL_SECRET; process.env.INBOUND_EMAIL_SECRET = 'my-secret'; diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 3e2442c..cac173a 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -16,7 +16,7 @@ const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; // Message types that always require explicit ack, regardless of the recipient's // auto_ack_on_pull preference. Losing a work order or fix request silently is // always worse than a queue backlog. -const RETAIN_TYPES = new Set(['work_order', 'fix_request']); +const RETAIN_TYPES = new Set(['work_order', 'fix_request', 'work_order_result']); // VALID_AGENT_URI is NOT a subset of SAFE_CHARS — agent://foo contains slashes which // are not in the allowlist. It is the only branch that accepts legacy agent:// URIs. // Do not delete it assuming it is a no-op; doing so would silently break backward @@ -75,8 +75,10 @@ export class InboxService { const toAgentId = recipient.agent_id; - // Check trust list using both agent_id and DID of sender - if (recipient.trusted_agents && recipient.trusted_agents.length > 0) { + // Check trust list using both agent_id and DID of sender. + // System-ingested channels (for example external email) can bypass this check + // and be routed into quarantine for later review. + if (!options.bypass_trust_check && recipient.trusted_agents && recipient.trusted_agents.length > 0) { const senderAllowed = recipient.trusted_agents.includes(envelope.from); if (!senderAllowed) { throw new Error(`Sender ${envelope.from} is not trusted by recipient ${toAgentId}`); @@ -113,7 +115,7 @@ export class InboxService { // cryptographic proof of identity, not just a claimed from value. throw new Error(`Sender ${envelope.from} is registered but missing signature — signature required for trust-list delivery`); } - } else if (recipient.trusted_agents?.includes(envelope.from)) { + } else if (!options.bypass_trust_check && recipient.trusted_agents?.includes(envelope.from)) { // Sender is named in the trust list but is not registered — cannot verify identity. // Reject rather than silently skip: an unregistered sender cannot prove they are // the trusted agent they claim to be (deregistered agent impersonation attack). @@ -139,20 +141,41 @@ export class InboxService { // always retain — losing them silently is worse than a queue backlog. const retainUntilAcked = options.retain_until_acked || RETAIN_TYPES.has(envelope.type) || false; + const initialStatus = options.initial_status || 'queued'; + const allowedInitialStatuses = new Set(['queued', 'review_pending']); + if (!allowedInitialStatuses.has(initialStatus)) { + throw new Error(`Invalid initial status: ${initialStatus}`); + } + + const systemMetadata = options.system_metadata && typeof options.system_metadata === 'object' + ? options.system_metadata + : {}; + const forbiddenSystemKeys = new Set([ + 'id', 'to_agent_id', 'from_agent_id', 'envelope', 'status', 'ttl_sec', + 'lease_until', 'attempts', 'ephemeral', 'ephemeral_ttl_sec', 'expires_at', + 'retain_until_acked', 'created_at', 'updated_at' + ]); + for (const key of Object.keys(systemMetadata)) { + if (forbiddenSystemKeys.has(key)) { + throw new Error(`system_metadata key is not allowed: ${key}`); + } + } + // Create message record const message = { id: envelope.id || uuid(), to_agent_id: toAgentId, from_agent_id: envelope.from, envelope, - status: 'queued', + status: initialStatus, ttl_sec: envelope.ttl_sec || parseInt(process.env.MESSAGE_TTL_SEC) || 86400, lease_until: null, attempts: 0, ephemeral, ephemeral_ttl_sec: ephemeralTTLSec, expires_at: ephemeralTTLSec ? Date.now() + (ephemeralTTLSec * 1000) : null, - retain_until_acked: retainUntilAcked + retain_until_acked: retainUntilAcked, + ...systemMetadata }; const created = await storage.createMessage(message); From 6b25ea8afdd40187c847b41714a39311abf7409a Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 5 Mar 2026 15:03:38 -0600 Subject: [PATCH 09/10] feat(email): Cloudflare DNS client, Resend provisioning script, email setup docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/cloudflare.js: ported from circleinbox — DNS CRUD, upsertDnsRecord, provisionResendDns(), Email Routing enable/catch-all API - scripts/provision-resend-dns.js: check + upsert all 4 Resend records (DKIM, SPF MX, SPF TXT, DMARC) against Cloudflare; safe to re-run - docs/EMAIL-SETUP.md: step-by-step operator checklist (env, Worker deploy, Resend DNS in Cloudflare, validation) - .env.example: add CLOUDFLARE_API_TOKEN/ZONE_ID and RESEND_DNS_* vars - workers/email-ingestion/wrangler.toml: remove invalid [[email]] section - README.md: link to EMAIL-SETUP.md from deployment checklist Made-with: Cursor --- .env.example | 13 ++ README.md | 2 + docs/EMAIL-SETUP.md | 72 ++++++ scripts/provision-resend-dns.js | 138 +++++++++++ src/lib/cloudflare.js | 314 ++++++++++++++++++++++++++ workers/email-ingestion/wrangler.toml | 6 +- 6 files changed, 541 insertions(+), 4 deletions(-) create mode 100644 docs/EMAIL-SETUP.md create mode 100644 scripts/provision-resend-dns.js create mode 100644 src/lib/cloudflare.js diff --git a/.env.example b/.env.example index b47bf8d..5005b83 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,16 @@ RESEND_WEBHOOK_SECRET= INBOUND_EMAIL_SECRET= # Domain used to construct agent email addresses INBOUND_EMAIL_DOMAIN=agentdispatch.io + +# Cloudflare API — used by scripts/provision-resend-dns.js and src/lib/cloudflare.js +# Token requires DNS:Edit (and Email Routing:Edit for inbound setup) +CLOUDFLARE_API_TOKEN= +# Zone ID for the sending/inbound domain (optional — looked up automatically if omitted) +CLOUDFLARE_ZONE_ID= + +# Resend DNS values — copy exact values from Resend dashboard → Domains → DNS Records +# Only needed when running scripts/provision-resend-dns.js +RESEND_DKIM_CONTENT= +RESEND_MX_CONTENT= +RESEND_SPF_CONTENT= +RESEND_MX_PRIORITY=10 diff --git a/README.md b/README.md index 5b40c94..4694872 100644 --- a/README.md +++ b/README.md @@ -944,6 +944,8 @@ Custom domain setup: `POST /api/agents/:agentId/outbox/domain` then `POST /api/a - [ ] Configure `RESEND_API_KEY` and `RESEND_WEBHOOK_SECRET` for outbound email - [ ] Configure `INBOUND_EMAIL_SECRET` and deploy Cloudflare Worker for inbound email +See [docs/EMAIL-SETUP.md](docs/EMAIL-SETUP.md) for a step-by-step email setup checklist (env vars, Worker deploy, DNS, validation). + ## Architecture ``` diff --git a/docs/EMAIL-SETUP.md b/docs/EMAIL-SETUP.md new file mode 100644 index 0000000..43e7671 --- /dev/null +++ b/docs/EMAIL-SETUP.md @@ -0,0 +1,72 @@ +# Email Setup Checklist + +Operator guide for enabling inbound and outbound email on an ADMP server. + +## 1. Server environment variables + +Set these where the ADMP server runs (e.g. `.env` or deployment config). + +| Variable | Required for | Description | +|----------|---------------|-------------| +| `RESEND_API_KEY` | Outbound | Resend API key for sending email | +| `RESEND_WEBHOOK_SECRET` | Outbound | Validates Resend delivery webhooks (Svix-signed) | +| `INBOUND_EMAIL_SECRET` | Inbound | Shared secret; Worker sends this in `X-Webhook-Secret` | +| `INBOUND_EMAIL_DOMAIN` | Inbound (optional) | Domain in agent addresses (default: `agentdispatch.io`) | + +- **Outbound only:** set `RESEND_API_KEY` and `RESEND_WEBHOOK_SECRET`. +- **Inbound:** set `INBOUND_EMAIL_SECRET` (and optionally `INBOUND_EMAIL_DOMAIN`). The same secret must be set in the Cloudflare Worker (see below). + +## 2. Cloudflare Worker (inbound) + +The Worker receives mail via Cloudflare Email Routing and POSTs to the ADMP server. + +- **Full steps:** see [workers/email-ingestion/README.md](../workers/email-ingestion/README.md). + +**Summary:** + +1. **Deploy the Worker** + ```bash + cd workers/email-ingestion + bun install + wrangler deploy + ``` + +2. **Set Worker secrets** (must match server) + ```bash + wrangler secret put ADMP_URL # e.g. https://api.yourdomain.com + wrangler secret put INBOUND_EMAIL_SECRET + ``` + +3. **DNS & Email Routing** (Cloudflare dashboard) + - Enable **Email Routing** on the zone for your inbound domain. + - Add a **Catch-all** rule: Action = **Send to Worker** → `admp-email-ingestion`. + +## 3. Resend (outbound) + +- Create a Resend account and add/verify your sending domain. +- In Resend dashboard: create an API key → set as `RESEND_API_KEY`. +- Create a webhook endpoint pointing to your server: + `POST https://your-admp-server/api/webhooks/resend` + Copy the signing secret → set as `RESEND_WEBHOOK_SECRET`. + +### Resend DNS records in Cloudflare + +Resend’s “Fill in your DNS Records” screen shows records for **domain verification (DKIM)**, **sending (SPF/MX)**, and optional **DMARC**. To add them in Cloudflare: + +1. In **Cloudflare Dashboard** → your zone → **DNS** → **Records**, add each record with the **Type**, **Name**, and **Content** (or **Target**) shown in Resend. Use **TTL** Auto unless you need a specific value. +2. **DKIM:** one TXT record (e.g. name `resend._domainkey`, content the long `p=MIGfMA...` string). +3. **SPF / sending:** MX and TXT for the subdomain Resend gives (e.g. `send`); set the **Priority** for the MX as shown (e.g. 10). +4. **DMARC (optional):** one TXT record name `_dmarc`, content e.g. `v=DMARC1; p=none;`. + +If your DNS is managed by a partner (e.g. CircleInbox), ask them how to add these records in their Cloudflare setup. + +## 4. Validation checklist + +- [ ] **Outbound:** Register an agent with `email_address` (or ensure agent has one). Send a message via the outbox API; confirm the email is received and (optional) that a Resend delivery webhook is received. +- [ ] **Inbound:** Send an email to an agent address (e.g. `acme.alice@your-inbound-domain`). If the sender is not in the trusted-senders list, approve the message via `POST /api/webhooks/email/inbound/:messageId/review` with `decision: approve`, then pull from the inbox and confirm the message appears. +- [ ] **Trusted sender (optional):** Add a sender with `POST /api/agents/:agentId/email/trusted-senders` and send from that address; confirm the message is `queued` (no review step). + +## 5. References + +- Inbound policy, trusted senders, and review endpoint: [AGENT-GUIDE.md](AGENT-GUIDE.md#email-receiving) and [README](../README.md). +- Worker implementation and address format: [workers/email-ingestion/README.md](../workers/email-ingestion/README.md). diff --git a/scripts/provision-resend-dns.js b/scripts/provision-resend-dns.js new file mode 100644 index 0000000..9befb57 --- /dev/null +++ b/scripts/provision-resend-dns.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * Provision Resend DNS records for a domain via Cloudflare API. + * + * Usage: + * CLOUDFLARE_API_TOKEN=xxx node scripts/provision-resend-dns.js [--check] + * + * Required env: + * CLOUDFLARE_API_TOKEN Cloudflare API token (DNS:Edit) + * CLOUDFLARE_ZONE_ID (optional) skip zone lookup + * + * Required env for provisioning (copy exact values from Resend dashboard): + * RESEND_DKIM_CONTENT Full DKIM TXT content (p=MIGfMA...wIDAQAB) + * RESEND_MX_CONTENT MX mail server (feedback-smtp.us-east-1.amazonses.com) + * RESEND_SPF_CONTENT SPF TXT content (v=spf1 include:amazonses.com ~all) + * RESEND_MX_PRIORITY MX priority (default: 10) + * + * Examples: + * # Check existing DNS records for a domain + * CLOUDFLARE_API_TOKEN=xxx node scripts/provision-resend-dns.js agentdispatch.io --check + * + * # Provision all Resend records (upsert — safe to re-run) + * CLOUDFLARE_API_TOKEN=xxx \ + * RESEND_DKIM_CONTENT="p=MIGfMA..." \ + * RESEND_MX_CONTENT="feedback-smtp.us-east-1.amazonses.com" \ + * RESEND_SPF_CONTENT="v=spf1 include:amazonses.com ~all" \ + * node scripts/provision-resend-dns.js agentdispatch.io + */ + +import { + getZoneId, + getDnsRecords, + provisionResendDns, + CloudflareApiError, +} from '../src/lib/cloudflare.js'; + +const domain = process.argv[2]; +const checkOnly = process.argv.includes('--check'); + +if (!domain) { + console.error('Usage: node scripts/provision-resend-dns.js [--check]'); + process.exit(1); +} + +async function check() { + console.log(`\nChecking DNS records for ${domain}...\n`); + const zoneId = await getZoneId(domain); + if (!zoneId) { + console.error(`Zone not found for ${domain}. Add it to Cloudflare first.`); + process.exit(1); + } + console.log(`Zone ID: ${zoneId}\n`); + + const records = await getDnsRecords(zoneId); + + const targets = [ + { type: 'TXT', name: `resend._domainkey.${domain}`, label: 'DKIM' }, + { type: 'MX', name: `send.${domain}`, label: 'SPF MX' }, + { type: 'TXT', name: `send.${domain}`, label: 'SPF TXT' }, + { type: 'TXT', name: `_dmarc.${domain}`, label: 'DMARC' }, + ]; + + console.log('Resend record status:'); + for (const t of targets) { + const match = records.find(r => r.type === t.type && r.name === t.name); + const status = match ? '✅ exists' : '❌ missing'; + const preview = match ? ` → ${match.content.slice(0, 60)}${match.content.length > 60 ? '...' : ''}` : ''; + console.log(` ${status} [${t.label}] ${t.name}${preview}`); + } + console.log(); +} + +async function provision() { + const dkimContent = process.env.RESEND_DKIM_CONTENT; + const mxContent = process.env.RESEND_MX_CONTENT; + const spfContent = process.env.RESEND_SPF_CONTENT; + const mxPriority = parseInt(process.env.RESEND_MX_PRIORITY || '10', 10); + + const missing = [ + !dkimContent && 'RESEND_DKIM_CONTENT', + !mxContent && 'RESEND_MX_CONTENT', + !spfContent && 'RESEND_SPF_CONTENT', + ].filter(Boolean); + + if (missing.length) { + console.error(`Missing required env vars: ${missing.join(', ')}`); + console.error('Copy exact values from the Resend dashboard → Domains → your domain → DNS Records'); + process.exit(1); + } + + console.log(`\nProvisioning Resend DNS records for ${domain}...\n`); + + const result = await provisionResendDns(domain, { + dkimContent, + mxContent, + spfContent, + mxPriority, + }); + + if (result.error) { + console.error(`Error: ${result.error}`); + process.exit(1); + } + + console.log(`Zone ID: ${result.zoneId}\n`); + for (const r of result.records) { + const icon = r.success ? '✅' : '❌'; + const detail = r.success ? '' : ` → ${r.error}`; + console.log(` ${icon} [${r.label}] ${r.type} ${r.name}${detail}`); + } + + if (result.success) { + console.log('\nAll records provisioned. Click Verify in the Resend dashboard (propagation 5–60 min).\n'); + } else { + console.error('\nSome records failed. Check errors above.\n'); + process.exit(1); + } +} + +async function main() { + try { + if (checkOnly) { + await check(); + } else { + await check(); + await provision(); + } + } catch (err) { + if (err instanceof CloudflareApiError) { + console.error(`Cloudflare API error: ${err.message}`); + } else { + console.error(err); + } + process.exit(1); + } +} + +main(); diff --git a/src/lib/cloudflare.js b/src/lib/cloudflare.js new file mode 100644 index 0000000..beebaf5 --- /dev/null +++ b/src/lib/cloudflare.js @@ -0,0 +1,314 @@ +/** + * Cloudflare DNS & Email Routing API client. + * Ported from circleinbox/api/lib/cloudflare.ts — adapted for agentdispatch. + * + * Required env vars: + * CLOUDFLARE_API_TOKEN — API token with DNS:Edit + Email Routing:Edit permissions + * CLOUDFLARE_ZONE_ID — (optional) skip zone lookup if already known + */ + +const CLOUDFLARE_API_URL = 'https://api.cloudflare.com/client/v4'; +const REQUEST_TIMEOUT = 30_000; + +function getToken() { + const token = process.env.CLOUDFLARE_API_TOKEN || process.env.CLOUDFLARE_API_KEY || ''; + if (!token) throw new CloudflareApiError('CLOUDFLARE_API_TOKEN is not set'); + return token; +} + +// ─── Error ──────────────────────────────────────────────────────────────────── + +export class CloudflareApiError extends Error { + constructor(message, statusCode, errors) { + super(message); + this.name = 'CloudflareApiError'; + this.statusCode = statusCode; + this.errors = errors; + } +} + +// ─── Core request ───────────────────────────────────────────────────────────── + +async function cfRequest(endpoint, method = 'GET', body) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); + + try { + const res = await fetch(`${CLOUDFLARE_API_URL}${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${getToken()}`, + 'Content-Type': 'application/json', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timer); + + const ct = res.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + const text = await res.text(); + throw new CloudflareApiError(`Unexpected response: ${text.slice(0, 100)}`, res.status); + } + + const data = await res.json(); + + if (!res.ok) { + const msg = data.errors?.map(e => e.message).join(', ') || `HTTP ${res.status}`; + throw new CloudflareApiError(msg, res.status, data.errors); + } + + return data; + } catch (err) { + clearTimeout(timer); + if (err instanceof CloudflareApiError) throw err; + if (err.name === 'AbortError') throw new CloudflareApiError('Request timed out'); + if (err instanceof TypeError) throw new CloudflareApiError(`Network error: ${err.message}`); + throw new CloudflareApiError(err.message || 'Unknown error'); + } +} + +// ─── Zones ──────────────────────────────────────────────────────────────────── + +export async function listZones() { + const data = await cfRequest('/zones?per_page=50'); + return data.success ? data.result : []; +} + +export async function getZoneId(domain) { + // Allow caller to short-circuit via env var + if (process.env.CLOUDFLARE_ZONE_ID) return process.env.CLOUDFLARE_ZONE_ID; + + // Strip to apex (e.g. sub.example.com → example.com) + const apex = domain.split('.').slice(-2).join('.'); + const data = await cfRequest(`/zones?name=${encodeURIComponent(apex)}`); + if (!data.success || data.result.length === 0) return null; + return data.result[0].id; +} + +// ─── DNS records ────────────────────────────────────────────────────────────── + +export async function getDnsRecords(zoneId) { + const data = await cfRequest(`/zones/${zoneId}/dns_records?per_page=200`); + return data.success ? data.result : []; +} + +/** + * Create a DNS record. + * @param {string} zoneId + * @param {{ type, name, content, ttl?, priority?, proxied? }} record + */ +export async function createDnsRecord(zoneId, record) { + const data = await cfRequest(`/zones/${zoneId}/dns_records`, 'POST', { + type: record.type, + name: record.name, + content: record.content, + ttl: record.ttl ?? 1, + priority: record.priority, + proxied: record.proxied ?? false, + }); + + if (!data.success) { + return { success: false, error: data.errors?.map(e => e.message).join(', ') }; + } + return { success: true, record: data.result }; +} + +export async function deleteDnsRecord(zoneId, recordId) { + const data = await cfRequest(`/zones/${zoneId}/dns_records/${recordId}`, 'DELETE'); + if (!data.success) { + return { success: false, error: data.errors?.map(e => e.message).join(', ') }; + } + return { success: true }; +} + +/** + * Upsert a DNS record: delete any existing record(s) with the same name+type, then create. + */ +export async function upsertDnsRecord(zoneId, record) { + const existing = await getDnsRecords(zoneId); + const stale = existing.filter(r => r.type === record.type && r.name === record.name); + for (const r of stale) { + await deleteDnsRecord(zoneId, r.id); + } + return createDnsRecord(zoneId, record); +} + +// ─── Resend email DNS setup ─────────────────────────────────────────────────── + +/** + * Provision all Resend-required DNS records for a sending domain. + * + * Resend uses a `send.` subdomain for SPF/MX and `resend._domainkey.` for DKIM. + * + * @param {string} domain The sending domain (e.g. "agentdispatch.io") + * @param {object} resendValues Exact values from Resend dashboard + * @param {string} resendValues.dkimContent Full DKIM TXT content (p=MIGfMA...) + * @param {string} resendValues.mxContent MX mail server (e.g. feedback-smtp.us-east-1.amazonses.com) + * @param {string} resendValues.spfContent SPF TXT content (v=spf1 include:...) + * @param {number} [resendValues.mxPriority] MX priority (default 10) + * @param {boolean} [upsert] If true (default), delete stale records before creating + */ +export async function provisionResendDns(domain, resendValues, upsert = true) { + const zoneId = await getZoneId(domain); + if (!zoneId) { + return { + success: false, + records: [], + error: `Zone not found for ${domain}. Ensure the domain is in your Cloudflare account.`, + }; + } + + const op = upsert ? upsertDnsRecord : createDnsRecord; + const results = []; + + const records = [ + { + label: 'DKIM', + type: 'TXT', + name: `resend._domainkey.${domain}`, + content: resendValues.dkimContent, + }, + { + label: 'SPF MX', + type: 'MX', + name: `send.${domain}`, + content: resendValues.mxContent, + priority: resendValues.mxPriority ?? 10, + }, + { + label: 'SPF TXT', + type: 'TXT', + name: `send.${domain}`, + content: resendValues.spfContent, + }, + { + label: 'DMARC', + type: 'TXT', + name: `_dmarc.${domain}`, + content: 'v=DMARC1; p=none;', + }, + ]; + + for (const rec of records) { + const { label, ...params } = rec; + const result = await op(zoneId, params); + results.push({ label, type: params.type, name: params.name, ...result }); + } + + return { + success: results.every(r => r.success), + zoneId, + records: results, + }; +} + +// ─── Email Routing ──────────────────────────────────────────────────────────── + +export async function getEmailRoutingSettings(zoneId) { + try { + const data = await cfRequest(`/zones/${zoneId}/email/routing`); + return { enabled: data.success && data.result?.enabled === true, settings: data.result }; + } catch (err) { + return { enabled: false, error: err.message }; + } +} + +export async function enableEmailRouting(zoneId) { + try { + const data = await cfRequest(`/zones/${zoneId}/email/routing/enable`, 'POST'); + return { success: data.success }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +export async function getEmailRoutingRules(zoneId) { + const data = await cfRequest(`/zones/${zoneId}/email/routing/rules`); + return data.success ? data.result : []; +} + +export async function getCatchAllRule(zoneId) { + try { + const data = await cfRequest(`/zones/${zoneId}/email/routing/rules/catch_all`); + return data.success ? data.result : null; + } catch { + return null; + } +} + +/** + * Set the catch-all rule to forward all inbound email to a Worker. + * @param {string} zoneId + * @param {string} workerName Name of the deployed Cloudflare Worker + */ +export async function setCatchAllToWorker(zoneId, workerName) { + try { + const data = await cfRequest(`/zones/${zoneId}/email/routing/rules/catch_all`, 'PUT', { + actions: [{ type: 'worker', value: [workerName] }], + matchers: [{ type: 'all' }], + enabled: true, + name: `Route all to ${workerName}`, + }); + if (!data.success) { + return { success: false, error: data.errors?.map(e => e.message).join(', ') }; + } + return { success: true, rule: data.result }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +export async function createEmailRoutingRule(zoneId, emailAddress, workerName) { + try { + const data = await cfRequest(`/zones/${zoneId}/email/routing/rules`, 'POST', { + actions: [{ type: 'worker', value: [workerName] }], + matchers: [{ type: 'literal', field: 'to', value: emailAddress }], + enabled: true, + name: `Route ${emailAddress} to ${workerName}`, + }); + if (!data.success) { + return { success: false, error: data.errors?.map(e => e.message).join(', ') }; + } + return { success: true, rule: data.result }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +export async function deleteEmailRoutingRule(zoneId, ruleId) { + try { + const data = await cfRequest(`/zones/${zoneId}/email/routing/rules/${ruleId}`, 'DELETE'); + return { success: data.success }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** + * Enable Email Routing on a zone and set the catch-all to a Worker. + * @param {string} domain + * @param {string} workerName + */ +export async function setupEmailRouting(domain, workerName) { + const zoneId = await getZoneId(domain); + if (!zoneId) { + return { success: false, error: `Zone not found for ${domain}` }; + } + + const status = await getEmailRoutingSettings(zoneId); + if (!status.enabled) { + const enable = await enableEmailRouting(zoneId); + if (!enable.success) console.warn('Could not enable email routing:', enable.error); + } + + const catchAll = await setCatchAllToWorker(zoneId, workerName); + return { + success: catchAll.success, + zoneId, + emailRoutingEnabled: true, + catchAllConfigured: catchAll.success, + error: catchAll.error, + }; +} diff --git a/workers/email-ingestion/wrangler.toml b/workers/email-ingestion/wrangler.toml index be8837b..fe60cb7 100644 --- a/workers/email-ingestion/wrangler.toml +++ b/workers/email-ingestion/wrangler.toml @@ -11,7 +11,5 @@ INBOUND_EMAIL_DOMAIN = "agentdispatch.io" # INBOUND_EMAIL_SECRET - Shared secret that authenticates Worker → ADMP requests # Must match INBOUND_EMAIL_SECRET on the ADMP server -[[email]] -# Catch-all Cloudflare Email Routing trigger. -# Configure a catch-all rule on agentdispatch.io in the Cloudflare dashboard: -# Action: Send to Worker → admp-email-ingestion +# Email Routing trigger is configured in the Cloudflare dashboard: +# agentdispatch.io → Email → Email Routing → Catch-all → Send to Worker → admp-email-ingestion From 63b5b1a8bac09760ad1c9c714ec5039cf149da2c Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 5 Mar 2026 15:08:40 -0600 Subject: [PATCH 10/10] fix(email): address PR review blocking issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - outbox.service: strip whsec_ prefix and base64-decode Svix secret before HMAC construction — without this, signature verification always fails when RESEND_WEBHOOK_SECRET is copied directly from the Resend dashboard - email-inbound: add inverse namespace guard — tenanted agents are no longer reachable at their un-namespaced address (alice@ vs acme.alice@) - email-inbound: add namespace mismatch test covering both wrong-namespace and no-namespace-to-tenanted-agent cases - outbox: Resend webhook 500 catch returns generic message (no error.message leak) - email.js: read INBOUND_EMAIL_DOMAIN lazily at call time, not at module load - server: gate RESEND_WEBHOOK_SECRET warning on RESEND_API_KEY being set Made-with: Cursor --- src/routes/email-inbound.js | 10 ++++++++ src/routes/outbox.js | 6 ++--- src/server.js | 2 +- src/server.test.js | 42 ++++++++++++++++++++++++++++++++++ src/services/outbox.service.js | 4 +++- src/utils/email.js | 9 ++++---- 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/routes/email-inbound.js b/src/routes/email-inbound.js index 1fd5322..14dac7f 100644 --- a/src/routes/email-inbound.js +++ b/src/routes/email-inbound.js @@ -105,10 +105,20 @@ router.post('/webhooks/email/inbound', async (req, res) => { // --- Agent resolution --- let agent = await storage.getAgent(to_agent); + + // Namespace guard: if a namespace was parsed from the address, the agent must + // belong to that tenant. Without this, a tenanted agent (acme.alice@) would + // also be reachable at the un-namespaced address (alice@). if (to_namespace && agent && agent.tenant_id !== to_namespace) { agent = null; } + // Inverse guard: if no namespace was in the address, reject agents that + // require one — their canonical address includes the namespace prefix. + if (!to_namespace && agent && agent.tenant_id) { + agent = null; + } + if (!agent) { return res.status(404).json({ error: 'AGENT_NOT_FOUND', diff --git a/src/routes/outbox.js b/src/routes/outbox.js index a048599..e7432f5 100644 --- a/src/routes/outbox.js +++ b/src/routes/outbox.js @@ -261,10 +261,8 @@ webhookRouter.post( res.status(200).json({ status: 'ok' }); } catch (error) { - res.status(500).json({ - error: 'WEBHOOK_FAILED', - message: error.message - }); + console.error('Resend webhook processing error:', error); + res.status(500).json({ error: 'WEBHOOK_FAILED' }); } } ); diff --git a/src/server.js b/src/server.js index 4d5644d..6065205 100644 --- a/src/server.js +++ b/src/server.js @@ -50,7 +50,7 @@ if (!process.env.RESEND_API_KEY) { ); } -if (!process.env.RESEND_WEBHOOK_SECRET) { +if (process.env.RESEND_API_KEY && !process.env.RESEND_WEBHOOK_SECRET) { console.warn( 'WARNING: RESEND_WEBHOOK_SECRET is not set. ' + 'Resend webhooks will accept unauthenticated requests. ' + diff --git a/src/server.test.js b/src/server.test.js index ae1697f..ccc76a7 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -5114,6 +5114,48 @@ test('email inbound: unknown agent returns 404', async () => { } }); +test('email inbound: namespace mismatch returns 404', async () => { + const origSecret = process.env.INBOUND_EMAIL_SECRET; + process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; + + // Register a tenanted agent via the correct endpoint + const agentId = `ns-guard-agent-${Date.now()}`; + const regRes = await request(app) + .post('/api/agents/register') + .send({ agent_id: agentId, tenant_id: 'acme' }); + assert.equal(regRes.status, 201); + + try { + // Email addressed to wrong namespace — should 404 + const res = await request(app) + .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') + .send({ + to_agent: agentId, + to_namespace: 'other-tenant', + from_email: 'sender@example.com', + subject: 'namespace mismatch test' + }); + assert.equal(res.status, 404); + assert.equal(res.body.error, 'AGENT_NOT_FOUND'); + + // Email with no namespace to a tenanted agent — should also 404 + const res2 = await request(app) + .post('/api/webhooks/email/inbound') + .set('x-webhook-secret', 'test-inbound-secret') + .send({ + to_agent: agentId, + from_email: 'sender@example.com', + subject: 'no namespace test' + }); + assert.equal(res2.status, 404); + assert.equal(res2.body.error, 'AGENT_NOT_FOUND'); + } finally { + if (origSecret === undefined) delete process.env.INBOUND_EMAIL_SECRET; + else process.env.INBOUND_EMAIL_SECRET = origSecret; + } +}); + test('email inbound: missing to_agent returns 400', async () => { const origSecret = process.env.INBOUND_EMAIL_SECRET; process.env.INBOUND_EMAIL_SECRET = 'test-inbound-secret'; diff --git a/src/services/outbox.service.js b/src/services/outbox.service.js index b63388a..93115b1 100644 --- a/src/services/outbox.service.js +++ b/src/services/outbox.service.js @@ -395,7 +395,9 @@ export class OutboxService { const signingString = `${svixId}.${svixTimestamp}.${rawBody}`; - const hmac = crypto.createHmac('sha256', secret); + // Svix secrets are delivered as "whsec_" — decode to raw key bytes + const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); + const hmac = crypto.createHmac('sha256', keyBytes); hmac.update(signingString); const computed = hmac.digest('base64'); diff --git a/src/utils/email.js b/src/utils/email.js index 249bd18..1427d34 100644 --- a/src/utils/email.js +++ b/src/utils/email.js @@ -2,8 +2,6 @@ * Email address helpers for ADMP agent email addresses */ -const DEFAULT_DOMAIN = process.env.INBOUND_EMAIL_DOMAIN || 'agentdispatch.io'; - /** * Compute the inbound email address for an agent. * @@ -13,10 +11,11 @@ const DEFAULT_DOMAIN = process.env.INBOUND_EMAIL_DOMAIN || 'agentdispatch.io'; * * @param {string} agentId * @param {string|null|undefined} tenantId - * @param {string} [domain] + * @param {string} [domain] - defaults to INBOUND_EMAIL_DOMAIN env var, read at call time * @returns {string} */ -export function agentEmailAddress(agentId, tenantId, domain = DEFAULT_DOMAIN) { +export function agentEmailAddress(agentId, tenantId, domain) { + const d = domain ?? (process.env.INBOUND_EMAIL_DOMAIN || 'agentdispatch.io'); const local = tenantId ? `${tenantId}.${agentId}` : agentId; - return `${local}@${domain}`; + return `${local}@${d}`; }