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/threads.ts

100 lines · 3.1 KB · TypeScript
import 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)
	})
}