mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
The list resolved the Archive folder with a loose name match ("Archives",
"Archived" or any name containing "archive"), while the store's archive
actions only accept the `archive` role or a folder named exactly
"Archive". With a role-less "Archives" folder the swipe and bulk archive
were offered but silently did nothing.
`findArchiveMailbox` now uses the webmail's rules (role first, then the
exact name, case-insensitive) and both store archive actions resolve
through it, so the list offers archiving only when it will run and
otherwise shows the existing "no Archive folder" message.
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
import type { Mailbox } from '../api/types';
|
|
|
|
export interface MailboxNode extends Mailbox {
|
|
children: MailboxNode[];
|
|
depth: number;
|
|
/**
|
|
* True for the virtual node that wraps a shared/group account's folders.
|
|
* It has no server-side mailbox behind it — it can be expanded but never
|
|
* selected or used as a move target.
|
|
*/
|
|
isAccountNode?: boolean;
|
|
}
|
|
|
|
// Id prefix for the virtual per-shared-account node. Matches the webmail's
|
|
// `shared-account-<accountId>` convention in [lib/utils.ts].
|
|
export const SHARED_ACCOUNT_NODE_PREFIX = 'shared-account-';
|
|
|
|
/** The user's own folders — everything not owned by a shared/group account. */
|
|
export function ownMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
|
return mailboxes.filter((m) => !m.isShared);
|
|
}
|
|
|
|
/**
|
|
* Folders belonging to the same account as `mailboxId`. Role lookups (trash,
|
|
* archive, junk…) must run inside one account: moving a message out of a
|
|
* shared folder into the user's own Trash isn't a thing JMAP can express.
|
|
* Falls back to the user's own folders when the id isn't known.
|
|
*/
|
|
export function mailboxesForSiblingOf(mailboxes: Mailbox[], mailboxId: string | null): Mailbox[] {
|
|
const current = mailboxId ? mailboxes.find((m) => m.id === mailboxId) : undefined;
|
|
if (!current?.isShared) return ownMailboxes(mailboxes);
|
|
return mailboxes.filter((m) => m.isShared && m.accountId === current.accountId);
|
|
}
|
|
|
|
/** The JMAP account a folder lives in: its owner when shared, undefined for the user's own. */
|
|
export function mailboxAccountId(mailboxes: Mailbox[], mailboxId: string | null): string | undefined {
|
|
const mailbox = mailboxId ? mailboxes.find((m) => m.id === mailboxId) : undefined;
|
|
return mailbox?.isShared ? mailbox.accountId : undefined;
|
|
}
|
|
|
|
/**
|
|
* Folders of one JMAP account: the user's own for `undefined`, else that
|
|
* shared account's. A message's role folders (trash, archive, junk…) must
|
|
* come from the account it lives in, not from whatever folder is open —
|
|
* ids are only unique per account. Empty when the account's folders are not
|
|
* loaded, so callers refuse instead of guessing.
|
|
*/
|
|
export function mailboxesOfAccount(mailboxes: Mailbox[], accountId: string | undefined): Mailbox[] {
|
|
if (!accountId) return ownMailboxes(mailboxes);
|
|
return mailboxes.filter((m) => m.isShared && m.accountId === accountId);
|
|
}
|
|
|
|
/**
|
|
* The folder a message is filed in among one account's `mailboxes`: the
|
|
* preferred one (the folder it was opened from) when the message is in it,
|
|
* otherwise the first of its folders that is known. `mailboxIds` comes from
|
|
* the server unprefixed, so it is matched on each folder's raw id.
|
|
*/
|
|
export function mailboxOfEmail(
|
|
mailboxes: Mailbox[],
|
|
mailboxIds: Record<string, boolean> | undefined,
|
|
preferredId?: string | null,
|
|
): Mailbox | undefined {
|
|
const holds = (m: Mailbox) => !!mailboxIds?.[m.originalId ?? m.id];
|
|
const preferred = preferredId ? mailboxes.find((m) => m.id === preferredId) : undefined;
|
|
if (preferred && holds(preferred)) return preferred;
|
|
return mailboxes.find(holds);
|
|
}
|
|
|
|
// Matches `ROLE_PRIORITY` from [lib/utils.ts] in the webmail.
|
|
const ROLE_PRIORITY: Record<string, number> = {
|
|
inbox: 0,
|
|
drafts: 1,
|
|
sent: 2,
|
|
archive: 3,
|
|
junk: 4,
|
|
spam: 4,
|
|
trash: 5,
|
|
};
|
|
|
|
// Drop root-level folders whose name collides with an existing role mailbox
|
|
// (e.g. a plain "Sent" folder when a role=sent mailbox already exists).
|
|
// Mirrors the webmail's `deduplicateMailboxes`; kept minimal (single account
|
|
// only). Only an *exact* (trimmed, case-insensitive) name collision counts:
|
|
// substring matching silently hid legitimate user folders whose name merely
|
|
// contained a role folder's name — "Old Inbox", "2025 Archive",
|
|
// "Sent to Accounting" (GitHub #771).
|
|
function deduplicate(mailboxes: Mailbox[]): Mailbox[] {
|
|
const roles = mailboxes.filter((m) => m.role);
|
|
const referencedParentIds = new Set<string>();
|
|
for (const m of mailboxes) {
|
|
if (m.parentId) referencedParentIds.add(m.parentId);
|
|
}
|
|
|
|
const result: Mailbox[] = [];
|
|
for (const m of mailboxes) {
|
|
if (m.role) { result.push(m); continue; }
|
|
// Never deduplicate nested folders — removing one would orphan its
|
|
// children to the root (GitHub #118).
|
|
if (m.parentId) { result.push(m); continue; }
|
|
const lower = m.name.trim().toLowerCase();
|
|
const dup = roles.find((r) => r.name.trim().toLowerCase() === lower);
|
|
if (!dup || referencedParentIds.has(m.id)) result.push(m);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function sortNodes(nodes: MailboxNode[]): void {
|
|
nodes.sort((a, b) => {
|
|
const ap = a.role ? (ROLE_PRIORITY[a.role] ?? 999) : 999;
|
|
const bp = b.role ? (ROLE_PRIORITY[b.role] ?? 999) : 999;
|
|
if (ap !== bp) return ap - bp;
|
|
const ao = a.sortOrder ?? 0;
|
|
const bo = b.sortOrder ?? 0;
|
|
if (ao !== bo) return ao - bo;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
for (const node of nodes) sortNodes(node.children);
|
|
}
|
|
|
|
function recalcDepths(nodes: MailboxNode[], base: number): void {
|
|
for (const n of nodes) {
|
|
n.depth = base;
|
|
if (n.children.length > 0) recalcDepths(n.children, base + 1);
|
|
}
|
|
}
|
|
|
|
// Build the root nodes for one account's folders (parent links only resolve
|
|
// within an account — a shared folder can't nest under an own folder).
|
|
function buildRoots(mailboxes: Mailbox[]): MailboxNode[] {
|
|
const deduped = deduplicate(mailboxes);
|
|
const map = new Map<string, MailboxNode>();
|
|
const roots: MailboxNode[] = [];
|
|
|
|
for (const m of deduped) {
|
|
map.set(m.id, { ...m, children: [], depth: 0 });
|
|
}
|
|
for (const m of deduped) {
|
|
const node = map.get(m.id)!;
|
|
if (m.parentId && map.has(m.parentId)) {
|
|
map.get(m.parentId)!.children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
const ACCOUNT_NODE_RIGHTS: Mailbox['myRights'] = {
|
|
mayReadItems: true,
|
|
mayAddItems: false,
|
|
mayRemoveItems: false,
|
|
maySetSeen: false,
|
|
maySetKeywords: false,
|
|
mayCreateChild: false,
|
|
mayRename: false,
|
|
mayDelete: false,
|
|
maySubmit: false,
|
|
};
|
|
|
|
/**
|
|
* Own folders first, then one collapsible node per shared/group account
|
|
* holding that account's folders — the same shape the webmail sidebar uses
|
|
* (GitHub #151), so a Stalwart group mailbox shows up as its own section
|
|
* rather than being mixed into the user's folder list.
|
|
*/
|
|
export function buildMailboxTree(
|
|
mailboxes: Mailbox[],
|
|
opts: {
|
|
/**
|
|
* Role folders to leave out of the user's own tree — e.g. the server's
|
|
* `scheduled` folder while the virtual Scheduled row is shown (#495).
|
|
*/
|
|
hideOwnRoles?: ReadonlySet<string>;
|
|
} = {},
|
|
): MailboxNode[] {
|
|
const own = mailboxes.filter((m) => !m.isShared && !(m.role && opts.hideOwnRoles?.has(m.role)));
|
|
const shared = mailboxes.filter((m) => m.isShared);
|
|
|
|
const roots = buildRoots(own);
|
|
recalcDepths(roots, 0);
|
|
sortNodes(roots);
|
|
|
|
if (shared.length === 0) return roots;
|
|
|
|
const byAccount = new Map<string, Mailbox[]>();
|
|
for (const m of shared) {
|
|
const accountId = m.accountId ?? 'unknown';
|
|
const list = byAccount.get(accountId);
|
|
if (list) list.push(m);
|
|
else byAccount.set(accountId, [m]);
|
|
}
|
|
|
|
const accountNodes: MailboxNode[] = [];
|
|
for (const [accountId, accountMailboxes] of byAccount) {
|
|
const accountRoots = buildRoots(accountMailboxes);
|
|
// Children sit at depth 1 so the account node reads as their header.
|
|
recalcDepths(accountRoots, 1);
|
|
sortNodes(accountRoots);
|
|
|
|
const accountName = accountMailboxes[0]?.accountName || accountId;
|
|
accountNodes.push({
|
|
id: `${SHARED_ACCOUNT_NODE_PREFIX}${accountId}`,
|
|
name: accountName,
|
|
sortOrder: 1000,
|
|
totalEmails: 0,
|
|
// Roll the account's unread up to the header so a collapsed section
|
|
// still shows there's something waiting.
|
|
unreadEmails: accountMailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0),
|
|
totalThreads: 0,
|
|
unreadThreads: 0,
|
|
myRights: ACCOUNT_NODE_RIGHTS,
|
|
isSubscribed: true,
|
|
accountId,
|
|
accountName,
|
|
isShared: true,
|
|
isAccountNode: true,
|
|
children: accountRoots,
|
|
depth: 0,
|
|
});
|
|
}
|
|
accountNodes.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return [...roots, ...accountNodes];
|
|
}
|
|
|
|
/** Every folder below `mailboxId` (any depth) plus the folder itself. */
|
|
export function mailboxSubtreeIds(mailboxes: Mailbox[], mailboxId: string): string[] {
|
|
const childrenOf = new Map<string, string[]>();
|
|
for (const m of mailboxes) {
|
|
if (!m.parentId) continue;
|
|
const list = childrenOf.get(m.parentId);
|
|
if (list) list.push(m.id);
|
|
else childrenOf.set(m.parentId, [m.id]);
|
|
}
|
|
const out: string[] = [];
|
|
const seen = new Set<string>();
|
|
const walk = (id: string) => {
|
|
if (seen.has(id)) return;
|
|
seen.add(id);
|
|
out.push(id);
|
|
for (const child of childrenOf.get(id) ?? []) walk(child);
|
|
};
|
|
walk(mailboxId);
|
|
return out;
|
|
}
|
|
|
|
// Flatten the tree in traversal order, skipping children of collapsed nodes.
|
|
export function flattenVisible(
|
|
nodes: MailboxNode[],
|
|
expanded: Set<string>,
|
|
): MailboxNode[] {
|
|
const out: MailboxNode[] = [];
|
|
const walk = (list: MailboxNode[]) => {
|
|
for (const n of list) {
|
|
out.push(n);
|
|
if (n.children.length > 0 && expanded.has(n.id)) walk(n.children);
|
|
}
|
|
};
|
|
walk(nodes);
|
|
return out;
|
|
}
|
|
|
|
export function findTrashMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
|
const roleMatch = mailboxes.find((m) => m.role === 'trash');
|
|
if (roleMatch) return roleMatch;
|
|
|
|
const names = ['trash', 'bin', 'deleted', 'deleted items', 'corbeille', 'messages supprimés', 'éléments supprimés', 'supprimé', 'supprimés'];
|
|
return mailboxes.find((m) => {
|
|
const lower = m.name.toLowerCase();
|
|
return names.includes(lower) || names.some((n) => lower.includes(n));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The Archive folder: the `archive` role, else a role-less folder named
|
|
* exactly "Archive" (any case), as the webmail matches it. Whatever offers
|
|
* the archive action must resolve through this, like the store's archive
|
|
* actions do: a looser list-only match used to offer archiving into e.g.
|
|
* "Archives", which the store then silently ignored. (#578)
|
|
*/
|
|
export function findArchiveMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
|
return mailboxes.find((m) => m.role === 'archive')
|
|
?? mailboxes.find((m) => m.name.toLowerCase() === 'archive');
|
|
}
|
|
|
|
export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
|
const roleMatch = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
|
|
if (roleMatch) return roleMatch;
|
|
|
|
const names = ['junk', 'spam', 'indésirables', 'indésirable', 'courrier indésirable'];
|
|
return mailboxes.find((m) => {
|
|
const lower = m.name.toLowerCase();
|
|
return names.includes(lower) || names.some((n) => lower.includes(n));
|
|
});
|
|
}
|