+ {isLoading ? (
+
+ ) : emails.length === 0 ? (
+
+
+
No emails in this mailbox
+
New messages will appear here
) : (
emails.map((email) => (
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index e7eda0d..544269c 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -1,8 +1,19 @@
"use client";
+import { useState, useEffect, useMemo } from "react";
+import DOMPurify from "dompurify";
import { Email } from "@/lib/jmap/types";
import { Button } from "@/components/ui/button";
-import { formatDate } from "@/lib/utils";
+import { Avatar } from "@/components/ui/avatar";
+import { formatDate, formatFileSize, cn } from "@/lib/utils";
+import {
+ parseAuthenticationResults,
+ parseSpamScore,
+ parseReceivedHeaders,
+ getSecurityStatus,
+ extractListHeaders,
+ formatBytes
+} from "@/lib/email-headers";
import {
Reply,
ReplyAll,
@@ -11,6 +22,35 @@ import {
Archive,
Star,
MoreVertical,
+ ChevronDown,
+ ChevronUp,
+ Download,
+ Paperclip,
+ Mail,
+ Clock,
+ Loader2,
+ AlertCircle,
+ ExternalLink,
+ Printer,
+ FileText,
+ FileImage,
+ FileVideo,
+ FileAudio,
+ FileArchive,
+ File,
+ Shield,
+ Image,
+ Circle,
+ X,
+ Check,
+ AlertTriangle,
+ Minus,
+ ShieldCheck,
+ ShieldAlert,
+ ShieldOff,
+ Network,
+ Hash,
+ List,
} from "lucide-react";
interface EmailViewerProps {
@@ -21,8 +61,58 @@ interface EmailViewerProps {
onDelete?: () => void;
onArchive?: () => void;
onToggleStar?: () => void;
+ onMarkAsRead?: (emailId: string, read: boolean) => void;
+ onSetColorTag?: (emailId: string, color: string | null) => void;
+ onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
}
+// Helper function to get file icon based on mime type or extension
+const getFileIcon = (name?: string, type?: string) => {
+ const ext = name?.split('.').pop()?.toLowerCase();
+ const mimeType = type?.toLowerCase();
+
+ if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
+ return FileImage;
+ }
+ if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) {
+ return FileVideo;
+ }
+ if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) {
+ return FileAudio;
+ }
+ if (mimeType === 'application/pdf' || ext === 'pdf') {
+ return FileText;
+ }
+ if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
+ return FileArchive;
+ }
+ if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext || '')) {
+ return FileText;
+ }
+ return File;
+};
+
+// Color options for email tags
+const colorOptions = [
+ { name: "Red", value: "red", color: "bg-red-500" },
+ { name: "Orange", value: "orange", color: "bg-orange-500" },
+ { name: "Yellow", value: "yellow", color: "bg-yellow-500" },
+ { name: "Green", value: "green", color: "bg-green-500" },
+ { name: "Blue", value: "blue", color: "bg-blue-500" },
+ { name: "Purple", value: "purple", color: "bg-purple-500" },
+ { name: "Pink", value: "pink", color: "bg-pink-500" },
+];
+
+const getCurrentColor = (keywords: Record
| undefined) => {
+ if (!keywords) return null;
+ for (const key of Object.keys(keywords)) {
+ if (key.startsWith("$color:") && keywords[key] === true) {
+ return key.replace("$color:", "");
+ }
+ }
+ return null;
+};
+
export function EmailViewer({
email,
onReply,
@@ -31,76 +121,838 @@ export function EmailViewer({
onDelete,
onArchive,
onToggleStar,
+ onMarkAsRead,
+ onSetColorTag,
+ onDownloadAttachment,
}: EmailViewerProps) {
+ const [showFullHeaders, setShowFullHeaders] = useState(false);
+ const [isLoadingContent, setIsLoadingContent] = useState(false);
+ const [allowExternalContent, setAllowExternalContent] = useState(false);
+ const [hasBlockedContent, setHasBlockedContent] = useState(false);
+ const [isLoadingEmail, setIsLoadingEmail] = useState(false);
+ const currentColor = getCurrentColor(email?.keywords);
+
+ useEffect(() => {
+ // Mark as read when email is viewed
+ if (email && !email.keywords?.$seen && onMarkAsRead) {
+ onMarkAsRead(email.id, true);
+ }
+ }, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
+
+ // Reset external content permission when email changes
+ useEffect(() => {
+ setAllowExternalContent(false);
+ setHasBlockedContent(false);
+ }, [email?.id]);
+
+ // Sanitize and prepare email HTML content
+ const emailContent = useMemo(() => {
+ if (!email) return { html: "", isHtml: false };
+
+ setIsLoadingContent(false);
+
+ // Check if we have body values
+ if (email.bodyValues) {
+ // Check if HTML content exists and if it's actually rich HTML or just plain text wrapper
+ let useHtmlVersion = false;
+ let htmlContent = '';
+
+ if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
+ htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
+
+ // Check if HTML is just a minimal wrapper around plain text
+ // by checking if it lacks common HTML formatting elements
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = htmlContent;
+ const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
+ const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
+ const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
+
+ // Use HTML if it has rich formatting, multiple paragraphs, or explicit line breaks
+ useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
+ }
+
+ // If we should use HTML version and it exists
+ if (useHtmlVersion && htmlContent) {
+ // Create a custom DOMPurify hook to handle external content
+ let blockedExternalContent = false;
+
+ const sanitizeConfig: any = {
+ ADD_TAGS: ['style'],
+ ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
+ ALLOW_DATA_ATTR: false,
+ FORCE_BODY: true,
+ FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
+ };
+
+ // If external content is not allowed, block images and external resources
+ if (!allowExternalContent) {
+ sanitizeConfig.FORBID_TAGS.push('link');
+ sanitizeConfig.FORBID_ATTR.push('background');
+
+ // Hook to modify src attributes
+ DOMPurify.addHook('afterSanitizeAttributes', (node: any) => {
+ // Block external images
+ if (node.tagName === 'IMG') {
+ const src = node.getAttribute('src');
+ if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
+ node.setAttribute('data-blocked-src', src);
+ // Use a subtle transparent placeholder
+ node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
+ node.setAttribute('alt', '');
+ node.style.display = 'none'; // Hide blocked images completely for cleaner look
+ blockedExternalContent = true;
+ }
+ }
+
+ // Block external stylesheets and resources in style attributes
+ if (node.style) {
+ const style = node.style.cssText;
+ if (style && style.includes('url(')) {
+ const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
+ if (urlMatch) {
+ node.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
+ blockedExternalContent = true;
+ }
+ }
+ }
+ });
+ }
+
+ // Sanitize HTML to prevent XSS
+ const cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
+
+ // Remove the hook after sanitization
+ DOMPurify.removeAllHooks();
+
+ // Update blocked content state
+ if (blockedExternalContent && !hasBlockedContent) {
+ setHasBlockedContent(true);
+ }
+
+ return {
+ html: cleanHtml,
+ isHtml: true
+ };
+ }
+
+ // Use text content if available (either as fallback or when HTML is minimal)
+ if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
+ const textContent = email.bodyValues[email.textBody[0].partId].value;
+
+ // Convert plain text to HTML with proper formatting
+ const htmlFromText = textContent
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/\r\n/g, ' ') // Windows line endings
+ .replace(/\r/g, ' ') // Old Mac line endings
+ .replace(/\n/g, ' ') // Unix line endings
+ .replace(/\t/g, ' ') // Convert tabs to spaces
+ .replace(/(https?:\/\/[^\s<]+)/g, '$1 '); // Don't match across tags
+
+ return {
+ html: htmlFromText,
+ isHtml: false
+ };
+ }
+ }
+
+ // Last resort: use preview
+ if (email.preview) {
+ setIsLoadingContent(true); // Show that we're using limited content
+ return {
+ html: `Loading full email content...
+ ${email.preview}
`,
+ isHtml: false
+ };
+ }
+
+ return {
+ html: 'No content available
',
+ isHtml: false
+ };
+ }, [email, allowExternalContent, hasBlockedContent]);
+
if (!email) {
return (
-
-
Select an email to view
+
+
+
+
+
+
No conversation selected
+
Choose a conversation from the list to read it here
+
);
}
const sender = email.from?.[0];
const isStarred = email.keywords?.$flagged;
+ const isImportant = email.keywords?.["$important"];
return (
-
- {/* Email Header */}
-
-
-
-
- {email.subject || "(no subject)"}
-
-
- From: {sender?.name || sender?.email}
- {formatDate(email.receivedAt)}
-
- {email.to && email.to.length > 0 && (
-
- To: {email.to.map(addr => addr.name || addr.email).join(", ")}
+
+ {/* Modern Header Section */}
+
+ {/* Subject Bar */}
+
+
+
+
+ {email.subject || "(no subject)"}
+
+
+
+
+ {new Date(email.receivedAt).toLocaleString('en-US', {
+ weekday: 'short',
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ })}
+
+ {email.hasAttachment && (
+
+
+ Attachments
+
+ )}
+ {isImportant && (
+
+ Important
+
+ )}
- )}
+
+
+ {/* Quick Actions */}
+
+ {/* Primary Reply Button */}
+
+
+ Reply
+
+
+ {/* Reply Options Dropdown */}
+
+
+
+
+
+
+
+ Reply all
+
+
+
+ Forward
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Compact Dynamic Color Picker */}
+
+
+
+
+
+ {/* Colors appear on hover */}
+
+
+ {colorOptions.map((option) => (
+
{
+ if (email) {
+ onSetColorTag?.(email.id, option.value);
+ }
+ }}
+ className={cn(
+ "w-6 h-6 rounded-full hover:scale-110 transition-transform",
+ option.color,
+ currentColor === option.value && "ring-2 ring-offset-1 ring-gray-400"
+ )}
+ title={option.name}
+ />
+ ))}
+ {currentColor && (
+
+ )}
+ {currentColor && (
+ {
+ if (email) {
+ onSetColorTag?.(email.id, null);
+ }
+ }}
+ className="w-6 h-6 rounded-full border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
+ title="Remove color"
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
- {/* Action Buttons */}
-
-
-
- Reply
-
-
-
- Reply All
-
-
-
- Forward
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {/* Sender Info */}
+
+
+
+
+
+
+
+ {sender?.name || sender?.email || "Unknown"}
+
+ {sender?.email && sender?.name && (
+
+ <{sender.email}>
+
+ )}
+
+
+
+ {email.to && email.to.length > 0 && (
+
+ To:
+
+ {email.to.slice(0, 2).map(r => r.name || r.email).join(", ")}
+ {email.to.length > 2 && (
+ setShowFullHeaders(!showFullHeaders)}
+ className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
+ >
+ +{email.to.length - 2} more
+
+ )}
+
+
+ )}
+
+ {(email.cc && email.cc.length > 0) && (
+
+ CC:
+
+ {email.cc.map(r => r.name || r.email).join(", ")}
+
+
+ )}
+
+
+ {/* Modern Expandable Details */}
+ {showFullHeaders && (
+
+ {/* Security & Authentication Section */}
+ {(email.authenticationResults || email.spamScore !== undefined) && (
+
+
+
+
+ Security & Authentication
+
+
+
+ {/* Authentication Results */}
+ {email.authenticationResults && (
+
+ {/* SPF Check */}
+ {email.authenticationResults.spf && (
+
+
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' &&
+
}
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' &&
+
}
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' &&
+
}
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' &&
+
}
+
+
SPF
+
+ {email.authenticationResults.spf.result}
+
+
+
+ {email.authenticationResults.spf.domain && (
+
+ {email.authenticationResults.spf.domain}
+
+ )}
+
+ )}
+
+ {/* DKIM Check */}
+ {email.authenticationResults.dkim && (
+
+
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' &&
+
}
+
+
DKIM
+
+ {email.authenticationResults.dkim.result}
+
+
+
+ {email.authenticationResults.dkim.domain && (
+
+ {email.authenticationResults.dkim.domain}
+
+ )}
+
+ )}
+
+ {/* DMARC Check */}
+ {email.authenticationResults.dmarc && (
+
+
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' &&
+
}
+
+
DMARC
+
+ {email.authenticationResults.dmarc.result}
+
+
+
+ {email.authenticationResults.dmarc.policy && (
+
+ Policy: {email.authenticationResults.dmarc.policy}
+
+ )}
+
+ )}
+
+ {/* Spam Score */}
+ {email.spamScore !== undefined && (
+
5 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800" :
+ email.spamScore > 2 ? "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800" :
+ "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800"
+ )}>
+
+
5 ? "text-red-600 dark:text-red-400" :
+ email.spamScore > 2 ? "text-amber-600 dark:text-amber-400" :
+ "text-green-600 dark:text-green-400"
+ )} />
+
+
Spam Score
+
5 ? "text-red-600 dark:text-red-400" :
+ email.spamScore > 2 ? "text-amber-600 dark:text-amber-400" :
+ "text-green-600 dark:text-green-400"
+ )}>
+ {email.spamScore.toFixed(1)}
+
+
+
+ {email.spamStatus && (
+
+ {email.spamStatus}
+
+ )}
+
+ )}
+
+ )}
+
+
+ )}
+
+ {/* Technical Details Section - Only show if we have useful technical info */}
+ {(email.messageId || email.replyTo?.length || (email.sentAt && email.receivedAt &&
+ Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000)) && (
+
+
+
+
+ Technical Details
+
+
+
+
+ {/* Message ID */}
+ {email.messageId && (
+
+
+
+
Message-ID:
+
+ {email.messageId}
+
+
+
+ )}
+
+ {/* Reply-To if different from sender */}
+ {email.replyTo && email.replyTo.length > 0 &&
+ (!email.from || email.replyTo[0].email !== email.from[0]?.email) && (
+
+
+
+
Reply-To:
+
+ {email.replyTo.map((recipient, i) => (
+
+ {recipient.name && {recipient.name} }
+ {recipient.email}
+
+ ))}
+
+
+
+ )}
+
+ {/* Time delay if significant (>1 minute difference) */}
+ {email.sentAt && email.receivedAt &&
+ Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000 && (
+
+
+
+
Delivery time:
+
+ {(() => {
+ const diff = Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime());
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+ if (days > 0) return `${days} day${days > 1 ? 's' : ''} ${hours % 24} hour${hours % 24 !== 1 ? 's' : ''}`;
+ if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ${minutes % 60} minute${minutes % 60 !== 1 ? 's' : ''}`;
+ return `${minutes} minute${minutes > 1 ? 's' : ''}`;
+ })()}
+
+
+
+ )}
+
+ {/* Part of conversation */}
+ {email.references && email.references.length > 0 && (
+
+
+
+
Part of conversation:
+
+ {email.references.length} previous message{email.references.length > 1 ? 's' : ''} in this thread
+
+
+
+ )}
+
+
+
+ )}
+
+ )}
+
+
setShowFullHeaders(!showFullHeaders)}
+ className="mt-3 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
+ >
+ {showFullHeaders ? (
+ <>
+
+ Hide details
+ >
+ ) : (
+ <>
+
+ Show details
+ >
+ )}
+
+
+
- {/* Email Body */}
-
-
- {email.preview && (
-
+ {/* Email Content Area */}
+
+ {/* Ultra Minimalist External Content Banner */}
+ {hasBlockedContent && !allowExternalContent && (
+
+
+ setAllowExternalContent(true)}
+ className="mx-auto flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
+ >
+
+ Show images
+
+
+
+ )}
+
+
+
+ {/* Inline Attachments */}
+ {email.attachments && email.attachments.length > 0 && (
+
+ {/* Image attachments as thumbnails */}
+ {email.attachments.filter(a =>
+ a.type?.startsWith('image/') ||
+ ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '')
+ ).length > 0 && (
+
+
+ {email.attachments
+ .filter(a =>
+ a.type?.startsWith('image/') ||
+ ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '')
+ )
+ .map((attachment, i) => (
+
{
+ if (attachment.blobId && onDownloadAttachment) {
+ onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
+ }
+ }}
+ >
+
+
+
+
+
+ {attachment.name}
+
+
+ ))}
+
+
+ )}
+
+ {/* Non-image attachments in a compact list */}
+ {email.attachments.filter(a =>
+ !a.type?.startsWith('image/') &&
+ !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '')
+ ).length > 0 && (
+
+
+ {email.attachments
+ .filter(a =>
+ !a.type?.startsWith('image/') &&
+ !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '')
+ )
+ .map((attachment, i) => {
+ const FileIcon = getFileIcon(attachment.name, attachment.type);
+ return (
+
{
+ if (attachment.blobId && onDownloadAttachment) {
+ onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
+ }
+ }}
+ >
+
+
+ {attachment.name || "Unnamed"}
+
+
+ ({formatFileSize(attachment.size)})
+
+
+ );
+ })}
+
+ )}
+
)}
+
+ {/* Email Body */}
+
+ {isLoadingContent && (
+
+
+ Loading full email content...
+
+ )}
+
+
+ {emailContent.isHtml ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Quick Reply Section */}
+
+
+
+
+ {
+ e.target.placeholder = "Type your reply...";
+ e.target.parentElement?.parentElement?.classList.add('focused');
+ }}
+ onBlur={(e) => {
+ e.target.placeholder = "Reply...";
+ if (!e.target.value) {
+ e.target.parentElement?.parentElement?.classList.remove('focused');
+ }
+ }}
+ />
+
+
+ Send
+
+
+
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 2d1ccb6..9c9e93b 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -1,8 +1,11 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect } from "react";
+import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { LanguageSwitcher } from "@/components/ui/language-switcher";
+import { useThemeStore } from "@/stores/theme-store";
import {
Inbox,
Send,
@@ -13,8 +16,16 @@ import {
PenSquare,
Search,
Menu,
+ LogOut,
+ ChevronRight,
+ ChevronDown,
+ Folder,
+ FolderOpen,
+ Sun,
+ Moon,
+ Monitor,
} from "lucide-react";
-import { cn } from "@/lib/utils";
+import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
interface SidebarProps {
@@ -22,38 +33,247 @@ interface SidebarProps {
selectedMailbox?: string;
onMailboxSelect?: (mailboxId: string) => void;
onCompose?: () => void;
+ onLogout?: () => void;
+ onSearch?: (query: string) => void;
+ quota?: { used: number; total: number } | null;
className?: string;
}
-const defaultMailboxes = [
- { id: "inbox", name: "Inbox", icon: Inbox, unread: 12 },
- { id: "sent", name: "Sent", icon: Send, unread: 0 },
- { id: "drafts", name: "Drafts", icon: File, unread: 2 },
- { id: "starred", name: "Starred", icon: Star, unread: 0 },
- { id: "archive", name: "Archive", icon: Archive, unread: 0 },
- { id: "trash", name: "Trash", icon: Trash2, unread: 0 },
-];
+// Map role to icon
+const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean) => {
+ const lowerName = name?.toLowerCase() || "";
+
+ if (hasChildren) {
+ // For folders with children, return open/closed folder icon
+ return isExpanded ? FolderOpen : Folder;
+ }
+
+ if (role === "inbox" || lowerName.includes("inbox")) return Inbox;
+ if (role === "sent" || lowerName.includes("sent")) return Send;
+ if (role === "drafts" || lowerName.includes("draft")) return File;
+ if (role === "trash" || lowerName.includes("trash")) return Trash2;
+ if (role === "archive" || lowerName.includes("archive")) return Archive;
+ if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
+ return Inbox; // Default icon
+};
+
+// Component for rendering a single mailbox node with its children
+function MailboxTreeItem({
+ node,
+ selectedMailbox,
+ expandedFolders,
+ onMailboxSelect,
+ onToggleExpand,
+ isCollapsed,
+}: {
+ node: MailboxNode;
+ selectedMailbox: string;
+ expandedFolders: Set
;
+ onMailboxSelect?: (id: string) => void;
+ onToggleExpand: (id: string) => void;
+ isCollapsed: boolean;
+}) {
+ const hasChildren = node.children.length > 0;
+ const isExpanded = expandedFolders.has(node.id);
+ const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded);
+ const indentPixels = node.depth * 16; // 16px per depth level
+
+ return (
+ <>
+
+ {/* Expand/Collapse Chevron */}
+ {hasChildren && (
+ {
+ e.stopPropagation();
+ onToggleExpand(node.id);
+ }}
+ className={cn(
+ "p-0.5 rounded mr-1 transition-all duration-200",
+ "hover:bg-gray-200 active:bg-gray-300"
+ )}
+ style={{ marginLeft: indentPixels }}
+ title={isExpanded ? "Collapse" : "Expand"}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Mailbox Button */}
+ onMailboxSelect?.(node.id)}
+ className={cn(
+ "flex-1 flex items-center text-left py-1 px-1 rounded",
+ "transition-colors duration-150"
+ )}
+ style={{
+ paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
+ }}
+ title={isCollapsed ? node.name : undefined}
+ >
+ 0 && "text-gray-600 dark:text-gray-400"
+ )} />
+ {!isCollapsed && (
+ <>
+ {node.name}
+ {node.unreadEmails > 0 && (
+
+ {node.unreadEmails}
+
+ )}
+ >
+ )}
+
+
+
+ {/* Render children if expanded */}
+ {hasChildren && isExpanded && !isCollapsed && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+ >
+ );
+}
export function Sidebar({
mailboxes = [],
- selectedMailbox = "inbox",
+ selectedMailbox = "",
onMailboxSelect,
onCompose,
+ onLogout,
+ onSearch,
+ quota,
className,
}: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
+ const [expandedFolders, setExpandedFolders] = useState>(new Set());
+ const { theme, setTheme, resolvedTheme } = useThemeStore();
+ const t = useTranslations('sidebar');
+
+ // Load expanded folders from localStorage on mount
+ useEffect(() => {
+ const stored = localStorage.getItem('expandedMailboxes');
+ if (stored) {
+ try {
+ const parsed = JSON.parse(stored);
+ setExpandedFolders(new Set(parsed));
+ } catch (e) {
+ console.error('Failed to parse expanded mailboxes:', e);
+ }
+ } else {
+ // By default, expand root folders that have children
+ const tree = buildMailboxTree(mailboxes);
+ const defaultExpanded = tree
+ .filter(node => node.children.length > 0)
+ .map(node => node.id);
+ setExpandedFolders(new Set(defaultExpanded));
+ }
+ }, [mailboxes]);
+
+ // Save expanded folders to localStorage when changed
+ const handleToggleExpand = (mailboxId: string) => {
+ setExpandedFolders((prev) => {
+ const next = new Set(prev);
+ if (next.has(mailboxId)) {
+ next.delete(mailboxId);
+ } else {
+ next.add(mailboxId);
+ }
+ localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next)));
+ return next;
+ });
+ };
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (searchQuery.trim() && onSearch) {
+ onSearch(searchQuery);
+ }
+ };
+
+ // Build hierarchical mailbox tree
+ const mailboxTree = buildMailboxTree(mailboxes);
+
+ // Keyboard navigation
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (!selectedMailbox || isCollapsed) return;
+
+ // Find the selected node in the tree
+ const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
+ for (const node of nodes) {
+ if (node.id === selectedMailbox) return node;
+ const found = findNode(node.children);
+ if (found) return found;
+ }
+ return null;
+ };
+
+ const selectedNode = findNode(mailboxTree);
+ if (!selectedNode) return;
+
+ // Handle arrow keys for expand/collapse
+ if (e.key === 'ArrowRight' && selectedNode.children.length > 0) {
+ // Expand folder
+ if (!expandedFolders.has(selectedMailbox)) {
+ handleToggleExpand(selectedMailbox);
+ }
+ } else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) {
+ // Collapse folder
+ if (expandedFolders.has(selectedMailbox)) {
+ handleToggleExpand(selectedMailbox);
+ }
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [selectedMailbox, isCollapsed, expandedFolders, mailboxTree]);
return (
{/* Header */}
-
+
- Compose
+ {t("compose")}
)}
@@ -72,51 +292,112 @@ export function Sidebar({
{/* Search */}
{!isCollapsed && (
)}
{/* Mailbox List */}
-
- {defaultMailboxes.map((mailbox) => (
-
onMailboxSelect?.(mailbox.id)}
- className={cn(
- "w-full flex items-center px-4 py-2 text-sm hover:bg-gray-100 transition-colors",
- selectedMailbox === mailbox.id && "bg-gray-100"
- )}
- >
-
- {!isCollapsed && (
- <>
- {mailbox.name}
- {mailbox.unread > 0 && (
-
- {mailbox.unread}
-
- )}
- >
- )}
-
- ))}
+
+ {mailboxes.length === 0 ? (
+
+ {!isCollapsed && t("loading_mailboxes")}
+
+ ) : (
+ <>
+ {/* Render hierarchical mailbox tree */}
+ {mailboxTree.map((node) => (
+
+ ))}
+ >
+ )}
{/* Footer */}
{!isCollapsed && (
-
-
Storage: 2.5 GB / 15 GB
+
+ {quota && quota.total > 0 && (
+
+ {t("storage")}: {formatFileSize(quota.used)} / {formatFileSize(quota.total)}
+
+ )}
+
+ {/* Language Switcher */}
+
+
+
+
+ {/* Theme Toggle */}
+
+
+ setTheme('light')}
+ className={cn(
+ "flex-1 flex items-center justify-center p-1.5 rounded transition-all",
+ theme === 'light'
+ ? "bg-white dark:bg-gray-700 shadow-sm"
+ : "hover:bg-gray-50 dark:hover:bg-gray-700/50"
+ )}
+ title="Light mode"
+ >
+
+
+ setTheme('dark')}
+ className={cn(
+ "flex-1 flex items-center justify-center p-1.5 rounded transition-all",
+ theme === 'dark'
+ ? "bg-white dark:bg-gray-700 shadow-sm"
+ : "hover:bg-gray-50 dark:hover:bg-gray-700/50"
+ )}
+ title="Dark mode"
+ >
+
+
+ setTheme('system')}
+ className={cn(
+ "flex-1 flex items-center justify-center p-1.5 rounded transition-all",
+ theme === 'system'
+ ? "bg-white dark:bg-gray-700 shadow-sm"
+ : "hover:bg-gray-50 dark:hover:bg-gray-700/50"
+ )}
+ title="System theme"
+ >
+
+
+
+
+
+ {onLogout && (
+
+
+ {t("sign_out")}
+
+ )}
)}
diff --git a/components/providers/theme-provider.tsx b/components/providers/theme-provider.tsx
new file mode 100644
index 0000000..33596c3
--- /dev/null
+++ b/components/providers/theme-provider.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { useEffect } from 'react';
+import { useThemeStore } from '@/stores/theme-store';
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const initializeTheme = useThemeStore((state) => state.initializeTheme);
+
+ useEffect(() => {
+ initializeTheme();
+ }, [initializeTheme]);
+
+ return <>{children}>;
+}
\ No newline at end of file
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..1f069e9
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,54 @@
+import { cn } from "@/lib/utils";
+
+interface AvatarProps {
+ name?: string;
+ email?: string;
+ size?: "sm" | "md" | "lg";
+ className?: string;
+}
+
+export function Avatar({ name, email, size = "md", className }: AvatarProps) {
+ const getInitials = () => {
+ if (name) {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
+ }
+ return name.slice(0, 2).toUpperCase();
+ }
+ if (email) {
+ return email[0].toUpperCase();
+ }
+ return "?";
+ };
+
+ const getBackgroundColor = () => {
+ const str = name || email || "";
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ const hue = Math.abs(hash) % 360;
+ return `hsl(${hue}, 70%, 50%)`;
+ };
+
+ const sizeClasses = {
+ sm: "w-8 h-8 text-xs",
+ md: "w-10 h-10 text-sm",
+ lg: "w-12 h-12 text-base",
+ };
+
+ return (
+
+ {getInitials()}
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index d465589..3ac9b70 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -12,15 +12,15 @@ const Button = React.forwardRef
(
return (
(
{
+ // Get the path without the locale prefix
+ const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
+
+ // Navigate to the same page with the new locale
+ router.push(`/${newLocale}${pathWithoutLocale}`);
+ };
+
+ return (
+
+ {locales.map((locale) => (
+ handleLanguageChange(locale)}
+ className={cn(
+ "flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
+ currentLocale === locale
+ ? "bg-white dark:bg-gray-700 shadow-sm font-medium"
+ : "hover:bg-gray-50 dark:hover:bg-gray-700/50"
+ )}
+ title={t(locale === 'en' ? 'english' : 'french')}
+ >
+ {locale === 'en' ? '🇬🇧' : '🇫🇷'}
+
+ {locale.toUpperCase()}
+
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
new file mode 100644
index 0000000..039687a
--- /dev/null
+++ b/components/ui/toast.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export type ToastType = "success" | "error" | "info" | "warning";
+
+export interface Toast {
+ id: string;
+ type: ToastType;
+ title: string;
+ message?: string;
+ duration?: number;
+}
+
+interface ToastProps {
+ toast: Toast;
+ onClose: (id: string) => void;
+}
+
+const icons = {
+ success: CheckCircle,
+ error: AlertCircle,
+ info: Info,
+ warning: AlertTriangle,
+};
+
+const styles = {
+ success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200",
+ error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200",
+ info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200",
+ warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200",
+};
+
+export function ToastItem({ toast, onClose }: ToastProps) {
+ const Icon = icons[toast.type];
+
+ useEffect(() => {
+ if (toast.duration && toast.duration > 0) {
+ const timer = setTimeout(() => {
+ onClose(toast.id);
+ }, toast.duration);
+ return () => clearTimeout(timer);
+ }
+ }, [toast, onClose]);
+
+ return (
+
+
+
+
{toast.title}
+ {toast.message && (
+
{toast.message}
+ )}
+
+
onClose(toast.id)}
+ className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
+ >
+
+
+
+ );
+}
+
+export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
+ return (
+
+ {toasts.map((toast) => (
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/i18n.ts b/i18n.ts
new file mode 100644
index 0000000..7522ed9
--- /dev/null
+++ b/i18n.ts
@@ -0,0 +1,24 @@
+import { getRequestConfig } from 'next-intl/server';
+
+export const locales = ['en', 'fr'] as const;
+export type Locale = (typeof locales)[number];
+
+export const defaultLocale: Locale = 'en';
+
+export default getRequestConfig(async ({ locale }) => {
+ // Validate that the incoming `locale` parameter is valid
+ if (!locales.includes(locale as any)) {
+ locale = defaultLocale;
+ }
+
+ // Use static imports for better compatibility
+ const messages = locale === 'fr'
+ ? (await import('./locales/fr/common.json')).default
+ : (await import('./locales/en/common.json')).default;
+
+ return {
+ messages,
+ timeZone: 'Europe/Paris',
+ now: new Date()
+ };
+});
\ No newline at end of file
diff --git a/lib/email-headers.ts b/lib/email-headers.ts
new file mode 100644
index 0000000..9fccf61
--- /dev/null
+++ b/lib/email-headers.ts
@@ -0,0 +1,203 @@
+import { AuthenticationResults } from './jmap/types';
+
+/**
+ * Parse Authentication-Results header to extract SPF, DKIM, DMARC results
+ */
+export function parseAuthenticationResults(header: string): AuthenticationResults {
+ const results: AuthenticationResults = {};
+
+ // Parse SPF
+ const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/);
+ if (spfMatch) {
+ results.spf = {
+ result: spfMatch[1] as any,
+ 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 any,
+ 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 any,
+ domain: dmarcMatch[2],
+ policy: dmarcMatch[3] as any
+ };
+ }
+
+ // Parse IP reverse lookup
+ const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
+ if (iprevMatch) {
+ results.iprev = {
+ result: iprevMatch[1] as any,
+ 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
+ */
+export function parseReceivedHeaders(headers: string[]): Array<{
+ from: string;
+ by: string;
+ timestamp?: string;
+ protocol?: string;
+ id?: string;
+}> {
+ const path: any[] = [];
+
+ 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-600 dark:text-green-400',
+ icon: 'check',
+ bgColor: 'bg-green-50 dark:bg-green-950/30',
+ borderColor: 'border-green-200 dark:border-green-800'
+ };
+ case 'fail':
+ case 'permerror':
+ return {
+ color: 'text-red-600 dark:text-red-400',
+ icon: 'x',
+ bgColor: 'bg-red-50 dark:bg-red-950/30',
+ borderColor: 'border-red-200 dark:border-red-800'
+ };
+ case 'softfail':
+ case 'neutral':
+ case 'temperror':
+ return {
+ color: 'text-amber-600 dark:text-amber-400',
+ icon: 'alert',
+ bgColor: 'bg-amber-50 dark:bg-amber-950/30',
+ borderColor: 'border-amber-200 dark:border-amber-800'
+ };
+ default:
+ return {
+ color: 'text-gray-400 dark:text-gray-500',
+ icon: 'minus',
+ bgColor: 'bg-gray-50 dark:bg-gray-900',
+ borderColor: 'border-gray-200 dark:border-gray-700'
+ };
+ }
+}
+
+/**
+ * Extract list headers (List-Unsubscribe, List-Id, etc.)
+ */
+export function extractListHeaders(headers: Record): {
+ listId?: string;
+ listUnsubscribe?: string;
+ listHelp?: string;
+ listPost?: string;
+} {
+ const result: any = {};
+
+ 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 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;
+}
\ No newline at end of file
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 2f39914..3236a26 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -1,120 +1,401 @@
-import { JmapClient, Session } from "jmap-jam";
-import type { Email, Mailbox, Thread, Identity } from "./types";
+import type { Email, Mailbox } from "./types";
-export class JMAPEmailClient {
- private client: JmapClient;
- private session: Session | null = null;
+export class JMAPClient {
+ private serverUrl: string;
+ private username: string;
+ private password: string;
+ private authHeader: string;
+ private apiUrl: string = "";
+ private accountId: string = "";
+ private downloadUrl: string = "";
constructor(serverUrl: string, username: string, password: string) {
- this.client = new JmapClient(serverUrl, {
- headers: {
- Authorization: `Basic ${btoa(`${username}:${password}`)}`,
- },
- });
+ this.serverUrl = serverUrl.replace(/\/$/, '');
+ this.username = username;
+ this.password = password;
+ this.authHeader = `Basic ${btoa(`${username}:${password}`)}`;
}
async connect(): Promise {
- this.session = await this.client.getSession();
- if (!this.session) {
- throw new Error("Failed to establish JMAP session");
+ console.log('FinalJMAPClient: Connecting to', this.serverUrl);
+
+ // Get the session first
+ const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
+ console.log('Fetching session from:', sessionUrl);
+
+ try {
+ const sessionResponse = await fetch(sessionUrl, {
+ method: 'GET',
+ headers: {
+ 'Authorization': this.authHeader,
+ },
+ });
+
+ if (!sessionResponse.ok) {
+ if (sessionResponse.status === 401) {
+ throw new Error('Invalid username or password');
+ }
+ throw new Error(`Failed to get session: ${sessionResponse.status}`);
+ }
+
+ const session = await sessionResponse.json();
+ console.log('Session:', session);
+
+ // Extract the API URL
+ this.apiUrl = session.apiUrl;
+ console.log('API URL:', this.apiUrl);
+
+ // Extract the download URL
+ this.downloadUrl = session.downloadUrl;
+
+ // Extract the account ID
+ const mailAccount = session.primaryAccounts?.["urn:ietf:params:jmap:mail"];
+ if (mailAccount) {
+ this.accountId = mailAccount;
+ } else {
+ // Try to find any account
+ const accounts = session.accounts;
+ if (accounts && Object.keys(accounts).length > 0) {
+ this.accountId = Object.keys(accounts)[0];
+ } else {
+ throw new Error('No mail account found in session');
+ }
+ }
+
+ console.log('Account ID:', this.accountId);
+ } catch (error) {
+ console.error('Connection failed:', error);
+ throw error;
+ }
+ }
+
+ private async request(methodCalls: any[]): Promise {
+ if (!this.apiUrl) {
+ throw new Error('Not connected. Call connect() first.');
+ }
+
+ const requestBody = {
+ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+ methodCalls: methodCalls,
+ };
+
+ console.log('Making request to:', this.apiUrl);
+ console.log('Request:', JSON.stringify(requestBody, null, 2));
+
+ const response = await fetch(this.apiUrl, {
+ method: 'POST',
+ headers: {
+ 'Authorization': this.authHeader,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ const responseText = await response.text();
+
+ if (!response.ok) {
+ console.error('Request failed:', response.status, responseText);
+ throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
+ }
+
+ let data;
+ try {
+ data = JSON.parse(responseText);
+ } catch (e) {
+ console.error('Failed to parse response:', responseText);
+ throw new Error('Invalid JSON response from server');
+ }
+
+ console.log('Response:', data);
+ return data;
+ }
+
+ async getQuota(): Promise<{ used: number; total: number } | null> {
+ try {
+ const response = await this.request([
+ ["Quota/get", {
+ accountId: this.accountId,
+ }, "0"]
+ ]);
+
+ if (response.methodResponses?.[0]?.[0] === "Quota/get") {
+ const quotas = response.methodResponses[0][1].list || [];
+ // Find the mail quota if it exists
+ const mailQuota = quotas.find((q: any) => q.resourceType === "mail" || q.scope === "mail");
+
+ if (mailQuota) {
+ return {
+ used: mailQuota.used ?? 0,
+ total: mailQuota.hardLimit ?? mailQuota.limit ?? 0
+ };
+ }
+ }
+
+ return null;
+ } catch (error) {
+ console.log('Quota not available or not supported:', error);
+ return null;
}
}
async getMailboxes(): Promise {
- if (!this.session) throw new Error("Not connected");
+ try {
+ const response = await this.request([
+ ["Mailbox/get", {
+ accountId: this.accountId,
+ }, "0"]
+ ]);
- const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
+ if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
+ const rawMailboxes = response.methodResponses[0][1].list || [];
+ console.log(`Got ${rawMailboxes.length} mailboxes`);
- const response = await this.client.request([
- ["Mailbox/get", { accountId }, "0"],
- ]);
+ // Map and ensure all required fields are present
+ const mailboxes = rawMailboxes.map((mb: any) => {
+ console.log(`Mailbox: ${mb.name} (ID: ${mb.id}, Role: ${mb.role}, Emails: ${mb.totalEmails}, Unread: ${mb.unreadEmails})`);
- return response[0][1].list as Mailbox[];
+ return {
+ id: mb.id,
+ name: mb.name,
+ parentId: mb.parentId || undefined,
+ role: mb.role || undefined,
+ sortOrder: mb.sortOrder ?? 0,
+ totalEmails: mb.totalEmails ?? 0,
+ unreadEmails: mb.unreadEmails ?? 0,
+ totalThreads: mb.totalThreads ?? 0,
+ unreadThreads: mb.unreadThreads ?? 0,
+ myRights: mb.myRights || {
+ mayReadItems: true,
+ mayAddItems: true,
+ mayRemoveItems: true,
+ maySetSeen: true,
+ maySetKeywords: true,
+ mayCreateChild: true,
+ mayRename: true,
+ mayDelete: true,
+ maySubmit: true,
+ },
+ isSubscribed: mb.isSubscribed ?? true,
+ } as Mailbox;
+ });
+
+ return mailboxes;
+ }
+
+ throw new Error('Unexpected response format');
+ } catch (error) {
+ console.error('Failed to get mailboxes:', error);
+ // Return default inbox with all required fields
+ return [{
+ id: 'INBOX',
+ name: 'Inbox',
+ role: 'inbox',
+ sortOrder: 0,
+ totalEmails: 0,
+ unreadEmails: 0,
+ totalThreads: 0,
+ unreadThreads: 0,
+ myRights: {
+ mayReadItems: true,
+ mayAddItems: true,
+ mayRemoveItems: true,
+ maySetSeen: true,
+ maySetKeywords: true,
+ mayCreateChild: true,
+ mayRename: true,
+ mayDelete: true,
+ maySubmit: true,
+ },
+ isSubscribed: true,
+ }] as Mailbox[];
+ }
}
async getEmails(mailboxId?: string, limit: number = 50): Promise {
- if (!this.session) throw new Error("Not connected");
+ try {
+ console.log(`Fetching emails for mailbox: ${mailboxId || 'all'}`);
- const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
+ // Build filter - only add inMailbox if we have a mailboxId
+ const filter: any = {};
+ if (mailboxId && mailboxId !== '') {
+ filter.inMailbox = mailboxId;
+ }
- const filter: any = {};
- if (mailboxId) {
- filter.inMailbox = mailboxId;
+ console.log('Email filter:', JSON.stringify(filter));
+
+ const response = await this.request([
+ ["Email/query", {
+ accountId: this.accountId,
+ filter: filter,
+ sort: [{ property: "receivedAt", isAscending: false }],
+ limit: limit,
+ }, "0"],
+ ["Email/get", {
+ accountId: this.accountId,
+ "#ids": {
+ resultOf: "0",
+ name: "Email/query",
+ path: "/ids",
+ },
+ properties: [
+ "id",
+ "threadId",
+ "mailboxIds",
+ "keywords",
+ "size",
+ "receivedAt",
+ "from",
+ "to",
+ "cc",
+ "subject",
+ "preview",
+ "hasAttachment",
+ ],
+ }, "1"],
+ ]);
+
+ // Log query results
+ if (response.methodResponses?.[0]?.[0] === "Email/query") {
+ const queryResult = response.methodResponses[0][1];
+ console.log(`Email/query returned ${queryResult.total} total, ${queryResult.ids?.length || 0} ids`);
+ }
+
+ if (response.methodResponses?.[1]?.[0] === "Email/get") {
+ const emails = response.methodResponses[1][1].list || [];
+ console.log(`Got ${emails.length} emails from Email/get`);
+
+ // Log first few emails for debugging
+ emails.slice(0, 3).forEach((email: any) => {
+ console.log(`Email: "${email.subject}" from ${email.from?.[0]?.email || 'unknown'}`);
+ });
+
+ return emails;
+ }
+
+ console.warn('Unexpected email response format');
+ return [];
+ } catch (error) {
+ console.error('Failed to get emails:', error);
+ return [];
}
-
- const response = await this.client.request([
- ["Email/query", {
- accountId,
- filter,
- sort: [{ property: "receivedAt", isAscending: false }],
- limit,
- }, "0"],
- ["Email/get", {
- accountId,
- "#ids": {
- resultOf: "0",
- name: "Email/query",
- path: "/ids",
- },
- properties: [
- "id", "threadId", "mailboxIds", "keywords", "size",
- "receivedAt", "from", "to", "cc", "subject", "preview",
- "hasAttachment",
- ],
- }, "1"],
- ]);
-
- return response[1][1].list as Email[];
}
async getEmail(emailId: string): Promise {
- if (!this.session) throw new Error("Not connected");
+ try {
+ const response = await this.request([
+ ["Email/get", {
+ accountId: this.accountId,
+ ids: [emailId],
+ properties: [
+ "id",
+ "threadId",
+ "mailboxIds",
+ "keywords",
+ "size",
+ "receivedAt",
+ "sentAt",
+ "from",
+ "to",
+ "cc",
+ "bcc",
+ "replyTo",
+ "subject",
+ "preview",
+ "textBody",
+ "htmlBody",
+ "bodyValues",
+ "hasAttachment",
+ "attachments",
+ "messageId",
+ "inReplyTo",
+ "references",
+ "headers",
+ ],
+ fetchTextBodyValues: true,
+ fetchHTMLBodyValues: true,
+ fetchAllBodyValues: true,
+ maxBodyValueBytes: 256000,
+ }, "0"],
+ ]);
- const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
+ if (response.methodResponses?.[0]?.[0] === "Email/get") {
+ const emails = response.methodResponses[0][1].list || [];
+ const email = emails[0];
- const response = await this.client.request([
- ["Email/get", {
- accountId,
- ids: [emailId],
- }, "0"],
- ]);
+ if (email) {
+ // Parse headers if available
+ if (email.headers) {
+ // Import the parsing functions
+ const { parseAuthenticationResults, parseSpamScore } = await import('@/lib/email-headers');
- const emails = response[0][1].list as Email[];
- return emails[0] || null;
+ // Parse Authentication-Results header
+ const authResultsHeader = email.headers['Authentication-Results'];
+ if (authResultsHeader) {
+ const headerValue = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader;
+ email.authenticationResults = parseAuthenticationResults(headerValue);
+ }
+
+ // Parse Spam headers
+ const spamHeaders = ['X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score'];
+ for (const header of spamHeaders) {
+ if (email.headers[header]) {
+ const headerValue = Array.isArray(email.headers[header]) ? email.headers[header][0] : email.headers[header];
+ const spamResult = parseSpamScore(headerValue);
+ if (spamResult) {
+ email.spamScore = spamResult.score;
+ email.spamStatus = spamResult.status;
+ break;
+ }
+ }
+ }
+ }
+
+ return email;
+ }
+
+ return null;
+ }
+
+ return null;
+ } catch (error) {
+ console.error('Failed to get email:', error);
+ return null;
+ }
}
async markAsRead(emailId: string, read: boolean = true): Promise {
- if (!this.session) throw new Error("Not connected");
-
- const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
-
- await this.client.request([
+ await this.request([
["Email/set", {
- accountId,
+ accountId: this.accountId,
update: {
[emailId]: {
- keywords: {
- "$seen": read,
- },
+ "keywords/$seen": read,
},
},
}, "0"],
]);
}
- async moveEmail(emailId: string, toMailboxId: string): Promise {
- if (!this.session) throw new Error("Not connected");
-
- const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
-
- await this.client.request([
+ async toggleStar(emailId: string, starred: boolean): Promise {
+ await this.request([
["Email/set", {
- accountId,
+ accountId: this.accountId,
update: {
[emailId]: {
- mailboxIds: {
- [toMailboxId]: true,
- },
+ "keywords/$flagged": starred,
+ },
+ },
+ }, "0"],
+ ]);
+ }
+
+ async updateEmailKeywords(emailId: string, keywords: Record): Promise {
+ await this.request([
+ ["Email/set", {
+ accountId: this.accountId,
+ update: {
+ [emailId]: {
+ keywords,
},
},
}, "0"],
@@ -122,48 +403,71 @@ export class JMAPEmailClient {
}
async deleteEmail(emailId: string): Promise {
- if (!this.session) throw new Error("Not connected");
-
- const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
-
- await this.client.request([
+ await this.request([
["Email/set", {
- accountId,
+ accountId: this.accountId,
destroy: [emailId],
}, "0"],
]);
}
- async searchEmails(query: string, limit: number = 50): Promise {
- if (!this.session) throw new Error("Not connected");
-
- const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
-
- const response = await this.client.request([
- ["Email/query", {
- accountId,
- filter: {
- text: query,
+ async moveEmail(emailId: string, toMailboxId: string): Promise {
+ await this.request([
+ ["Email/set", {
+ accountId: this.accountId,
+ update: {
+ [emailId]: {
+ mailboxIds: { [toMailboxId]: true },
+ },
},
- sort: [{ property: "receivedAt", isAscending: false }],
- limit,
}, "0"],
- ["Email/get", {
- accountId,
- "#ids": {
- resultOf: "0",
- name: "Email/query",
- path: "/ids",
- },
- properties: [
- "id", "threadId", "mailboxIds", "keywords", "size",
- "receivedAt", "from", "to", "cc", "subject", "preview",
- "hasAttachment",
- ],
- }, "1"],
]);
+ }
- return response[1][1].list as Email[];
+ async searchEmails(query: string, limit: number = 50): Promise {
+ try {
+ const response = await this.request([
+ ["Email/query", {
+ accountId: this.accountId,
+ filter: {
+ text: query,
+ },
+ sort: [{ property: "receivedAt", isAscending: false }],
+ limit: limit,
+ }, "0"],
+ ["Email/get", {
+ accountId: this.accountId,
+ "#ids": {
+ resultOf: "0",
+ name: "Email/query",
+ path: "/ids",
+ },
+ properties: [
+ "id",
+ "threadId",
+ "mailboxIds",
+ "keywords",
+ "size",
+ "receivedAt",
+ "from",
+ "to",
+ "cc",
+ "subject",
+ "preview",
+ "hasAttachment",
+ ],
+ }, "1"],
+ ]);
+
+ if (response.methodResponses?.[1]?.[0] === "Email/get") {
+ return response.methodResponses[1][1].list || [];
+ }
+
+ return [];
+ } catch (error) {
+ console.error('Search failed:', error);
+ return [];
+ }
}
async sendEmail(
@@ -171,48 +475,103 @@ export class JMAPEmailClient {
subject: string,
body: string,
cc?: string[],
- bcc?: string[],
- attachments?: any[]
+ bcc?: string[]
): Promise {
- if (!this.session) throw new Error("Not connected");
+ const emailId = `draft-${Date.now()}`;
- const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
-
- // Create email draft
- const emailCreate = {
- from: [{ email: this.session.username }],
- to: to.map(email => ({ email })),
- cc: cc?.map(email => ({ email })),
- bcc: bcc?.map(email => ({ email })),
- subject,
- textBody: [{ type: "text/plain", value: body }],
- htmlBody: [{ type: "text/html", value: body.replace(/\n/g, " ") }],
- };
-
- // Create and send email
- await this.client.request([
+ await this.request([
["Email/set", {
- accountId,
+ accountId: this.accountId,
create: {
- draft: emailCreate,
+ [emailId]: {
+ from: [{ email: this.username }],
+ to: to.map(email => ({ email })),
+ cc: cc?.map(email => ({ email })),
+ bcc: bcc?.map(email => ({ email })),
+ subject: subject,
+ keywords: {},
+ mailboxIds: {},
+ bodyValues: {
+ "1": {
+ value: body,
+ },
+ },
+ textBody: [
+ {
+ partId: "1",
+ },
+ ],
+ },
},
}, "0"],
["EmailSubmission/set", {
- accountId,
+ accountId: this.accountId,
create: {
- submission: {
- emailId: "#draft",
- envelope: {
- mailFrom: { email: this.session.username },
- rcptTo: [
- ...to.map(email => ({ email })),
- ...(cc || []).map(email => ({ email })),
- ...(bcc || []).map(email => ({ email })),
- ],
- },
+ "1": {
+ emailId: `#${emailId}`,
},
},
}, "1"],
]);
}
+
+ getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
+ if (!this.downloadUrl) {
+ throw new Error('Download URL not available. Please reconnect.');
+ }
+
+ // The downloadUrl is a URI Template (RFC 6570 level 1) with variables
+ // like {accountId}, {blobId}, {name}, and {type}
+ let url = this.downloadUrl;
+
+ // Replace template variables with actual values
+ url = url.replace('{accountId}', encodeURIComponent(this.accountId));
+ url = url.replace('{blobId}', encodeURIComponent(blobId));
+
+ // Replace {name} - use a default if not provided
+ const fileName = name || 'download';
+ url = url.replace('{name}', encodeURIComponent(fileName));
+
+ // Replace {type} - URL encode it since it may contain slashes (e.g., "application/pdf")
+ // If type is not provided, use a generic binary type
+ const mimeType = type || 'application/octet-stream';
+ url = url.replace('{type}', encodeURIComponent(mimeType));
+
+ return url;
+ }
+
+ async downloadBlob(blobId: string, name?: string, type?: string): Promise {
+ const url = this.getBlobDownloadUrl(blobId, name, type);
+
+ try {
+ const response = await fetch(url, {
+ headers: {
+ 'Authorization': this.authHeader,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to download attachment: ${response.status}`);
+ }
+
+ // Get the blob from the response
+ const blob = await response.blob();
+
+ // Create a temporary URL for the blob
+ const blobUrl = URL.createObjectURL(blob);
+
+ // Create a temporary anchor element and trigger download
+ const a = document.createElement('a');
+ a.href = blobUrl;
+ a.download = name || 'download';
+ document.body.appendChild(a);
+ a.click();
+
+ // Clean up
+ document.body.removeChild(a);
+ URL.revokeObjectURL(blobUrl);
+ } catch (error) {
+ throw error;
+ }
+ }
}
\ No newline at end of file
diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts
index ab759b7..6edd456 100644
--- a/lib/jmap/types.ts
+++ b/lib/jmap/types.ts
@@ -15,8 +15,46 @@ export interface Email {
preview?: string;
textBody?: EmailBodyPart[];
htmlBody?: EmailBodyPart[];
+ bodyValues?: Record;
attachments?: Attachment[];
hasAttachment: boolean;
+ // Extended header information
+ messageId?: string;
+ inReplyTo?: string[];
+ references?: string[];
+ headers?: Record;
+ // Security headers parsed
+ authenticationResults?: AuthenticationResults;
+ spamScore?: number;
+ spamStatus?: 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 {
diff --git a/lib/utils.ts b/lib/utils.ts
index 13ab9f4..8cbe955 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,5 +1,6 @@
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));
@@ -29,4 +30,81 @@ export function formatDate(date: Date | string): string {
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;
+}
+
+// Build a hierarchical tree structure from flat mailbox array
+export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
+ const mailboxMap = new Map();
+ const rootMailboxes: MailboxNode[] = [];
+
+ // First pass: create nodes for all mailboxes
+ mailboxes.forEach(mailbox => {
+ mailboxMap.set(mailbox.id, {
+ ...mailbox,
+ children: [],
+ depth: 0
+ });
+ });
+
+ // Second pass: build tree structure
+ mailboxes.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;
+ }
+ });
+
+ // Sort mailboxes at each level by sortOrder
+ const sortNodes = (nodes: MailboxNode[]) => {
+ nodes.sort((a, b) => a.sortOrder - b.sortOrder);
+ 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;
}
\ No newline at end of file
diff --git a/locales/en/common.json b/locales/en/common.json
new file mode 100644
index 0000000..6d5304a
--- /dev/null
+++ b/locales/en/common.json
@@ -0,0 +1,197 @@
+{
+ "login": {
+ "title": "root.cloud Webmail",
+ "subtitle": "Sign in to access your email",
+ "server_label": "JMAP Server",
+ "server_placeholder": "https://mail.example.com",
+ "username_label": "Username",
+ "username_placeholder": "user@example.com",
+ "password_label": "Password",
+ "password_placeholder": "Enter your password",
+ "sign_in": "Sign in",
+ "signing_in": "Signing in...",
+ "secure_connection": "Secure JMAP connection to your email server",
+ "security_note": "For security, passwords are never stored. Use your browser's password manager for convenience.",
+ "error": {
+ "invalid_credentials": "Invalid username or password",
+ "connection_failed": "Failed to connect to the server",
+ "generic": "An error occurred. Please try again."
+ }
+ },
+ "sidebar": {
+ "compose": "Compose",
+ "search_placeholder": "Search mail...",
+ "storage": "Storage",
+ "sign_out": "Sign out",
+ "loading_mailboxes": "Loading mailboxes...",
+ "theme": {
+ "light": "Light mode",
+ "dark": "Dark mode",
+ "system": "System theme"
+ },
+ "mailboxes": {
+ "inbox": "Inbox",
+ "sent": "Sent",
+ "drafts": "Drafts",
+ "trash": "Trash",
+ "archive": "Archive",
+ "starred": "Starred",
+ "all_mail": "All Mail",
+ "spam": "Spam",
+ "important": "Important"
+ },
+ "expand": "Expand",
+ "collapse": "Collapse"
+ },
+ "email_list": {
+ "no_emails": "No emails",
+ "no_emails_description": "Start by composing a new email",
+ "loading": "Loading emails...",
+ "unread": "unread",
+ "to_me": "To me",
+ "to_recipients": "To {{count}} recipients",
+ "and_others": "and {{count}} others",
+ "draft": "Draft",
+ "starred": "Starred"
+ },
+ "email_viewer": {
+ "no_email_selected": "No email selected",
+ "no_email_description": "Select an email from the list to view it here",
+ "reply": "Reply",
+ "reply_all": "Reply All",
+ "forward": "Forward",
+ "delete": "Delete",
+ "archive": "Archive",
+ "star": "Star",
+ "unstar": "Unstar",
+ "mark_unread": "Mark as unread",
+ "mark_read": "Mark as read",
+ "print": "Print",
+ "attachments": "Attachments",
+ "download": "Download",
+ "from": "From",
+ "to": "To",
+ "cc": "CC",
+ "bcc": "BCC",
+ "date": "Date",
+ "subject": "Subject",
+ "show_details": "Show details",
+ "hide_details": "Hide details",
+ "external_content_warning": "Images and external content have been blocked",
+ "load_external_content": "Load images",
+ "message_details": "Message Details",
+ "authentication": {
+ "title": "Authentication",
+ "status": {
+ "verified": "Verified",
+ "warning": "Warning",
+ "none": "Not authenticated"
+ },
+ "spf": {
+ "pass": "SPF Pass",
+ "fail": "SPF Fail",
+ "none": "No SPF"
+ },
+ "dkim": {
+ "pass": "DKIM Valid",
+ "fail": "DKIM Invalid",
+ "none": "No DKIM"
+ },
+ "dmarc": {
+ "pass": "DMARC Pass",
+ "fail": "DMARC Fail",
+ "none": "No DMARC"
+ },
+ "spam_score": "Spam Score"
+ },
+ "headers": {
+ "routing": "Routing",
+ "received": "Received",
+ "message_id": "Message ID",
+ "list_info": "List Information"
+ },
+ "color_tag": {
+ "title": "Color Tag",
+ "red": "Red",
+ "orange": "Orange",
+ "yellow": "Yellow",
+ "green": "Green",
+ "blue": "Blue",
+ "purple": "Purple",
+ "none": "None"
+ }
+ },
+ "email_composer": {
+ "new_message": "New Message",
+ "reply_to": "Reply",
+ "reply_all_to": "Reply All",
+ "forward_message": "Forward",
+ "to": "To",
+ "cc": "CC",
+ "bcc": "BCC",
+ "subject": "Subject",
+ "body_placeholder": "Write your message...",
+ "send": "Send",
+ "cancel": "Cancel",
+ "attach": "Attach files",
+ "quote": {
+ "reply_header": "On {{date}}, {{sender}} wrote:",
+ "forward_header": "---------- Forwarded message ----------",
+ "from": "From: {{sender}}",
+ "date": "Date: {{date}}",
+ "subject": "Subject: {{subject}}",
+ "to": "To: {{recipients}}"
+ }
+ },
+ "common": {
+ "loading": "Loading...",
+ "error": "Error",
+ "success": "Success",
+ "cancel": "Cancel",
+ "save": "Save",
+ "delete": "Delete",
+ "edit": "Edit",
+ "close": "Close",
+ "search": "Search",
+ "refresh": "Refresh",
+ "settings": "Settings",
+ "help": "Help",
+ "logout": "Logout",
+ "yes": "Yes",
+ "no": "No",
+ "unknown": "Unknown"
+ },
+ "notifications": {
+ "email_sent": "Email sent successfully",
+ "email_deleted": "Email deleted",
+ "email_archived": "Email archived",
+ "email_starred": "Email starred",
+ "email_unstarred": "Email unstarred",
+ "email_marked_read": "Email marked as read",
+ "email_marked_unread": "Email marked as unread",
+ "copied_to_clipboard": "Copied to clipboard",
+ "error_sending": "Failed to send email",
+ "error_deleting": "Failed to delete email",
+ "error_loading": "Failed to load emails"
+ },
+ "date": {
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "this_week": "This week",
+ "last_week": "Last week",
+ "this_month": "This month",
+ "older": "Older",
+ "just_now": "Just now",
+ "minutes_ago": "{{count}} minute ago",
+ "minutes_ago_plural": "{{count}} minutes ago",
+ "hours_ago": "{{count}} hour ago",
+ "hours_ago_plural": "{{count}} hours ago",
+ "days_ago": "{{count}} day ago",
+ "days_ago_plural": "{{count}} days ago"
+ },
+ "language": {
+ "title": "Language",
+ "english": "English",
+ "french": "Français"
+ }
+}
\ No newline at end of file
diff --git a/locales/fr/common.json b/locales/fr/common.json
new file mode 100644
index 0000000..171b769
--- /dev/null
+++ b/locales/fr/common.json
@@ -0,0 +1,197 @@
+{
+ "login": {
+ "title": "root.cloud Webmail",
+ "subtitle": "Connectez-vous pour accéder à vos emails",
+ "server_label": "Serveur JMAP",
+ "server_placeholder": "https://mail.exemple.com",
+ "username_label": "Nom d'utilisateur",
+ "username_placeholder": "utilisateur@exemple.com",
+ "password_label": "Mot de passe",
+ "password_placeholder": "Entrez votre mot de passe",
+ "sign_in": "Se connecter",
+ "signing_in": "Connexion en cours...",
+ "secure_connection": "Connexion JMAP sécurisée à votre serveur de messagerie",
+ "security_note": "Pour votre sécurité, les mots de passe ne sont jamais stockés. Utilisez le gestionnaire de mots de passe de votre navigateur pour plus de commodité.",
+ "error": {
+ "invalid_credentials": "Nom d'utilisateur ou mot de passe invalide",
+ "connection_failed": "Échec de la connexion au serveur",
+ "generic": "Une erreur s'est produite. Veuillez réessayer."
+ }
+ },
+ "sidebar": {
+ "compose": "Composer",
+ "search_placeholder": "Rechercher un email...",
+ "storage": "Stockage",
+ "sign_out": "Se déconnecter",
+ "loading_mailboxes": "Chargement des boîtes mail...",
+ "theme": {
+ "light": "Mode clair",
+ "dark": "Mode sombre",
+ "system": "Thème système"
+ },
+ "mailboxes": {
+ "inbox": "Boîte de réception",
+ "sent": "Envoyés",
+ "drafts": "Brouillons",
+ "trash": "Corbeille",
+ "archive": "Archives",
+ "starred": "Favoris",
+ "all_mail": "Tous les messages",
+ "spam": "Spam",
+ "important": "Important"
+ },
+ "expand": "Développer",
+ "collapse": "Réduire"
+ },
+ "email_list": {
+ "no_emails": "Aucun email",
+ "no_emails_description": "Commencez par composer un nouvel email",
+ "loading": "Chargement des emails...",
+ "unread": "non lu",
+ "to_me": "À moi",
+ "to_recipients": "À {{count}} destinataires",
+ "and_others": "et {{count}} autres",
+ "draft": "Brouillon",
+ "starred": "Favori"
+ },
+ "email_viewer": {
+ "no_email_selected": "Aucun email sélectionné",
+ "no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
+ "reply": "Répondre",
+ "reply_all": "Répondre à tous",
+ "forward": "Transférer",
+ "delete": "Supprimer",
+ "archive": "Archiver",
+ "star": "Marquer comme favori",
+ "unstar": "Retirer des favoris",
+ "mark_unread": "Marquer comme non lu",
+ "mark_read": "Marquer comme lu",
+ "print": "Imprimer",
+ "attachments": "Pièces jointes",
+ "download": "Télécharger",
+ "from": "De",
+ "to": "À",
+ "cc": "CC",
+ "bcc": "CCI",
+ "date": "Date",
+ "subject": "Objet",
+ "show_details": "Afficher les détails",
+ "hide_details": "Masquer les détails",
+ "external_content_warning": "Les images et le contenu externe ont été bloqués",
+ "load_external_content": "Charger les images",
+ "message_details": "Détails du message",
+ "authentication": {
+ "title": "Authentification",
+ "status": {
+ "verified": "Vérifié",
+ "warning": "Attention",
+ "none": "Non authentifié"
+ },
+ "spf": {
+ "pass": "SPF Validé",
+ "fail": "SPF Échoué",
+ "none": "Pas de SPF"
+ },
+ "dkim": {
+ "pass": "DKIM Valide",
+ "fail": "DKIM Invalide",
+ "none": "Pas de DKIM"
+ },
+ "dmarc": {
+ "pass": "DMARC Validé",
+ "fail": "DMARC Échoué",
+ "none": "Pas de DMARC"
+ },
+ "spam_score": "Score de spam"
+ },
+ "headers": {
+ "routing": "Routage",
+ "received": "Reçu",
+ "message_id": "ID du message",
+ "list_info": "Information de liste"
+ },
+ "color_tag": {
+ "title": "Étiquette de couleur",
+ "red": "Rouge",
+ "orange": "Orange",
+ "yellow": "Jaune",
+ "green": "Vert",
+ "blue": "Bleu",
+ "purple": "Violet",
+ "none": "Aucune"
+ }
+ },
+ "email_composer": {
+ "new_message": "Nouveau message",
+ "reply_to": "Répondre",
+ "reply_all_to": "Répondre à tous",
+ "forward_message": "Transférer",
+ "to": "À",
+ "cc": "CC",
+ "bcc": "CCI",
+ "subject": "Objet",
+ "body_placeholder": "Écrivez votre message...",
+ "send": "Envoyer",
+ "cancel": "Annuler",
+ "attach": "Joindre des fichiers",
+ "quote": {
+ "reply_header": "Le {{date}}, {{sender}} a écrit :",
+ "forward_header": "---------- Message transféré ----------",
+ "from": "De : {{sender}}",
+ "date": "Date : {{date}}",
+ "subject": "Objet : {{subject}}",
+ "to": "À : {{recipients}}"
+ }
+ },
+ "common": {
+ "loading": "Chargement...",
+ "error": "Erreur",
+ "success": "Succès",
+ "cancel": "Annuler",
+ "save": "Enregistrer",
+ "delete": "Supprimer",
+ "edit": "Modifier",
+ "close": "Fermer",
+ "search": "Rechercher",
+ "refresh": "Actualiser",
+ "settings": "Paramètres",
+ "help": "Aide",
+ "logout": "Déconnexion",
+ "yes": "Oui",
+ "no": "Non",
+ "unknown": "Inconnu"
+ },
+ "notifications": {
+ "email_sent": "Email envoyé avec succès",
+ "email_deleted": "Email supprimé",
+ "email_archived": "Email archivé",
+ "email_starred": "Email ajouté aux favoris",
+ "email_unstarred": "Email retiré des favoris",
+ "email_marked_read": "Email marqué comme lu",
+ "email_marked_unread": "Email marqué comme non lu",
+ "copied_to_clipboard": "Copié dans le presse-papiers",
+ "error_sending": "Échec de l'envoi de l'email",
+ "error_deleting": "Échec de la suppression de l'email",
+ "error_loading": "Échec du chargement des emails"
+ },
+ "date": {
+ "today": "Aujourd'hui",
+ "yesterday": "Hier",
+ "this_week": "Cette semaine",
+ "last_week": "La semaine dernière",
+ "this_month": "Ce mois-ci",
+ "older": "Plus ancien",
+ "just_now": "À l'instant",
+ "minutes_ago": "Il y a {{count}} minute",
+ "minutes_ago_plural": "Il y a {{count}} minutes",
+ "hours_ago": "Il y a {{count}} heure",
+ "hours_ago_plural": "Il y a {{count}} heures",
+ "days_ago": "Il y a {{count}} jour",
+ "days_ago_plural": "Il y a {{count}} jours"
+ },
+ "language": {
+ "title": "Langue",
+ "english": "English",
+ "french": "Français"
+ }
+}
\ No newline at end of file
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..9ee0f8d
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,14 @@
+import createMiddleware from 'next-intl/middleware';
+import { locales, defaultLocale } from './i18n';
+
+export default createMiddleware({
+ locales,
+ defaultLocale,
+ localePrefix: 'as-needed', // Don't show locale in URL for default language
+ localeDetection: true // Enable browser language detection
+});
+
+export const config = {
+ // Skip all paths that should not be internationalized
+ matcher: ['/((?!api|_next|.*\\..*).*)']
+};
\ No newline at end of file
diff --git a/next.config.js b/next.config.js
new file mode 100644
index 0000000..7ecfb8f
--- /dev/null
+++ b/next.config.js
@@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
+
+module.exports = nextConfig;
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 13e2094..c96f023 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,15 @@
"name": "jmap-webmail",
"version": "0.1.0",
"dependencies": {
+ "@types/dompurify": "^3.0.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "dompurify": "^3.2.7",
"jmap-jam": "^0.11.0",
"lucide-react": "^0.544.0",
"next": "15.5.4",
"next-auth": "^4.24.11",
+ "next-intl": "^4.3.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1",
@@ -60,6 +63,66 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@formatjs/ecma402-abstract": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
+ "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "2.2.7",
+ "@formatjs/intl-localematcher": "0.6.1",
+ "decimal.js": "^10.4.3",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz",
+ "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/fast-memoize": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
+ "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/icu-messageformat-parser": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz",
+ "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.4",
+ "@formatjs/icu-skeleton-parser": "1.8.14",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/icu-skeleton-parser": {
+ "version": "1.8.14",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz",
+ "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.4",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/intl-localematcher": {
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
+ "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2"
+ }
+ },
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@@ -694,6 +757,12 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/@schummar/icu-type-parser": {
+ "version": "1.21.5",
+ "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
+ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -979,6 +1048,15 @@
"tailwindcss": "4.1.14"
}
},
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz",
@@ -1009,6 +1087,12 @@
"@types/react": "^19.0.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001746",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz",
@@ -1080,6 +1164,12 @@
"url": "https://github.com/sponsors/kossnocorp"
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
"node_modules/detect-libc": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
@@ -1090,6 +1180,15 @@
"node": ">=8"
}
},
+ "node_modules/dompurify": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+ "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -1111,6 +1210,18 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/intl-messageformat": {
+ "version": "10.7.16",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz",
+ "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.4",
+ "@formatjs/fast-memoize": "2.2.7",
+ "@formatjs/icu-messageformat-parser": "2.11.2",
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1456,6 +1567,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/next": {
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz",
@@ -1540,6 +1660,33 @@
}
}
},
+ "node_modules/next-intl": {
+ "version": "4.3.9",
+ "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.9.tgz",
+ "integrity": "sha512-4oSROHlgy8a5Qr2vH69wxo9F6K0uc6nZM2GNzqSe6ET79DEzOmBeSijCRzD5txcI4i+XTGytu4cxFsDXLKEDpQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/amannn"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/intl-localematcher": "^0.5.4",
+ "negotiator": "^1.0.0",
+ "use-intl": "^4.3.9"
+ },
+ "peerDependencies": {
+ "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
+ "typescript": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -1855,7 +2002,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -1872,6 +2019,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/use-intl": {
+ "version": "4.3.9",
+ "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.9.tgz",
+ "integrity": "sha512-bZu+h13HIgOvsoGleQtUe4E6gM49CRm+AH36KnJVB/qb1+Beo7jr7HNrR8YWH8oaOkQfGNm6vh0HTepxng8UTg==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "^2.2.0",
+ "@schummar/icu-type-parser": "1.21.5",
+ "intl-messageformat": "^10.5.14"
+ },
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
+ }
+ },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
diff --git a/package.json b/package.json
index 236b073..77589ed 100644
--- a/package.json
+++ b/package.json
@@ -8,12 +8,15 @@
"start": "next start"
},
"dependencies": {
+ "@types/dompurify": "^3.0.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "dompurify": "^3.2.7",
"jmap-jam": "^0.11.0",
"lucide-react": "^0.544.0",
"next": "15.5.4",
"next-auth": "^4.24.11",
+ "next-intl": "^4.3.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1",
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
new file mode 100644
index 0000000..bc6177a
--- /dev/null
+++ b/stores/auth-store.ts
@@ -0,0 +1,118 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { JMAPClient } from '@/lib/jmap/client';
+
+interface AuthState {
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ error: string | null;
+ serverUrl: string | null;
+ username: string | null;
+ client: JMAPClient | null;
+
+ login: (serverUrl: string, username: string, password: string) => Promise;
+ logout: () => void;
+ checkAuth: () => Promise;
+ clearError: () => void;
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set, get) => ({
+ isAuthenticated: false,
+ isLoading: false,
+ error: null,
+ serverUrl: null,
+ username: null,
+ client: null,
+
+ login: async (serverUrl, username, password) => {
+ set({ isLoading: true, error: null });
+
+ try {
+ // Create JMAP client
+ const client = new JMAPClient(serverUrl, username, password);
+
+ // Try to connect
+ await client.connect();
+
+ // Success - save state (but NOT the password)
+ set({
+ isAuthenticated: true,
+ isLoading: false,
+ serverUrl,
+ username,
+ client,
+ error: null,
+ });
+
+ return true;
+ } catch (error) {
+ console.error('Login error:', error);
+ let errorKey = 'generic';
+
+ // Map common errors to translation keys
+ if (error instanceof Error) {
+ if (error.message.includes('401') || error.message.includes('Unauthorized')) {
+ errorKey = 'invalid_credentials';
+ } else if (error.message.includes('network') || error.message.includes('Failed to fetch')) {
+ errorKey = 'connection_failed';
+ }
+ }
+
+ set({
+ isLoading: false,
+ error: errorKey,
+ isAuthenticated: false,
+ client: null,
+ });
+ return false;
+ }
+ },
+
+ logout: () => {
+ set({
+ isAuthenticated: false,
+ serverUrl: null,
+ username: null,
+ client: null,
+ error: null,
+ });
+
+ // Clear persisted storage
+ localStorage.removeItem('auth-storage');
+ },
+
+ checkAuth: async () => {
+ const state = get();
+
+ // If authenticated but no client (e.g., after page refresh), we can't restore the session
+ // because we don't store passwords for security reasons
+ if (state.isAuthenticated && !state.client) {
+ // Reset auth state - user will need to log in again
+ set({
+ isAuthenticated: false,
+ isLoading: false,
+ client: null,
+ serverUrl: null,
+ username: null,
+ });
+ }
+
+ // Mark loading as complete
+ set({ isLoading: false });
+ },
+
+ clearError: () => set({ error: null }),
+ }),
+ {
+ name: 'auth-storage',
+ partialize: (state) => ({
+ // Only persist non-sensitive data
+ serverUrl: state.serverUrl,
+ username: state.username,
+ // Don't persist isAuthenticated since we can't restore the session without a password
+ }),
+ }
+ )
+);
\ No newline at end of file
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 6241b95..cb5ef35 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { Email, Mailbox } from "@/lib/jmap/types";
+import { JMAPClient } from "@/lib/jmap/client";
interface EmailStore {
emails: Email[];
@@ -8,6 +9,9 @@ interface EmailStore {
selectedMailbox: string;
isLoading: boolean;
error: string | null;
+ searchQuery: string;
+ quota: { used: number; total: number } | null;
+ processingReadStatus: Set; // Track emails being marked as read/unread
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
@@ -15,18 +19,35 @@ interface EmailStore {
selectMailbox: (mailboxId: string) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
+ setSearchQuery: (query: string) => void;
+ setQuota: (quota: { used: number; total: number } | null) => void;
+
+ // JMAP operations
+ fetchMailboxes: (client: JMAPClient) => Promise;
+ fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise;
+ fetchEmailContent: (client: JMAPClient, emailId: string) => Promise;
+ fetchQuota: (client: JMAPClient) => Promise;
+ sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[]) => Promise;
+ deleteEmail: (client: JMAPClient, emailId: string) => Promise;
+ markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise;
+ moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise;
+ searchEmails: (client: JMAPClient, query: string) => Promise;
+ toggleStar: (client: JMAPClient, emailId: string) => Promise;
// Mock data for demo
loadMockData: () => void;
}
-export const useEmailStore = create((set) => ({
+export const useEmailStore = create((set, get) => ({
emails: [],
mailboxes: [],
selectedEmail: null,
- selectedMailbox: "inbox",
+ selectedMailbox: "",
isLoading: false,
error: null,
+ searchQuery: "",
+ quota: null,
+ processingReadStatus: new Set(),
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
@@ -34,6 +55,323 @@ export const useEmailStore = create((set) => ({
selectMailbox: (mailboxId) => set({ selectedMailbox: mailboxId }),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
+ setSearchQuery: (query) => set({ searchQuery: query }),
+ setQuota: (quota) => set({ quota }),
+
+ // JMAP operations
+ fetchMailboxes: async (client) => {
+ set({ isLoading: true, error: null });
+ try {
+ const mailboxes = await client.getMailboxes();
+ set({ mailboxes, isLoading: false });
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
+ isLoading: false
+ });
+ }
+ },
+
+ fetchEmails: async (client, mailboxId) => {
+ set({ isLoading: true, error: null, emails: [] }); // Clear emails immediately for better loading UX
+ try {
+ const emails = await client.getEmails(mailboxId || get().selectedMailbox);
+ set({ emails, isLoading: false });
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "Failed to fetch emails",
+ isLoading: false,
+ emails: []
+ });
+ }
+ },
+
+ fetchEmailContent: async (client, emailId) => {
+ try {
+ const email = await client.getEmail(emailId);
+ if (email) {
+ set({ selectedEmail: email });
+ }
+ return email;
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "Failed to fetch email content"
+ });
+ return null;
+ }
+ },
+
+ fetchQuota: async (client) => {
+ try {
+ const quota = await client.getQuota();
+ set({ quota });
+ } catch (error) {
+ console.log('Failed to fetch quota:', error);
+ // Don't set error state as quota is optional
+ }
+ },
+
+ sendEmail: async (client, to, subject, body, cc, bcc) => {
+ set({ isLoading: true, error: null });
+ try {
+ await client.sendEmail(to, subject, body, cc, bcc);
+ // Refresh emails after sending
+ await get().fetchEmails(client);
+ set({ isLoading: false });
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "Failed to send email",
+ isLoading: false
+ });
+ throw error;
+ }
+ },
+
+ deleteEmail: async (client, emailId) => {
+ try {
+ // Get the email to check if it's unread and which mailboxes it belongs to
+ const email = get().emails.find(e => e.id === emailId);
+ if (!email) return;
+
+ const isUnread = !email.keywords?.$seen;
+
+ await client.deleteEmail(emailId);
+
+ // Remove from local state and update mailbox counters if needed
+ set((state) => {
+ let updatedMailboxes = state.mailboxes;
+
+ // If the email was unread, decrement the unread counters
+ if (isUnread && email.mailboxIds) {
+ updatedMailboxes = state.mailboxes.map(mailbox => {
+ if (email.mailboxIds[mailbox.id]) {
+ return {
+ ...mailbox,
+ totalEmails: Math.max(0, mailbox.totalEmails - 1),
+ unreadEmails: Math.max(0, mailbox.unreadEmails - 1),
+ totalThreads: Math.max(0, mailbox.totalThreads - 1),
+ unreadThreads: Math.max(0, mailbox.unreadThreads - 1)
+ };
+ }
+ return mailbox;
+ });
+ } else if (email.mailboxIds) {
+ // If email was read, only decrement total counters
+ updatedMailboxes = state.mailboxes.map(mailbox => {
+ if (email.mailboxIds[mailbox.id]) {
+ return {
+ ...mailbox,
+ totalEmails: Math.max(0, mailbox.totalEmails - 1),
+ totalThreads: Math.max(0, mailbox.totalThreads - 1)
+ };
+ }
+ return mailbox;
+ });
+ }
+
+ return {
+ emails: state.emails.filter(e => e.id !== emailId),
+ selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
+ mailboxes: updatedMailboxes
+ };
+ });
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "Failed to delete email"
+ });
+ throw error;
+ }
+ },
+
+ markAsRead: async (client, emailId, read) => {
+ try {
+ // Check if this email is already being processed
+ const processingKey = `${emailId}-${read}`;
+ const currentProcessing = get().processingReadStatus;
+ if (currentProcessing.has(processingKey)) {
+ console.log(`Already processing markAsRead for ${emailId} to ${read}`);
+ return; // Already being processed
+ }
+
+ // Get the email to check its current state and mailboxes
+ const email = get().emails.find(e => e.id === emailId);
+ if (!email) return;
+
+ // Check if already in the desired state
+ const isCurrentlyRead = email.keywords?.$seen === true;
+ if (isCurrentlyRead === read) {
+ console.log(`Email ${emailId} already has $seen=${read}`);
+ return; // Already in desired state
+ }
+
+ // Add to processing set
+ set((state) => ({
+ processingReadStatus: new Set([...state.processingReadStatus, processingKey])
+ }));
+
+ await client.markAsRead(emailId, read);
+
+ // Update local state including mailbox counters
+ set((state) => {
+ // Remove from processing set
+ const newProcessingSet = new Set(state.processingReadStatus);
+ newProcessingSet.delete(processingKey);
+
+ // Only update counters if the state is actually changing
+ const emailInState = state.emails.find(e => e.id === emailId);
+ if (!emailInState) return { processingReadStatus: newProcessingSet };
+
+ const wasRead = emailInState.keywords?.$seen === true;
+ if (wasRead === read) {
+ console.log(`Email ${emailId} state unchanged, skipping counter update`);
+ return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
+ }
+
+ const updatedMailboxes = state.mailboxes.map(mailbox => {
+ // Check if this email belongs to this mailbox
+ if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) {
+ // Adjust unread counter: -1 if marking as read, +1 if marking as unread
+ const delta = read ? -1 : 1;
+ return {
+ ...mailbox,
+ unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
+ unreadThreads: Math.max(0, mailbox.unreadThreads + delta)
+ };
+ }
+ return mailbox;
+ });
+
+ return {
+ emails: state.emails.map(e =>
+ e.id === emailId ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
+ ),
+ selectedEmail: state.selectedEmail?.id === emailId
+ ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
+ : state.selectedEmail,
+ mailboxes: updatedMailboxes,
+ processingReadStatus: newProcessingSet
+ };
+ });
+ } catch (error) {
+ // Remove from processing set on error
+ set((state) => {
+ const newProcessingSet = new Set(state.processingReadStatus);
+ newProcessingSet.delete(`${emailId}-${read}`);
+ return {
+ processingReadStatus: newProcessingSet,
+ error: error instanceof Error ? error.message : "Failed to update email"
+ };
+ });
+ throw error;
+ }
+ },
+
+ moveToMailbox: async (client, emailId, destinationMailboxId) => {
+ try {
+ // Get the email to check its current mailboxes and read status
+ const email = get().emails.find(e => e.id === emailId);
+ if (!email) return;
+
+ const isUnread = !email.keywords?.$seen;
+ const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
+
+ await client.moveEmail(emailId, destinationMailboxId);
+
+ // Update local state and mailbox counters
+ set((state) => {
+ // Update mailbox counters
+ const updatedMailboxes = state.mailboxes.map(mailbox => {
+ // Remove from current mailboxes
+ if (currentMailboxIds.includes(mailbox.id)) {
+ return {
+ ...mailbox,
+ totalEmails: Math.max(0, mailbox.totalEmails - 1),
+ unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
+ totalThreads: Math.max(0, mailbox.totalThreads - 1),
+ unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
+ };
+ }
+ // Add to destination mailbox
+ else if (mailbox.id === destinationMailboxId) {
+ return {
+ ...mailbox,
+ totalEmails: mailbox.totalEmails + 1,
+ unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
+ totalThreads: mailbox.totalThreads + 1,
+ unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
+ };
+ }
+ return mailbox;
+ });
+
+ // Update the email's mailboxIds
+ const updatedEmails = state.emails.map(e => {
+ if (e.id === emailId) {
+ return {
+ ...e,
+ mailboxIds: { [destinationMailboxId]: true }
+ };
+ }
+ return e;
+ });
+
+ // If moved email is selected, update selectedEmail too
+ const updatedSelectedEmail = state.selectedEmail?.id === emailId
+ ? { ...state.selectedEmail, mailboxIds: { [destinationMailboxId]: true } }
+ : state.selectedEmail;
+
+ return {
+ emails: updatedEmails,
+ selectedEmail: updatedSelectedEmail,
+ mailboxes: updatedMailboxes
+ };
+ });
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "Failed to move email"
+ });
+ throw error;
+ }
+ },
+
+ searchEmails: async (client, query) => {
+ set({ isLoading: true, error: null, searchQuery: query, emails: [] }); // Clear emails for loading state
+ try {
+ const emails = await client.searchEmails(query);
+ set({ emails, isLoading: false });
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "Failed to search emails",
+ isLoading: false,
+ emails: []
+ });
+ }
+ },
+
+ toggleStar: async (client, emailId) => {
+ try {
+ const email = get().emails.find(e => e.id === emailId);
+ if (!email) return;
+
+ const isFlagged = email.keywords.$flagged || false;
+ await client.toggleStar(emailId, !isFlagged);
+
+ // Update local state
+ set((state) => ({
+ emails: state.emails.map(e =>
+ e.id === emailId ? { ...e, keywords: { ...e.keywords, $flagged: !isFlagged } } : e
+ ),
+ selectedEmail: state.selectedEmail?.id === emailId
+ ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $flagged: !isFlagged } }
+ : state.selectedEmail
+ }));
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : "Failed to update star"
+ });
+ throw error;
+ }
+ },
loadMockData: () => {
const mockEmails: Email[] = [
diff --git a/stores/locale-store.ts b/stores/locale-store.ts
new file mode 100644
index 0000000..7cc5e96
--- /dev/null
+++ b/stores/locale-store.ts
@@ -0,0 +1,19 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+interface LocaleStore {
+ locale: string;
+ setLocale: (locale: string) => void;
+}
+
+export const useLocaleStore = create()(
+ persist(
+ (set) => ({
+ locale: 'en',
+ setLocale: (locale) => set({ locale }),
+ }),
+ {
+ name: 'locale-storage',
+ }
+ )
+);
\ No newline at end of file
diff --git a/stores/theme-store.ts b/stores/theme-store.ts
new file mode 100644
index 0000000..1c27a40
--- /dev/null
+++ b/stores/theme-store.ts
@@ -0,0 +1,83 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+type Theme = 'light' | 'dark' | 'system';
+
+interface ThemeState {
+ theme: Theme;
+ resolvedTheme: 'light' | 'dark';
+ setTheme: (theme: Theme) => void;
+ toggleTheme: () => void;
+ initializeTheme: () => void;
+}
+
+const getSystemTheme = (): 'light' | 'dark' => {
+ if (typeof window === 'undefined') return 'light';
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+};
+
+const applyTheme = (theme: 'light' | 'dark') => {
+ if (typeof document === 'undefined') return;
+
+ const root = document.documentElement;
+ if (theme === 'dark') {
+ root.classList.add('dark');
+ } else {
+ root.classList.remove('dark');
+ }
+};
+
+export const useThemeStore = create()(
+ persist(
+ (set, get) => ({
+ theme: 'system',
+ resolvedTheme: 'light',
+
+ setTheme: (theme) => {
+ const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
+ applyTheme(resolvedTheme);
+ set({ theme, resolvedTheme });
+ },
+
+ toggleTheme: () => {
+ const { theme } = get();
+ const nextTheme: Theme =
+ theme === 'light' ? 'dark' :
+ theme === 'dark' ? 'system' : 'light';
+ get().setTheme(nextTheme);
+ },
+
+ initializeTheme: () => {
+ const { theme } = get();
+ const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
+ applyTheme(resolvedTheme);
+ set({ resolvedTheme });
+
+ // Listen for system theme changes
+ if (typeof window !== 'undefined') {
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ const handleChange = () => {
+ const { theme } = get();
+ if (theme === 'system') {
+ const newResolvedTheme = getSystemTheme();
+ applyTheme(newResolvedTheme);
+ set({ resolvedTheme: newResolvedTheme });
+ }
+ };
+
+ // Modern browsers
+ if (mediaQuery.addEventListener) {
+ mediaQuery.addEventListener('change', handleChange);
+ } else {
+ // Fallback for older browsers
+ mediaQuery.addListener(handleChange);
+ }
+ }
+ },
+ }),
+ {
+ name: 'theme-storage',
+ partialize: (state) => ({ theme: state.theme }),
+ }
+ )
+);
\ No newline at end of file
diff --git a/stores/toast-store.ts b/stores/toast-store.ts
new file mode 100644
index 0000000..b151b26
--- /dev/null
+++ b/stores/toast-store.ts
@@ -0,0 +1,52 @@
+import { create } from "zustand";
+import { Toast, ToastType } from "@/components/ui/toast";
+
+interface ToastStore {
+ toasts: Toast[];
+ addToast: (toast: Omit) => void;
+ removeToast: (id: string) => void;
+ clearToasts: () => void;
+}
+
+export const useToastStore = create((set) => ({
+ toasts: [],
+
+ addToast: (toast) => {
+ const id = Math.random().toString(36).substring(2, 11);
+ const newToast: Toast = {
+ ...toast,
+ id,
+ duration: toast.duration ?? 5000, // Default 5 seconds
+ };
+
+ set((state) => ({
+ toasts: [...state.toasts, newToast],
+ }));
+ },
+
+ removeToast: (id) => {
+ set((state) => ({
+ toasts: state.toasts.filter((toast) => toast.id !== id),
+ }));
+ },
+
+ clearToasts: () => {
+ set({ toasts: [] });
+ },
+}));
+
+// Helper functions for common toast types
+export const toast = {
+ success: (title: string, message?: string) => {
+ useToastStore.getState().addToast({ type: "success", title, message });
+ },
+ error: (title: string, message?: string) => {
+ useToastStore.getState().addToast({ type: "error", title, message, duration: 10000 });
+ },
+ info: (title: string, message?: string) => {
+ useToastStore.getState().addToast({ type: "info", title, message });
+ },
+ warning: (title: string, message?: string) => {
+ useToastStore.getState().addToast({ type: "warning", title, message });
+ },
+};
\ No newline at end of file
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 7848dc7..d4e90b6 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,6 +1,7 @@
import type { Config } from "tailwindcss";
export default {
+ darkMode: 'class',
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",