← Public packages
@kody/agent-mail
AgentMail inboxes for agents: create/list inboxes, messages, threads, and Svix webhook ingress.
src/handle-webhook.ts
138 lines · 3.9 KB · TypeScriptimport { clean } from './core.ts'
import { verifyDeliverySignature } from './delivery-auth.ts'
const agentmailWebhookSecret = '{{secret:agentmailWebhookSecret|scope=user}}'
export type HandleWebhookInput = {
webhook?: { name?: string; packageKodyId?: string }
request?: {
body?: string
text?: string
json?: unknown
headers?: Record<string, string | undefined>
}
}
function resolveRawBody(request: HandleWebhookInput['request']) {
if (typeof request?.body === 'string') return request.body
if (typeof request?.text === 'string') return request.text
if (request?.json !== undefined) return JSON.stringify(request.json)
return ''
}
function secretIsConfigured(secret: string) {
const value = secret.trim()
if (!value) return false
if (value.includes('{{secret:')) return false
return value.startsWith('whsec_')
}
function headerValue(
headers: Record<string, string | undefined>,
name: string,
) {
const wanted = name.toLowerCase()
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === wanted) return String(value ?? '').trim()
}
return ''
}
function slimMessage(raw: Record<string, unknown>) {
return {
messageId: clean(raw.message_id || raw.id) || null,
threadId: clean(raw.thread_id) || null,
inboxId: clean(raw.inbox_id) || null,
subject: clean(raw.subject) || null,
from: raw.from ?? null,
to: raw.to ?? null,
preview: clean(raw.preview || raw.text || raw.snippet).slice(0, 240) || null,
}
}
/**
* Inbound Kody webhook for AgentMail Svix deliveries (message.received, etc.).
* Declared without platform verification; when agentmailWebhookSecret is present,
* verifies the delivery signature in-handler. Parses `{webhook, request}`.
*
* @param input.webhook - Minted webhook metadata from Kody ingress
* @param input.request - Raw request envelope (`body`/`json`/`headers`)
* @returns `{ ok, eventType, eventId, message?, skipped? }`
*
* @example
* import handleWebhook from 'kody:@kody/agent-mail/handle-webhook'
* const result = await handleWebhook({
* request: {
* body: '{"event_type":"message.received","event_id":"evt_1"}',
* json: { event_type: 'message.received', event_id: 'evt_1' },
* headers: {
* 'svix-id': 'msg_1',
* 'svix-timestamp': '1710000000',
* 'svix-signature': 'v1,abc',
* },
* },
* })
*/
export default async function handleWebhook(input: HandleWebhookInput = {}) {
const request = input.request ?? {}
const body = resolveRawBody(request)
const headers = request.headers ?? {}
let skipped: string | undefined
if (secretIsConfigured(agentmailWebhookSecret)) {
await verifyDeliverySignature({
secret: agentmailWebhookSecret,
body,
headers,
})
} else {
skipped = 'svix-verify-skipped-no-agentmailWebhookSecret'
}
let payload: Record<string, unknown> = {}
if (request.json && typeof request.json === 'object' && !Array.isArray(request.json)) {
payload = request.json as Record<string, unknown>
} else if (body) {
try {
const parsed = JSON.parse(body) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
payload = parsed as Record<string, unknown>
}
} catch {
return {
ok: false as const,
eventType: null,
eventId: null,
error: 'invalid-json-body',
skipped,
}
}
}
const eventType =
clean(payload.event_type) ||
clean(payload.type) ||
clean((payload.event as { type?: string } | undefined)?.type) ||
null
const eventId =
clean(payload.event_id) ||
clean(payload.id) ||
headerValue(headers, 'svix-id') ||
null
const message =
payload.message && typeof payload.message === 'object'
? slimMessage(payload.message as Record<string, unknown>)
: payload.data && typeof payload.data === 'object'
? slimMessage(payload.data as Record<string, unknown>)
: undefined
return {
ok: true as const,
eventType,
eventId,
...(message ? { message } : {}),
...(skipped ? { skipped } : {}),
webhookName: clean(input.webhook?.name) || 'inbound',
}
}