diff --git a/FEATURES.md b/FEATURES.md index 3fb35ec4..1043c7c1 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,9 +4,9 @@ - Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables) - Gmail-style threading with inline expansion and an optional conversation toggle -- Unified mailbox view across all connected accounts – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in -- Cross-account "All accounts" views – All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message -- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list +- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account +- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message +- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes - Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom - Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies - Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning @@ -117,7 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL - Configurable signature position (above or below quoted text) - Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions - Shared folders across accounts -- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync +- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync - Multiple JMAP servers per deployment with optional auto-pick by email domain - Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) @@ -125,7 +125,7 @@ Automatic browser detection with persistent preference. Configurable locale URL - Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required - Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page -- Admin policy gates for the aggregate mail views – enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle +- Admin policy gates for the Unified Mailbox – enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); each gated view still respects the user's own toggle - Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps) - File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts - Admin toggle for search-engine indexing (`robots.txt` / `noindex`) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 3b0550d1..464b795c 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -11,7 +11,7 @@ import type { ComposerDraftData } from "@/components/email/email-composer"; import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; -import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types"; +import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types"; import { useAccountStore } from "@/stores/account-store"; import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; @@ -110,7 +110,7 @@ export default function Home() { const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); - const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); + const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null); const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); @@ -356,10 +356,7 @@ export default function Home() { useProMultiAccountMailboxes(); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); - const enableAllMailView = useSettingsStore((s) => s.enableAllMailView); const delayedSendSupported = client?.hasDelayedSend() ?? true; - const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled')); - const showAllMailMailbox = allMailViewEnabled && enableAllMailView; // Cross-account "All accounts" views: a sub-feature of the unified mailbox, so // they require Unified Mailbox to be enabled, plus the admin gate and the @@ -377,18 +374,36 @@ export default function Home() { const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails; const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading; const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified); + const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount); + const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled')); const accounts = useAccountStore((s) => s.accounts); const connectedAccountsSignature = useMemo( () => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","), [accounts], ); + // Cross-account is "active" when the user opted in, the admin allows it, and + // more than one account is connected. Drives the sidebar header label: the + // old "All accounts" when spanning accounts, else "Unified Mailbox". + const crossAccountActive = + unifiedCrossAccount && + unifiedCrossAccountGate && + accounts.filter((a) => a.isConnected).length > 1; // Builds the populated UnifiedAccountClient[] used by the unified-view - // effects and one-shot actions in this page. Reads the includeGroup - // setting at call time so the latest toggle value is always honored. + // effects and one-shot actions in this page. Reads the settings at call time + // so the latest toggle values are always honored. When the cross-account + // sub-option is off, the unified mailbox stays within the active account + // boundary (its own + shared folders); when on, it spans every login account. const buildPopulatedUnifiedAccounts = useCallback(async (): Promise => { + // Cross-account scope requires both the per-user opt-in and the admin + // capability gate; otherwise stay within the active account boundary. + const crossAccount = useSettingsStore.getState().unifiedCrossAccount + && usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled'); return buildUnifiedAccountClients({ includeGroup: useSettingsStore.getState().includeGroupInUnified, + scopeToClientAccountId: crossAccount + ? undefined + : (useAccountStore.getState().activeAccountId ?? undefined), }); }, []); @@ -986,29 +1001,52 @@ export default function Home() { }; }, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]); - // Push notifications: set up once per client and tear down when the client - // goes away (logout or account switch). Kept separate from the fetch effect - // above so it still runs when data was prefetched at login time. + // Push notifications: set up once per CONNECTED client and tear down when the + // clients go away (logout or account switch). Kept separate from the fetch + // effect above so it still runs when data was prefetched at login time. + // + // We bind every connected login, not just the active one: background accounts + // must drive the unified-section counters too. The active client keeps the + // full handler (current list / scheduled / calendar / filters); background + // logins only re-project the unified counts by rebuilding the unified scope + // (which refreshes every account's cached mailbox list), since their changes + // never touch the active `mailboxes`. (#281 background push) useEffect(() => { if (!isAuthenticated || !client) return; - try { - client.onStateChange((change) => handleStateChange(change, client)); - const pushEnabled = client.setupPushNotifications(); - if (pushEnabled) { - setPushConnected(true); - debug.log('push', '[Push] Push notifications successfully enabled'); - } else { - debug.log('push', '[Push] Push notifications not available on this server'); + const clients = useAuthStore.getState().getAllConnectedClients(); + const cleanups: Array<() => void> = []; + + for (const [accId, c] of clients) { + try { + if (accId === activeAccountId) { + c.onStateChange((change) => handleStateChange(change, c)); + } else { + c.onStateChange(() => { + buildPopulatedUnifiedAccounts() + .then((built) => { + refreshCrossCounts(built); + refreshUnifiedCounts(built); + }) + .catch(() => { /* per-account fetch failures surface elsewhere */ }); + }); + } + c.setupPushNotifications(); + cleanups.push(() => c.closePushNotifications()); + } catch (error) { + debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error); } - } catch (error) { - debug.log('push', '[Push] Failed to setup push notifications:', error); + } + + if (cleanups.length > 0) { + setPushConnected(true); + debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`); } return () => { - client.closePushNotifications(); + cleanups.forEach((fn) => fn()); }; - }, [isAuthenticated, client, handleStateChange, setPushConnected]); + }, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]); // Keep unified mailbox counts in sync when the feature is enabled and more // than one account is connected. Runs whenever the set of connected accounts @@ -1025,7 +1063,7 @@ export default function Home() { if (built.length < 2 && !hasGroupEntry && !isEmbedded) return; refreshUnifiedCounts(built); }); - }, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]); + }, [enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, activeAccountId, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]); // System-notification click handler. The push SW navigates the user back // here with `?email=` (specific email it built the toast from) or @@ -1823,7 +1861,18 @@ export default function Home() { } const populated = await buildPopulatedUnifiedAccounts(); - await fetchUnifiedEmailsAction(populated, role); + // Keep an active search across the switch and re-run it in this view + // (mirrors normal mailboxes), preserving advanced filters; otherwise browse. + if (client && (!isFilterEmpty(searchFilters) || searchQuery)) { + useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null }); + if (!isFilterEmpty(searchFilters)) { + await advancedSearch(client); + } else { + await searchEmails(client, searchQuery); + } + } else { + await fetchUnifiedEmailsAction(populated, role); + } refreshUnifiedCounts(populated); return; } @@ -1845,7 +1894,18 @@ export default function Home() { } const populated = await buildPopulatedUnifiedAccounts(); - await fetchCrossViewAction(populated, view); + // Keep an active search across the switch and re-run it in this view + // (mirrors normal mailboxes), preserving advanced filters; otherwise browse. + if (client && (!isFilterEmpty(searchFilters) || searchQuery)) { + useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null }); + if (!isFilterEmpty(searchFilters)) { + await advancedSearch(client); + } else { + await searchEmails(client, searchQuery); + } + } else { + await fetchCrossViewAction(populated, view); + } refreshCrossCounts(populated); return; } @@ -2191,13 +2251,16 @@ export default function Home() { setSearchQuery(""); clearSearchFilters(); if (!client) return; - // In unified view the active "mailbox" is a virtual role, so refresh via - // the unified fan-out instead of fetchEmails. + // In unified view the active "mailbox" is a virtual role or cross view, so + // refresh via the unified fan-out instead of fetchEmails. if (isUnifiedView) { + const populated = await buildPopulatedUnifiedAccounts(); const role = useEmailStore.getState().unifiedRole; + const cross = useEmailStore.getState().crossView; if (role) { - const populated = await buildPopulatedUnifiedAccounts(); await fetchUnifiedEmailsAction(populated, role); + } else if (cross) { + await fetchCrossViewAction(populated, cross); } return; } @@ -2229,41 +2292,64 @@ export default function Home() { }; }, []); + // Blobs are scoped per JMAP account. In the unified/All-Mail view the open + // message may belong to another login (route to its client) or to a delegated + // shared account (same client, but the owner's accountId in the download URL). + // Resolve both from the email's source so attachments on cross-account + // messages can be viewed/downloaded instead of 404ing against the active + // account. + const resolveBlobSource = useCallback((email: typeof selectedEmail) => { + const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined; + const blobClient = clientAccountId + ? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client) + : client; + const accountId = isUnifiedView ? email?.sourceAccountId : undefined; + return { blobClient, accountId, clientAccountId }; + }, [isUnifiedView, client]); + const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => { - if (!client) return; + const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail); + if (!blobClient) return; try { const { mailAttachmentAction } = useSettingsStore.getState(); if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { - setPreviewAttachment({ blobId, name, type }); + setPreviewAttachment({ blobId, name, type, accountId, clientAccountId }); return; } - await client.downloadBlob(blobId, name, type); + await blobClient.downloadBlob(blobId, name, type, accountId); } catch (error) { console.error("Failed to download attachment:", error); } }; - const handlePreviewAttachmentDownload = useCallback(async () => { - if (!client || !previewAttachment) return; + const previewBlobClient = useCallback(() => { + const id = previewAttachment?.clientAccountId; + return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client; + }, [previewAttachment, client]); - await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type); - }, [client, previewAttachment]); + const handlePreviewAttachmentDownload = useCallback(async () => { + const c = previewBlobClient(); + if (!c || !previewAttachment) return; + + await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId); + }, [previewBlobClient, previewAttachment]); const getPreviewAttachmentContent = useCallback(async () => { - if (!client || !previewAttachment) { + const c = previewBlobClient(); + if (!c || !previewAttachment) { throw new Error('No attachment selected'); } - const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type); + const blob = await c.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId); return { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream', }; - }, [client, previewAttachment]); + }, [previewBlobClient, previewAttachment]); const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; @@ -2413,14 +2499,12 @@ export default function Home() { // Get current mailbox name for mobile header const currentMailboxName = isScheduledView ? t('sidebar.scheduled') - : selectedMailbox === ALL_MAIL_MAILBOX_ID - ? t('sidebar.mailboxes.all_mail') - : (() => { - const mb = mailboxes.find(m => m.id === selectedMailbox); - return mb - ? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)) - : "Inbox"; - })(); + : (() => { + const mb = mailboxes.find(m => m.id === selectedMailbox); + return mb + ? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)) + : "Inbox"; + })(); const isFocusedMailLayout = mailLayout === 'focus'; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); @@ -2708,7 +2792,7 @@ export default function Home() { selectedKeyword={selectedKeyword} scheduledTotal={scheduledTotal} showScheduledMailbox={delayedSendSupported} - showAllMailMailbox={showAllMailMailbox} + crossAccountActive={crossAccountActive} showCrossUnread={showCrossUnread} showCrossStarred={showCrossStarred} showCrossAll={showCrossAll} @@ -2835,8 +2919,8 @@ export default function Home() { className={cn("ps-9 h-9", searchQuery && "pe-8")} data-search-input data-tour="search-input" - disabled={isUnifiedView || isScheduledView} - title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined} + disabled={isScheduledView} + title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined} /> {searchQuery && ( -
+

