feat: add unified mailbox across accounts and sidebar icons toggle

This commit is contained in:
Linus Rath
2026-04-14 17:36:13 +02:00
parent 7fcefa53c9
commit f22699fe20
28 changed files with 1889 additions and 649 deletions
+30
View File
@@ -39,6 +39,9 @@ export interface Email {
// S/MIME support
blobId?: string;
bodyStructure?: EmailBodyPart;
// Unified mailbox support — set when displaying emails from multiple accounts
accountId?: string;
accountLabel?: string;
}
export interface AuthenticationResults {
@@ -724,4 +727,31 @@ export interface FileNodeFilter {
parentId?: string | null;
name?: string;
type?: string;
}
// Unified mailbox virtual IDs and types
export const UNIFIED_INBOX = '__unified_inbox__';
export const UNIFIED_SENT = '__unified_sent__';
export const UNIFIED_DRAFTS = '__unified_drafts__';
export const UNIFIED_TRASH = '__unified_trash__';
export const UNIFIED_ARCHIVE = '__unified_archive__';
export const UNIFIED_JUNK = '__unified_junk__';
export type UnifiedMailboxRole = 'inbox' | 'sent' | 'drafts' | 'trash' | 'archive' | 'junk';
export const UNIFIED_MAILBOX_IDS: Record<UnifiedMailboxRole, string> = {
inbox: UNIFIED_INBOX,
sent: UNIFIED_SENT,
drafts: UNIFIED_DRAFTS,
trash: UNIFIED_TRASH,
archive: UNIFIED_ARCHIVE,
junk: UNIFIED_JUNK,
};
export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fromEntries(
Object.entries(UNIFIED_MAILBOX_IDS).map(([role, id]) => [id, role as UnifiedMailboxRole])
) as Record<string, UnifiedMailboxRole>;
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
+170
View File
@@ -0,0 +1,170 @@
import type { Email, Mailbox, UnifiedMailboxRole } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export interface UnifiedAccountClient {
accountId: string;
accountLabel: string;
client: IJMAPClient;
mailboxes: Mailbox[];
}
export interface UnifiedFetchResult {
emails: Email[];
total: number;
hasMore: boolean;
errors: Map<string, string>; // accountId -> error message
}
export interface UnifiedMailboxCounts {
role: UnifiedMailboxRole;
unreadEmails: number;
totalEmails: number;
}
const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
'inbox', 'sent', 'drafts', 'trash', 'archive', 'junk',
];
/**
* Finds the first mailbox matching the given role.
*/
export function findMailboxByRole(
mailboxes: Mailbox[],
role: UnifiedMailboxRole,
): Mailbox | undefined {
return mailboxes.find((m) => m.role === role);
}
/**
* Fetches emails from all accounts for a given unified role, merges and sorts
* them by receivedAt descending. Per-account failures are collected in the
* errors map while successful results are still returned.
*/
export async function fetchUnifiedEmails(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
const errors = new Map<string, string>();
// Build one fetch task per account, wrapping each in a catch so we can
// track per-account errors while still using Promise.allSettled.
type AccountResult = {
account: UnifiedAccountClient;
result: { emails: Email[]; total: number; hasMore: boolean };
} | null;
const promises = accounts.map(
async (account): Promise<AccountResult> => {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (!mailbox) return null;
try {
const result = await account.client.getEmails(
mailbox.id,
undefined,
limit,
position,
);
return { account, result };
} catch (err) {
errors.set(
account.accountId,
err instanceof Error ? err.message : String(err),
);
return null;
}
},
);
const results = await Promise.allSettled(promises);
let mergedEmails: Email[] = [];
let totalSum = 0;
let anyHasMore = false;
for (const outcome of results) {
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
const { account, result } = outcome.value;
// Decorate each email with the source account info.
for (const email of result.emails) {
email.accountId = account.accountId;
email.accountLabel = account.accountLabel;
}
mergedEmails = mergedEmails.concat(result.emails);
totalSum += result.total;
if (result.hasMore) {
anyHasMore = true;
}
}
// Sort merged emails by receivedAt descending.
mergedEmails.sort((a, b) => {
const dateA = new Date(a.receivedAt).getTime();
const dateB = new Date(b.receivedAt).getTime();
return dateB - dateA;
});
return {
emails: mergedEmails,
total: totalSum,
hasMore: anyHasMore,
errors,
};
}
/**
* Aggregates unread and total email counts across all accounts for each
* unified mailbox role. Only includes roles that exist in at least one account.
*/
export function fetchUnifiedMailboxCounts(
accounts: UnifiedAccountClient[],
): UnifiedMailboxCounts[] {
const counts: UnifiedMailboxCounts[] = [];
for (const role of ALL_UNIFIED_ROLES) {
let unreadEmails = 0;
let totalEmails = 0;
let found = false;
for (const account of accounts) {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (mailbox) {
found = true;
unreadEmails += mailbox.unreadEmails;
totalEmails += mailbox.totalEmails;
}
}
if (found) {
counts.push({ role, unreadEmails, totalEmails });
}
}
return counts;
}
/**
* Returns the list of unified roles that exist in at least one account's
* mailboxes.
*/
export function getUnifiedRoles(
accounts: UnifiedAccountClient[],
): UnifiedMailboxRole[] {
const roles: UnifiedMailboxRole[] = [];
for (const role of ALL_UNIFIED_ROLES) {
for (const account of accounts) {
if (findMailboxByRole(account.mailboxes, role)) {
roles.push(role);
break;
}
}
}
return roles;
}
+35 -1
View File
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types";
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug";
export function cn(...inputs: ClassValue[]) {
@@ -380,6 +381,39 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
return rootMailboxes;
}
/**
* Builds virtual MailboxNode entries for unified mailbox roles with aggregated counts.
*/
export function buildUnifiedMailboxNodes(
counts: Array<{ role: UnifiedMailboxRole; unreadEmails: number; totalEmails: number }>,
): MailboxNode[] {
return counts.map((count) => ({
id: UNIFIED_MAILBOX_IDS[count.role],
name: count.role, // Display name is handled by i18n in the component
role: count.role,
parentId: undefined,
sortOrder: 0,
totalEmails: count.totalEmails,
unreadEmails: count.unreadEmails,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
children: [],
depth: 0,
}));
}
// Flatten a mailbox tree for rendering with proper depth info
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
const result: MailboxNode[] = [];