Skip to content
← Public packages

@kody/mailroom

Offline-first PWA for your Kody mailbox: sent and received threads, copy-prompt for agents, no send.

src/app.ts

181 lines · 5.5 KB · TypeScript
import { packageContext } from 'kody:runtime'
import { listInboxes, listMail, getMailMessage } from './mail.ts'
import buildPrompt from './prompt.ts'
import { filterThreads, groupMessages, messageWhen } from './threads.ts'
import getThread from './thread.ts'
import type { MailDirection } from './types.ts'
import { artResponse } from './ui/art.ts'
import {
	needsCanonicalAppRedirect,
	numberParam,
	serviceWorkerAllowedPath,
} from './ui/app-url.ts'
import { mailroomManifest } from './ui/manifest.ts'
import { renderAppPage } from './ui/page.ts'
import { serviceWorkerScript } from './ui/sw-script.ts'
import { getAppVersion } from './version.ts'

/**
 * Hosted Mailroom app: received/sent threads, copy-prompt, PWA, IndexedDB cache.
 * The package app runtime calls this default export as `fetch`.
 *
 * @example
 * import handleApp from 'kody:@kody/mailroom/app'
 *
 * const response = await handleApp(new Request('https://example.com/'))
 */
export default async function handleApp(request: Request): Promise<Response> {
	const url = new URL(request.url)
	const appBasePath = packageContext?.appBasePath || '/packages/mailroom'
	const hostedUrl =
		packageContext?.hostedUrl || `https://kody.kody.run${appBasePath}`
	if (needsCanonicalAppRedirect(url.pathname, appBasePath)) {
		url.pathname = `${appBasePath}/`
		return Response.redirect(url, 308)
	}
	const path = stripMount(url.pathname, appBasePath)

	if (path === '/manifest.webmanifest') {
		return json(
			mailroomManifest(appBasePath),
			'application/manifest+json; charset=utf-8',
		)
	}
	const artwork = artResponse(path)
	if (artwork) return artwork
	if (path === '/sw.js') {
		return new Response(serviceWorkerScript(appBasePath), {
			headers: {
				'Content-Type': 'application/javascript; charset=utf-8',
				'Cache-Control': 'no-cache',
				'Service-Worker-Allowed': serviceWorkerAllowedPath(appBasePath),
			},
		})
	}
	if (path === '/api/inboxes') {
		return json({ inboxes: await listInboxes() })
	}
	if (path === '/api/threads') {
		const direction: MailDirection =
			url.searchParams.get('direction') === 'outbound' ? 'outbound' : 'inbound'
		const query = url.searchParams.get('q') || ''
		const limit = numberParam(url.searchParams.get('limit'), 100)
		const [messages, inboxes] = await Promise.all([
			listMail({ direction, limit }),
			listInboxes().catch(() => []),
		])
		const threads = filterThreads(groupMessages(messages, direction), query)
		return json({
			direction,
			threads,
			total: threads.length,
			inboxes,
		})
	}
	const threadDetail = path.match(/^\/api\/threads\/([^/]+)$/)
	if (threadDetail) {
		const thread = await getThread({
			threadId: decodeURIComponent(threadDetail[1] ?? ''),
		})
		if (!thread) return json({ error: 'not_found' }, undefined, 404)
		return json(thread)
	}
	const messageDetail = path.match(/^\/api\/messages\/([^/]+)$/)
	if (messageDetail) {
		const message = await getMailMessage(decodeURIComponent(messageDetail[1] ?? ''))
		if (!message) return json({ error: 'not_found' }, undefined, 404)
		return json(message)
	}
	if (path === '/api/search') {
		const query = url.searchParams.get('q') || ''
		const direction: MailDirection =
			url.searchParams.get('direction') === 'outbound' ? 'outbound' : 'inbound'
		if (!query.trim()) return json({ query, direction, threads: [] })
		const messages = await listMail({
			query,
			direction,
			limit: numberParam(url.searchParams.get('limit'), 100),
		})
		return json({
			query,
			direction,
			threads: groupMessages(messages, direction),
		})
	}
	if (path === '/api/prompt') {
		try {
			const result = await buildPrompt({
				threadId: url.searchParams.get('thread') || undefined,
				messageId: url.searchParams.get('message') || undefined,
			})
			return json(result)
		} catch (error) {
			return json(
				{ error: error instanceof Error ? error.message : 'prompt_failed' },
				undefined,
				400,
			)
		}
	}
	if (path === '/api/status') {
		const [inbound, outbound, inboxes] = await Promise.all([
			listMail({ direction: 'inbound', limit: 100 }),
			listMail({ direction: 'outbound', limit: 100 }),
			listInboxes().catch(() => []),
		])
		const inboundThreads = groupMessages(inbound, 'inbound')
		const outboundThreads = groupMessages(outbound, 'outbound')
		return json({
			inbound: {
				total: inboundThreads.length,
				latestAt: inboundThreads[0]?.latestAt || null,
			},
			outbound: {
				total: outboundThreads.length,
				latestAt: outboundThreads[0]?.latestAt || null,
			},
			inboxes,
			latestMessageAt: newest([
				...inbound.map(messageWhen),
				...outbound.map(messageWhen),
			]),
		})
	}
	if (path === '/api/version') {
		return json(await getAppVersion())
	}

	const version = await getAppVersion()
	return new Response(renderAppPage({ appBasePath, hostedUrl, version }), {
		headers: {
			'Content-Type': 'text/html; charset=utf-8',
			'Cache-Control': 'no-store',
		},
	})
}

function newest(values: Array<string>): string | null {
	const present = values.filter(Boolean).sort()
	return present.at(-1) || null
}

function stripMount(pathname: string, appBasePath: string): string {
	let rest = pathname
	if (pathname === appBasePath || pathname.startsWith(`${appBasePath}/`)) {
		rest = pathname.slice(appBasePath.length) || '/'
	}
	if (!rest.startsWith('/')) rest = `/${rest}`
	if (rest.length > 1 && rest.endsWith('/')) rest = rest.slice(0, -1)
	return rest || '/'
}

function json(
	data: unknown,
	contentType = 'application/json; charset=utf-8',
	status = 200,
) {
	return new Response(JSON.stringify(data), {
		status,
		headers: { 'Content-Type': contentType },
	})
}