← Public packages
@kody/mailroom
Offline-first PWA for your Kody mailbox: sent and received threads, copy-prompt for agents, no send.
src/threads.ts
100 lines · 3.1 KB · TypeScriptimport type { MailDirection, MailMessage, MailSummary, MailThread } from './types.ts'
export function threadKey(message: Pick<MailSummary, 'id' | 'thread_id'>): string {
return (message.thread_id || message.id || '').trim()
}
export function messageWhen(message: Pick<MailSummary, 'received_at' | 'sent_at' | 'created_at'>): string {
return message.received_at || message.sent_at || message.created_at || ''
}
export function snippetFrom(message: Pick<MailSummary, 'subject'> & Partial<Pick<MailMessage, 'text_body' | 'html_body'>>): string {
const text = (message.text_body || '')
.trim()
.replace(/\s+/g, ' ')
if (text) return text.slice(0, 160)
const html = String(message.html_body || '')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (html) return html.slice(0, 160)
return String(message.subject || '').trim()
}
export function participantsOf(message: MailSummary): Array<string> {
const values = [
message.from_address,
message.envelope_from,
...(message.to_addresses || []),
]
.map((value) => String(value || '').trim())
.filter(Boolean)
return [...new Set(values)]
}
export function groupMessages(
messages: Array<MailSummary>,
direction: MailDirection,
): Array<MailThread> {
const groups = new Map<string, MailThread>()
for (const message of messages) {
if (message.direction !== direction) continue
const id = threadKey(message)
if (!id) continue
const when = messageWhen(message)
const existing = groups.get(id)
if (!existing) {
groups.set(id, {
id,
direction,
subject: String(message.subject || '').trim() || '(no subject)',
snippet: snippetFrom(message),
latestAt: when,
messageCount: 1,
from_address: message.from_address,
to_addresses: message.to_addresses || [],
participants: participantsOf(message),
messages: [message],
})
continue
}
existing.messages.push(message)
existing.messageCount += 1
existing.participants = [
...new Set([...existing.participants, ...participantsOf(message)]),
]
if (when >= existing.latestAt) {
existing.latestAt = when
existing.subject = String(message.subject || existing.subject).trim() || existing.subject
existing.snippet = snippetFrom(message) || existing.snippet
existing.from_address = message.from_address
existing.to_addresses = message.to_addresses || existing.to_addresses
}
}
for (const thread of groups.values()) {
thread.messages.sort((a, b) => messageWhen(a).localeCompare(messageWhen(b)))
}
return [...groups.values()].sort((a, b) => b.latestAt.localeCompare(a.latestAt))
}
export function filterThreads(threads: Array<MailThread>, query: string): Array<MailThread> {
const q = query.trim().toLowerCase()
if (!q) return threads
return threads.filter((thread) => {
const hay = [
thread.subject,
thread.snippet,
...thread.participants,
...thread.messages.flatMap((message) => [
message.subject,
message.from_address,
...(message.to_addresses || []),
]),
]
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}