mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
44c77b26kept the server's order for sender and subject sorted lists. That is right for Stalwart 1.0, which sorts lowercased byte-order keys, but 0.16 compares the raw bytes, capitals first, so on every 0.16 server a sender list read "Adam, Carla, Eve, bea, dave, zed". - keep the server's order only for accounts that list `urn:ietf:params:jmap:principals:owner` (every 1.0 account, never 0.16); elsewhere pages and thread groups are re-sorted case-insensitively, as before44c77b26- the client comparator puts a message without a sender or subject last in both directions, as both versions do Expanding a thread marked it read in the login's own account instead of the shared or group account it lives in, so Stalwart answered notFound or marked an unrelated message with the same id, and sinced7004b38the thread also stayed unread. The write now names the owning account. Moving mail between category tabs had the same fault: it took a listed message's display-only account reference, and now routes each message like the other keyword writes.
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import type { Email, ThreadGroup } from "./jmap/types";
|
|
import { compareEmails, hasTextLevels, type SortLevel } from "./message-list-order";
|
|
|
|
/**
|
|
* Client-side identity of a thread.
|
|
*
|
|
* JMAP thread ids are only unique within their account (Stalwart hands out
|
|
* per-account counters such as "b", "c", …), so in views that merge several
|
|
* accounts two unrelated conversations can share a `threadId`. Emails fetched
|
|
* through the aggregate paths carry their source stamps; scoping the key by
|
|
* them keeps those threads apart. Unstamped emails (single-account views) keep
|
|
* the bare id, so nothing changes there. `ThreadGroup.threadId` stays the raw
|
|
* id for JMAP calls - `threadIdFromKey` is the reverse mapping.
|
|
*/
|
|
export function threadKeyFor(
|
|
email: Pick<Email, "threadId" | "sourceClientAccountId" | "sourceAccountId">,
|
|
): string {
|
|
const scope = [email.sourceClientAccountId, email.sourceAccountId].filter(Boolean).join("/");
|
|
return scope ? `${scope}:${email.threadId}` : email.threadId;
|
|
}
|
|
|
|
/**
|
|
* The JMAP thread id behind a `threadKeyFor` key. JMAP ids never contain ":"
|
|
* (RFC 8620 §1.2 limits them to the URL-safe base64 alphabet), so the last
|
|
* separator always splits the scope from the id.
|
|
*/
|
|
export function threadIdFromKey(threadKey: string): string {
|
|
const at = threadKey.lastIndexOf(":");
|
|
return at === -1 ? threadKey : threadKey.slice(at + 1);
|
|
}
|
|
|
|
/**
|
|
* Groups emails by their threadId and creates ThreadGroup objects for UI display.
|
|
* Single-email threads are still returned as ThreadGroups with emailCount=1.
|
|
* When disableThreading is true, each email is placed into its own group using
|
|
* its message ID as the key, so the list shows individual messages.
|
|
*
|
|
* @param threadEmailCounts - Optional map of threadKey → total email count across
|
|
* all folders (from Thread/get). When provided, emailCount reflects the full
|
|
* thread size rather than just the emails in the current folder.
|
|
*/
|
|
export function groupEmailsByThread(
|
|
emails: Email[],
|
|
disableThreading = false,
|
|
threadEmailCounts?: Map<string, number>,
|
|
): ThreadGroup[] {
|
|
if (!emails || emails.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Group by the account-scoped thread key (or by message id when threading
|
|
// is disabled): bare thread ids collide across accounts in aggregate views.
|
|
const threadMap = new Map<string, Email[]>();
|
|
|
|
for (const email of emails) {
|
|
const key = disableThreading ? email.id : threadKeyFor(email);
|
|
if (!threadMap.has(key)) {
|
|
threadMap.set(key, []);
|
|
}
|
|
threadMap.get(key)!.push(email);
|
|
}
|
|
|
|
// Convert to ThreadGroup array
|
|
const threadGroups: ThreadGroup[] = [];
|
|
|
|
for (const [threadKey, threadEmails] of threadMap) {
|
|
// Sort emails by receivedAt descending (newest first)
|
|
const sortedEmails = [...threadEmails].sort(
|
|
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
|
);
|
|
|
|
const latestEmail = sortedEmails[0];
|
|
|
|
// Collect unique participant names from all emails in thread
|
|
const participantNames = getThreadParticipants(sortedEmails);
|
|
|
|
// Check for unread, starred, pinned, and attachments
|
|
const hasUnread = sortedEmails.some(e => !e.keywords?.$seen);
|
|
const hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
|
|
const hasPinned = sortedEmails.some(e => e.keywords?.['$pinned']);
|
|
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
|
|
const hasAnswered = sortedEmails.some(e => e.keywords?.$answered);
|
|
const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded);
|
|
|
|
threadGroups.push({
|
|
// With threading off the "thread" is the single message, keyed by its id.
|
|
threadId: disableThreading ? latestEmail.id : latestEmail.threadId,
|
|
threadKey,
|
|
emails: sortedEmails,
|
|
latestEmail,
|
|
participantNames,
|
|
hasUnread,
|
|
hasStarred,
|
|
hasPinned,
|
|
hasAttachment,
|
|
hasAnswered,
|
|
hasForwarded,
|
|
emailCount: threadEmailCounts?.get(threadKey) ?? sortedEmails.length,
|
|
});
|
|
}
|
|
|
|
return threadGroups;
|
|
}
|
|
|
|
/**
|
|
* Sorts thread groups to mirror the order the email list was fetched in.
|
|
* Threads containing a pinned email ($pinned keyword) stay on top, mirroring
|
|
* the server-side pinned-first sort of the email list. Below that, each thread
|
|
* takes the position of whichever of its emails sorts first under `order`
|
|
* (RFC 8621 §4.4.3 thread collapsing semantics) - with the default order that
|
|
* is the latest email's receivedAt date, newest first.
|
|
*
|
|
* A sender or subject order the list keeps as the server collated it
|
|
* (`keepServerTextOrder`, Stalwart 1.0; see `hasTextLevels`) is not re-sorted.
|
|
* The groups arrive in fetched order - groupEmailsByThread keeps the order in
|
|
* which each thread first appears - so each thread already sits where its
|
|
* first-ranked email does, and only pinned threads move.
|
|
*/
|
|
export function sortThreadGroups(
|
|
groups: ThreadGroup[],
|
|
order: SortLevel[] = [],
|
|
opts: { keepServerTextOrder?: boolean } = {},
|
|
): ThreadGroup[] {
|
|
if (opts.keepServerTextOrder && hasTextLevels(order)) {
|
|
return [...groups].sort((a, b) => (b.hasPinned ? 1 : 0) - (a.hasPinned ? 1 : 0));
|
|
}
|
|
const compare = compareEmails(order);
|
|
const representative = new Map<ThreadGroup, Email>();
|
|
for (const group of groups) {
|
|
representative.set(
|
|
group,
|
|
group.emails.reduce((best, email) => (compare(email, best) < 0 ? email : best), group.emails[0] ?? group.latestEmail),
|
|
);
|
|
}
|
|
return [...groups].sort(
|
|
(a, b) =>
|
|
(b.hasPinned ? 1 : 0) - (a.hasPinned ? 1 : 0) ||
|
|
compare(representative.get(a)!, representative.get(b)!)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extracts unique participant names from a list of emails.
|
|
* Includes both senders and recipients, limited to avoid UI overflow.
|
|
*/
|
|
export function getThreadParticipants(emails: Email[], maxNames: number = 4): string[] {
|
|
const seen = new Set<string>();
|
|
const names: string[] = [];
|
|
|
|
for (const email of emails) {
|
|
// Add sender
|
|
if (email.from && email.from.length > 0) {
|
|
const sender = email.from[0];
|
|
const senderName = sender.name || sender.email.split('@')[0];
|
|
const key = sender.email.toLowerCase();
|
|
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
names.push(senderName);
|
|
}
|
|
}
|
|
|
|
// Stop if we have enough names
|
|
if (names.length >= maxNames) break;
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Merges newly fetched thread emails into an existing thread group.
|
|
* Used when expanding a thread to show all emails (some may not have been in the original list).
|
|
*/
|
|
export function mergeThreadEmails(
|
|
existingGroup: ThreadGroup,
|
|
fetchedEmails: Email[]
|
|
): ThreadGroup {
|
|
// Create a map of existing emails by ID
|
|
const emailMap = new Map<string, Email>();
|
|
|
|
for (const email of existingGroup.emails) {
|
|
emailMap.set(email.id, email);
|
|
}
|
|
|
|
// Add fetched emails that aren't already in the group
|
|
for (const email of fetchedEmails) {
|
|
if (!emailMap.has(email.id)) {
|
|
emailMap.set(email.id, email);
|
|
}
|
|
}
|
|
|
|
// Convert back to array and sort
|
|
const mergedEmails = Array.from(emailMap.values()).sort(
|
|
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
|
);
|
|
|
|
const latestEmail = mergedEmails[0];
|
|
const participantNames = getThreadParticipants(mergedEmails);
|
|
const hasUnread = mergedEmails.some(e => !e.keywords?.$seen);
|
|
const hasStarred = mergedEmails.some(e => e.keywords?.$flagged);
|
|
const hasPinned = mergedEmails.some(e => e.keywords?.['$pinned']);
|
|
const hasAttachment = mergedEmails.some(e => e.hasAttachment);
|
|
const hasAnswered = mergedEmails.some(e => e.keywords?.$answered);
|
|
const hasForwarded = mergedEmails.some(e => e.keywords?.$forwarded);
|
|
|
|
return {
|
|
threadId: existingGroup.threadId,
|
|
threadKey: existingGroup.threadKey,
|
|
emails: mergedEmails,
|
|
latestEmail,
|
|
participantNames,
|
|
hasUnread,
|
|
hasStarred,
|
|
hasPinned,
|
|
hasAttachment,
|
|
hasAnswered,
|
|
hasForwarded,
|
|
emailCount: mergedEmails.length,
|
|
};
|
|
}
|
|
|
|
/** Active prefix for new keyword tags written to JMAP */
|
|
export const KEYWORD_PREFIX = "$label:";
|
|
/** Legacy prefix still recognised when reading */
|
|
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
|
|
|
/**
|
|
* Gets every tag id set on a message.
|
|
* Reads both the current $label: prefix and the legacy $color: prefix.
|
|
* A tag written under both spellings is one tag, so it is returned once.
|
|
*/
|
|
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
|
|
if (!keywords) return [];
|
|
const tags = new Set<string>();
|
|
for (const key of Object.keys(keywords)) {
|
|
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
|
tags.add(
|
|
key.startsWith(KEYWORD_PREFIX)
|
|
? key.slice(KEYWORD_PREFIX.length)
|
|
: key.slice(KEYWORD_PREFIX_LEGACY.length)
|
|
);
|
|
}
|
|
}
|
|
return [...tags];
|
|
}
|
|
|
|
/**
|
|
* The complete keyword map after toggling one tag, or after clearing every tag
|
|
* (`tagId` null), for writing as a whole. Every value in such a map must be
|
|
* true (RFC 8621 section 4.1.1), so a tag comes off by leaving its key out,
|
|
* and so do keywords the local copy only marks false (`$seen: false` after
|
|
* marking a message unread). Both prefixes name the same tag, so taking a tag
|
|
* off clears whichever spellings are set.
|
|
*/
|
|
export function toggleTagKeywords(
|
|
keywords: Record<string, boolean> | undefined,
|
|
tagId: string | null,
|
|
): Record<string, true> {
|
|
const next: Record<string, true> = {};
|
|
for (const [key, value] of Object.entries(keywords ?? {})) {
|
|
if (value === true) next[key] = true;
|
|
}
|
|
if (tagId === null) {
|
|
for (const key of Object.keys(next)) {
|
|
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) delete next[key];
|
|
}
|
|
return next;
|
|
}
|
|
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId].filter((key) => next[key]);
|
|
if (activeKeys.length > 0) {
|
|
for (const key of activeKeys) delete next[key];
|
|
} else {
|
|
next[KEYWORD_PREFIX + tagId] = true;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Gets the first tag id set on a message, if any.
|
|
* Reads both the current $label: prefix and the legacy $color: prefix.
|
|
* @deprecated Use getEmailTagIds for multi-tag support.
|
|
*/
|
|
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
|
|
const tags = getEmailTagIds(keywords);
|
|
return tags.length > 0 ? tags[0] : null;
|
|
}
|
|
|
|
/**
|
|
* The first tag id found anywhere in a thread, if any.
|
|
*/
|
|
export function getThreadTagId(emails: Email[]): string | null {
|
|
for (const email of emails) {
|
|
const color = getEmailTagId(email.keywords);
|
|
if (color) return color;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Every tag anywhere in a thread, deduplicated.
|
|
*
|
|
* A collapsed thread row stands in for all its messages, so it has to account
|
|
* for all their tags - showing only the first message's would hide the rest
|
|
* with nothing to indicate they exist.
|
|
*/
|
|
export function getThreadTagIds(emails: Email[]): string[] {
|
|
const tags = new Set<string>();
|
|
for (const email of emails) {
|
|
for (const tag of getEmailTagIds(email.keywords)) {
|
|
tags.add(tag);
|
|
}
|
|
}
|
|
return [...tags];
|
|
}
|