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
This commit is contained in:
Matthieu MALVACHE
2025-10-02 13:57:19 +02:00
parent 11cc516bb7
commit 5f45cbe993
10 changed files with 568 additions and 32 deletions
+4
View File
@@ -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
+11 -1
View File
@@ -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);
+242 -8
View File
@@ -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({
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted"
title="More actions"
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
</Button>
{/* More Actions Dropdown */}
<div className="relative group">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted"
title="More actions"
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
</Button>
<div className="absolute right-0 top-full mt-1 w-44 bg-background rounded-md shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10">
<button
onClick={() => setShowSourceModal(true)}
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
>
<Code className="w-4 h-4" />
{t('view_source')}
</button>
<button
onClick={() => window.print()}
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
>
<Printer className="w-4 h-4" />
{t('print')}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -1090,6 +1277,53 @@ export function EmailViewer({
</div>
</div>
</div>
{/* Email Source Modal */}
{showSourceModal && email && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={() => setShowSourceModal(false)}
>
<div
className="bg-background rounded-lg shadow-2xl border border-border w-full max-w-4xl max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<Code className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">{t('email_source')}</h2>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={copySourceToClipboard}
className="flex items-center gap-1.5"
>
<Copy className="w-4 h-4" />
{t('copy_source')}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSourceModal(false)}
className="h-8 w-8"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Modal Content */}
<div className="flex-1 overflow-auto p-4 bg-muted/30">
<pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-words bg-background border border-border rounded-lg p-4">
{generateEmailSource(email)}
</pre>
</div>
</div>
</div>
)}
</div>
);
}
+26 -5
View File
@@ -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 */}
<button
onClick={() => onMailboxSelect?.(node.id)}
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
disabled={isVirtualNode}
className={cn(
"flex-1 flex items-center text-left py-1 px-1 rounded",
"transition-colors duration-150"
"transition-colors duration-150",
isVirtualNode && "cursor-default"
)}
style={{
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
@@ -133,7 +153,8 @@ function MailboxTreeItem({
"w-4 h-4 mr-2 flex-shrink-0 transition-colors",
hasChildren && isExpanded && "text-primary",
selectedMailbox === node.id && "text-accent-foreground",
!hasChildren && node.depth > 0 && "text-muted-foreground"
!hasChildren && node.depth > 0 && "text-muted-foreground",
node.isShared && "text-blue-500" // Shared folders in blue
)} />
{!isCollapsed && (
<>
+40 -7
View File
@@ -217,6 +217,7 @@ export class JMAPClient {
const mailboxes = rawMailboxes.map((mb: any) => {
return {
id: mb.id,
originalId: undefined, // Primary account uses original IDs
name: mb.name,
parentId: mb.parentId || undefined,
role: mb.role || undefined,
@@ -253,6 +254,7 @@ export class JMAPClient {
// Return default inbox with all required fields
return [{
id: 'INBOX',
originalId: undefined,
name: 'Inbox',
role: 'inbox',
sortOrder: 0,
@@ -309,9 +311,10 @@ export class JMAPClient {
// Map mailboxes with account info
const mailboxes = rawMailboxes.map((mb: any) => {
return {
id: mb.id,
id: isPrimary ? mb.id : `${accountId}:${mb.id}`, // Namespace shared mailbox IDs
originalId: mb.id, // Keep original ID for JMAP queries
name: mb.name,
parentId: mb.parentId || undefined,
parentId: mb.parentId ? (isPrimary ? mb.parentId : `${accountId}:${mb.parentId}`) : undefined,
role: mb.role || undefined,
sortOrder: mb.sortOrder ?? 0,
totalEmails: mb.totalEmails ?? 0,
@@ -397,10 +400,24 @@ export class JMAPClient {
if (response.methodResponses?.[1]?.[0] === "Email/get") {
const emails = response.methodResponses[1][1].list || [];
// If fetching from a shared account, namespace the mailboxIds to match our store
const isSharedAccount = accountId && accountId !== this.accountId;
if (isSharedAccount) {
emails.forEach((email: any) => {
if (email.mailboxIds) {
const namespacedMailboxIds: Record<string, boolean> = {};
Object.keys(email.mailboxIds).forEach(mbId => {
namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId];
});
email.mailboxIds = namespacedMailboxIds;
}
});
}
return emails;
}
console.warn('Unexpected email response format');
return [];
} catch (error) {
console.error('Failed to get emails:', error);
@@ -408,11 +425,14 @@ export class JMAPClient {
}
}
async getEmail(emailId: string): Promise<Email | null> {
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
try {
// Use provided accountId or fallback to primary account
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Email/get", {
accountId: this.accountId,
accountId: targetAccountId,
ids: [emailId],
properties: [
"id",
@@ -451,6 +471,16 @@ export class JMAPClient {
const email = emails[0];
if (email) {
// If fetching from a shared account, namespace the mailboxIds to match our store
const isSharedAccount = accountId && accountId !== this.accountId;
if (isSharedAccount && email.mailboxIds) {
const namespacedMailboxIds: Record<string, boolean> = {};
Object.keys(email.mailboxIds).forEach(mbId => {
namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId];
});
email.mailboxIds = namespacedMailboxIds;
}
// Parse headers if available
if (email.headers) {
// Import the parsing functions
@@ -491,10 +521,13 @@ export class JMAPClient {
}
}
async markAsRead(emailId: string, read: boolean = true): Promise<void> {
async markAsRead(emailId: string, read: boolean = true, accountId?: string): Promise<void> {
// Use provided accountId or fallback to primary account
const targetAccountId = accountId || this.accountId;
await this.request([
["Email/set", {
accountId: this.accountId,
accountId: targetAccountId,
update: {
[emailId]: {
"keywords/$seen": read,
+11 -1
View File
@@ -1,3 +1,8 @@
export interface EmailHeader {
name: string;
value: string;
}
export interface Email {
id: string;
threadId: string;
@@ -22,7 +27,7 @@ export interface Email {
messageId?: string;
inReplyTo?: string[];
references?: string[];
headers?: Record<string, string | string[]>;
headers?: Record<string, string | string[]> | EmailHeader[];
// Security headers parsed
authenticationResults?: AuthenticationResults;
spamScore?: number;
@@ -89,6 +94,7 @@ export interface Attachment {
export interface Mailbox {
id: string;
originalId?: string; // Original JMAP ID (for shared mailboxes)
name: string;
parentId?: string;
role?: string;
@@ -109,6 +115,10 @@ export interface Mailbox {
maySubmit: boolean;
};
isSubscribed: boolean;
// Shared folder support
accountId?: string;
accountName?: string;
isShared?: boolean;
}
export interface Thread {
+194 -6
View File
@@ -48,13 +48,69 @@ export interface MailboxNode extends Mailbox {
depth: number;
}
// Role priority for mailbox ordering (lower number = higher priority)
const ROLE_PRIORITY: Record<string, number> = {
inbox: 0,
drafts: 1,
sent: 2,
archive: 3,
junk: 4,
spam: 4, // Treat spam same as junk
trash: 5,
};
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const roleMap = new Map<string, Mailbox>();
const nameMap = new Map<string, Mailbox>();
const result: Mailbox[] = [];
// First pass: collect mailboxes with roles
mailboxes.forEach(mb => {
if (mb.role) {
roleMap.set(mb.role, mb);
}
});
// Second pass: filter out duplicates
mailboxes.forEach(mb => {
// If this mailbox has a role, always keep it
if (mb.role) {
result.push(mb);
return;
}
// Check if this is a duplicate of a role-based mailbox
const lowerName = mb.name.toLowerCase();
const isDuplicate = Array.from(roleMap.values()).some(roleMb => {
const roleLowerName = roleMb.name.toLowerCase();
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
});
// Only keep if not a duplicate
if (!isDuplicate) {
result.push(mb);
}
});
return result;
}
// Build a hierarchical tree structure from flat mailbox array
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
// Deduplicate mailboxes first
const deduplicated = deduplicateMailboxes(mailboxes);
// Separate own and shared mailboxes
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
const mailboxMap = new Map<string, MailboxNode>();
const rootMailboxes: MailboxNode[] = [];
// First pass: create nodes for all mailboxes
mailboxes.forEach(mailbox => {
// First pass: create nodes for own mailboxes
ownMailboxes.forEach(mailbox => {
mailboxMap.set(mailbox.id, {
...mailbox,
children: [],
@@ -62,8 +118,8 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
});
});
// Second pass: build tree structure
mailboxes.forEach(mailbox => {
// Second pass: build tree structure for own mailboxes
ownMailboxes.forEach(mailbox => {
const node = mailboxMap.get(mailbox.id)!;
if (mailbox.parentId && mailboxMap.has(mailbox.parentId)) {
@@ -77,9 +133,141 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
}
});
// Sort mailboxes at each level by sortOrder
// If we have shared mailboxes, create a virtual "Shared Folders" parent
if (sharedMailboxes.length > 0) {
// Group shared mailboxes by account
const accountGroups = new Map<string, Mailbox[]>();
sharedMailboxes.forEach(mb => {
const accountId = mb.accountId || 'unknown';
if (!accountGroups.has(accountId)) {
accountGroups.set(accountId, []);
}
accountGroups.get(accountId)!.push(mb);
});
// Create virtual nodes for each shared account
const sharedAccountNodes: MailboxNode[] = [];
accountGroups.forEach((accountMailboxes, accountId) => {
// Create account nodes
const accountMailboxMap = new Map<string, MailboxNode>();
const accountRootNodes: MailboxNode[] = [];
// Create nodes for this account's mailboxes
accountMailboxes.forEach(mailbox => {
accountMailboxMap.set(mailbox.id, {
...mailbox,
children: [],
depth: 2 // Account level is depth 1, these are depth 2
});
});
// Build tree for this account's mailboxes
accountMailboxes.forEach(mailbox => {
const node = accountMailboxMap.get(mailbox.id)!;
if (mailbox.parentId && accountMailboxMap.has(mailbox.parentId)) {
const parent = accountMailboxMap.get(mailbox.parentId)!;
parent.children.push(node);
node.depth = parent.depth + 1;
} else {
accountRootNodes.push(node);
}
});
// Create virtual account folder node
const accountName = accountMailboxes[0]?.accountName || accountId;
const accountNode: MailboxNode = {
id: `shared-account-${accountId}`,
name: accountName,
sortOrder: 1000, // After all own folders
totalEmails: accountMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
unreadEmails: accountMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
accountId: accountId,
accountName: accountName,
isShared: true,
children: accountRootNodes,
depth: 1,
};
sharedAccountNodes.push(accountNode);
});
// Create virtual "Shared Folders" root node
const sharedFoldersNode: MailboxNode = {
id: 'shared-folders-root',
name: 'Shared Folders',
sortOrder: 999, // After all own folders
totalEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
unreadEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
isShared: true,
children: sharedAccountNodes,
depth: 0,
};
rootMailboxes.push(sharedFoldersNode);
}
// Smart multi-level sorting
const sortNodes = (nodes: MailboxNode[]) => {
nodes.sort((a, b) => a.sortOrder - b.sortOrder);
nodes.sort((a, b) => {
// 1. Priority: Own folders before shared folders
if (a.isShared !== b.isShared) {
return a.isShared ? 1 : -1;
}
// 2. Priority: Role-based ordering (inbox first, trash last, etc.)
const aPriority = a.role ? (ROLE_PRIORITY[a.role] ?? 999) : 999;
const bPriority = b.role ? (ROLE_PRIORITY[b.role] ?? 999) : 999;
if (aPriority !== bPriority) {
return aPriority - bPriority;
}
// 3. Priority: Year folders (e.g., "2025", "2024") sorted numerically descending
const aIsYear = /^\d{4}$/.test(a.name);
const bIsYear = /^\d{4}$/.test(b.name);
if (aIsYear && bIsYear) {
return parseInt(b.name) - parseInt(a.name); // Descending: 2025, 2024, 2023...
}
// 4. Fallback: Server sortOrder
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
// 5. Fallback: Alphabetical by name
return a.name.localeCompare(b.name);
});
// Recursively sort children
nodes.forEach(node => {
if (node.children.length > 0) {
sortNodes(node.children);
+5
View File
@@ -64,6 +64,10 @@
"mark_unread": "Mark as unread",
"mark_read": "Mark as read",
"print": "Print",
"view_source": "View source",
"email_source": "Email Source",
"copy_source": "Copy to clipboard",
"source_copied": "Source copied to clipboard",
"attachments": "Attachments",
"download": "Download",
"from": "From",
@@ -171,6 +175,7 @@
"email_marked_read": "Email marked as read",
"email_marked_unread": "Email marked as unread",
"copied_to_clipboard": "Copied to clipboard",
"source_copied": "Source copied to clipboard",
"error_sending": "Failed to send email",
"error_deleting": "Failed to delete email",
"error_loading": "Failed to load emails"
+5
View File
@@ -64,6 +64,10 @@
"mark_unread": "Marquer comme non lu",
"mark_read": "Marquer comme lu",
"print": "Imprimer",
"view_source": "Voir la source",
"email_source": "Source de l'email",
"copy_source": "Copier dans le presse-papiers",
"source_copied": "Source copiée dans le presse-papiers",
"attachments": "Pièces jointes",
"download": "Télécharger",
"from": "De",
@@ -171,6 +175,7 @@
"email_marked_read": "Email marqué comme lu",
"email_marked_unread": "Email marqué comme non lu",
"copied_to_clipboard": "Copié dans le presse-papiers",
"source_copied": "Source copiée 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"
+30 -4
View File
@@ -97,7 +97,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
fetchMailboxes: async (client) => {
set({ isLoading: true, error: null });
try {
const mailboxes = await client.getMailboxes();
const mailboxes = await client.getAllMailboxes();
set({ mailboxes, isLoading: false });
} catch (error) {
set({
@@ -110,9 +110,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
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);
const targetMailboxId = mailboxId || get().selectedMailbox;
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === targetMailboxId);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || targetMailboxId;
const emails = await client.getEmails(jmapMailboxId, accountId);
set({ emails, isLoading: false });
} catch (error) {
console.error('Failed to fetch emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch emails",
isLoading: false,
@@ -123,7 +134,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
fetchEmailContent: async (client, emailId) => {
try {
const email = await client.getEmail(emailId);
// Find the selected mailbox to determine accountId (for shared folders)
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const email = await client.getEmail(emailId, accountId);
if (email) {
set({ selectedEmail: email });
}
@@ -241,7 +261,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
processingReadStatus: new Set([...state.processingReadStatus, processingKey])
}));
await client.markAsRead(emailId, read);
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
await client.markAsRead(emailId, read, accountId);
// Update local state including mailbox counters
set((state) => {