import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types"; import type { UnifiedMailboxRole } from "./jmap/types"; import { debug } from "./debug"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } export function generateUUID(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } // Fallback: construct a v4 UUID from crypto.getRandomValues const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } 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, }); } /** * Format a date/time string respecting the user's 12h/24h time format preference. */ export function formatDateTime( date: Date | string, timeFormat: '12h' | '24h', options?: { weekday?: 'short' | 'long'; year?: 'numeric'; month?: 'short' | 'long'; day?: 'numeric'; second?: '2-digit'; timeZoneName?: 'short'; dateOnly?: boolean; } ): string { const d = typeof date === 'string' ? new Date(date) : date; if (isNaN(d.getTime())) return typeof date === 'string' ? date : ''; const localeOptions: Intl.DateTimeFormatOptions = {}; if (options?.weekday) localeOptions.weekday = options.weekday; if (options?.year) localeOptions.year = options.year; if (options?.month) localeOptions.month = options.month; if (options?.day) localeOptions.day = options.day; if (!options?.dateOnly) { localeOptions.hour = '2-digit'; localeOptions.minute = '2-digit'; localeOptions.hour12 = timeFormat === '12h'; if (options?.second) localeOptions.second = options.second; if (options?.timeZoneName) localeOptions.timeZoneName = options.timeZoneName; } return d.toLocaleString(undefined, localeOptions); } 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 = { 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 result: Mailbox[] = []; const removed: { id: string; name: string; matchedRole: string; parentId?: string }[] = []; // Group role mailboxes by account so deduplication is scoped per-account const rolesByAccount = new Map(); mailboxes.forEach(mb => { if (mb.role) { const key = mb.accountId || ''; if (!rolesByAccount.has(key)) rolesByAccount.set(key, []); rolesByAccount.get(key)!.push(mb); } }); // Build a set of IDs that are referenced as parents const referencedParentIds = new Set(); mailboxes.forEach(mb => { if (mb.parentId) referencedParentIds.add(mb.parentId); }); // Filter out duplicates scoped to the same account mailboxes.forEach(mb => { // If this mailbox has a role, always keep it if (mb.role) { result.push(mb); return; } // Never deduplicate nested mailboxes - only root-level folders can be // duplicates of role-based mailboxes. Removing a nested folder that happens // to share a name with a role folder (e.g. a subfolder named "Sent") would // orphan its children to root level. (GitHub #118) if (mb.parentId) { result.push(mb); return; } // Check if this root-level mailbox is a duplicate of a role-based mailbox in the SAME account const accountKey = mb.accountId || ''; const accountRoles = rolesByAccount.get(accountKey) || []; const lowerName = mb.name.toLowerCase(); const matchedRole = accountRoles.find(roleMb => { const roleLowerName = roleMb.name.toLowerCase(); // Check for common duplicates: "Sent Mail" vs "Sent", etc. return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName); }); const isDuplicate = !!matchedRole; // Only keep if not a duplicate if (!isDuplicate) { result.push(mb); } else { removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId }); // Warn if this removed mailbox is a parent of other mailboxes (orphan risk) if (referencedParentIds.has(mb.id)) { debug.warn('jmap', `[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` + `Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` + `Children referencing parentId "${mb.id}" will be orphaned to root level.` ); } } }); if (removed.length > 0) { debug.log('jmap', `[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed); } return result; } // Build a hierarchical tree structure from flat mailbox array export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { debug.log('jmap', `[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`); // Deduplicate mailboxes first const deduplicated = deduplicateMailboxes(mailboxes); if (deduplicated.length !== mailboxes.length) { debug.log('jmap', `[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`); } // Separate own and shared mailboxes const ownMailboxes = deduplicated.filter(mb => !mb.isShared); const sharedMailboxes = deduplicated.filter(mb => mb.isShared); const mailboxMap = new Map(); const rootMailboxes: MailboxNode[] = []; // First pass: create nodes for own mailboxes ownMailboxes.forEach(mailbox => { mailboxMap.set(mailbox.id, { ...mailbox, children: [], depth: 0 }); }); // Helper to recursively recalculate depths after tree is built const recalculateDepths = (nodes: MailboxNode[], baseDepth: number) => { for (const node of nodes) { node.depth = baseDepth; if (node.children.length > 0) { recalculateDepths(node.children, baseDepth + 1); } } }; // Second pass: build tree structure for own mailboxes const orphanedMailboxes: { id: string; name: string; parentId: string }[] = []; 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); } else { // Root level mailbox or orphaned mailbox if (mailbox.parentId) { orphanedMailboxes.push({ id: mailbox.id, name: mailbox.name, parentId: mailbox.parentId }); } rootMailboxes.push(node); } }); if (orphanedMailboxes.length > 0) { debug.warn('jmap', `[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`, orphanedMailboxes ); } // Third pass: correctly calculate depths from the root down recalculateDepths(rootMailboxes, 0); // Log tree depth statistics const maxDepth = (nodes: MailboxNode[]): number => { let max = 0; for (const node of nodes) { max = Math.max(max, node.depth); if (node.children.length > 0) max = Math.max(max, maxDepth(node.children)); } return max; }; debug.log('jmap', `[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` + `max depth: ${maxDepth(rootMailboxes)}, ` + `total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}` ); // For each shared account, create a virtual top-level account node // containing that account's mailboxes. This places shared accounts as // peers of the primary account's folders rather than nesting them under // a "Shared Folders" wrapper. (GitHub #151) if (sharedMailboxes.length > 0) { // Group shared mailboxes by account const accountGroups = new Map(); sharedMailboxes.forEach(mb => { const accountId = mb.accountId || 'unknown'; if (!accountGroups.has(accountId)) { accountGroups.set(accountId, []); } accountGroups.get(accountId)!.push(mb); }); accountGroups.forEach((accountMailboxes, accountId) => { // Create nodes for this account's mailboxes const accountMailboxMap = new Map(); const accountRootNodes: MailboxNode[] = []; accountMailboxes.forEach(mailbox => { accountMailboxMap.set(mailbox.id, { ...mailbox, children: [], depth: 0, }); }); // 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); } else { accountRootNodes.push(node); } }); // Render the shared account's mailboxes flush with primary-account // mailboxes (depth 0) so the indents line up. The virtual account // node visually wraps them via its chevron/header rather than via // an extra indent level. (GitHub #151) recalculateDepths(accountRootNodes, 0); // Create virtual account folder node at top level (depth 0) 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: 0, }; rootMailboxes.push(accountNode); }); } // 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; } /** * 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[] = []; 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; } export function getMailboxPath( mailbox: Pick, allMailboxes: Array>, separator = ' › ', ): string { const byId = new Map(allMailboxes.map(m => [m.id, m])); const names: string[] = [mailbox.name]; const visited = new Set([mailbox.id]); let parentId = mailbox.parentId; while (parentId && !visited.has(parentId)) { visited.add(parentId); const parent = byId.get(parentId); if (!parent) break; names.unshift(parent.name); parentId = parent.parentId; } return names.join(separator); }