{t('new_message')}

{saveStatus === 'saving' && (
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index f8229ca1..7cdfb1f7 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -295,6 +295,7 @@ export function EmailContextMenu({ handleAction(showBatchActions ? onBatchDelete! : onDelete!) } @@ -306,7 +307,7 @@ export function EmailContextMenu({ {/* Move to submenu */} {moveTree.length > 0 && ( - + {(() => { const renderNodes = (nodes: MailboxNode[]) => { return nodes.map((node) => { @@ -319,6 +320,7 @@ export function EmailContextMenu({ handleAction(() => showBatchActions @@ -410,6 +412,7 @@ export function EmailContextMenu({ handleAction( showBatchActions @@ -429,6 +432,7 @@ export function EmailContextMenu({ handleAction(() => showBatchActions diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b58892bf..bc260cfd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -560,6 +560,8 @@ export function ContactSidebarPanel({ interface DraggableAttachmentChipProps { attachment: EffectiveAttachment; client: IJMAPClient | null; + /** Owner accountId for the blob when it lives in a delegated/shared account. */ + accountId?: string; enabled: boolean; downloadName?: string; children: (dragProps: { @@ -570,14 +572,14 @@ interface DraggableAttachmentChipProps { }) => React.ReactNode; } -function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) { +function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) { const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', getBlobUrl: async () => { if (attachment.blobId && client) { try { - return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type); + return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId); } catch { return null; } @@ -595,7 +597,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, downloadName, ch } return null; }, - }), [attachment, client, downloadName]); + }), [attachment, client, accountId, downloadName]); const drag = useAttachmentDrag(source, enabled); return <>{children(drag)}; } @@ -714,6 +716,18 @@ export function EmailViewer({ const { tabletListVisible } = useUIStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId)); + // Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle + // downloads) are account-scoped. In the unified / All-Mail view the open + // message may belong to another login (route to its client) or a delegated + // shared account (same client, owner accountId in the URL). Resolve both from + // the message's source so cross-account blob fetches don't 404 against the + // active account. + const isUnifiedView = useEmailStore((s) => s.isUnifiedView); + const blobClient = useMemo(() => { + const scid = isUnifiedView ? email?.sourceClientAccountId : undefined; + return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client; + }, [isUnifiedView, email?.sourceClientAccountId, client]); + const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined; // List-Unsubscribe mailto: send the message ourselves - this is a webmail // client, handing a mailto: URL to the OS mail handler goes nowhere for @@ -1251,7 +1265,7 @@ export function EmailViewer({ async function processTnef() { try { debug.time('TNEF fetch blob', 'email'); - const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!); + const blobBytes = await blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId); debug.timeEnd('TNEF fetch blob', 'email'); debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes'); @@ -1304,7 +1318,7 @@ export function EmailViewer({ processTnef(); return () => { cancelled = true; }; - }, [email, client]); + }, [email, client, blobClient, blobAccountId]); // Embedded message/rfc822 unwrapping // When Outlook forwards an email as an attachment, the outer email body is @@ -1341,7 +1355,7 @@ export function EmailViewer({ async function unwrapEmbedded() { try { - const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!); + const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId); if (cancelled) { debug.groupEnd(); return; } if (blobBytes.byteLength === 0) { debug.warn('email', 'Embedded RFC822: Fetched blob is empty'); @@ -1381,7 +1395,7 @@ export function EmailViewer({ unwrapEmbedded(); return () => { cancelled = true; }; - }, [email, client]); + }, [email, client, blobClient, blobAccountId]); // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { @@ -1427,7 +1441,7 @@ export function EmailViewer({ await Promise.all(cidAttachments.map(async (att) => { const cidValue = att.cid!.replace(/^<|>$/g, ''); try { - const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type); + const objectUrl = await blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId); if (!cancelled) { urls[cidValue] = objectUrl; objectUrls.push(objectUrl); @@ -1449,7 +1463,7 @@ export function EmailViewer({ cancelled = true; objectUrls.forEach(url => URL.revokeObjectURL(url)); }; - }, [client, email?.id, pluginRenderedAttachments, email?.attachments]); + }, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]); const effectiveAttachments = useMemo(() => { if (pluginRenderedAttachments.length > 0) { @@ -1927,8 +1941,8 @@ export function EmailViewer({ for (const attachment of effectiveAttachments) { const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type)); try { - if (attachment.blobId && client) { - const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type); + if (attachment.blobId && blobClient) { + const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId); zip.file(entryName, blob); added++; } else if (attachment.tnefData) { @@ -1960,7 +1974,7 @@ export function EmailViewer({ } finally { setIsDownloadingAll(false); } - }, [isDownloadingAll, effectiveAttachments, client, email]); + }, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]); // Shared "Download all" chip, shown only when bundling is worthwhile (2+). const downloadAllButton = effectiveAttachments.length > 1 ? ( @@ -2002,8 +2016,8 @@ export function EmailViewer({ await Promise.all(imageAttachments.map(async (att) => { let url: string | undefined; try { - if (att.blobId && client) { - url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type); + if (att.blobId && blobClient) { + url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId); } else if (att.decryptedAttachment) { const bytes = getAttachmentContentBytes(att.decryptedAttachment); if (!bytes || bytes.byteLength === 0) return; @@ -2034,7 +2048,7 @@ export function EmailViewer({ cancelled = true; createdUrls.forEach((url) => URL.revokeObjectURL(url)); }; - }, [effectiveAttachments, client, attachmentImagePreviewsEnabled]); + }, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]); // Iframe for rendering HTML emails true-to-life const iframeRef = useRef(null); @@ -2820,6 +2834,7 @@ export function EmailViewer({ variant="default" size="sm" onClick={() => onEditDraft()} + data-testid="edit-draft" className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0" title={t('tooltips.edit_draft')} > @@ -3731,7 +3746,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const thumbUrl = imageThumbUrls[attachment.id]; return ( - + {(dragProps) => (
handleEffectiveAttachmentOpen(attachment)} + data-testid="attachment" + data-attachment-name={attachment.name} draggable={dragProps.draggable} onPointerEnter={dragProps.onPointerEnter} onDragStart={dragProps.onDragStart} @@ -3812,7 +3829,7 @@ export function EmailViewer({ const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - + {(dragProps) => (
+ {(dragProps) => (
handleEffectiveAttachmentOpen(attachment)} + data-testid="attachment" + data-attachment-name={attachment.name} draggable={dragProps.draggable} onPointerEnter={dragProps.onPointerEnter} onDragStart={dragProps.onDragStart} @@ -4591,7 +4610,7 @@ export function EmailViewer({ const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - + {(dragProps) => (
+ {(dragProps) => (
handleEffectiveAttachmentOpen(attachment)} + data-testid="attachment" + data-attachment-name={attachment.name} draggable={dragProps.draggable} onPointerEnter={dragProps.onPointerEnter} onDragStart={dragProps.onDragStart} @@ -4729,7 +4750,7 @@ export function EmailViewer({ const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - + {(dragProps) => (
( const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; // Show the originating folder in the aggregate "All …" views. - const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder; + const showSourceFolder = isUnifiedView && !!email.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const isChecked = selectedEmailIds.has(email.id); @@ -463,7 +463,7 @@ export const ThreadListItem = React.forwardRef state.getAccountById); const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined; // In Sent/Drafts folders, show recipient instead of sender (which is always diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 3782c779..122a4bdb 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -79,9 +79,10 @@ interface SidebarProps { onRefreshMailboxes?: () => void; scheduledTotal?: number; showScheduledMailbox?: boolean; - /** Gated "All Mail" virtual folder that merges all of the account's folders. */ - showAllMailMailbox?: boolean; - /** Gated cross-account views in the "All accounts" section. */ + /** True when the unified view spans multiple login accounts (cross-account). + * Drives the section header: "All accounts" when true, else "Unified Mailbox". */ + crossAccountActive?: boolean; + /** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */ showCrossUnread?: boolean; showCrossStarred?: boolean; showCrossAll?: boolean; @@ -258,6 +259,7 @@ interface SidebarRowProps { testRole?: string | null; testName?: string; testMailboxId?: string; + testShared?: boolean; } function SidebarRow({ @@ -281,6 +283,7 @@ function SidebarRow({ testRole, testName, testMailboxId, + testShared, }: SidebarRowProps) { const t = useTranslations('sidebar'); const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP; @@ -293,6 +296,7 @@ function SidebarRow({ data-folder-role={testRole ?? undefined} data-folder-name={testName ?? undefined} data-mailbox-id={testMailboxId ?? undefined} + data-shared={testShared ? 'true' : undefined} style={{ paddingBlock: 'var(--density-sidebar-py)' }} className={cn( "group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150", @@ -372,6 +376,7 @@ function SidebarSectionHeader({ first, icon, sub, + testId, }: { label: string; expanded: boolean; @@ -382,6 +387,7 @@ function SidebarSectionHeader({ first?: boolean; icon?: ReactNode; sub?: boolean; + testId?: string; }) { if (isCollapsed) { return first ? null :
; @@ -396,6 +402,9 @@ function SidebarSectionHeader({ return (