feat: add Elastic built-in theme
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 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();
|
||||
});
|
||||
});
|
||||
+109
-31
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user