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