From 152ec992627dfa7e49eeb7fc818cd0b4ab3b1940 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:28:15 +0200 Subject: [PATCH] feat: add Elastic built-in theme --- app/(main)/[locale]/page.tsx | 54 ++- lib/__tests__/builtin-themes.test.ts | 5 +- lib/builtin-themes.ts | 320 ++++++++++++++++++ .../email-store-unified-actions.test.ts | 127 +++++++ stores/email-store.ts | 140 ++++++-- 5 files changed, 601 insertions(+), 45 deletions(-) create mode 100644 stores/__tests__/email-store-unified-actions.test.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 63611cb4..ede5842e 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -301,6 +301,7 @@ export default function Home() { fetchTagCounts, fetchEmailContent, isUnifiedView, + unifiedRole, scheduledEmails, scheduledTotal, scheduledHasMore, @@ -1388,8 +1389,19 @@ export default function Home() { const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; - // Check if we're currently in the trash or junk folder - const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); + // In unified view the trash destination and current-folder check must come + // from the email's own account, not the active one. (#281) + const actionMailboxes = + isUnifiedView && emailToDelete.accountId + ? (accountMailboxes[emailToDelete.accountId] ?? mailboxes) + : mailboxes; + + // Check if we're currently in the trash or junk folder. In unified view the + // "current folder" is the unified role within the email's account. + const currentMailbox = isUnifiedView + ? (actionMailboxes.find(m => m.role === unifiedRole && emailToDelete.mailboxIds?.[m.id]) + ?? actionMailboxes.find(m => m.role === unifiedRole)) + : mailboxes.find(m => m.id === selectedMailbox); const isInTrash = currentMailbox?.role === 'trash'; const isInJunk = currentMailbox?.role === 'junk'; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; @@ -1410,10 +1422,10 @@ export default function Home() { console.error("Failed to permanently delete email:", error); } } else { - // Not in trash: always move to trash + // Not in trash: always move to trash (in the email's own account). const trashMailbox = - mailboxes.find(m => m.role === 'trash' && !m.isShared) ?? - mailboxes.find(m => { + actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ?? + actionMailboxes.find(m => { if (m.isShared) return false; const lower = m.name.toLowerCase(); return lower.includes('trash') || lower.includes('deleted'); @@ -1436,9 +1448,27 @@ export default function Home() { const handleArchive = async (emailToArchive: Email | null = selectedEmail) => { if (!client || !emailToArchive) return; + // In unified view the archive folder (and any year/month subfolders we + // create) must live in the email's own account, reached through that + // account's client. (#281) + const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined; + const archiveClient = archiveAccountId + ? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client) + : client; // Read fresh mailboxes from the store – batch archive calls this in a loop, // and each iteration needs to see folders created by prior iterations. - const currentMailboxes = useEmailStore.getState().mailboxes; + const readMailboxes = () => { + const s = useEmailStore.getState(); + return archiveAccountId + ? (s.accountMailboxes[archiveAccountId] ?? s.mailboxes) + : s.mailboxes; + }; + const refreshMailboxes = () => + archiveAccountId + ? useEmailStore.getState().fetchAccountMailboxes(archiveClient, archiveAccountId) + : fetchMailboxes(client); + + const currentMailboxes = readMailboxes(); const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive"); if (!archiveMailbox) return; @@ -1458,21 +1488,21 @@ export default function Home() { m => m.name === year && m.parentId === archiveId ); if (!yearMailbox) { - yearMailbox = await client.createMailbox(year, archiveId); - await fetchMailboxes(client); + yearMailbox = await archiveClient.createMailbox(year, archiveId); + await refreshMailboxes(); } if (archiveMode === 'year') { await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); } else { const yearId = yearMailbox.originalId || yearMailbox.id; - const afterYear = useEmailStore.getState().mailboxes; + const afterYear = readMailboxes(); let monthMailbox = afterYear.find( m => m.name === month && m.parentId === yearId ); if (!monthMailbox) { - monthMailbox = await client.createMailbox(month, yearId); - await fetchMailboxes(client); + monthMailbox = await archiveClient.createMailbox(month, yearId); + await refreshMailboxes(); } await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id); } @@ -1483,7 +1513,7 @@ export default function Home() { setConversationEmails([]); } - void fetchMailboxes(client); + void refreshMailboxes(); } catch (error) { console.error("Failed to archive email:", error); } diff --git a/lib/__tests__/builtin-themes.test.ts b/lib/__tests__/builtin-themes.test.ts index 69ae9468..937e5c1e 100644 --- a/lib/__tests__/builtin-themes.test.ts +++ b/lib/__tests__/builtin-themes.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { BUILTIN_THEMES } from '../builtin-themes'; describe('BUILTIN_THEMES', () => { - it('contains exactly 3 themes', () => { - expect(BUILTIN_THEMES).toHaveLength(3); + it('contains exactly 5 themes', () => { + expect(BUILTIN_THEMES).toHaveLength(5); }); it('all themes have required fields', () => { @@ -43,6 +43,7 @@ describe('BUILTIN_THEMES', () => { expect(names).toContain('Nord'); expect(names).toContain('Catppuccin'); expect(names).toContain('Solarized'); + expect(names).toContain('Roundcube Elastic'); }); it('theme IDs are unique', () => { diff --git a/lib/builtin-themes.ts b/lib/builtin-themes.ts index 05326cf7..16093461 100644 --- a/lib/builtin-themes.ts +++ b/lib/builtin-themes.ts @@ -328,6 +328,313 @@ const solarizedCSS = ` --color-chart-5: #6c71c4; }`; +// Roundcube "Elastic" skin recreation. +// +// Colour tokens are lifted from skins/elastic/styles/colors.less (CC BY-SA): +// accent #37beff font #27353a border #ddd +// error #ff5552 success #41b849 warning #ffd452 +// task-menu #2f3a3f (always dark, both modes) +// list-select tint(#37beff, 90%) -> #ebf8ff +// Dark mode mirrors @color-dark-* (background #21292c, font #c5d1d3, …). +const elasticCSS = ` +:root { + --color-border: #dddddd; + --color-input: #ced4da; + --color-ring: #37beff; + --color-background: #ffffff; + --color-foreground: #27353a; + --color-primary: #37beff; + --color-primary-foreground: #ffffff; + --color-secondary: #f4f4f4; + --color-secondary-foreground: #27353a; + --color-muted: #f4f4f4; + --color-muted-foreground: #737677; + --color-accent: #e6f6ff; + --color-accent-foreground: #0070a8; + --color-destructive: #ff5552; + --color-destructive-foreground: #ffffff; + --color-popover: #ffffff; + --color-popover-foreground: #27353a; + --color-sidebar: #ffffff; + --color-sidebar-foreground: #27353a; + --color-sidebar-border: #dddddd; + --color-sidebar-accent: #ebf8ff; + --color-sidebar-accent-foreground: #27353a; + --color-card: #ffffff; + --color-card-foreground: #27353a; + --color-success: #41b849; + --color-success-foreground: #ffffff; + --color-warning: #ffd452; + --color-warning-foreground: #27353a; + --color-info: #37beff; + --color-info-foreground: #ffffff; + --color-selection: #ebf8ff; + --color-selection-foreground: #27353a; + --color-unread: #ffd452; + --color-chart-1: #37beff; + --color-chart-2: #41b849; + --color-chart-3: #ffd452; + --color-chart-4: #ff5552; + --color-chart-5: #9b59b6; + /* Elastic is a 14px Roboto skin with tighter list rows than Bulwark's default */ + --font-size-base: 14px; + --list-item-height: 40px; +} +.dark { + --color-border: #4d6066; + --color-input: #4d6066; + --color-ring: #37beff; + --color-background: #21292c; + --color-foreground: #ffffff; + --color-primary: #37beff; + --color-primary-foreground: #ffffff; + --color-secondary: #2c373a; + --color-secondary-foreground: #ffffff; + --color-muted: #2c373a; + /* Roundcube keeps most text near the bright font colour, only true hints + dim out. Bulwark applies muted-foreground far more widely, so brighten it + toward @color-dark-font (#c5d1d3) to match Elastic's overall brightness. */ + --color-muted-foreground: #b3bec1; + --color-accent: #374549; + --color-accent-foreground: #37beff; + --color-destructive: #ff5552; + --color-destructive-foreground: #ffffff; + --color-popover: #161b1d; + --color-popover-foreground: #ffffff; + --color-sidebar: #21292c; + --color-sidebar-foreground: #ffffff; + --color-sidebar-border: #4d6066; + --color-sidebar-accent: #374549; + --color-sidebar-accent-foreground: #c5d1d3; + --color-card: #1a2225; + --color-card-foreground: #ffffff; + --color-success: #41b849; + --color-success-foreground: #ffffff; + --color-warning: #ffd452; + --color-warning-foreground: #21292c; + --color-info: #37beff; + --color-info-foreground: #ffffff; + --color-selection: #374549; + --color-selection-foreground: #37beff; + --color-unread: #b88a00; + --color-chart-1: #37beff; + --color-chart-2: #41b849; + --color-chart-3: #ffd452; + --color-chart-4: #ff5552; + --color-chart-5: #b48ead; +}`; + +// Component-level overrides that the colour tokens alone can't express: +// Roboto typography and Elastic's signature dark "task menu" rail (which is +// dark in both light and dark mode). Scoped under the skin body attribute so +// it cleanly detaches when the theme is switched off. +const elasticSkin = ` +body[data-theme-skin="builtin-roundcube-elastic"] { + font-family: Roboto, "Helvetica Neue", "Segoe UI", Arial, "Noto Sans", sans-serif; +} + +/* ── Task menu (left navigation rail) ─────────────────────────── */ +body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary { + background-color: #2f3a3f !important; + border-right: 1px solid rgba(0, 0, 0, 0.25) !important; +} +/* In dark mode the black hairline vanishes against the dark canvas - use a + light one so the rail still reads as a distinct column. (.dark lives on + , so it must be an ancestor of the skinned .) */ +.dark body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary { + border-right-color: rgba(255, 255, 255, 0.1) !important; +} +/* Light icons + labels on the dark rail */ +body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary a, +body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary button, +body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .text-muted-foreground { + color: #e7edee !important; +} +/* Hover slab */ +body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary a:hover, +body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary button:hover { + background-color: #41525a !important; + color: #ffffff !important; +} +/* Selected task: brighter slab, accent-blue glyph (matches Elastic) */ +body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .bg-primary\\/10 { + background-color: #41525a !important; +} +body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .text-primary { + color: #37beff !important; +} + +/* ── Flatten Bulwark's soft radii to Elastic's Bootstrap-flat look ── */ +/* Elastic uses ~4px corners on buttons/inputs/cards; rounded-full (pills, + avatars, toggles) is intentionally left alone. */ +body[data-theme-skin="builtin-roundcube-elastic"] .rounded-md, +body[data-theme-skin="builtin-roundcube-elastic"] .rounded-lg, +body[data-theme-skin="builtin-roundcube-elastic"] .rounded-xl, +body[data-theme-skin="builtin-roundcube-elastic"] .rounded-2xl, +body[data-theme-skin="builtin-roundcube-elastic"] .rounded-3xl, +body[data-theme-skin="builtin-roundcube-elastic"] input, +body[data-theme-skin="builtin-roundcube-elastic"] textarea, +body[data-theme-skin="builtin-roundcube-elastic"] button:not(.rounded-full) { + border-radius: 4px !important; +} + +/* ── Unify panel backgrounds like Roundcube ───────────────────── */ +/* In Elastic the folder list, message list and content pane all share one + canvas (white / dark); only the narrow task rail is dark. Bulwark's folder + sidebar uses \`bg-secondary\` (a grey panel), so repaint it with the main + background. The folder sidebar carries the \`border-r\` class; the dark task + rail uses \`bg-secondary\` WITHOUT it (inline-styled border), so this + qualifier leaves the rail dark. */ +body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r { + background-color: var(--color-background) !important; +} +/* The empty "No conversation selected" pane uses a muted diagonal gradient; + flatten it to the plain canvas so the content area is one solid colour. */ +body[data-theme-skin="builtin-roundcube-elastic"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 { + background: var(--color-background) !important; +} + +/* The message-viewer body sits on a faint muted backing (bg-muted/30); flatten + it to the plain canvas so the whole content pane - search bar, action + toolbar, mail header and body - is one uniform background colour. The + search/toolbar/header are bordered \`bg-background\` strips, so they already + match once we leave them untinted. */ +body[data-theme-skin="builtin-roundcube-elastic"] .bg-muted\\/30 { + background-color: var(--color-background) !important; +} + +/* ── Elastic primary buttons (.btn-primary) ──────────────────── */ +/* Solid accent fill, white label + icon, hairline border, focus halo. Light + = @color-main (#37beff); dark = @color-dark-main (darken 30% -> #006a9d) + with the bright #37beff outline, exactly like the on-switch. */ +body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground { + background-color: #37beff !important; + border: 1px solid #37beff !important; + color: #ffffff !important; +} +body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:hover { + background-color: #19b6fe !important; + border-color: #0bb0ff !important; +} +body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:focus-visible { + box-shadow: 0 0 0 0.2rem rgba(55, 190, 255, 0.3) !important; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground { + background-color: #006a9d !important; + border-color: #37beff !important; + color: #ffffff !important; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:hover { + background-color: #0079b3 !important; +} + +/* ── Elastic on/off switch (.custom-switch) ──────────────────── */ +/* Track + knob restyled to the Bootstrap-derived Elastic switch. The toggle + is a [role="switch"] button with a single inner knob. */ +body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] { + background-color: #ced4da !important; + border: 1px solid #b8c0c8 !important; +} +body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] > span { + background-color: #ffffff !important; +} +body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"][aria-checked="true"] { + background-color: #37beff !important; + border-color: #37beff !important; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] { + background-color: #4d6066 !important; + border-color: #5d7077 !important; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] > span { + background-color: #c5d1d3 !important; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"][aria-checked="true"] { + background-color: #006a9d !important; + border-color: #37beff !important; +} + +/* ── Recipient name/email rendered as links (like Roundcube) ──── */ +/* Sender + recipient triggers (RecipientPopover) sit at \`text-foreground\` + and only turn blue on hover; Elastic shows them link-blue at rest + (@color-link #00acff, brighter in dark). The three-class combo is unique + to these recipient buttons. */ +body[data-theme-skin="builtin-roundcube-elastic"] button.hover\\:text-primary.hover\\:underline.cursor-pointer { + color: #00acff !important; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] button.hover\\:text-primary.hover\\:underline.cursor-pointer { + color: #37beff !important; +} +/* The sender's email address printed under the name is muted grey; Roundcube + shows it link-blue too. It's the div immediately after the name row + (.flex.items-center.flex-wrap), which the contact-card org line is not, so + this sibling selector scopes it to the sender email only. */ +body[data-theme-skin="builtin-roundcube-elastic"] .flex.items-center.flex-wrap + div.text-muted-foreground.truncate { + color: #00acff !important; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] .flex.items-center.flex-wrap + div.text-muted-foreground.truncate { + color: #37beff !important; +} + +/* ── Elastic context / popup menus ───────────────────────────── */ +/* Roundcube highlights the hovered menu entry with a solid accent fill and + white text (@color-menu-hover-background: @color-main, @color-menu-hover: + #fff) - the same in light and dark. */ +body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover, +body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus, +body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus-visible { + background-color: #37beff !important; + color: #ffffff !important; +} +/* Icons (currentColor) and muted accessories (shortcuts, submenu chevron) + follow the white text on hover. */ +body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover svg, +body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus svg, +body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover .text-muted-foreground { + color: #ffffff !important; +} + +/* ── Elastic folder list: unread count as a rounded pill ─────── */ +/* Roundcube shows the unread count in a small grey pill on the right, and no + badge at all when a folder has nothing unread. Bulwark renders plain + "unread / total" text; the counts container is + span.ml-2.flex-shrink-0.gap-1.items-baseline holding an unread span + (.font-semibold), an optional "/" (.text-muted-foreground/60) and the total + (.text-muted-foreground). Reshape it into a pill, drop the total + slash, + and hide the whole badge when there's no unread span. */ +body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline { + align-items: center !important; + justify-content: center; + min-width: 1.6rem; + height: 1.2rem; + padding: 0 0.45rem; + border-radius: 0.75rem; + background-color: #e4e8ea; +} +body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span.text-muted-foreground, +body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span.text-muted-foreground\\/60 { + display: none !important; +} +body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span { + color: #5e6b70 !important; + font-weight: 600 !important; +} +/* No unread span -> no badge (matches Sent/Drafts in Roundcube). */ +body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline:not(:has(span.font-semibold)) { + display: none !important; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline { + background-color: #3f4e55; +} +.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span { + color: #c9d3d5 !important; +} + +/* A slightly bolder accent bar on the selected folder, like Elastic. */ +body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r .border-l-2.border-primary { + border-left-width: 3px !important; +}`; + export const BUILTIN_THEMES: InstalledTheme[] = [ { id: 'builtin-qui', @@ -373,4 +680,17 @@ export const BUILTIN_THEMES: InstalledTheme[] = [ enabled: true, builtIn: true, }, + { + id: 'builtin-roundcube-elastic', + name: 'Roundcube Elastic', + version: '1.0.0', + author: 'Built-in', + description: 'Faithful recreation of Roundcube\'s Elastic skin - Roboto, the #37beff blue, and the dark task-menu rail', + css: elasticCSS, + skin: elasticSkin, + variants: ['light', 'dark'], + typography: { fontSans: 'Roboto, "Helvetica Neue", Arial, sans-serif', baseFontSize: '14px' }, + enabled: true, + builtIn: true, + }, ]; diff --git a/stores/__tests__/email-store-unified-actions.test.ts b/stores/__tests__/email-store-unified-actions.test.ts new file mode 100644 index 00000000..e772a99b --- /dev/null +++ b/stores/__tests__/email-store-unified-actions.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Regression coverage for issue #281: single-email actions performed in the +// unified inbox must be routed to the *email's own account* client, not the +// active account's. Sending them to the active account silently no-ops +// server-side (JMAP returns notUpdated without throwing), so the change is lost +// on the next reload. + +function makeMailbox(overrides: Partial = {}): Mailbox { + return { + id: 'inbox', + name: 'Inbox', + sortOrder: 0, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + myRights: { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: true, + mayRename: true, + mayDelete: true, + maySubmit: true, + }, + isSubscribed: true, + isShared: false, + ...overrides, + }; +} + +function makeEmail(overrides: Partial = {}): Email { + return { + id: 'email-1', + threadId: 'thread-1', + subject: 'Hi', + receivedAt: new Date().toISOString(), + keywords: {}, + mailboxIds: {}, + ...overrides, + } as Email; +} + +function makeClient() { + return { + markAsRead: vi.fn().mockResolvedValue(undefined), + toggleStar: vi.fn().mockResolvedValue(undefined), + moveEmail: vi.fn().mockResolvedValue(undefined), + } as unknown as IJMAPClient; +} + +describe('unified-view single-email action routing (#281)', () => { + let activeClient: IJMAPClient; // account-a, also the "passed" client + let accountBClient: IJMAPClient; + + beforeEach(() => { + activeClient = makeClient(); + accountBClient = makeClient(); + + // Route account-b to its own client; account-a falls back to the active one. + useAuthStore.setState({ + getClientForAccount: (id: string) => + (id === 'account-b' ? accountBClient : undefined) as never, + } as never); + + useEmailStore.setState({ + isUnifiedView: true, + unifiedRole: 'inbox', + viewingAccountId: null, + selectedMailbox: '', + mailboxes: [makeMailbox({ id: 'a-inbox', role: 'inbox' })], + accountMailboxes: { + 'account-a': [makeMailbox({ id: 'a-inbox', role: 'inbox' })], + 'account-b': [ + makeMailbox({ id: 'b-inbox', role: 'inbox' }), + makeMailbox({ id: 'b-archive', name: 'Archive', role: 'archive' }), + ], + }, + processingReadStatus: new Set(), + selectedEmail: null, + selectedEmailIds: new Set(), + emails: [ + makeEmail({ id: 'email-b', accountId: 'account-b', keywords: {}, mailboxIds: { 'b-inbox': true } }), + ], + }); + }); + + it('routes markAsRead to the email’s account client', async () => { + await useEmailStore.getState().markAsRead(activeClient, 'email-b', true); + + expect(accountBClient.markAsRead).toHaveBeenCalledWith('email-b', true, undefined); + expect(activeClient.markAsRead).not.toHaveBeenCalled(); + }); + + it('routes toggleStar to the email’s account client', async () => { + await useEmailStore.getState().toggleStar(activeClient, 'email-b'); + + expect(accountBClient.toggleStar).toHaveBeenCalledWith('email-b', true); + expect(activeClient.toggleStar).not.toHaveBeenCalled(); + }); + + it('routes moveToMailbox to the email’s account client with that account’s destination', async () => { + await useEmailStore.getState().moveToMailbox(activeClient, 'email-b', 'b-archive'); + + expect(accountBClient.moveEmail).toHaveBeenCalledWith('email-b', 'b-archive', undefined); + expect(activeClient.moveEmail).not.toHaveBeenCalled(); + }); + + it('still uses the active/passed client outside unified view', async () => { + useEmailStore.setState({ + isUnifiedView: false, + emails: [makeEmail({ id: 'email-a', accountId: 'account-a', mailboxIds: { 'a-inbox': true } })], + }); + + await useEmailStore.getState().markAsRead(activeClient, 'email-a', true); + + expect(activeClient.markAsRead).toHaveBeenCalledWith('email-a', true, undefined); + expect(accountBClient.markAsRead).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index e19895c4..8e8a3d0b 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -179,7 +179,9 @@ interface EmailStore { batchArchive: (client: IJMAPClient) => Promise; // Spam operations - spamUndoCache: Map; + // `sourceAccountId` (when set) is the unified-view email's owning account, + // used to route the undo back to the right account client. (#281) + spamUndoCache: Map; markAsSpam: (client: IJMAPClient, emailId: string) => Promise; undoSpam: (client: IJMAPClient, emailId: string) => Promise; batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise; @@ -313,6 +315,47 @@ function resolveActionMailboxes(): Mailbox[] { return state.mailboxes; } +/** + * Resolves the JMAP client, mailbox list, and JMAP accountId to use for a + * single-email action. + * + * In unified view each email carries the `accountId` of the account it came + * from. The mutation must be routed to that account's own client/session, or it + * is sent to the active account whose server doesn't know the id, so JMAP + * `Email/set` silently returns `notUpdated` and the change is lost on the next + * reload (issue #281). The per-account client already targets the owning + * account, so no explicit JMAP `accountId` override is needed, and its cached + * mailbox list (populated by `buildUnifiedAccountClients`) is used to resolve + * role-based destinations like trash/archive. + * + * For the normal single-account / viewing-account flow this preserves the + * existing behavior exactly: the active/viewing client, its mailbox list, and + * the shared-mailbox accountId derived from the currently selected mailbox. + */ +function resolveEmailActionContext( + email: { accountId?: string }, + passedClient: IJMAPClient, +): { client: IJMAPClient; mailboxes: Mailbox[]; accountId: string | undefined } { + const state = useEmailStore.getState(); + if (state.isUnifiedView && email.accountId) { + const perAccountClient = useAuthStore.getState().getClientForAccount(email.accountId); + if (perAccountClient) { + return { + client: perAccountClient, + mailboxes: state.accountMailboxes[email.accountId] ?? state.mailboxes, + accountId: undefined, + }; + } + } + const mailboxes = resolveActionMailboxes(); + const currentMailbox = mailboxes.find((mb) => mb.id === state.selectedMailbox); + return { + client: resolveActionClient(passedClient), + mailboxes, + accountId: currentMailbox?.isShared ? currentMailbox.accountId : undefined, + }; +} + /** * Builds the `UnifiedAccountClient[]` list used by every unified fan-out * action (browse, load-more, search). Each entry has a JMAP client plus a @@ -332,6 +375,10 @@ export async function buildUnifiedAccountClients( const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected); const allClients = useAuthStore.getState().getAllConnectedClients(); const built: UnifiedAccountClient[] = []; + // Per-account mailbox lists gathered here are cached into `accountMailboxes` + // after the fan-out so single-email actions can resolve role-based + // destinations (trash/archive) in the email's own account (issue #281). + const fetchedMailboxes: Record = {}; for (const a of authAccounts) { const c = allClients.get(a.id); if (!c) continue; @@ -341,6 +388,7 @@ export async function buildUnifiedAccountClients( ? mailboxes.filter((m) => !m.isShared) : mailboxes; built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, isShared: false }); + fetchedMailboxes[a.id] = ownMailboxes; if (includeGroup) { const sharedByOwner = new Map(); @@ -365,6 +413,11 @@ export async function buildUnifiedAccountClients( /* skip account on mailbox fetch failure */ } } + if (Object.keys(fetchedMailboxes).length > 0) { + useEmailStore.setState((state) => ({ + accountMailboxes: { ...state.accountMailboxes, ...fetchedMailboxes }, + })); + } return built; } @@ -898,17 +951,21 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isUnread = !email.keywords?.$seen; - const effectiveClient = resolveActionClient(client); + // In unified view route to the email's own account (client + that + // account's mailbox list); otherwise the active/viewing context. (#281) + const { client: effectiveClient, mailboxes, accountId } = resolveEmailActionContext(email, client); // Get delete action preference from settings const deleteAction = useSettingsStore.getState().deleteAction; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; - // Determine accountId for shared folders - const selectedMailboxId = get().selectedMailbox; - const mailboxes = resolveActionMailboxes(); - const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId); - const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; + // The email's current mailbox drives the junk auto-permanent-delete rule. + // In unified view it comes from the email's own folders (matching the + // unified role), not the active account's selected mailbox. + const currentMailbox = get().isUnifiedView + ? (mailboxes.find(mb => email.mailboxIds?.[mb.id] && mb.role === get().unifiedRole) + ?? mailboxes.find(mb => email.mailboxIds?.[mb.id])) + : mailboxes.find(mb => mb.id === get().selectedMailbox); // If in junk folder and setting is enabled, permanently delete const isInJunk = currentMailbox?.role === 'junk'; @@ -1046,13 +1103,11 @@ export const useEmailStore = create((set, get) => ({ processingReadStatus: new Set([...state.processingReadStatus, processingKey]) })); - // Determine accountId for shared folders - const selectedMailboxId = get().selectedMailbox; - const mailboxes = resolveActionMailboxes(); - const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); - const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + // In unified view route to the email's own account client; otherwise use + // the active/viewing client and the shared-folder accountId. (#281) + const { client: actionClient, accountId } = resolveEmailActionContext(email, client); - await resolveActionClient(client).markAsRead(emailId, read, accountId); + await actionClient.markAsRead(emailId, read, accountId); // Update local state including mailbox counters set((state) => { @@ -1116,15 +1171,15 @@ export const useEmailStore = create((set, get) => ({ const isUnread = !email.keywords?.$seen; const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : []; - const { selectedMailbox } = get(); - const mailboxes = resolveActionMailboxes(); - const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); - const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; + // In unified view route to the email's own account (client + that + // account's mailbox list, where the destination id lives); otherwise the + // active/viewing context. (#281) + const { client: actionClient, mailboxes, accountId } = resolveEmailActionContext(email, client); const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; - await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId); + await actionClient.moveEmail(emailId, jmapDestId, accountId); set((state) => { const updatedMailboxes = state.mailboxes.map(mailbox => { @@ -1356,10 +1411,9 @@ export const useEmailStore = create((set, get) => ({ return; } - const mailboxes = resolveActionMailboxes(); - const effectiveClient = resolveActionClient(client); - const currentMailbox = mailboxes.find(mb => mb.id === state.selectedMailbox); - const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; + // In unified view route to the email's own account (client + that + // account's mailbox list); otherwise the active/viewing context. (#281) + const { client: effectiveClient, mailboxes, accountId } = resolveEmailActionContext(email, client); const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; @@ -1550,7 +1604,9 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isFlagged = email.keywords.$flagged || false; - await resolveActionClient(client).toggleStar(emailId, !isFlagged); + // In unified view route to the email's own account client. (#281) + const { client: actionClient } = resolveEmailActionContext(email, client); + await actionClient.toggleStar(emailId, !isFlagged); // Update local state set((state) => ({ @@ -1883,24 +1939,33 @@ export const useEmailStore = create((set, get) => ({ // Spam operations markAsSpam: async (client, emailId) => { - const { selectedMailbox, emails } = get(); - const mailboxes = resolveActionMailboxes(); - const email = emails.find(e => e.id === emailId); + const email = get().emails.find(e => e.id === emailId); if (!email) return; - const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); + // In unified view route to the email's own account (client + that account's + // mailbox list); otherwise the active/viewing context. (#281) + const { client: actionClient, mailboxes, accountId } = resolveEmailActionContext(email, client); + + // The email's current mailbox is what undo restores it to. In unified view + // derive it from the email's own folders (preferring the unified role), + // otherwise the active account's selected mailbox. + const currentMailbox = get().isUnifiedView + ? (mailboxes.find(mb => email.mailboxIds?.[mb.id] && mb.role === get().unifiedRole) + ?? mailboxes.find(mb => email.mailboxIds?.[mb.id])) + : mailboxes.find(m => m.id === get().selectedMailbox); if (!currentMailbox) return; get().spamUndoCache.set(emailId, { emailId, originalMailboxId: currentMailbox.originalId || currentMailbox.id, - accountId: currentMailbox.accountId, + accountId, + sourceAccountId: get().isUnifiedView ? email.accountId : undefined, }); try { const isUnread = !email.keywords?.$seen; const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread; - await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId, alsoMarkRead); + await actionClient.markAsSpam(emailId, accountId, alsoMarkRead); set(state => ({ emails: state.emails.filter(e => e.id !== emailId), @@ -1921,11 +1986,17 @@ export const useEmailStore = create((set, get) => ({ let targetMailboxId: string; let accountId: string | undefined; + // In unified view the cache records the email's owning account so the undo + // is routed to that account's client, not the active one. (#281) + let undoClient: IJMAPClient = resolveActionClient(client); if (cachedData) { // Use cached original mailbox (more accurate for immediate undo) targetMailboxId = cachedData.originalMailboxId; accountId = cachedData.accountId; + if (cachedData.sourceAccountId) { + undoClient = useAuthStore.getState().getClientForAccount(cachedData.sourceAccountId) ?? undoClient; + } get().spamUndoCache.delete(emailId); } else { // Fall back to finding Inbox (generic "not spam" button/menu) @@ -1946,8 +2017,15 @@ export const useEmailStore = create((set, get) => ({ } try { - await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId); - await get().fetchEmails(client, selectedMailbox); + await undoClient.undoSpam(emailId, targetMailboxId, accountId); + // Refresh the view the user is actually looking at. + if (get().isUnifiedView && get().unifiedRole) { + const includeGroup = useSettingsStore.getState().includeGroupInUnified; + const accounts = await buildUnifiedAccountClients({ includeGroup }); + await get().fetchUnifiedEmails(accounts, get().unifiedRole!); + } else { + await get().fetchEmails(client, selectedMailbox); + } } catch (error) { console.error('Failed to restore email:', error); throw error;