diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e22a646 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# JMAP Webmail Configuration +# Copy this file to .env.local and fill in your values + +# JMAP server URL (required) +# This is the URL of your JMAP-compatible mail server +NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com diff --git a/.gitignore b/.gitignore index 5ef6a52..3d07a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,9 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files .env* +!.env.example # vercel .vercel diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..58f8aa2 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx tsc --noEmit && npx eslint . --ext .ts,.tsx diff --git a/TODO.md b/TODO.md index 253950c..4f2a8a2 100644 --- a/TODO.md +++ b/TODO.md @@ -84,7 +84,7 @@ - [x] Add attachment upload support - [x] Implement batch operations (mark read/unread, delete multiple) - [x] Implement functional quick reply form -- [ ] Add email threading support +- [x] Add email threading support (Gmail-style inline expansion) ### Real-time Updates - [x] Set up EventSource for JMAP push notifications @@ -245,11 +245,39 @@ All settings are now properly wired to their functionality: - **Navigation**: j/k or arrows (next/prev email), Enter/o (open), Esc (close) - **Actions**: r (reply), R/a (reply all), f (forward), s (star), e (archive), #/Del (delete), u (unread) - **Global**: c (compose), / (search), ? (help), Shift+G (refresh), Ctrl+A (select all) +- **Threads**: x (expand/collapse thread) - Disabled when typing in inputs or when composer is open +### Email Threading (2025-12-10) +- **Style**: Gmail-style inline expansion (desktop), full-screen conversation view (mobile) +- **Files**: + - lib/thread-utils.ts - Thread grouping utilities + - lib/jmap/types.ts - ThreadGroup interface + - lib/jmap/client.ts - getThread(), getThreadEmails() methods + - stores/email-store.ts - Thread expansion state (expandedThreadIds, threadEmailsCache) + - components/email/thread-list-item.tsx - Collapsed/expanded thread view (desktop), tap-to-open (mobile) + - components/email/thread-email-item.tsx - Compact email within thread + - components/email/thread-conversation-view.tsx - Full-screen mobile conversation view + - components/email/email-list.tsx - Groups emails by threadId +- **Desktop Features**: + - Threads grouped by threadId (client-side grouping) + - Collapsed view shows: participants, email count badge, latest subject/date + - Expanded view shows: all emails in thread with indentation + - Lazy loading: complete thread fetched via Thread/get on expansion + - State preserved per-mailbox, cleared on mailbox switch + - Keyboard: x to expand/collapse selected thread +- **Mobile Features**: + - Tap thread → full-screen conversation view (no inline expansion) + - Collapsible email cards, most recent auto-expanded + - Full HTML email content with DOMPurify sanitization + - External content blocking with user override + - Inline attachments with download support + - Reply/Reply All/Forward buttons on expanded cards + - Back navigation returns to email list + ### Feature Completeness - **Authentication**: ✅ Complete (secure design, no password storage) -- **Email Operations**: ✅ Complete except threading +- **Email Operations**: ✅ Complete (including threading) - **Real-time Updates**: ✅ Complete (EventSource push, toast notifications, status indicator) - **UI Enhancements**: ✅ Settings fully integrated, drag-drop, context menus, mobile responsive, keyboard shortcuts - **Contacts/Address Book**: ❌ Not started diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 7941bac..c7b007e 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -16,6 +16,89 @@ export default function LoginPage() { const serverUrl = process.env.NEXT_PUBLIC_JMAP_SERVER_URL; + // All hooks must be called unconditionally at the top + const [formData, setFormData] = useState({ + username: "", + password: "", + }); + + const [savedUsernames, setSavedUsernames] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [filteredSuggestions, setFilteredSuggestions] = useState([]); + const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); + const suggestionsRef = useRef(null); + const inputRef = useRef(null); + const justSelectedSuggestion = useRef(false); + + // Set page title + useEffect(() => { + if (serverUrl) { + document.title = `${t('title')} - Webmail`; + } + }, [t, serverUrl]); + + // Load saved usernames from localStorage on mount + useEffect(() => { + if (!serverUrl) return; + const saved = localStorage.getItem("webmail_usernames"); + if (saved) { + try { + const usernames = JSON.parse(saved); + setSavedUsernames(usernames); + } catch { + console.error("Failed to parse saved usernames"); + } + } + }, [serverUrl]); + + useEffect(() => { + if (isAuthenticated) { + router.push(`/${params.locale}`); + } + }, [isAuthenticated, router, params.locale]); + + useEffect(() => { + clearError(); + }, [formData, clearError]); + + // Filter suggestions based on input + useEffect(() => { + if (!serverUrl) return; + // Skip showing suggestions if we just selected one + if (justSelectedSuggestion.current) { + justSelectedSuggestion.current = false; + return; + } + + if (formData.username && savedUsernames.length > 0) { + const filtered = savedUsernames.filter(username => + username.toLowerCase().includes(formData.username.toLowerCase()) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + } else if (formData.username === "" && savedUsernames.length > 0) { + setFilteredSuggestions(savedUsernames); + setShowSuggestions(false); // Don't show on empty input + } else { + setShowSuggestions(false); + } + setSelectedSuggestionIndex(-1); + }, [formData.username, savedUsernames, serverUrl]); + + // Close suggestions when clicking outside + useEffect(() => { + if (!serverUrl) return; + const handleClickOutside = (event: MouseEvent) => { + if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) && + inputRef.current && !inputRef.current.contains(event.target as Node)) { + setShowSuggestions(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [serverUrl]); + // Show error if JMAP server URL is not configured if (!serverUrl) { return ( @@ -33,37 +116,6 @@ export default function LoginPage() { ); } - // Set page title - useEffect(() => { - document.title = `${t('title')} - Webmail`; - }, [t]); - - const [formData, setFormData] = useState({ - username: "", - password: "", - }); - - const [savedUsernames, setSavedUsernames] = useState([]); - const [showSuggestions, setShowSuggestions] = useState(false); - const [filteredSuggestions, setFilteredSuggestions] = useState([]); - const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); - const suggestionsRef = useRef(null); - const inputRef = useRef(null); - const justSelectedSuggestion = useRef(false); - - // Load saved usernames from localStorage on mount - useEffect(() => { - const saved = localStorage.getItem("webmail_usernames"); - if (saved) { - try { - const usernames = JSON.parse(saved); - setSavedUsernames(usernames); - } catch { - console.error("Failed to parse saved usernames"); - } - } - }, []); - // Save username on successful login const saveUsername = (username: string) => { const saved = localStorage.getItem("webmail_usernames"); @@ -96,52 +148,6 @@ export default function LoginPage() { )); }; - useEffect(() => { - if (isAuthenticated) { - router.push(`/${params.locale}`); - } - }, [isAuthenticated, router, params.locale]); - - useEffect(() => { - clearError(); - }, [formData, clearError]); - - // Filter suggestions based on input - useEffect(() => { - // Skip showing suggestions if we just selected one - if (justSelectedSuggestion.current) { - justSelectedSuggestion.current = false; - return; - } - - if (formData.username && savedUsernames.length > 0) { - const filtered = savedUsernames.filter(username => - username.toLowerCase().includes(formData.username.toLowerCase()) - ); - setFilteredSuggestions(filtered); - setShowSuggestions(filtered.length > 0); - } else if (formData.username === "" && savedUsernames.length > 0) { - setFilteredSuggestions(savedUsernames); - setShowSuggestions(false); // Don't show on empty input - } else { - setShowSuggestions(false); - } - setSelectedSuggestionIndex(-1); - }, [formData.username, savedUsernames]); - - // Close suggestions when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) && - inputRef.current && !inputRef.current.contains(event.target as Node)) { - setShowSuggestions(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - const handleUsernameChange = (e: React.ChangeEvent) => { setFormData({ ...formData, username: e.target.value }); }; diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 1dd9116..17a8437 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -7,7 +7,9 @@ import { Sidebar } from "@/components/layout/sidebar"; import { EmailList } from "@/components/email/email-list"; import { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; +import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header"; +import { ThreadGroup, Email } from "@/lib/jmap/types"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -34,6 +36,10 @@ export default function Home() { const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [initialCheckDone, setInitialCheckDone] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false); + // Mobile conversation view state + const [conversationThread, setConversationThread] = useState(null); + const [conversationEmails, setConversationEmails] = useState([]); + const [isLoadingConversation, setIsLoadingConversation] = useState(false); const markAsReadTimeoutRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore(); @@ -555,10 +561,55 @@ export default function Home() { // Handle back navigation from viewer on mobile const handleMobileBack = () => { + // If in conversation view, clear it + if (conversationThread) { + setConversationThread(null); + setConversationEmails([]); + } selectEmail(null); setActiveView("list"); }; + // Handle opening conversation view on mobile + const handleOpenConversation = async (thread: ThreadGroup) => { + if (!client) return; + + setConversationThread(thread); + setIsLoadingConversation(true); + setActiveView("viewer"); + + try { + // Fetch complete thread emails + const emails = await client.getThreadEmails(thread.threadId); + setConversationEmails(emails); + } catch (error) { + console.error('Failed to fetch thread emails:', error); + // Fall back to thread.emails + setConversationEmails(thread.emails); + } finally { + setIsLoadingConversation(false); + } + }; + + // Handle reply from conversation view + const handleConversationReply = (email: Email) => { + selectEmail(email); + setComposerMode('reply'); + setShowComposer(true); + }; + + const handleConversationReplyAll = (email: Email) => { + selectEmail(email); + setComposerMode('replyAll'); + setShowComposer(true); + }; + + const handleConversationForward = (email: Email) => { + selectEmail(email); + setComposerMode('forward'); + setShowComposer(true); + }; + return (
@@ -628,6 +679,7 @@ export default function Home() { selectedEmailId={selectedEmail?.id} isLoading={isLoading} onEmailSelect={handleEmailSelect} + onOpenConversation={handleOpenConversation} // Context menu handlers onReply={(email) => { selectEmail(email); @@ -683,37 +735,58 @@ export default function Home() { "md:flex-1 md:relative" )} > - {/* Mobile Header for Viewer */} - {isMobile && activeView === "viewer" && ( - - )} - - - { if (client) { await markAsRead(client, emailId, read); } }} - onDownloadAttachment={handleDownloadAttachment} - onQuickReply={handleQuickReply} - currentUserEmail={client?.["username"]} - currentUserName={client?.["username"]?.split("@")[0]} - className={isMobile ? "flex-1" : undefined} /> - + ) : ( + <> + {/* Mobile Header for Viewer */} + {isMobile && activeView === "viewer" && ( + + )} + + + { + if (client) { + await markAsRead(client, emailId, read); + } + }} + onDownloadAttachment={handleDownloadAttachment} + onQuickReply={handleQuickReply} + currentUserEmail={client?.["username"]} + currentUserName={client?.["username"]?.split("@")[0]} + className={isMobile ? "flex-1" : undefined} + /> + + + )}
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 91d099d..cfbc8db 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -1,14 +1,15 @@ "use client"; -import { Email } from "@/lib/jmap/types"; -import { EmailListItem } from "./email-list-item"; +import { Email, ThreadGroup } from "@/lib/jmap/types"; +import { ThreadListItem } from "./thread-list-item"; import { EmailContextMenu } from "./email-context-menu"; import { cn } from "@/lib/utils"; import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; +import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils"; import { useContextMenu } from "@/hooks/use-context-menu"; interface EmailListProps { @@ -17,6 +18,8 @@ interface EmailListProps { onEmailSelect?: (email: Email) => void; className?: string; isLoading?: boolean; + // Mobile conversation view handler + onOpenConversation?: (thread: ThreadGroup) => void; // Context menu actions onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; @@ -35,6 +38,7 @@ export function EmailList({ onEmailSelect, className, isLoading = false, + onOpenConversation, onReply, onReplyAll, onForward, @@ -58,8 +62,19 @@ export function EmailList({ isLoadingMore, mailboxes, selectedMailbox, + expandedThreadIds, + threadEmailsCache, + isLoadingThread, + toggleThreadExpansion, + fetchThreadEmails, } = useEmailStore(); + // Group emails by thread + const threadGroups = useMemo(() => { + const groups = groupEmailsByThread(emails); + return sortThreadGroups(groups); + }, [emails]); + // Context menu state const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); @@ -116,6 +131,20 @@ export function EmailList({ } }, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]); + // Handle thread expansion and fetch complete thread + const handleToggleThreadExpansion = useCallback(async (threadId: string) => { + const isExpanded = expandedThreadIds.has(threadId); + + if (!isExpanded && client) { + // Expanding - fetch complete thread emails + toggleThreadExpansion(threadId); + await fetchThreadEmails(client, threadId); + } else { + // Collapsing - just toggle + toggleThreadExpansion(threadId); + } + }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]); + useEffect(() => { const observer = new IntersectionObserver( (entries) => { @@ -231,7 +260,7 @@ export function EmailList({ )}

- {isLoading ? 'Loading...' : emails.length > 0 ? `${emails.length} conversations` : 'No conversations'} + {isLoading ? 'Loading...' : threadGroups.length > 0 ? `${threadGroups.length} conversations` : 'No conversations'}

@@ -259,13 +288,18 @@ export function EmailList({ ) : (
- {emails.map((email) => ( - onEmailSelect?.(email)} + {threadGroups.map((thread) => ( + handleToggleThreadExpansion(thread.threadId)} + onEmailSelect={(email) => onEmailSelect?.(email)} onContextMenu={openContextMenu} + onOpenConversation={onOpenConversation} /> ))} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx new file mode 100644 index 0000000..a62cc63 --- /dev/null +++ b/components/email/thread-conversation-view.tsx @@ -0,0 +1,489 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import DOMPurify from "dompurify"; +import { Email, ThreadGroup } from "@/lib/jmap/types"; +import { Avatar } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { formatDate, formatFileSize, cn } from "@/lib/utils"; +import { + ArrowLeft, + ChevronDown, + ChevronUp, + Reply, + ReplyAll, + Forward, + Paperclip, + Star, + Download, + Loader2, + FileText, + FileImage, + FileVideo, + FileAudio, + FileArchive, + File, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useSettingsStore } from "@/stores/settings-store"; + +interface ThreadConversationViewProps { + thread: ThreadGroup; + emails: Email[]; + isLoading?: boolean; + onBack: () => void; + onReply?: (email: Email) => void; + onReplyAll?: (email: Email) => void; + onForward?: (email: Email) => void; + onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; + onMarkAsRead?: (emailId: string, read: boolean) => 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; + } + return File; +}; + +export function ThreadConversationView({ + thread, + emails, + isLoading = false, + onBack, + onReply, + onReplyAll, + onForward, + onDownloadAttachment, + onMarkAsRead, +}: ThreadConversationViewProps) { + const t = useTranslations(); + const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); + + // Track which emails are expanded (most recent by default) + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [allowExternalContent, setAllowExternalContent] = useState>(new Set()); + + // Auto-expand most recent email AND all unread emails when thread opens + useEffect(() => { + if (emails.length > 0) { + const idsToExpand = new Set(); + + // Always expand most recent + idsToExpand.add(emails[0].id); + + // Also expand all unread emails + emails.forEach(email => { + if (!email.keywords?.$seen) { + idsToExpand.add(email.id); + } + }); + + setExpandedIds(idsToExpand); + } + }, [emails]); + + const toggleExpanded = (emailId: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(emailId)) { + next.delete(emailId); + } else { + next.add(emailId); + } + return next; + }); + }; + + const toggleAllowExternal = (emailId: string) => { + setAllowExternalContent(prev => { + const next = new Set(prev); + next.add(emailId); + return next; + }); + }; + + if (isLoading) { + return ( +
+
+ +

{t("threads.loading")}

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ {thread.latestEmail.subject || t("email_viewer.no_subject")} +

+

+ {t("threads.messages_other", { count: emails.length })} +

+
+
+ + {/* Email Cards */} +
+
+ {emails.map((email, index) => ( + toggleExpanded(email.id)} + onAllowExternal={() => toggleAllowExternal(email.id)} + onReply={onReply ? () => onReply(email) : undefined} + onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined} + onForward={onForward ? () => onForward(email) : undefined} + onDownloadAttachment={onDownloadAttachment} + onMarkAsRead={onMarkAsRead} + /> + ))} +
+
+
+ ); +} + +// Individual email card component +interface EmailCardProps { + email: Email; + isExpanded: boolean; + isLatest: boolean; + allowExternal: boolean; + onToggleExpanded: () => void; + onAllowExternal: () => void; + onReply?: () => void; + onReplyAll?: () => void; + onForward?: () => void; + onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; + onMarkAsRead?: (emailId: string, read: boolean) => void; +} + +function EmailCard({ + email, + isExpanded, + isLatest: _isLatest, + allowExternal, + onToggleExpanded, + onAllowExternal, + onReply, + onReplyAll, + onForward, + onDownloadAttachment, + onMarkAsRead, +}: EmailCardProps) { + const t = useTranslations(); + const sender = email.from?.[0]; + const isUnread = !email.keywords?.$seen; + const isStarred = email.keywords?.$flagged; + const [hasBlockedContent, setHasBlockedContent] = useState(false); + + // Mark as read when email is expanded + useEffect(() => { + // Only trigger if expanded, email is unread, and we have a handler + if (!isExpanded || !onMarkAsRead || email.keywords?.$seen) { + return; + } + + const markAsReadDelay = useSettingsStore.getState().markAsReadDelay; + + // Never auto-mark + if (markAsReadDelay === -1) { + return; + } + + // Instant mark + if (markAsReadDelay === 0) { + onMarkAsRead(email.id, true); + return; + } + + // Delayed mark + const timeout = setTimeout(() => { + onMarkAsRead(email.id, true); + }, markAsReadDelay); + + return () => clearTimeout(timeout); + }, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]); + + // Sanitize and prepare email HTML content + const emailContent = useMemo(() => { + if (!email) return { html: "", isHtml: false }; + + if (email.bodyValues) { + let useHtmlVersion = false; + let htmlContent = ''; + + if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { + htmlContent = email.bodyValues[email.htmlBody[0].partId].value; + + 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; + + useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags); + } + + if (useHtmlVersion && htmlContent) { + let blockedExternalContent = false; + + const sanitizeConfig = { + ADD_TAGS: ['style'], + ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'], + FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'], + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'], + }; + + if (!allowExternal) { + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + 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); + node.removeAttribute('src'); + node.setAttribute('alt', '[Image blocked]'); + blockedExternalContent = true; + } + } + if (node.hasAttribute('style')) { + const style = node.getAttribute('style'); + if (style && /url\s*\(/i.test(style)) { + const cleanStyle = style.replace(/url\s*\([^)]*\)/gi, 'none'); + node.setAttribute('style', cleanStyle); + blockedExternalContent = true; + } + } + }); + } + + const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig); + DOMPurify.removeHook('afterSanitizeAttributes'); + + if (blockedExternalContent) { + setHasBlockedContent(true); + } + + return { html: sanitized, isHtml: true }; + } + + // Plain text fallback + if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { + const text = email.bodyValues[email.textBody[0].partId].value; + const htmlEscaped = text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
') + .replace(/(https?:\/\/[^\s<]+)/g, '$1'); + return { html: htmlEscaped, isHtml: false }; + } + } + + // Fallback to preview + if (email.preview) { + return { html: email.preview.replace(/\n/g, '
'), isHtml: false }; + } + + return { html: "", isHtml: false }; + }, [email, allowExternal]); + + return ( +
+ {/* Card Header - Always visible */} + + + {/* Expanded Content */} + {isExpanded && ( +
+ {/* External content warning */} + {hasBlockedContent && !allowExternal && ( +
+ + {t("email_viewer.external_content_warning")} + + +
+ )} + + {/* Email Body */} +
+
+
+ + {/* Attachments */} + {email.attachments && email.attachments.length > 0 && ( +
+
+ {email.attachments.map((attachment, idx) => { + const Icon = getFileIcon(attachment.name, attachment.type); + return ( + + ); + })} +
+
+ )} + + {/* Action Buttons */} +
+ {onReply && ( + + )} + {onReplyAll && ( + + )} + {onForward && ( + + )} +
+
+ )} +
+ ); +} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx new file mode 100644 index 0000000..ffe8b34 --- /dev/null +++ b/components/email/thread-email-item.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { formatDate } from "@/lib/utils"; +import { Email } from "@/lib/jmap/types"; +import { cn } from "@/lib/utils"; +import { Avatar } from "@/components/ui/avatar"; +import { Paperclip, Star, Circle } from "lucide-react"; + +interface ThreadEmailItemProps { + email: Email; + selected?: boolean; + isLast?: boolean; + onClick?: () => void; + onContextMenu?: (e: React.MouseEvent, email: Email) => void; +} + +export function ThreadEmailItem({ + email, + selected, + isLast = false, + onClick, + onContextMenu, +}: ThreadEmailItemProps) { + const isUnread = !email.keywords?.$seen; + const isStarred = email.keywords?.$flagged; + const sender = email.from?.[0]; + + const handleContextMenu = (e: React.MouseEvent) => { + onContextMenu?.(e, email); + }; + + return ( +
+
+ {/* Unread indicator */} + {isUnread && ( +
+ +
+ )} + + {/* Small Avatar */} + + + {/* Content */} +
+ {/* Single line: Sender, indicators, preview, date */} +
+ + {sender?.name || sender?.email?.split('@')[0] || "Unknown"} + + + {/* Indicators */} +
+ {isStarred && ( + + )} + {email.hasAttachment && ( + + )} +
+ + {/* Preview snippet */} + + {email.preview || "No preview"} + + + {/* Date */} + + {formatDate(email.receivedAt)} + +
+
+
+
+ ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx new file mode 100644 index 0000000..fbdcc62 --- /dev/null +++ b/components/email/thread-list-item.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { formatDate } from "@/lib/utils"; +import { Email, ThreadGroup } from "@/lib/jmap/types"; +import { cn } from "@/lib/utils"; +import { Avatar } from "@/components/ui/avatar"; +import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2 } from "lucide-react"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useUIStore } from "@/stores/ui-store"; +import { getThreadColorTag } from "@/lib/thread-utils"; +import { ThreadEmailItem } from "./thread-email-item"; + +interface ThreadListItemProps { + thread: ThreadGroup; + isExpanded: boolean; + selectedEmailId?: string; + isLoading?: boolean; + expandedEmails?: Email[]; // Full thread emails when expanded + onToggleExpand: () => void; + onEmailSelect: (email: Email) => void; + onContextMenu?: (e: React.MouseEvent, email: Email) => void; + onOpenConversation?: (thread: ThreadGroup) => void; // Mobile: open full conversation view +} + +// Color tag mapping +const colorTags = { + red: "bg-red-50 dark:bg-red-950/30", + orange: "bg-orange-50 dark:bg-orange-950/30", + yellow: "bg-yellow-50 dark:bg-yellow-950/30", + green: "bg-green-50 dark:bg-green-950/30", + blue: "bg-blue-50 dark:bg-blue-950/30", + purple: "bg-purple-50 dark:bg-purple-950/30", + pink: "bg-pink-50 dark:bg-pink-950/30", +} as const; + +export function ThreadListItem({ + thread, + isExpanded, + selectedEmailId, + isLoading = false, + expandedEmails, + onToggleExpand, + onEmailSelect, + onContextMenu, + onOpenConversation, +}: ThreadListItemProps) { + const showPreview = useSettingsStore((state) => state.showPreview); + const isMobile = useUIStore((state) => state.isMobile); + const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread; + + // Get color tag from thread + const threadColor = getThreadColorTag(thread.emails); + const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null; + + // Check if latest email is selected + const isSelected = selectedEmailId === latestEmail.id || + thread.emails.some(e => e.id === selectedEmailId); + + // Single email thread - render as regular email, no expand + if (emailCount === 1) { + return ( + onEmailSelect(latestEmail)} + onContextMenu={onContextMenu} + showPreview={showPreview} + colorTag={colorTag} + /> + ); + } + + // Get emails to display when expanded + const emailsToShow = expandedEmails || thread.emails; + + const handleHeaderClick = (e: React.MouseEvent) => { + // Mobile: open conversation view instead of inline expansion + if (isMobile && onOpenConversation) { + onOpenConversation(thread); + return; + } + + // Desktop: If clicking directly on the expand icon area, toggle expansion + // Otherwise, select the latest email + const target = e.target as HTMLElement; + if (target.closest('[data-expand-toggle]')) { + onToggleExpand(); + } else { + // Clicking on the row selects the latest email but also expands + if (!isExpanded) { + onToggleExpand(); + } + onEmailSelect(latestEmail); + } + }; + + const handleContextMenu = (e: React.MouseEvent) => { + onContextMenu?.(e, latestEmail); + }; + + return ( +
+ {/* Thread Header (collapsed view) */} +
+
+ {/* Expand/Collapse Button - Hidden on mobile */} + {!isMobile && ( + + )} + + {/* Unread indicator */} + {hasUnread && ( +
+ +
+ )} + + {/* Avatar */} + + + {/* Content */} +
+ {/* First Line: Participants and Date */} +
+
+ + {participantNames.join(", ")} + + {/* Email count badge */} + + {emailCount} + +
+ {hasStarred && ( + + )} + {hasAttachment && ( + + )} +
+
+ + {formatDate(latestEmail.receivedAt)} + +
+ + {/* Second Line: Subject */} +
+ {latestEmail.subject || "(no subject)"} +
+ + {/* Third Line: Preview */} + {showPreview && ( +

+ {latestEmail.preview || "No preview available"} +

+ )} +
+
+
+ + {/* Expanded Thread Emails - Desktop only */} + {isExpanded && !isMobile && ( +
+ {isLoading ? ( +
+ + Loading conversation... +
+ ) : ( + emailsToShow.map((email, index) => ( + onEmailSelect(email)} + onContextMenu={onContextMenu} + /> + )) + )} +
+ )} +
+ ); +} + +// Single email item (for threads with only 1 email) +function SingleEmailItem({ + email, + selected, + onClick, + onContextMenu, + showPreview, + colorTag, +}: { + email: Email; + selected: boolean; + onClick: () => void; + onContextMenu?: (e: React.MouseEvent, email: Email) => void; + showPreview: boolean; + colorTag: string | null; +}) { + const isUnread = !email.keywords?.$seen; + const isStarred = email.keywords?.$flagged; + const sender = email.from?.[0]; + + const handleContextMenu = (e: React.MouseEvent) => { + onContextMenu?.(e, email); + }; + + return ( +
+
+ {/* Spacer for alignment with thread items */} +
+ + {/* Unread indicator */} + {isUnread && ( +
+ +
+ )} + + {/* Avatar */} + + + {/* Content */} +
+ {/* First Line: Sender and Date */} +
+
+ + {sender?.name || sender?.email || "Unknown"} + +
+ {isStarred && ( + + )} + {email.hasAttachment && ( + + )} +
+
+ + {formatDate(email.receivedAt)} + +
+ + {/* Second Line: Subject */} +
+ {email.subject || "(no subject)"} +
+ + {/* Third Line: Preview */} + {showPreview && ( +

+ {email.preview || "No preview available"} +

+ )} +
+
+
+ ); +} diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index 06333d9..fabd287 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -119,6 +119,22 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod ))}
+ + {/* Threads Section */} +
+

