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:
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user