Menu
Public repository / c3ef99b092caa55fb6a3eed9d3e776734cc99e97
AkurAI-Mail / src/lib/webmail/threads.ts
Latest change 22202d1bde69be4ace8a299dffd91f8ff24378df - Recover deployed webmail frontend (frontend parity with EC2 a6afffc) by Ólafur Búi Ólafsson
/** Client-side conversation grouping for the message list. */
export interface ThreadMessage {
id: string;
messageId?: string;
inReplyTo?: string;
references?: string[];
subject?: string;
from?: string;
to?: string;
timestamp?: number;
unread?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- rows carry arbitrary API fields
[key: string]: any;
}
export interface Thread<M extends ThreadMessage = ThreadMessage> {
key: string;
/** Oldest first. */
messages: M[];
latest: M;
unreadCount: number;
}
/** Strips one reply/forward prefix (Re:, Fwd:, Fw:, AW:, SV:) and lowercases. */
export function normalizeSubject(subject = "") {
return subject.replace(/^\s*(re|fwd?|aw|sv)\s*:\s*/i, "").trim().toLowerCase();
}
/** Same sender, or the two messages are a reply pair (a.from = b.to or b.from = a.to). */
export function sameParticipants(a: ThreadMessage, b: ThreadMessage) {
const aFrom = a.from?.toLowerCase();
const aTo = a.to?.toLowerCase();
const bFrom = b.from?.toLowerCase();
const bTo = b.to?.toLowerCase();
if (!aFrom || !bFrom) return false;
if (aFrom === bFrom) return true;
return (!!aTo && aFrom === bTo) || (!!bTo && bFrom === aTo);
}
function hasHeaders(message: ThreadMessage) {
return !!(message.messageId || message.inReplyTo || message.references?.length);
}
/**
* Groups messages into threads. Messages are joined by In-Reply-To/References;
* messages without any threading headers fall back to a normalized subject plus
* matching participants. Threads are sorted newest first.
*/
export function groupThreads<M extends ThreadMessage>(messages: M[]): Thread<M>[] {
const parent = new Map<string, string>();
const root = (id: string) => {
while (parent.get(id) && parent.get(id) !== id) id = parent.get(id)!;
return id;
};
const union = (a: string, b: string) => {
const ra = root(a);
const rb = root(b);
if (ra !== rb) parent.set(ra, rb);
};
for (const message of messages) parent.set(message.id, message.id);
const byMessageId = new Map<string, string>();
for (const message of messages) if (message.messageId) byMessageId.set(message.messageId, message.id);
for (const message of messages) {
for (const ref of [message.inReplyTo, ...(message.references ?? [])].filter(Boolean) as string[]) {
const target = byMessageId.get(ref);
if (target) union(message.id, target);
}
}
const bySubject = new Map<string, M[]>();
for (const message of messages) {
const subject = normalizeSubject(message.subject);
if (!subject) continue;
if (!bySubject.has(subject)) bySubject.set(subject, []);
bySubject.get(subject)!.push(message);
}
for (const group of bySubject.values()) {
for (let i = 0; i < group.length; i++) {
for (let j = i + 1; j < group.length; j++) {
const a = group[i]!;
const b = group[j]!;
if (!hasHeaders(a) && !hasHeaders(b) && sameParticipants(a, b)) union(a.id, b.id);
}
}
}
const groups = new Map<string, M[]>();
for (const message of messages) {
const key = root(message.id);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(message);
}
const threads: Thread<M>[] = [];
for (const [key, members] of groups) {
members.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
threads.push({ key, messages: members, latest: members[members.length - 1]!, unreadCount: members.filter((m) => m.unread).length });
}
threads.sort((a, b) => (b.latest.timestamp ?? 0) - (a.latest.timestamp ?? 0));
return threads;
}