mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-23 16:01:15 +00:00
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:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user