AkurAI Build
Menu

Public repository / 4f0b1d374fab4075f2b552b5f250bca5f28afee9

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;
}