Skip to content
← 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 · TypeScript
import { 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',
	}
}