AkurAI Build
Menu

Public repository / 0216cf0f6008aae88a648a82e9b3a462a6688302

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