+ {t("shortcuts.sections.threads")} +

+
+ {KEYBOARD_SHORTCUTS.threads.map((shortcut) => ( + + ))} +
+
{/* Footer tip */} diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 0997715..47ad3b4 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -29,6 +29,9 @@ export interface KeyboardShortcutHandlers { // Selection onSelectAll?: () => void; onDeselectAll?: () => void; + + // Thread actions + onToggleThreadExpansion?: () => void; } export interface UseKeyboardShortcutsOptions { @@ -199,6 +202,14 @@ export function useKeyboardShortcuts({ h.onRefresh?.(); } break; + + // Thread actions + case "x": + if (selectedEmailId) { + event.preventDefault(); + h.onToggleThreadExpansion?.(); + } + break; } }, [selectedEmailId] @@ -261,4 +272,7 @@ export const KEYBOARD_SHORTCUTS = { { key: "Shift + G", description: "shortcuts.global.refresh" }, { key: "Ctrl + A", description: "shortcuts.global.select_all" }, ], + threads: [ + { key: "x", description: "shortcuts.threads.expand_collapse" }, + ], } as const; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d11dca3..bb25dc3 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread } from "./types"; // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { @@ -799,6 +799,92 @@ export class JMAPClient { } } + // Thread methods for conversation view + async getThread(threadId: string, accountId?: string): Promise { + try { + const targetAccountId = accountId || this.accountId; + + const response = await this.request([ + ["Thread/get", { + accountId: targetAccountId, + ids: [threadId], + }, "0"], + ]); + + if (response.methodResponses?.[0]?.[0] === "Thread/get") { + const threads = response.methodResponses[0][1].list || []; + return threads[0] || null; + } + + return null; + } catch (error) { + console.error('Failed to get thread:', error); + return null; + } + } + + async getThreadEmails(threadId: string, accountId?: string): Promise { + try { + const targetAccountId = accountId || this.accountId; + + // First get the thread to find all email IDs + const thread = await this.getThread(threadId, accountId); + if (!thread || !thread.emailIds || thread.emailIds.length === 0) { + return []; + } + + // Fetch all emails in the thread + const response = await this.request([ + ["Email/get", { + accountId: targetAccountId, + ids: thread.emailIds, + properties: [ + "id", + "threadId", + "mailboxIds", + "keywords", + "size", + "receivedAt", + "from", + "to", + "cc", + "subject", + "preview", + "hasAttachment", + ], + }, "0"], + ]); + + if (response.methodResponses?.[0]?.[0] === "Email/get") { + const emails = response.methodResponses[0][1].list || []; + + // If fetching from a shared account, namespace the mailboxIds + const isSharedAccount = accountId && accountId !== this.accountId; + if (isSharedAccount) { + emails.forEach((email: Email) => { + if (email.mailboxIds) { + const namespacedMailboxIds: Record = {}; + Object.keys(email.mailboxIds).forEach(mbId => { + namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId]; + }); + email.mailboxIds = namespacedMailboxIds; + } + }); + } + + // Sort by receivedAt descending (newest first) + return emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + } + + return []; + } catch (error) { + console.error('Failed to get thread emails:', error); + return []; + } + } + async createDraft( to: string[], subject: string, diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 1243a1e..22efb4b 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -130,6 +130,18 @@ export interface Thread { emailIds: string[]; } +// Thread grouping for UI display +export interface ThreadGroup { + threadId: string; + emails: Email[]; // Emails in this thread (sorted by receivedAt desc) + latestEmail: Email; // Most recent email + participantNames: string[];// Unique participant names + hasUnread: boolean; // Any unread emails in thread + hasStarred: boolean; // Any starred emails in thread + hasAttachment: boolean; // Any email has attachment + emailCount: number; // Total emails in thread +} + export interface Identity { id: string; name: string; diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts new file mode 100644 index 0000000..ebcde2e --- /dev/null +++ b/lib/thread-utils.ts @@ -0,0 +1,163 @@ +import type { Email, ThreadGroup } from "./jmap/types"; + +/** + * Groups emails by their threadId and creates ThreadGroup objects for UI display. + * Single-email threads are still returned as ThreadGroups with emailCount=1. + */ +export function groupEmailsByThread(emails: Email[]): ThreadGroup[] { + if (!emails || emails.length === 0) { + return []; + } + + // Group emails by threadId + const threadMap = new Map(); + + for (const email of emails) { + const threadId = email.threadId; + if (!threadMap.has(threadId)) { + threadMap.set(threadId, []); + } + threadMap.get(threadId)!.push(email); + } + + // Convert to ThreadGroup array + const threadGroups: ThreadGroup[] = []; + + for (const [threadId, threadEmails] of threadMap) { + // Sort emails by receivedAt descending (newest first) + const sortedEmails = [...threadEmails].sort( + (a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + + const latestEmail = sortedEmails[0]; + + // Collect unique participant names from all emails in thread + const participantNames = getThreadParticipants(sortedEmails); + + // Check for unread, starred, and attachments + const hasUnread = sortedEmails.some(e => !e.keywords?.$seen); + const hasStarred = sortedEmails.some(e => e.keywords?.$flagged); + const hasAttachment = sortedEmails.some(e => e.hasAttachment); + + threadGroups.push({ + threadId, + emails: sortedEmails, + latestEmail, + participantNames, + hasUnread, + hasStarred, + hasAttachment, + emailCount: sortedEmails.length, + }); + } + + return threadGroups; +} + +/** + * Sorts thread groups by their latest email's receivedAt date (newest first). + */ +export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] { + return [...groups].sort( + (a, b) => new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime() + ); +} + +/** + * Extracts unique participant names from a list of emails. + * Includes both senders and recipients, limited to avoid UI overflow. + */ +export function getThreadParticipants(emails: Email[], maxNames: number = 4): string[] { + const seen = new Set(); + const names: string[] = []; + + for (const email of emails) { + // Add sender + if (email.from && email.from.length > 0) { + const sender = email.from[0]; + const senderName = sender.name || sender.email.split('@')[0]; + const key = sender.email.toLowerCase(); + + if (!seen.has(key)) { + seen.add(key); + names.push(senderName); + } + } + + // Stop if we have enough names + if (names.length >= maxNames) break; + } + + return names; +} + +/** + * Merges newly fetched thread emails into an existing thread group. + * Used when expanding a thread to show all emails (some may not have been in the original list). + */ +export function mergeThreadEmails( + existingGroup: ThreadGroup, + fetchedEmails: Email[] +): ThreadGroup { + // Create a map of existing emails by ID + const emailMap = new Map(); + + for (const email of existingGroup.emails) { + emailMap.set(email.id, email); + } + + // Add fetched emails that aren't already in the group + for (const email of fetchedEmails) { + if (!emailMap.has(email.id)) { + emailMap.set(email.id, email); + } + } + + // Convert back to array and sort + const mergedEmails = Array.from(emailMap.values()).sort( + (a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + + const latestEmail = mergedEmails[0]; + const participantNames = getThreadParticipants(mergedEmails); + const hasUnread = mergedEmails.some(e => !e.keywords?.$seen); + const hasStarred = mergedEmails.some(e => e.keywords?.$flagged); + const hasAttachment = mergedEmails.some(e => e.hasAttachment); + + return { + threadId: existingGroup.threadId, + emails: mergedEmails, + latestEmail, + participantNames, + hasUnread, + hasStarred, + hasAttachment, + emailCount: mergedEmails.length, + }; +} + +/** + * Gets color tag from email keywords (if any). + */ +export function getEmailColorTag(keywords: Record | undefined): string | null { + if (!keywords) return null; + + for (const key of Object.keys(keywords)) { + if (key.startsWith("$color:") && keywords[key] === true) { + return key.replace("$color:", ""); + } + } + + return null; +} + +/** + * Checks if a thread has any color tag (returns first found). + */ +export function getThreadColorTag(emails: Email[]): string | null { + for (const email of emails) { + const color = getEmailColorTag(email.keywords); + if (color) return color; + } + return null; +} diff --git a/locales/en/common.json b/locales/en/common.json index 0f2e019..477bdd6 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -448,7 +448,8 @@ "sections": { "navigation": "Navigation", "actions": "Email Actions", - "global": "Global" + "global": "Global", + "threads": "Threads" }, "navigation": { "next_email": "Next email", @@ -472,6 +473,22 @@ "help": "Show shortcuts", "refresh": "Refresh emails", "select_all": "Select all" + }, + "threads": { + "expand_collapse": "Expand/collapse thread" } + }, + "threads": { + "messages_one": "{count} message", + "messages_other": "{count} messages", + "expand": "Expand conversation", + "collapse": "Collapse conversation", + "loading": "Loading conversation...", + "mark_read": "Mark conversation as read", + "mark_unread": "Mark conversation as unread", + "archive": "Archive conversation", + "delete": "Delete conversation", + "star": "Star conversation", + "unstar": "Unstar conversation" } } \ No newline at end of file diff --git a/locales/fr/common.json b/locales/fr/common.json index 505f7f1..6f1be08 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -448,7 +448,8 @@ "sections": { "navigation": "Navigation", "actions": "Actions email", - "global": "Global" + "global": "Global", + "threads": "Conversations" }, "navigation": { "next_email": "Email suivant", @@ -472,6 +473,22 @@ "help": "Afficher les raccourcis", "refresh": "Actualiser les emails", "select_all": "Tout sélectionner" + }, + "threads": { + "expand_collapse": "Développer/réduire la conversation" } + }, + "threads": { + "messages_one": "{count} message", + "messages_other": "{count} messages", + "expand": "Développer la conversation", + "collapse": "Réduire la conversation", + "loading": "Chargement de la conversation...", + "mark_read": "Marquer la conversation comme lue", + "mark_unread": "Marquer la conversation comme non lue", + "archive": "Archiver la conversation", + "delete": "Supprimer la conversation", + "star": "Marquer la conversation comme favorite", + "unstar": "Retirer des favoris" } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 70874cb..0669a14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,8 @@ "eslint-config-next": "^16.0.8", "eslint-plugin-react": "^7.37.5", "globals": "^16.5.0", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", "tailwindcss": "^4.1.17", "typescript": "^5" } @@ -2504,6 +2506,35 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2922,6 +2953,39 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2957,6 +3021,23 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3215,6 +3296,19 @@ "node": ">=10.13.0" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -3993,6 +4087,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4195,6 +4296,19 @@ "node": ">=6.9.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4439,6 +4553,22 @@ "hermes-estree": "0.25.1" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -4674,6 +4804,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5353,6 +5499,49 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lint-staged": { + "version": "16.2.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", + "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "listr2": "^9.0.5", + "micromatch": "^4.0.8", + "nano-spawn": "^2.0.0", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5376,6 +5565,26 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5454,6 +5663,19 @@ "node": ">=8.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5487,6 +5709,19 @@ "dev": true, "license": "MIT" }, + "node_modules/nano-spawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5878,6 +6113,22 @@ "node": "^10.13.0 || >=12.0.0" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -6020,6 +6271,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/po-parser": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-1.0.2.tgz", @@ -6262,6 +6526,23 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6273,6 +6554,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6564,6 +6852,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6594,6 +6925,33 @@ "node": ">= 0.4" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6707,6 +7065,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -7299,12 +7673,84 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 3f869e2..88c2274 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "next build --turbopack", "start": "next start", "lint": "next lint", - "lint:fix": "next lint --fix" + "lint:fix": "next lint --fix", + "prepare": "husky", + "typecheck": "tsc --noEmit" }, "dependencies": { "@types/dompurify": "^3.0.5", @@ -35,6 +37,8 @@ "eslint-config-next": "^16.0.8", "eslint-plugin-react": "^7.37.5", "globals": "^16.5.0", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", "tailwindcss": "^4.1.17", "typescript": "^5" } diff --git a/stores/email-store.ts b/stores/email-store.ts index fb16080..8827763 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -22,6 +22,11 @@ interface EmailStore { lastPushUpdate: number | null; // Timestamp of last push update newEmailNotification: Email | null; // New email notification for toast + // Thread expansion state + expandedThreadIds: Set; // Which threads are expanded in the list + threadEmailsCache: Map; // Cache of fully fetched thread emails + isLoadingThread: string | null; // Thread ID currently being loaded + setEmails: (emails: Email[]) => void; setMailboxes: (mailboxes: Mailbox[]) => void; selectEmail: (email: Email | null) => void; @@ -60,6 +65,12 @@ interface EmailStore { handleNewEmailNotification: (email: Email) => void; clearNewEmailNotification: () => void; + // Thread expansion actions + toggleThreadExpansion: (threadId: string) => void; + fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise; + collapseAllThreads: () => void; + updateThreadCache: (threadId: string, emails: Email[]) => void; + // Mock data for demo loadMockData: () => void; } @@ -83,10 +94,22 @@ export const useEmailStore = create((set, get) => ({ lastPushUpdate: null, newEmailNotification: null, + // Thread expansion state + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + isLoadingThread: null, + setEmails: (emails) => set({ emails }), setMailboxes: (mailboxes) => set({ mailboxes }), selectEmail: (email) => set({ selectedEmail: email }), - selectMailbox: (mailboxId) => set({ selectedMailbox: mailboxId, selectedEmail: null, selectedEmailIds: new Set() }), + selectMailbox: (mailboxId) => set({ + selectedMailbox: mailboxId, + selectedEmail: null, + selectedEmailIds: new Set(), + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + isLoadingThread: null, + }), setLoading: (loading) => set({ isLoading: loading }), setLoadingEmail: (loading) => set({ isLoadingEmail: loading }), setError: (error) => set({ error }), @@ -795,6 +818,70 @@ export const useEmailStore = create((set, get) => ({ set({ newEmailNotification: null }); }, + // Thread expansion actions + toggleThreadExpansion: (threadId) => { + const { expandedThreadIds } = get(); + const newExpandedThreadIds = new Set(expandedThreadIds); + + if (newExpandedThreadIds.has(threadId)) { + newExpandedThreadIds.delete(threadId); + } else { + newExpandedThreadIds.add(threadId); + } + + set({ expandedThreadIds: newExpandedThreadIds }); + }, + + fetchThreadEmails: async (client, threadId) => { + const { threadEmailsCache, selectedMailbox, mailboxes } = get(); + + // Check if we already have this thread cached + const cachedEmails = threadEmailsCache.get(threadId); + if (cachedEmails && cachedEmails.length > 0) { + return cachedEmails; + } + + // Set loading state + set({ isLoadingThread: threadId }); + + try { + // Determine accountId for shared folders + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + + // Fetch all emails in the thread + const emails = await client.getThreadEmails(threadId, accountId); + + // Update cache + const newCache = new Map(get().threadEmailsCache); + newCache.set(threadId, emails); + + set({ + threadEmailsCache: newCache, + isLoadingThread: null + }); + + return emails; + } catch (error) { + console.error('Failed to fetch thread emails:', error); + set({ isLoadingThread: null }); + return []; + } + }, + + collapseAllThreads: () => { + set({ + expandedThreadIds: new Set(), + isLoadingThread: null + }); + }, + + updateThreadCache: (threadId, emails) => { + const newCache = new Map(get().threadEmailsCache); + newCache.set(threadId, emails); + set({ threadEmailsCache: newCache }); + }, + loadMockData: () => { const mockEmails: Email[] = [ {