Menu
Public repository / 10d19fe0941c186ec244d1f0e2af6b19200dcb0c
AkurAI-Mail / src/lib/webmail/threads.js
Latest change 10d19fe0941c186ec244d1f0e2af6b19200dcb0c - OLI-248: pure webmail logic as plain ES modules with JSDoc types; inline icon module by Ólafur Búi Ólafsson
/**
* A list row: the API's message fields plus threading headers.
* @typedef {{ id: string, messageId?: string, inReplyTo?: string, references?: string[], subject?: string, from?: string, to?: string, timestamp?: number, unread?: boolean, [key: string]: any }} ThreadMessage
* @typedef {{ key: string, messages: ThreadMessage[], latest: ThreadMessage, unreadCount: number }} Thread Messages oldest first.
*/
/** Client-side conversation grouping for the message list. */
/**
* Strips one reply/forward prefix (Re:, Fwd:, Fw:, AW:, SV:) and lowercases.
* @param {any} [subject]
*/
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).
* @param {ThreadMessage} a
* @param {ThreadMessage} b
*/
export function sameParticipants(a, b) {
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);
}
/**
* @param {ThreadMessage} message
*/
function hasHeaders(message) {
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.
*/
/**
* @param {ThreadMessage[]} messages
* @returns {Thread[]}
*/
export function groupThreads(messages) {
/** @type {Map<string, string>} */
const parent = new Map();
/** @param {string} id */
const root = (id) => {
for (let next = parent.get(id); next && next !== id; next = parent.get(id)) id = next;
return id;
};
/** @param {string} a @param {string} b */
const union = (a, b) => {
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);
/** @type {Map<string, string>} */
const byMessageId = new Map();
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 ?? [])]) {
const target = ref && byMessageId.get(ref);
if (target) union(message.id, target);
}
}
/** @type {Map<string, ThreadMessage[]>} */
const bySubject = new Map();
for (const message of messages) {
const subject = normalizeSubject(message.subject);
if (!subject) continue;
const same = bySubject.get(subject) ?? [];
same.push(message);
bySubject.set(subject, same);
}
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);
}
}
}
/** @type {Map<string, ThreadMessage[]>} */
const groups = new Map();
for (const message of messages) {
const key = root(message.id);
const members = groups.get(key) ?? [];
members.push(message);
groups.set(key, members);
}
const threads = [];
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;
}