From 5f45cbe993158bc183e7ca9fedb825fcc099dd38 Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Thu, 2 Oct 2025 13:57:19 +0200 Subject: [PATCH] feat: Add comprehensive shared folders support with multi-account access Implemented full support for JMAP shared folders (delegated mailboxes): Core Features: - Multi-account mailbox fetching from all available JMAP accounts - Virtual "Shared Folders" parent with account grouping in sidebar - ID namespacing to prevent collisions across accounts (e.g., "b0:a" for shared Inbox) - Account-aware email operations (fetch, view, mark as read) Technical Implementation: - Store all accounts from JMAP session (primary + shared) - Transform mailbox IDs: primary uses original, shared use "accountId:mailboxId" format - Store originalId for JMAP queries while using namespaced IDs in UI - Transform email mailboxIds to match namespaced mailbox IDs for proper counter updates - Pass accountId parameter through entire operation chain (fetch, view, mark read) UI/UX Enhancements: - Blue Users/User icons for shared folders in sidebar - Hierarchical display with account grouping - Proper unread counters that update when marking emails read - Auto-mark emails as read when opened in shared folders Bug Fixes: - Fixed email content fetching showing wrong message in shared folders - Fixed unread counters not updating for shared mailboxes - Fixed mark-as-read not working on server for shared accounts - Cleaned up debug logging --- TODO.md | 4 + app/[locale]/page.tsx | 12 +- components/email/email-viewer.tsx | 250 +++++++++++++++++++++++++++++- components/layout/sidebar.tsx | 31 +++- lib/jmap/client.ts | 47 +++++- lib/jmap/types.ts | 12 +- lib/utils.ts | 200 +++++++++++++++++++++++- locales/en/common.json | 5 + locales/fr/common.json | 5 + stores/email-store.ts | 34 +++- 10 files changed, 568 insertions(+), 32 deletions(-) diff --git a/TODO.md b/TODO.md index a992ff8..fbed3af 100644 --- a/TODO.md +++ b/TODO.md @@ -60,6 +60,10 @@ - [x] Fetch and display storage quota - [x] Add server capability detection - [x] Implement keep-alive mechanism +- [x] Add shared folders support (multi-account access) +- [x] Implement ID namespacing for shared mailboxes +- [x] Fix unread counters for shared folders +- [x] Auto-mark emails as read when opened in shared folders ### Email Operations - [x] Wire up real email fetching from JMAP diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 552b7e2..d45e451 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -324,9 +324,19 @@ export default function Home() { // Fetch the full content try { - const fullEmail = await client.getEmail(email.id); + // Find selected mailbox to determine accountId (for shared folders) + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + // Only pass accountId for shared mailboxes + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + + const fullEmail = await client.getEmail(email.id, accountId); if (fullEmail) { selectEmail(fullEmail); + + // Automatically mark as read after opening (if unread) + if (!fullEmail.keywords?.$seen) { + await markAsRead(client, fullEmail.id, true); + } } } catch (error) { console.error('Failed to fetch email content:', error); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cff68a0..ee71112 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -51,7 +51,10 @@ import { Network, Hash, List, + Code, + Copy, } from "lucide-react"; +import { useTranslations } from "next-intl"; interface EmailViewerProps { email: Email | null; @@ -133,12 +136,15 @@ export function EmailViewer({ currentUserEmail, currentUserName, }: EmailViewerProps) { + const t = useTranslations('email_viewer'); + const tNotifications = useTranslations('notifications'); const [showFullHeaders, setShowFullHeaders] = useState(false); const [allowExternalContent, setAllowExternalContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false); const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); + const [showSourceModal, setShowSourceModal] = useState(false); const currentColor = getCurrentColor(email?.keywords); useEffect(() => { @@ -154,8 +160,170 @@ export function EmailViewer({ setHasBlockedContent(false); setQuickReplyText(""); setIsQuickReplyFocused(false); + setShowSourceModal(false); }, [email?.id]); + // Generate email source for viewing + const generateEmailSource = (email: Email): string => { + let source = ''; + + // Headers + source += '=== EMAIL HEADERS ===\n\n'; + if (email.messageId) source += `Message-ID: ${email.messageId}\n`; + if (email.from) source += `From: ${email.from.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.to) source += `To: ${email.to.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.cc) source += `Cc: ${email.cc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.bcc) source += `Bcc: ${email.bcc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.replyTo) source += `Reply-To: ${email.replyTo.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.subject) source += `Subject: ${email.subject}\n`; + if (email.sentAt) source += `Date: ${new Date(email.sentAt).toUTCString()}\n`; + if (email.receivedAt) source += `Received-At: ${new Date(email.receivedAt).toUTCString()}\n`; + if (email.inReplyTo) source += `In-Reply-To: ${email.inReplyTo.join(', ')}\n`; + if (email.references) source += `References: ${email.references.join(', ')}\n`; + + // Additional headers + if (email.headers) { + source += '\n--- Additional Headers ---\n'; + // Check if headers is an array (which seems to be the case based on error) + if (Array.isArray(email.headers)) { + email.headers.forEach((header: any) => { + if (header && typeof header === 'object') { + source += `${header.name || 'Unknown'}: ${header.value || ''}\n`; + } + }); + } else { + // Handle as object + Object.entries(email.headers).forEach(([key, value]) => { + const val = Array.isArray(value) ? value.join(', ') : String(value); + source += `${key}: ${val}\n`; + }); + } + } + + // Authentication results + if (email.authenticationResults) { + source += '\n--- Authentication Results ---\n'; + if (email.authenticationResults.spf) { + source += `SPF: ${email.authenticationResults.spf.result}`; + if (email.authenticationResults.spf.domain) source += ` (${email.authenticationResults.spf.domain})`; + source += '\n'; + } + if (email.authenticationResults.dkim) { + source += `DKIM: ${email.authenticationResults.dkim.result}`; + if (email.authenticationResults.dkim.domain) source += ` (${email.authenticationResults.dkim.domain})`; + source += '\n'; + } + if (email.authenticationResults.dmarc) { + source += `DMARC: ${email.authenticationResults.dmarc.result}`; + if (email.authenticationResults.dmarc.policy) source += ` policy=${email.authenticationResults.dmarc.policy}`; + source += '\n'; + } + } + + if (email.spamScore !== undefined) { + source += `Spam Score: ${email.spamScore}`; + if (email.spamStatus) source += ` (${email.spamStatus})`; + source += '\n'; + } + + // Metadata + source += '\n=== EMAIL METADATA ===\n\n'; + source += `Email ID: ${email.id}\n`; + source += `Thread ID: ${email.threadId}\n`; + source += `Size: ${formatFileSize(email.size)}\n`; + source += `Has Attachment: ${email.hasAttachment ? 'Yes' : 'No'}\n`; + if (email.keywords) { + const keywords = Object.entries(email.keywords) + .filter(([_, v]) => v) + .map(([k]) => k) + .join(', '); + if (keywords) source += `Keywords: ${keywords}\n`; + } + + // Attachments + if (email.attachments && email.attachments.length > 0) { + source += '\n=== ATTACHMENTS ===\n\n'; + email.attachments.forEach((att, i) => { + source += `[${i + 1}] ${att.name || 'Unnamed'}\n`; + source += ` Type: ${att.type}\n`; + source += ` Size: ${formatFileSize(att.size)}\n`; + source += ` Blob ID: ${att.blobId}\n`; + if (att.cid) source += ` Content-ID: ${att.cid}\n`; + source += '\n'; + }); + } + + // Body content + source += '\n=== EMAIL BODY ===\n\n'; + + let hasBodyContent = false; + + // Text version + if (email.textBody?.[0]?.partId && email.bodyValues?.[email.textBody[0].partId]) { + const textValue = email.bodyValues[email.textBody[0].partId].value; + if (textValue && textValue.trim()) { + source += '--- Plain Text Version ---\n\n'; + source += textValue; + source += '\n\n'; + hasBodyContent = true; + } + } + + // HTML version + if (email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]) { + const htmlValue = email.bodyValues[email.htmlBody[0].partId].value; + if (htmlValue && htmlValue.trim()) { + source += '--- HTML Version ---\n\n'; + source += htmlValue; + source += '\n\n'; + hasBodyContent = true; + } + } + + // All body values if we haven't found content yet + if (!hasBodyContent && email.bodyValues) { + const bodyKeys = Object.keys(email.bodyValues); + if (bodyKeys.length > 0) { + source += '--- Body Parts ---\n\n'; + bodyKeys.forEach((key, index) => { + const bodyValue = email.bodyValues![key].value; + if (bodyValue && bodyValue.trim()) { + source += `Part ${index + 1} (${key}):\n`; + source += bodyValue; + source += '\n\n'; + hasBodyContent = true; + } + }); + } + } + + // Preview if no body + if (!hasBodyContent && email.preview) { + source += '--- Preview Only ---\n\n'; + source += email.preview; + source += '\n'; + } + + if (!hasBodyContent && !email.preview) { + source += '(No body content available)\n'; + } + + return source; + }; + + const copySourceToClipboard = async () => { + if (!email) return; + + try { + const source = generateEmailSource(email); + await navigator.clipboard.writeText(source); + // Could add a toast notification here + console.log(tNotifications('source_copied')); + } catch (err) { + console.error('Failed to copy source:', err); + } + }; + // Sanitize and prepare email HTML content const emailContent = useMemo(() => { if (!email) return { html: "", isHtml: false }; @@ -548,14 +716,33 @@ export function EmailViewer({ - + {/* More Actions Dropdown */} +
+ +
+ + +
+
@@ -1090,6 +1277,53 @@ export function EmailViewer({ + + {/* Email Source Modal */} + {showSourceModal && email && ( +
setShowSourceModal(false)} + > +
e.stopPropagation()} + > + {/* Modal Header */} +
+
+ +

{t('email_source')}

+
+
+ + +
+
+ + {/* Modal Content */} +
+
+                {generateEmailSource(email)}
+              
+
+
+
+ )} ); } \ No newline at end of file diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 6aef053..6cff32c 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -30,6 +30,8 @@ import { Globe, Settings, ChevronUp, + Users, + User, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils"; import { Mailbox } from "@/lib/jmap/types"; @@ -46,9 +48,24 @@ interface SidebarProps { } // Map role to icon -const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean) => { +const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => { const lowerName = name?.toLowerCase() || ""; + // Shared folders root node + if (id === 'shared-folders-root') { + return isExpanded ? FolderOpen : Users; + } + + // Shared account nodes + if (id?.startsWith('shared-account-')) { + return isExpanded ? FolderOpen : User; + } + + // Shared mailboxes (but not virtual nodes) + if (isShared && hasChildren && !id?.startsWith('shared-')) { + return isExpanded ? FolderOpen : Folder; + } + if (hasChildren) { // For folders with children, return open/closed folder icon return isExpanded ? FolderOpen : Folder; @@ -81,8 +98,9 @@ function MailboxTreeItem({ }) { const hasChildren = node.children.length > 0; const isExpanded = expandedFolders.has(node.id); - const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded); + const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id); const indentPixels = node.depth * 16; // 16px per depth level + const isVirtualNode = node.id.startsWith('shared-'); // Virtual nodes for shared folder organization return ( <> @@ -119,10 +137,12 @@ function MailboxTreeItem({ {/* Mailbox Button */}