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 */}