← Public packages
@kody/mailroom
Offline-first PWA for your Kody mailbox: sent and received threads, copy-prompt for agents, no send.
src/mail.ts
116 lines · 3.9 KB · TypeScriptimport { kody } from 'kody:runtime'
import type { MailDirection, MailMessage, MailSummary } from './types.ts'
const DEFAULT_LIMIT = 100
function asStringArray(value: unknown): Array<string> {
if (!Array.isArray(value)) return []
return value.map((item) => String(item)).filter(Boolean)
}
export function asSummary(row: Record<string, unknown>): MailSummary {
return {
id: String(row.id || ''),
direction: row.direction === 'outbound' ? 'outbound' : 'inbound',
inbox_id: row.inbox_id == null ? null : String(row.inbox_id),
thread_id: row.thread_id == null ? null : String(row.thread_id),
from_address: row.from_address == null ? null : String(row.from_address),
envelope_from: row.envelope_from == null ? null : String(row.envelope_from),
to_addresses: asStringArray(row.to_addresses),
subject: row.subject == null ? null : String(row.subject),
message_id_header:
row.message_id_header == null ? null : String(row.message_id_header),
processing_status: String(row.processing_status || ''),
classification: String(row.classification || ''),
delivery_status:
row.delivery_status == null ? null : String(row.delivery_status),
received_at: row.received_at == null ? null : String(row.received_at),
sent_at: row.sent_at == null ? null : String(row.sent_at),
created_at: String(row.created_at || ''),
updated_at: String(row.updated_at || ''),
}
}
export function asMessage(row: Record<string, unknown>): MailMessage {
const attachments = Array.isArray(row.attachments) ? row.attachments : []
return {
...asSummary(row),
cc_addresses: asStringArray(row.cc_addresses),
bcc_addresses: asStringArray(row.bcc_addresses),
reply_to_addresses: asStringArray(row.reply_to_addresses),
in_reply_to_header:
row.in_reply_to_header == null ? null : String(row.in_reply_to_header),
references: asStringArray(row.references),
text_body: row.text_body == null ? null : String(row.text_body),
html_body: row.html_body == null ? null : String(row.html_body),
attachments: attachments.map((item) => {
const att = (item || {}) as Record<string, unknown>
return {
id: String(att.id || ''),
filename: att.filename == null ? null : String(att.filename),
content_type: att.content_type == null ? null : String(att.content_type),
size: typeof att.size === 'number' ? att.size : null,
}
}),
}
}
export async function listMail(input: {
direction?: MailDirection
query?: string
limit?: number
inbox_id?: string
} = {}): Promise<Array<MailSummary>> {
const limit = Math.min(Math.max(input.limit ?? DEFAULT_LIMIT, 1), 200)
if (input.query?.trim()) {
const result = await kody.emailMessageSearch({
query: input.query.trim(),
direction: input.direction,
inbox_id: input.inbox_id,
limit,
})
return (result.messages || []).map((row) =>
asSummary(row as unknown as Record<string, unknown>),
)
}
const result = await kody.emailMessageList({
direction: input.direction,
inbox_id: input.inbox_id,
limit,
})
return (result.messages || []).map((row) =>
asSummary(row as unknown as Record<string, unknown>),
)
}
export async function listMailbox(limit = DEFAULT_LIMIT): Promise<Array<MailSummary>> {
const [inbound, outbound] = await Promise.all([
listMail({ direction: 'inbound', limit }),
listMail({ direction: 'outbound', limit }),
])
const seen = new Set<string>()
const all: Array<MailSummary> = []
for (const message of [...inbound, ...outbound]) {
if (!message.id || seen.has(message.id)) continue
seen.add(message.id)
all.push(message)
}
return all
}
export async function getMailMessage(messageId: string): Promise<MailMessage | null> {
const id = messageId.trim()
if (!id) throw new Error('messageId is required.')
try {
const row = await kody.emailMessageGet({ message_id: id })
if (!row?.id) return null
return asMessage(row as unknown as Record<string, unknown>)
} catch {
return null
}
}
export async function listInboxes() {
const result = await kody.emailInboxList({})
return result.inboxes || []
}