Initial release: JMAP Webmail Client

A modern, privacy-focused webmail client built with Next.js and the JMAP protocol.
Designed for Stalwart Mail Server.

Features:
- Full email operations (compose, reply, forward, threading)
- Real-time push notifications
- Dark/light theme support
- Mobile responsive design
- Keyboard shortcuts
- Drag-and-drop organization
- i18n (English/French)
- Security-first (external content blocked, HTML sanitization)
This commit is contained in:
Matthieu MALVACHE
2025-12-10 17:54:22 +01:00
committed by Matthieu MALVACHE
commit cf21a84263
79 changed files with 21821 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import { useSettingsStore } from '@/stores/settings-store';
/**
* Debug logger that respects the debugMode setting.
* Use this instead of console.log for conditional debug output.
*/
export const debug = {
/**
* Log a debug message (only when debugMode is enabled)
*/
log: (...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) {
console.log('[DEBUG]', ...args);
}
},
/**
* Log a warning message (only when debugMode is enabled)
*/
warn: (...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) {
console.warn('[DEBUG]', ...args);
}
},
/**
* Log an error message (always logs, regardless of debugMode)
*/
error: (...args: unknown[]) => {
console.error('[ERROR]', ...args);
},
/**
* Start a collapsed console group (only when debugMode is enabled)
*/
group: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.group(`[DEBUG] ${label}`);
}
},
/**
* End a console group (only when debugMode is enabled)
*/
groupEnd: () => {
if (useSettingsStore.getState().debugMode) {
console.groupEnd();
}
},
/**
* Start a performance timer (only when debugMode is enabled)
*/
time: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.time(`[DEBUG] ${label}`);
}
},
/**
* End a performance timer (only when debugMode is enabled)
*/
timeEnd: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.timeEnd(`[DEBUG] ${label}`);
}
},
/**
* Log a table (only when debugMode is enabled)
*/
table: (data: unknown) => {
if (useSettingsStore.getState().debugMode) {
console.table(data);
}
}
};
+230
View File
@@ -0,0 +1,230 @@
import { AuthenticationResults } from './jmap/types';
/**
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
*/
export function parseAuthenticationResults(header: string): AuthenticationResults {
const results: AuthenticationResults = {};
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
type DmarcResult = 'pass' | 'fail' | 'none';
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
// Parse SPF
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/);
if (spfMatch) {
results.spf = {
result: spfMatch[1] as SpfResult,
domain: spfMatch[2]
};
}
// Parse DKIM
const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s]+))?(?:\s+header\.s=([^\s]+))?/);
if (dkimMatch) {
results.dkim = {
result: dkimMatch[1] as DkimResult,
domain: dkimMatch[2],
selector: dkimMatch[3]
};
}
// Parse DMARC
const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s]+))?(?:\s+policy\.dmarc=(\w+))?/);
if (dmarcMatch) {
results.dmarc = {
result: dmarcMatch[1] as DmarcResult,
domain: dmarcMatch[2],
policy: dmarcMatch[3] as DmarcPolicy | undefined
};
}
// Parse IP reverse lookup
const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
if (iprevMatch) {
results.iprev = {
result: iprevMatch[1] as 'pass' | 'fail',
ip: iprevMatch[2]
};
}
return results;
}
/**
* Parse spam score from X-Spam-Result or X-Spam-Status headers
*/
export function parseSpamScore(header: string): { score: number; status: string } | null {
// Try X-Spam-Status format: "No, score=-0.25"
const statusMatch = header.match(/^(Yes|No),?\s+score=([-\d.]+)/i);
if (statusMatch) {
return {
status: statusMatch[1].toLowerCase(),
score: parseFloat(statusMatch[2])
};
}
// Try to extract just the score
const scoreMatch = header.match(/score[=:]?\s*([-\d.]+)/i);
if (scoreMatch) {
const score = parseFloat(scoreMatch[1]);
return {
score,
status: score > 5 ? 'spam' : 'ham'
};
}
return null;
}
/**
* Parse Received headers to extract mail routing path
*/
interface ReceivedHeaderInfo {
from: string;
by: string;
timestamp?: string;
protocol?: string;
id?: string;
}
export function parseReceivedHeaders(headers: string[]): ReceivedHeaderInfo[] {
const path: ReceivedHeaderInfo[] = [];
for (const header of headers) {
const fromMatch = header.match(/from\s+([^\s]+)(?:\s+\([^)]+\))?/);
const byMatch = header.match(/by\s+([^\s]+)/);
const dateMatch = header.match(/;\s+(.+)$/);
const protoMatch = header.match(/with\s+(\w+)/);
const idMatch = header.match(/id\s+([^\s;]+)/);
if (fromMatch || byMatch) {
path.push({
from: fromMatch?.[1] || 'unknown',
by: byMatch?.[1] || 'unknown',
timestamp: dateMatch?.[1],
protocol: protoMatch?.[1],
id: idMatch?.[1]
});
}
}
return path;
}
/**
* Format bytes to human readable size
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
/**
* Get security status color and icon based on result
*/
export function getSecurityStatus(result?: string): {
color: string;
icon: 'check' | 'x' | 'alert' | 'minus';
bgColor: string;
borderColor: string;
} {
switch (result) {
case 'pass':
return {
color: 'text-green-700 dark:text-green-400',
icon: 'check',
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-l-4 border-green-600 dark:border-green-500'
};
case 'fail':
case 'permerror':
return {
color: 'text-red-700 dark:text-red-400',
icon: 'x',
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-l-4 border-red-600 dark:border-red-500'
};
case 'softfail':
case 'neutral':
case 'temperror':
return {
color: 'text-amber-700 dark:text-amber-400',
icon: 'alert',
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-l-4 border-amber-600 dark:border-amber-500'
};
default:
return {
color: 'text-gray-700 dark:text-gray-400',
icon: 'minus',
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-l-4 border-gray-400 dark:border-gray-600'
};
}
}
/**
* Parse X-Spam-LLM header to extract AI verdict and explanation
*/
export function parseSpamLLM(header: string): { verdict: string; explanation: string } | null {
// Format: "LEGITIMATE (explanation)" or "SPAM (explanation)"
// Trim the header first to remove any leading/trailing whitespace
const trimmed = header.trim();
const match = trimmed.match(/^(LEGITIMATE|SPAM|SUSPICIOUS)\s*\((.+)\)\s*$/i);
if (match) {
return {
verdict: match[1].toUpperCase(),
explanation: match[2].trim()
};
}
return null;
}
/**
* Extract list headers (List-Unsubscribe, List-Id, etc.)
*/
interface ListHeaders {
listId?: string;
listUnsubscribe?: string;
listHelp?: string;
listPost?: string;
}
export function extractListHeaders(headers: Record<string, string | string[]>): ListHeaders {
const result: ListHeaders = {};
if (headers['List-Id']) {
result.listId = Array.isArray(headers['List-Id'])
? headers['List-Id'][0]
: headers['List-Id'];
}
if (headers['List-Unsubscribe']) {
const unsub = Array.isArray(headers['List-Unsubscribe'])
? headers['List-Unsubscribe'][0]
: headers['List-Unsubscribe'];
// Extract URL from <url> format
const match = unsub.match(/<([^>]+)>/);
result.listUnsubscribe = match ? match[1] : unsub;
}
if (headers['List-Help']) {
result.listHelp = Array.isArray(headers['List-Help'])
? headers['List-Help'][0]
: headers['List-Help'];
}
if (headers['List-Post']) {
result.listPost = Array.isArray(headers['List-Post'])
? headers['List-Post'][0]
: headers['List-Post'];
}
return result;
}
+43
View File
@@ -0,0 +1,43 @@
import { debug } from "./debug";
interface ErrorReport {
error: Error;
errorInfo?: React.ErrorInfo;
zone: string;
timestamp: Date;
userAgent: string;
url: string;
}
/**
* Report an error to the logging system.
* In debug mode, logs detailed information to the console.
* Future: Can be extended to send to external error tracking services.
*/
export function reportError(
error: Error,
zone: string,
errorInfo?: React.ErrorInfo
): void {
const report: ErrorReport = {
error,
errorInfo,
zone,
timestamp: new Date(),
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "SSR",
url: typeof window !== "undefined" ? window.location.href : "",
};
// Always log errors
debug.error(`[ErrorBoundary:${zone}]`, error.message, {
stack: error.stack,
componentStack: errorInfo?.componentStack,
url: report.url,
timestamp: report.timestamp.toISOString(),
});
// Future: Send to error tracking service (Sentry, etc.)
// if (process.env.NODE_ENV === 'production') {
// sendToErrorService(report);
// }
}
+1437
View File
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
export interface EmailHeader {
name: string;
value: string;
}
export interface Email {
id: string;
threadId: string;
mailboxIds: Record<string, boolean>;
keywords: Record<string, boolean>;
size: number;
receivedAt: string;
from?: EmailAddress[];
to?: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
replyTo?: EmailAddress[];
subject?: string;
sentAt?: string;
preview?: string;
textBody?: EmailBodyPart[];
htmlBody?: EmailBodyPart[];
bodyValues?: Record<string, EmailBodyValue>;
attachments?: Attachment[];
hasAttachment: boolean;
// Extended header information
messageId?: string;
inReplyTo?: string[];
references?: string[];
headers?: Record<string, string | string[]>;
// Security headers parsed
authenticationResults?: AuthenticationResults;
spamScore?: number;
spamStatus?: string;
spamLLM?: {
verdict: string;
explanation: string;
};
}
export interface AuthenticationResults {
spf?: {
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
domain?: string;
ip?: string;
};
dkim?: {
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
domain?: string;
selector?: string;
};
dmarc?: {
result: 'pass' | 'fail' | 'none';
policy?: 'reject' | 'quarantine' | 'none';
domain?: string;
};
iprev?: {
result: 'pass' | 'fail';
ip?: string;
};
}
export interface EmailBodyValue {
value: string;
isEncodingProblem?: boolean;
isTruncated?: boolean;
}
export interface EmailAddress {
name?: string;
email: string;
}
export interface EmailBodyPart {
partId: string;
blobId: string;
size: number;
name?: string;
type: string;
charset?: string;
disposition?: string;
cid?: string;
language?: string[];
location?: string;
subParts?: EmailBodyPart[];
}
export interface Attachment {
partId: string;
blobId: string;
size: number;
name?: string;
type: string;
charset?: string;
cid?: string;
disposition?: string;
}
export interface Mailbox {
id: string;
originalId?: string; // Original JMAP ID (for shared mailboxes)
name: string;
parentId?: string;
role?: string;
sortOrder: number;
totalEmails: number;
unreadEmails: number;
totalThreads: number;
unreadThreads: number;
myRights: {
mayReadItems: boolean;
mayAddItems: boolean;
mayRemoveItems: boolean;
maySetSeen: boolean;
maySetKeywords: boolean;
mayCreateChild: boolean;
mayRename: boolean;
mayDelete: boolean;
maySubmit: boolean;
};
isSubscribed: boolean;
// Shared folder support
accountId?: string;
accountName?: string;
isShared?: boolean;
}
export interface Thread {
id: string;
emailIds: string[];
}
// Thread grouping for UI display
export interface ThreadGroup {
threadId: string;
emails: Email[]; // Emails in this thread (sorted by receivedAt desc)
latestEmail: Email; // Most recent email
participantNames: string[];// Unique participant names
hasUnread: boolean; // Any unread emails in thread
hasStarred: boolean; // Any starred emails in thread
hasAttachment: boolean; // Any email has attachment
emailCount: number; // Total emails in thread
}
export interface Identity {
id: string;
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
mayDelete: boolean;
}
export interface EmailSubmission {
id: string;
identityId: string;
emailId: string;
threadId?: string;
envelope: {
mailFrom: EmailAddress;
rcptTo: EmailAddress[];
};
sendAt?: string;
undoStatus: "pending" | "final" | "canceled";
deliveryStatus?: Record<string, DeliveryStatus>;
dsnBlobIds?: string[];
mdnBlobIds?: string[];
}
export interface DeliveryStatus {
smtpReply: string;
delivered: "queued" | "yes" | "no" | "unknown";
displayed: "unknown" | "yes";
}
// JMAP Push Notification Types (RFC 8620 Section 7)
export interface StateChange {
'@type': 'StateChange';
changed: {
[accountId: string]: {
Email?: string;
Mailbox?: string;
Thread?: string;
EmailDelivery?: string;
EmailSubmission?: string;
Identity?: string;
};
};
}
export interface PushSubscription {
id: string;
deviceClientId: string;
url: string;
keys: {
p256dh: string;
auth: string;
} | null;
expires: string | null;
types: string[] | null;
}
// For tracking last known states
export interface AccountStates {
[accountId: string]: {
Email?: string;
Mailbox?: string;
Thread?: string;
};
}
+163
View File
@@ -0,0 +1,163 @@
import type { Email, ThreadGroup } from "./jmap/types";
/**
* Groups emails by their threadId and creates ThreadGroup objects for UI display.
* Single-email threads are still returned as ThreadGroups with emailCount=1.
*/
export function groupEmailsByThread(emails: Email[]): ThreadGroup[] {
if (!emails || emails.length === 0) {
return [];
}
// Group emails by threadId
const threadMap = new Map<string, Email[]>();
for (const email of emails) {
const threadId = email.threadId;
if (!threadMap.has(threadId)) {
threadMap.set(threadId, []);
}
threadMap.get(threadId)!.push(email);
}
// Convert to ThreadGroup array
const threadGroups: ThreadGroup[] = [];
for (const [threadId, 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, and attachments
const hasUnread = sortedEmails.some(e => !e.keywords?.$seen);
const hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
threadGroups.push({
threadId,
emails: sortedEmails,
latestEmail,
participantNames,
hasUnread,
hasStarred,
hasAttachment,
emailCount: sortedEmails.length,
});
}
return threadGroups;
}
/**
* Sorts thread groups by their latest email's receivedAt date (newest first).
*/
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
return [...groups].sort(
(a, b) => new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
);
}
/**
* 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 hasAttachment = mergedEmails.some(e => e.hasAttachment);
return {
threadId: existingGroup.threadId,
emails: mergedEmails,
latestEmail,
participantNames,
hasUnread,
hasStarred,
hasAttachment,
emailCount: mergedEmails.length,
};
}
/**
* Gets color tag from email keywords (if any).
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
return key.replace("$color:", "");
}
}
return null;
}
/**
* Checks if a thread has any color tag (returns first found).
*/
export function getThreadColorTag(emails: Email[]): string | null {
for (const email of emails) {
const color = getEmailColorTag(email.keywords);
if (color) return color;
}
return null;
}
+297
View File
@@ -0,0 +1,297 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + "...";
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Types for mailbox tree
export interface MailboxNode extends Mailbox {
children: MailboxNode[];
depth: number;
}
// Role priority for mailbox ordering (lower number = higher priority)
const ROLE_PRIORITY: Record<string, number> = {
inbox: 0,
drafts: 1,
sent: 2,
archive: 3,
junk: 4,
spam: 4, // Treat spam same as junk
trash: 5,
};
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const roleMap = new Map<string, Mailbox>();
const result: Mailbox[] = [];
// First pass: collect mailboxes with roles
mailboxes.forEach(mb => {
if (mb.role) {
roleMap.set(mb.role, mb);
}
});
// Second pass: filter out duplicates
mailboxes.forEach(mb => {
// If this mailbox has a role, always keep it
if (mb.role) {
result.push(mb);
return;
}
// Check if this is a duplicate of a role-based mailbox
const lowerName = mb.name.toLowerCase();
const isDuplicate = Array.from(roleMap.values()).some(roleMb => {
const roleLowerName = roleMb.name.toLowerCase();
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
});
// Only keep if not a duplicate
if (!isDuplicate) {
result.push(mb);
}
});
return result;
}
// Build a hierarchical tree structure from flat mailbox array
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
// Deduplicate mailboxes first
const deduplicated = deduplicateMailboxes(mailboxes);
// Separate own and shared mailboxes
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
const mailboxMap = new Map<string, MailboxNode>();
const rootMailboxes: MailboxNode[] = [];
// First pass: create nodes for own mailboxes
ownMailboxes.forEach(mailbox => {
mailboxMap.set(mailbox.id, {
...mailbox,
children: [],
depth: 0
});
});
// Second pass: build tree structure for own mailboxes
ownMailboxes.forEach(mailbox => {
const node = mailboxMap.get(mailbox.id)!;
if (mailbox.parentId && mailboxMap.has(mailbox.parentId)) {
const parent = mailboxMap.get(mailbox.parentId)!;
parent.children.push(node);
node.depth = parent.depth + 1;
} else {
// Root level mailbox or orphaned mailbox
rootMailboxes.push(node);
node.depth = 0;
}
});
// If we have shared mailboxes, create a virtual "Shared Folders" parent
if (sharedMailboxes.length > 0) {
// Group shared mailboxes by account
const accountGroups = new Map<string, Mailbox[]>();
sharedMailboxes.forEach(mb => {
const accountId = mb.accountId || 'unknown';
if (!accountGroups.has(accountId)) {
accountGroups.set(accountId, []);
}
accountGroups.get(accountId)!.push(mb);
});
// Create virtual nodes for each shared account
const sharedAccountNodes: MailboxNode[] = [];
accountGroups.forEach((accountMailboxes, accountId) => {
// Create account nodes
const accountMailboxMap = new Map<string, MailboxNode>();
const accountRootNodes: MailboxNode[] = [];
// Create nodes for this account's mailboxes
accountMailboxes.forEach(mailbox => {
accountMailboxMap.set(mailbox.id, {
...mailbox,
children: [],
depth: 2 // Account level is depth 1, these are depth 2
});
});
// Build tree for this account's mailboxes
accountMailboxes.forEach(mailbox => {
const node = accountMailboxMap.get(mailbox.id)!;
if (mailbox.parentId && accountMailboxMap.has(mailbox.parentId)) {
const parent = accountMailboxMap.get(mailbox.parentId)!;
parent.children.push(node);
node.depth = parent.depth + 1;
} else {
accountRootNodes.push(node);
}
});
// Create virtual account folder node
const accountName = accountMailboxes[0]?.accountName || accountId;
const accountNode: MailboxNode = {
id: `shared-account-${accountId}`,
name: accountName,
sortOrder: 1000, // After all own folders
totalEmails: accountMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
unreadEmails: accountMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
accountId: accountId,
accountName: accountName,
isShared: true,
children: accountRootNodes,
depth: 1,
};
sharedAccountNodes.push(accountNode);
});
// Create virtual "Shared Folders" root node
const sharedFoldersNode: MailboxNode = {
id: 'shared-folders-root',
name: 'Shared Folders',
sortOrder: 999, // After all own folders
totalEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
unreadEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
isShared: true,
children: sharedAccountNodes,
depth: 0,
};
rootMailboxes.push(sharedFoldersNode);
}
// Smart multi-level sorting
const sortNodes = (nodes: MailboxNode[]) => {
nodes.sort((a, b) => {
// 1. Priority: Own folders before shared folders
if (a.isShared !== b.isShared) {
return a.isShared ? 1 : -1;
}
// 2. Priority: Role-based ordering (inbox first, trash last, etc.)
const aPriority = a.role ? (ROLE_PRIORITY[a.role] ?? 999) : 999;
const bPriority = b.role ? (ROLE_PRIORITY[b.role] ?? 999) : 999;
if (aPriority !== bPriority) {
return aPriority - bPriority;
}
// 3. Priority: Year folders (e.g., "2025", "2024") sorted numerically descending
const aIsYear = /^\d{4}$/.test(a.name);
const bIsYear = /^\d{4}$/.test(b.name);
if (aIsYear && bIsYear) {
return parseInt(b.name) - parseInt(a.name); // Descending: 2025, 2024, 2023...
}
// 4. Fallback: Server sortOrder
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
// 5. Fallback: Alphabetical by name
return a.name.localeCompare(b.name);
});
// Recursively sort children
nodes.forEach(node => {
if (node.children.length > 0) {
sortNodes(node.children);
}
});
};
sortNodes(rootMailboxes);
return rootMailboxes;
}
// Flatten a mailbox tree for rendering with proper depth info
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
const result: MailboxNode[] = [];
const traverse = (nodes: MailboxNode[], depth: number = 0) => {
nodes.forEach(node => {
result.push({ ...node, depth });
if (node.children.length > 0) {
traverse(node.children, depth + 1);
}
});
};
traverse(nodes);
return result;
}