← 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 · TypeScriptimport { 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 },
})
}