feat: add unified mailbox across accounts and sidebar icons toggle
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user