feat: add Elastic built-in theme

This commit is contained in:
Linus Rath
2026-06-01 23:28:15 +02:00
parent ce401c0f59
commit 152ec99262
5 changed files with 601 additions and 45 deletions
+42 -12
View File
@@ -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);
}
+3 -2
View File
@@ -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', () => {
+320
View File
@@ -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
<html>, so it must be an ancestor of the skinned <body>.) */
.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 <span> 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,
},
];
@@ -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> = {}): 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> = {}): 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 emails 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 emails 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 emails account client with that accounts 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();
});
});
+109 -31
View File
@@ -179,7 +179,9 @@ interface EmailStore {
batchArchive: (client: IJMAPClient) => Promise<void>;
// Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
// `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<string, { emailId: string; originalMailboxId: string; accountId?: string; sourceAccountId?: string }>;
markAsSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
@@ -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<string, Mailbox[]> = {};
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<string, Mailbox[]>();
@@ -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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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;