This commit is contained in:
Linus Rath
2026-07-16 18:08:40 +02:00
93 changed files with 4818 additions and 249 deletions
+5
View File
@@ -9,3 +9,8 @@ scripts/
TODO.md
*.md
!README.md
# Sibling projects / test harness - not part of the webmail image
examples/
integration/
e2e/
**/node_modules
+2
View File
@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { FaviconBadge } from "@/components/favicon-badge";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
@@ -132,6 +133,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
<FaviconBadge />
{children}
</body>
</html>
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import { FaviconBadge } from '@/components/favicon-badge';
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { Mailbox } from '@/lib/jmap/types';
vi.mock('@/hooks/use-favicon-badge', () => ({
useFaviconBadge: vi.fn(),
}));
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
return {
name: patch.id,
sortOrder: 0,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
isSubscribed: true,
myRights: {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
...patch,
} as Mailbox;
}
const initialMailboxes = useEmailStore.getState().mailboxes;
beforeEach(() => {
useEmailStore.setState({ mailboxes: initialMailboxes });
useSettingsStore.setState({ faviconUnreadBadge: true });
});
afterEach(() => {
useEmailStore.setState({ mailboxes: initialMailboxes });
useSettingsStore.setState({ faviconUnreadBadge: true });
vi.clearAllMocks();
});
describe('FaviconBadge', () => {
it('badges the unread count of the primary inbox', () => {
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
});
const { container } = render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
expect(container.firstChild).toBeNull(); // renders no markup
});
it('disables the badge when the setting is off', () => {
useSettingsStore.setState({ faviconUnreadBadge: false });
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
});
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
});
it('ignores a shared inbox, even when it sorts first', () => {
// Shared and group inboxes ship in the same `mailboxes` array. A plain
// `role === 'inbox'` lookup would badge somebody else's inbox on a
// delegated setup, so the store's canonical `!isShared` filter is required.
useEmailStore.setState({
mailboxes: [
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
],
});
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
});
it('badges zero when there is no inbox yet', () => {
useEmailStore.setState({ mailboxes: [] });
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
});
});
@@ -148,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/reply-identity', () => ({
resolveReplyFrom: () => null,
findComposeIdentityId: () => null,
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
@@ -227,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => {
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
@@ -241,7 +244,7 @@ describe('RecipientChipInput drag and drop', () => {
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
});
it('onDragEnd clears the opacity class on the chip', async () => {
@@ -340,4 +343,121 @@ describe('RecipientChipInput drag and drop', () => {
const ccLabel = await screen.findByText('cc_label');
expect(ccLabel).toBeInTheDocument();
});
// ─── Reordering within / across fields (#593) ─────────────────────────────────
// jsdom ignores `clientX` in fireEvent's init for drag events (it's a
// read-only MouseEvent getter) and gives every element a zero-size rect at
// (0,0). So we dispatch events with `clientX` forced via defineProperty; with
// the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
const chipByText = async (text: string) =>
(await screen.findByText(text)).closest('[draggable]') as HTMLElement;
/** Dispatch a drag event with a real clientX (fireEvent init drops it). */
const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
const e = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(e, 'clientX', { value: clientX });
Object.defineProperty(e, 'dataTransfer', { value: dt });
act(() => { fireEvent(el, e); });
};
const BEFORE = -100;
const AFTER = 100;
/** Ordered chip labels of the field-container that holds `anchorText`. */
const orderIn = (anchorText: string) => {
const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const c = containers.find(el =>
Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
) as HTMLElement;
return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
};
/** All draggable chips (across fields) whose label contains `text`. */
const draggableChipsWith = (text: string) =>
Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com');
const carol = await chipByText('carol@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
fireDnd('drop', carol, dt, AFTER);
expect(orderIn('bob@example.com')).toEqual([
'bob@example.com', 'carol@example.com', 'alice@example.com',
]);
});
it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('carol@example.com');
const carol = await chipByText('carol@example.com');
const alice = await chipByText('alice@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
fireDnd('drop', alice, dt, BEFORE);
expect(orderIn('alice@example.com')).toEqual([
'carol@example.com', 'alice@example.com', 'bob@example.com',
]);
});
it('dropping a chip onto its own position leaves the order unchanged', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('bob@example.com');
const bob = await chipByText('bob@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
fireDnd('drop', bob, dt, BEFORE);
expect(orderIn('bob@example.com')).toEqual([
'alice@example.com', 'bob@example.com', 'carol@example.com',
]);
});
it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com'); // To
const y = await chipByText('y@example.com'); // Cc
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt });
fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
fireDnd('drop', y, dt, BEFORE);
// alice lands between x and y; To no longer holds it (count only real chips,
// not the leftover jsdom drag-preview element)
expect(orderIn('x@example.com')).toEqual([
'x@example.com', 'alice@example.com', 'y@example.com',
]);
expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
});
it('shows a drop caret only while a chip is dragged over the field', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com');
const bob = await chipByText('bob@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt });
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
fireDnd('dragover', bob, dt, BEFORE);
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
fireEvent.dragEnd(alice);
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
});
});
@@ -147,7 +147,10 @@ vi.mock('@/lib/email-sanitization', () => ({
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/reply-identity', () => ({
resolveReplyFrom: () => null,
findComposeIdentityId: () => null,
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
+107 -19
View File
@@ -11,7 +11,7 @@ import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
@@ -823,7 +823,7 @@ export function EmailComposer({
}, [composerClient, plainTextMode, mode]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
? `<div>${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
@@ -936,7 +936,10 @@ export function EmailComposer({
}
}, [plainTextMode]);
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
// Move a chip from one recipient field to another. `toIndex`, when given,
// inserts at that position in the destination (drag-and-drop reordering,
// #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button).
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => {
if (fromField === toField) return;
const setters = { to: setTo, cc: setCc, bcc: setBcc };
const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
@@ -946,7 +949,13 @@ export function EmailComposer({
const idx = prev.findIndex(r => sameRecipient(r, recipient));
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
});
setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
setters[toField](prev => {
if (prev.some(r => sameRecipient(r, recipient))) return prev;
const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length));
const next = [...prev];
next.splice(at, 0, recipient);
return next;
});
if (toField === 'cc') setShowCc(true);
if (toField === 'bcc') setShowBcc(true);
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
@@ -2041,7 +2050,7 @@ export function EmailComposer({
};
return (
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
@@ -2099,6 +2108,7 @@ export function EmailComposer({
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
data-testid="composer-send"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 me-1.5" />
@@ -2133,6 +2143,7 @@ export function EmailComposer({
</div>
) : identities.length > 1 ? (
<select
data-testid="composer-from"
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
@@ -2164,7 +2175,7 @@ export function EmailComposer({
})}
</select>
) : (
<span className="text-sm text-foreground flex-1 truncate">
<span data-testid="composer-from" className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
@@ -2227,7 +2238,7 @@ export function EmailComposer({
</div>
{/* To field */}
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<div data-testid="composer-to" className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<RecipientChipInput
chips={to}
@@ -2372,6 +2383,7 @@ export function EmailComposer({
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
data-testid="composer-subject"
type="text"
placeholder={t('subject_placeholder')}
value={subject}
@@ -2594,6 +2606,7 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
data-testid="composer-send"
className="rounded-e-none border-e border-primary-foreground/20"
>
<Send className="w-4 h-4 me-2" />
@@ -2632,6 +2645,7 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
data-testid="composer-send"
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 me-2" />
@@ -2895,7 +2909,7 @@ function RecipientChipInput({
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void;
}) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
@@ -2904,6 +2918,9 @@ function RecipientChipInput({
const [editValue, setEditValue] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
// Gap (0..chips.length) a dragged chip would drop into; drives the insertion
// caret and positional drop for reordering (#593). null when not dragging.
const [dropIndex, setDropIndex] = useState<number | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
// Focus edit input when editing starts
@@ -3060,27 +3077,81 @@ function RecipientChipInput({
onAutoBlur(e, field);
};
const isChipDrag = (e: React.DragEvent) =>
e.dataTransfer.types.includes('application/x-recipient-chip');
// Dragging over empty container space (past the last chip / over the input)
// targets the end of the list.
const handleContainerDragOver = (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
if (!isChipDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
setDropIndex(chips.length);
};
const handleContainerDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
setDropIndex(null);
}
};
// Dragging over a chip picks the gap before or after it based on which half
// the pointer is in (mirrored for RTL). stopPropagation keeps the container
// handler from overriding this finer target.
const handleChipDragOver = (e: React.DragEvent, index: number) => {
if (!isChipDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const rtl = typeof window !== 'undefined' &&
getComputedStyle(e.currentTarget as Element).direction === 'rtl';
const past = rtl
? e.clientX < rect.left + rect.width / 2
: e.clientX > rect.left + rect.width / 2;
setIsDragOver(true);
setDropIndex(past ? index + 1 : index);
};
// Insert the dragged chip at `target`. Same-field is a local reorder;
// cross-field routes through onMoveChip with the destination index (#593).
const performDrop = (e: React.DragEvent, target: number) => {
e.preventDefault();
setIsDragOver(false);
setDropIndex(null);
setDraggingIndex(null);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number };
try {
payload = JSON.parse(raw);
} catch {
return;
}
const { recipient, fromField, fromIndex } = payload;
const to = Math.max(0, Math.min(target, chips.length));
if (fromField === field) {
const from = typeof fromIndex === 'number'
? fromIndex
: chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? ''));
if (from < 0 || from >= chips.length) return;
// Removing the source before `to` shifts the target left by one.
const insertAt = to > from ? to - 1 : to;
if (insertAt === from) return; // dropped onto its own position
const next = [...chips];
const [moved] = next.splice(from, 1);
next.splice(insertAt, 0, moved);
onChipsChange(next);
} else {
onMoveChip(recipient, fromField, field, to);
}
};
const handleContainerDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField === field) return;
onMoveChip(recipient, fromField, field);
performDrop(e, dropIndex ?? chips.length);
};
return (
@@ -3100,20 +3171,29 @@ function RecipientChipInput({
const isEditing = editingChip?.index === i;
const chipDisplay = formatChipDisplay(chip);
return (
<React.Fragment key={`${chip.email}-${i}`}>
{dropIndex === i && (
<span
aria-hidden
data-testid="recipient-drop-caret"
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
/>
)}
<span
key={`${chip.email}-${i}`}
draggable={!isEditing}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i }));
// Show the address while dragging, matching the email-list drag preview.
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
requestAnimationFrame(() => dragPreview.remove());
setDraggingIndex(i);
}}
onDragEnd={() => setDraggingIndex(null)}
onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }}
onDragOver={(e) => handleChipDragOver(e, i)}
onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
isEditing
@@ -3179,8 +3259,16 @@ function RecipientChipInput({
)}
</button>
</span>
</React.Fragment>
);
})}
{dropIndex === chips.length && chips.length > 0 && (
<span
aria-hidden
data-testid="recipient-drop-caret"
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
/>
)}
{!editingChip && (
<input
ref={inputRef}
+1
View File
@@ -1175,6 +1175,7 @@ export function EmailViewer({
id: email.id,
contentType,
bodyStructure: email.bodyStructure,
bodyValues: email.bodyValues,
attachments: email.attachments,
blobId: email.blobId,
from: email.from,
+6 -3
View File
@@ -10,6 +10,10 @@ import { buildSignatureBlock } from "@/components/email/signature-block";
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
// Reusable style for the quote bar when quoting email text (like in a reply).
const QUOTE_BAR_STYLE =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
/**
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
* quoted/forwarded original email. The HTML is stored in the `html` attribute
@@ -68,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
const dom = document.createElement("div");
dom.setAttribute(QUOTED_HTML_MARKER, "");
dom.className = "quoted-html-island";
dom.style.cssText =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
dom.style.cssText = QUOTE_BAR_STYLE;
// CRITICAL: render the quoted email inside a Shadow Root. The app's
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
@@ -192,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
* must be what serializeEditorContent emits too (round-trip consistency).
*/
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
}
+65
View File
@@ -41,6 +41,7 @@ import {
Heading1,
Heading2,
Table as TableIcon,
Baseline,
Trash2,
Rows3,
Columns3,
@@ -143,6 +144,14 @@ function ToolbarSeparator() {
const TABLE_PICKER_ROWS = 6;
const TABLE_PICKER_COLS = 8;
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
// round-trips; the TextStyle/Color extensions are already registered to
// preserve pasted colours - this palette just adds a UI to set them.
const TEXT_COLORS = [
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
];
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
return (
@@ -336,6 +345,19 @@ export function RichTextEditor({
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
const colorWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorMenuOpen) return;
const handler = (e: MouseEvent) => {
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
setColorMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [colorMenuOpen]);
useEffect(() => {
if (!tableMenuOpen) return;
@@ -386,6 +408,49 @@ export function RichTextEditor({
>
<Strikethrough className="w-4 h-4" />
</ToolbarButton>
<div ref={colorWrapperRef} className="relative">
<ToolbarButton
active={!!editor.getAttributes("textStyle").color}
onClick={() => setColorMenuOpen((v) => !v)}
title="Text color"
>
{/* The icon itself previews the active colour - no layout shift. */}
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
</ToolbarButton>
{colorMenuOpen && (
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
{TEXT_COLORS.map((color) => (
<button
key={color}
type="button"
title={color}
onClick={() => {
editor.chain().focus().setColor(color).run();
setColorMenuOpen(false);
}}
className={cn(
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
)}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="h-px bg-border my-1.5" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
onClick={() => {
editor.chain().focus().unsetColor().run();
setColorMenuOpen(false);
}}
>
<RemoveFormatting className="w-4 h-4" /> Remove color
</button>
</div>
)}
</div>
<ToolbarSeparator />
+29 -3
View File
@@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
// so parseHTML can recognise it on the way back in (initial content, drafts).
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
/**
* Force every link in the rendered signature to open in a new tab.
*
* Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is
* what serializeEditorContent emits into the sent message, and the recipient's
* copy should stay exactly as the user wrote it. Without this the composer's
* signature is a set of live, target-less anchors in the main document (the
* message body gets a sandboxed iframe; this does not), so one stray click
* navigates the whole app away and takes the unsent draft with it.
*/
function forceLinksToNewTab(root: HTMLElement): void {
root.querySelectorAll("a[href]").forEach((a) => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
});
}
/**
* SignatureBlock — an atomic, NON-editable block node that carries the
* *verbatim* HTML of the user's identity signature in its `html` attribute.
@@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
dom.className = "signature-block-island";
// CRITICAL: render the signature inside a Shadow Root. The app's global
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
// would otherwise cascade INTO the signature and destroy its layout -
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
const inner = document.createElement("div");
// Read-only: a signature is inserted/removed as a unit, not edited inline.
inner.contentEditable = "false";
inner.innerHTML = node.attrs.html || "";
// Track what we were given, not what's in the DOM: forceLinksToNewTab
// rewrites the markup, so inner.innerHTML no longer round-trips against
// attrs.html and comparing the two would rewrite on every transaction.
let appliedHtml = node.attrs.html || "";
inner.innerHTML = appliedHtml;
forceLinksToNewTab(inner);
shadow.appendChild(inner);
return {
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
stopEvent: () => false,
update: (updatedNode) => {
if (updatedNode.type.name !== "signatureBlock") return false;
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
inner.innerHTML = updatedNode.attrs.html || "";
const nextHtml = updatedNode.attrs.html || "";
if (nextHtml !== appliedHtml) {
appliedHtml = nextHtml;
inner.innerHTML = nextHtml;
forceLinksToNewTab(inner);
}
return true;
},
+9 -4
View File
@@ -13,7 +13,12 @@ declare module "@tiptap/core" {
/**
* Adds a `dir` attribute to block nodes so the composer can mark individual
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing).
*
* The default is `"auto"`: each block detects its own direction from its first
* strong character, so a paragraph typed in English renders LTR and one typed
* in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins
* an explicit `ltr`/`rtl` when you want to override the auto-detection, and the
* attribute round-trips to HTML so the direction is preserved in the sent mail.
*/
export const TextDirection = Extension.create({
@@ -29,10 +34,10 @@ export const TextDirection = Extension.create({
types: this.options.types,
attributes: {
dir: {
default: null,
parseHTML: (element) => element.getAttribute("dir") || null,
default: "auto",
parseHTML: (element) => element.getAttribute("dir") || "auto",
renderHTML: (attributes) =>
attributes.dir ? { dir: attributes.dir } : {},
attributes.dir ? { dir: attributes.dir } : { dir: "auto" },
},
},
},
+6 -2
View File
@@ -166,6 +166,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
ref={ref}
{...dragHandlers}
{...longPressHandlers}
data-testid="email-list-item"
data-email-id={email.id}
data-subject={email.subject || ''}
data-unread={isUnread ? 'true' : 'false'}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
resolvedColorTag ? resolvedColorTag : (
@@ -660,7 +664,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onToggle={toggleThreadSelection}
selectLabel={tBatch('select')}
/>
{!isMobile && !isFocusedMailLayout && (
{!isMobile && (
<button
data-expand-toggle
onClick={(e) => {
@@ -892,7 +896,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
</div>
{isExpanded && !isMobile && !isFocusedMailLayout && (
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
/**
* Badges the browser-tab favicon with the inbox unread count, so new mail is
* visible without focusing the tab. See issue #560.
*
* Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
* default.
*
* Mounted in the root layout rather than on the mail route: the badge belongs
* to the tab, not to a page. Mounting it on the mail page unmounted it — and so
* cleared the badge, and flickered the icon — on every hop to /settings,
* /calendar or /contacts.
*
* Renders nothing.
*/
export function FaviconBadge() {
// The store's canonical inbox selector. `role === 'inbox'` alone is not
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
// a delegated setup the first match can be somebody else's inbox.
const inboxUnread = useEmailStore(
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
);
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
useFaviconBadge(inboxUnread, enabled);
return null;
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
@@ -305,7 +305,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtml(formData.htmlSignature)
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
}}
/>
</div>
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useSettingsStore } from '@/stores/settings-store';
function useSyncIdentities() {
@@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const handleSetPrimary = useCallback((identity: Identity) => {
setPreferredPrimary(identity.id);
// Persist to the synced settings (keyed by username, matching how
// loadIdentities reads it back) so the choice survives a new browser /
// cleared site data and reaches other devices (#507).
const username = useAuthStore.getState().username || '';
if (username) {
// Persist the choice per account in the synced settings store so it
// survives clearing site data, follows the user across devices, and shows
// up in exported settings (issue #507). JMAP identity ids are account-
// scoped, so the default is keyed by the active account.
const activeAccountId = useAccountStore.getState().activeAccountId;
if (activeAccountId) {
const current = useSettingsStore.getState().preferredIdentityIds;
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
...current,
[username]: identity.id,
[activeAccountId]: identity.id,
});
}
// Re-sort: move the preferred identity to the front
+6
View File
@@ -151,6 +151,8 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
data-testid="account-switcher"
data-active-account-id={activeAccountId ?? undefined}
className={cn(
"flex items-center gap-2 rounded-md transition-colors",
variant === "rail"
@@ -213,6 +215,9 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
>
<button
onClick={() => handleSwitch(account.id)}
data-testid="account-option"
data-account-id={account.id}
data-account-email={account.email || account.username}
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
@@ -274,6 +279,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<div className="border-t border-border">
<button
onClick={handleAddAccount}
data-testid="add-account"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
+26 -2
View File
@@ -220,8 +220,13 @@ function SidebarRowCounts({
) : null;
return (
<span className="ms-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
<span
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
data-testid="folder-counts"
data-unread={unreadCount}
data-total={totalCount}
>
{unreadNode}
{unreadCount > 0 && totalCount > 0 && (
<span className="text-xs text-muted-foreground/60">/</span>
@@ -249,6 +254,10 @@ interface SidebarRowProps {
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
/** Stable identifiers for integration tests (not user-visible). */
testRole?: string | null;
testName?: string;
testMailboxId?: string;
}
function SidebarRow({
@@ -269,6 +278,9 @@ function SidebarRow({
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
testRole,
testName,
testMailboxId,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -277,6 +289,10 @@ function SidebarRow({
<div
{...(dropHandlers || {})}
onContextMenu={onContextMenu}
data-testid="folder-row"
data-folder-role={testRole ?? undefined}
data-folder-name={testName ?? undefined}
data-mailbox-id={testMailboxId ?? undefined}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -477,6 +493,9 @@ function MailboxTreeItem({
<SidebarRow
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
label={label}
testRole={node.role}
testName={node.name}
testMailboxId={node.id}
depth={node.depth}
isSelected={isSelected}
isVirtual={isVirtualNode}
@@ -1047,6 +1066,9 @@ export function Sidebar({
key={unifiedId}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons, count.role)} />}
label={t(`unified_${count.role}`)}
testRole={count.role}
testName={`unified-${count.role}`}
testMailboxId={unifiedId}
depth={0}
isSelected={isSelected}
unread={count.unreadEmails}
@@ -1068,6 +1090,8 @@ export function Sidebar({
key={id}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
label={label}
testName={id}
testMailboxId={id}
depth={0}
isSelected={isSelected}
unread={unread}
+8 -1
View File
@@ -118,7 +118,7 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
@@ -231,6 +231,13 @@ export function LayoutSettings() {
/>
</SettingItem>
<SettingItem label={t('favicon_unread_badge.label')} description={t('favicon_unread_badge.description')}>
<ToggleSwitch
checked={faviconUnreadBadge}
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
/>
</SettingItem>
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
<SettingItem
label={t('unified_mailbox.label')}
+2
View File
@@ -79,6 +79,8 @@ export default [
"e2e/**",
"local-data/**/*.mjs",
"benchmark/**",
"examples/**",
"integration/**",
],
},
];
+559
View File
@@ -0,0 +1,559 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { StrictMode } from 'react';
import { render, renderHook, waitFor } from '@testing-library/react';
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
import { renderBadgedFavicon } from '@/lib/favicon-badge';
vi.mock('@/lib/favicon-badge', () => ({
renderBadgedFavicon: vi.fn((_source: string, count: number) =>
count > 0 ? `data:image/svg+xml,BADGED-${count}` : null,
),
}));
const renderBadgedFaviconMock = vi.mocked(renderBadgedFavicon);
const ORIGINAL_HREF = '/branding/Bulwark_Favicon.svg';
/** The link the hook owns: the only one it may ever touch. */
function badgeLink(): HTMLLinkElement | null {
return document.querySelector<HTMLLinkElement>('link[data-favicon-badge]');
}
/** The base icon link the page (or React) rendered: must survive untouched. */
function baseLinks(): HTMLLinkElement[] {
return Array.from(
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]:not([data-favicon-badge])'),
);
}
/** Every icon link in <head>, in document order. The browser honours the last. */
function iconLinks(): HTMLLinkElement[] {
return Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
}
function lastIconLink(): HTMLLinkElement | null {
return iconLinks().at(-1) ?? null;
}
/**
* What Next/React does on a client-side navigation: it re-hoists its metadata
* icon link into <head>, appending a *fresh* node after everything already
* there — including our badge link.
*/
function rehoistBaseIcon(href = ORIGINAL_HREF): HTMLLinkElement {
const link = document.createElement('link');
link.rel = 'icon';
link.href = href;
document.head.appendChild(link);
return link;
}
/** Drains microtasks (MutationObserver callbacks) and one macrotask. */
async function settle(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function svgResponse(body = '<svg viewBox="0 0 1000 1000"/>') {
return new Response(body, { status: 200, headers: { 'content-type': 'image/svg+xml' } });
}
function Badger({ count }: { count: number }) {
useFaviconBadge(count);
return null;
}
beforeEach(() => {
document.head.innerHTML = `<link rel="icon" href="${ORIGINAL_HREF}">`;
vi.stubGlobal('fetch', vi.fn(async () => svgResponse()));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
// document.head outlives every test, so a spy on it that a failing assertion
// never got to restore would leak into the next test's counts.
vi.restoreAllMocks();
});
describe('useFaviconBadge', () => {
it('appends its own badged icon link when the count is positive', async () => {
renderHook(() => useFaviconBadge(3));
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(badgeLink()!.getAttribute('type')).toBe('image/svg+xml');
// Last-declared icon wins, so ours must be last in <head>.
expect(document.head.lastElementChild).toBe(badgeLink());
});
it('never removes or mutates an icon link it did not create', async () => {
const before = baseLinks()[0];
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
expect(before.isConnected).toBe(true);
expect(before.getAttribute('href')).toBe(ORIGINAL_HREF);
expect(baseLinks()).toHaveLength(1);
});
it('leaves every icon link it did not create intact, including non-SVG fallbacks', async () => {
document.head.innerHTML =
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
const survivors = baseLinks();
expect(survivors).toHaveLength(2);
expect(survivors[0].getAttribute('href')).toBe('/a.svg');
expect(survivors[0].getAttribute('type')).toBe('image/svg+xml');
expect(survivors[1].getAttribute('href')).toBe('/a.png');
expect(survivors[1].getAttribute('type')).toBe('image/png');
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
});
it('does not throw when React owns the icon link and its subtree is deleted', async () => {
// React 19 hoists <link rel="icon"> into <head> and keeps a fiber pointing at
// that DOM node. Removing it out from under React makes the commit phase throw
// "Cannot read properties of null (reading 'removeChild')" when the fiber is
// later deleted. The hook must therefore never touch a node it did not create.
document.head.innerHTML = '';
const { unmount } = render(
<>
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
<Badger count={3} />
</>,
);
await waitFor(() => expect(badgeLink()).not.toBeNull());
// The React-owned node is still there, untouched.
const reactOwned = baseLinks();
expect(reactOwned).toHaveLength(1);
expect(reactOwned[0].getAttribute('href')).toBe(ORIGINAL_HREF);
expect(() => unmount()).not.toThrow();
expect(badgeLink()).toBeNull();
});
it('replaces its own link rather than mutating its href', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const first = badgeLink();
rerender({ n: 4 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-4');
});
// Firefox ignores an in-place href change on the favicon link.
expect(badgeLink()).not.toBe(first);
expect(first!.isConnected).toBe(false);
});
it('clears the badge by inserting a fresh link carrying the base href, not by removing its own', async () => {
// The field bug: Firefox does not re-evaluate the favicon when an icon link
// is *removed* — a removal is not an insertion, so it keeps painting the
// last icon it was handed and the stale "99+" badge sticks until a hard
// reload. Clearing must therefore be an insertion: our own link is replaced
// by a brand-new node carrying the original base href.
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const badged = badgeLink()!;
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
expect(badged.isConnected).toBe(false);
expect(restored.getAttribute('type')).toBe('image/svg+xml');
expect(lastIconLink()).toBe(restored);
// And the base link the page rendered is still untouched.
const survivors = baseLinks();
expect(survivors).toHaveLength(1);
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('inserts the base-href link exactly once while the count stays at zero', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
const appendSpy = vi.spyOn(document.head, 'appendChild');
const ownAppends = () =>
appendSpy.mock.calls.filter(
([node]) => node instanceof Element && node.matches('link[data-favicon-badge]'),
).length;
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(ownAppends()).toBe(1);
// Neither further renders at the same count nor unrelated <head> churn (an
// observer tick) may re-insert it: no remove/append thrash on every tick.
rerender({ n: 0 });
rerender({ n: 0 });
document.head.appendChild(document.createElement('meta'));
await settle();
expect(ownAppends()).toBe(1);
expect(badgeLink()).toBe(restored);
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
appendSpy.mockRestore();
});
it('keeps its own link last even while it is only carrying the base href', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const own = badgeLink()!;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(badgeLink()).toBe(own); // moved, not recreated
});
it('badges again with a fresh insertion when the count leaves zero', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ n: 0 });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const cleared = badgeLink()!;
rerender({ n: 7 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-7');
});
expect(badgeLink()).not.toBe(cleared);
expect(cleared.isConnected).toBe(false);
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
expect(baseLinks()).toHaveLength(1);
});
it('removes only its own link on unmount', async () => {
const { unmount } = renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
unmount();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('does not fetch, and adds no link, while the count is zero', async () => {
renderHook(() => useFaviconBadge(0));
await Promise.resolve();
await Promise.resolve();
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
});
it('leaves the icon alone when the base is not SVG', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('binary', { headers: { 'content-type': 'image/png' } })),
);
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(fetch).toHaveBeenCalled());
// Assert on the observable end state, not merely on the href being
// unchanged: the href is also unchanged *before* the catch block runs,
// so a href-only assertion would pass even if the code went on to swap
// the icon a tick later. The renderer must never be reached.
await waitFor(() => expect(renderBadgedFaviconMock).not.toHaveBeenCalled());
expect(badgeLink()).toBeNull();
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('leaves the icon alone when the base fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('does nothing when there is no icon link to read', async () => {
document.head.innerHTML = '';
renderHook(() => useFaviconBadge(3));
await Promise.resolve(); // let any deferred async work start
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
});
it('fetches the base icon exactly once under StrictMode and rapid count changes', async () => {
// StrictMode double-invokes effects, and a count change while the fetch is in
// flight re-runs the effect: neither may issue a second request.
let resolveFetch: (response: Response) => void = () => {};
const inFlight = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
vi.stubGlobal(
'fetch',
vi.fn(() => inFlight),
);
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
initialProps: { n: 1 },
wrapper: StrictMode,
});
rerender({ n: 2 });
rerender({ n: 5 });
expect(fetch).toHaveBeenCalledTimes(1);
resolveFetch(svgResponse());
await waitFor(() => {
// The badge lands on the latest count, not the one in flight at fetch time.
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-5');
});
rerender({ n: 6 });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-6');
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it('does not re-render the badge when the count is unchanged', async () => {
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
initialProps: { n: 3 },
});
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const settled = badgeLink();
renderBadgedFaviconMock.mockClear();
rerender({ n: 3 });
// The element identity alone is not evidence: `count` is the effect's only
// dependency, so React would skip the effect regardless. Assert the
// renderer was not invoked again — that is the behaviour under test.
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBe(settled);
});
describe('when the setting is off', () => {
it('adds no link and does not fetch, however high the count', async () => {
renderHook(() => useFaviconBadge(3, false));
await settle();
expect(fetch).not.toHaveBeenCalled();
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(1);
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('clears a showing badge by inserting a fresh link carrying the base href', async () => {
// Same guarantee as the count-back-to-zero clear, and for the same reason:
// Firefox re-evaluates the favicon on an *insertion* and on nothing else.
// Turning the setting off by *removing* our link would leave the stale
// badge painted on the tab until a hard reload.
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
const badged = badgeLink()!;
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const restored = badgeLink()!;
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
expect(badged.isConnected).toBe(false);
expect(restored.getAttribute('type')).toBe('image/svg+xml');
expect(lastIconLink()).toBe(restored);
// And the base link the page rendered is still untouched.
const survivors = baseLinks();
expect(survivors).toHaveLength(1);
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
});
it('re-badges with a fresh insertion when the setting is turned back on', async () => {
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const cleared = badgeLink()!;
rerender({ on: true });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(badgeLink()).not.toBe(cleared);
expect(cleared.isConnected).toBe(false);
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
expect(baseLinks()).toHaveLength(1);
});
it('keeps its base-href link last when React re-hoists its icon', async () => {
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
initialProps: { on: true },
});
await waitFor(() => expect(badgeLink()).not.toBeNull());
rerender({ on: false });
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
const own = badgeLink()!;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(badgeLink()).toBe(own); // moved, not recreated
});
});
describe('when React re-hoists its icon link on a client-side navigation', () => {
it('moves its own link back to the end so the badge keeps winning', async () => {
// The field bug: Inbox badges the tab, a hop to /calendar makes React
// re-insert its metadata <link rel="icon"> *after* ours, the base icon
// wins again and the badge vanishes.
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
expect(lastIconLink()).toBe(badgeLink());
const own = badgeLink()!;
const rehoisted = rehoistBaseIcon();
expect(lastIconLink()).toBe(rehoisted); // the badge is now outranked
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(document.head.lastElementChild).toBe(own);
expect(badgeLink()).toBe(own); // moved, not recreated
expect(rehoisted.isConnected).toBe(true); // and React's node is untouched
});
it('restores the badge without the count changing', async () => {
// The "never comes back" half of the bug. Navigating back to the inbox
// does not change the unread count, so nothing re-runs the count effect:
// the observer alone must put the badge back on top.
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
renderBadgedFaviconMock.mockClear();
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
rerender({ n: 3 }); // same count: no effect re-run to lean on
await settle();
expect(lastIconLink()).toBe(badgeLink());
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('re-applies the badge if its own link is removed entirely', async () => {
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
badgeLink()!.remove(); // React blows away part of <head>
await waitFor(() => {
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
});
expect(lastIconLink()).toBe(badgeLink());
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
});
it('settles: re-appending its own link does not feed the observer a loop', async () => {
// Moving our link fires the observer again. If the move is not guarded by
// "am I already last?", that second run moves it again, for ever. Count
// the appends of *our* node: exactly one, and it must stop growing.
renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
const own = badgeLink()!;
const appendSpy = vi.spyOn(document.head, 'appendChild');
const ownAppends = () => appendSpy.mock.calls.filter(([node]) => node === own).length;
rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(own));
expect(ownAppends()).toBe(1);
await settle();
expect(ownAppends()).toBe(1); // the observer's own mutation is a no-op
expect(lastIconLink()).toBe(own);
// Base + re-hoisted base + exactly one badge: nothing was duplicated.
expect(iconLinks()).toHaveLength(3);
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
appendSpy.mockRestore();
});
it('still never removes or mutates a link it did not create', async () => {
document.head.innerHTML =
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
const { unmount } = render(
<>
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
<Badger count={3} />
</>,
);
await waitFor(() => expect(badgeLink()).not.toBeNull());
const rehoisted = rehoistBaseIcon();
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
const survivors = baseLinks();
expect(survivors).toHaveLength(4);
expect(survivors.map((l) => l.getAttribute('href'))).toEqual([
'/a.svg',
'/a.png',
ORIGINAL_HREF,
ORIGINAL_HREF,
]);
expect(survivors[1].getAttribute('type')).toBe('image/png');
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
expect(rehoisted.isConnected).toBe(true);
// The React-owned node is still React's to delete.
expect(() => unmount()).not.toThrow();
});
it('disconnects the observer on unmount and leaves nothing of its own behind', async () => {
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
const { unmount } = renderHook(() => useFaviconBadge(3));
await waitFor(() => expect(badgeLink()).not.toBeNull());
unmount();
expect(disconnect).toHaveBeenCalled();
expect(badgeLink()).toBeNull();
// A post-unmount re-hoist must not resurrect the badge.
rehoistBaseIcon();
await settle();
expect(badgeLink()).toBeNull();
expect(baseLinks()).toHaveLength(2);
disconnect.mockRestore();
});
});
});
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { useCallback, useEffect, useRef } from 'react';
import { renderBadgedFavicon } from '@/lib/favicon-badge';
import { debug } from '@/lib/debug';
// Our own link, and only ever our own. Next's metadata `icons` (app/(main)/
// layout.tsx) renders <link rel="icon"> through React, which hoists it into
// <head> and keeps a fiber pointing at that DOM node. Removing it out from
// under React leaves the fiber holding a detached node, and the next commit
// that deletes that fiber throws "Cannot read properties of null (reading
// 'removeChild')". So we never remove or mutate a node we did not create:
// instead we append an *extra* icon link, marked as ours. The last-declared
// icon wins in browsers, so ours overrides the base without deleting it.
//
// Ours is never removed to clear the badge, though — only on unmount. Firefox
// re-evaluates the favicon on an *insertion* and on nothing else: a removal
// leaves it painting the last icon it was handed, which is how a read inbox
// kept a stale "99+" in the tab. Clearing therefore re-inserts our link with
// the original base href in place of the badge (see `apply`).
const MARKER = 'data-favicon-badge';
const OWN_SELECTOR = `link[${MARKER}]`;
const ICON_SELECTOR = 'link[rel~="icon"]';
const BASE_SELECTOR = `${ICON_SELECTOR}:not([${MARKER}])`;
// Both the badged icon and the untouched base we fall back to are SVG: the hook
// disables itself unless the fetched base is served as image/svg+xml, so by the
// time either link exists that content type is a proven fact, not a guess.
const ICON_TYPE = 'image/svg+xml';
function removeOwnLink(): void {
document.querySelectorAll(OWN_SELECTOR).forEach((el) => el.remove());
}
function ownLink(): HTMLLinkElement | null {
return document.head.querySelector<HTMLLinkElement>(OWN_SELECTOR);
}
/** True when ours is the last icon link in <head>, i.e. the one the browser uses. */
function isLastIconLink(link: HTMLLinkElement): boolean {
const icons = document.head.querySelectorAll<HTMLLinkElement>(ICON_SELECTOR);
return icons[icons.length - 1] === link;
}
/**
* Appends a fresh icon link of ours, replacing any previous one of ours.
*
* Always a remove-then-append of a *new* node, never an href mutation: Firefox
* only re-evaluates the favicon when an icon link is inserted. It ignores an
* in-place href change, and — the count-back-to-zero bug — it equally ignores a
* removal, happily painting the last icon it was handed. So even *clearing* the
* badge is done by inserting: see `apply`, which re-inserts our link carrying
* the original base href rather than deleting it.
*/
function setOwnLink(href: string): void {
removeOwnLink();
const link = document.createElement('link');
link.rel = 'icon';
link.type = ICON_TYPE;
link.href = href;
link.setAttribute(MARKER, '');
document.head.appendChild(link);
}
/**
* Draws `count` as a badge on the browser-tab favicon, unless `enabled` is
* false (the `faviconUnreadBadge` setting).
*
* The base icon is read from the rendered <link rel="icon">, so admin and
* per-domain branding overrides (configManager `faviconUrl`) are respected
* without plumbing config to the client.
*
* Every failure — no icon link, a fetch error, a non-SVG base, unparseable
* source — leaves the existing favicon untouched.
*/
export function useFaviconBadge(count: number, enabled = true): void {
// Disabled is just "nothing to show", i.e. exactly a count of zero, so it
// rides the same paths: no fetch while we have never badged, and — the part
// that matters — clearing an *existing* badge by inserting a fresh link
// carrying the base href rather than removing ours, which Firefox would
// ignore (see `apply`). Switching the setting off therefore restores the
// plain icon immediately, with no reload.
const effectiveCount = enabled ? count : 0;
const baseSource = useRef<string | null>(null);
const baseHref = useRef<string | null>(null);
// The href our own link currently carries, and the hook's whole state machine:
// null -> nothing of ours is in <head> (we have never badged)
// baseHref -> ours is in <head>, showing the unbadged base icon
// a data: URL -> ours is in <head>, showing the badge
const appliedHref = useRef<string | null>(null);
const disabled = useRef(false);
const fetchStarted = useRef(false);
const unmounted = useRef(false);
const latestCount = useRef(effectiveCount);
latestCount.current = effectiveCount;
// Declared before the badge effect so that on a StrictMode remount it runs
// first and clears `unmounted` before the badge effect reads it.
useEffect(() => {
unmounted.current = false;
return () => {
unmounted.current = true;
// Restore the server-rendered favicon by removing our override. Nothing
// else in <head> is ours to touch.
removeOwnLink();
appliedHref.current = null;
};
}, []);
// Reads the refs rather than a closure over `count`, so that the reply to an
// in-flight fetch — and the MutationObserver below, which outlives any single
// render — lands on the newest count, not the one that started it.
const apply = useCallback(() => {
if (unmounted.current || disabled.current) return;
const current = latestCount.current;
if (current <= 0) {
// Clearing the badge is an *insertion*, not a removal.
//
// The field bug: with 133 unread the tab showed "99+", the user read
// everything, the store went to 0 — and Firefox kept painting "99+" until
// a hard reload. Removing our link is not an insertion, and Firefox only
// re-evaluates the favicon on an insertion; a removal leaves it painting
// the last icon it was handed. So instead of deleting our link we replace
// it with a fresh one carrying the *original* base href: same pixels as
// the untouched base link below it, but handed to the browser as a new
// icon, which it does repaint.
//
// Never badged (`appliedHref` still null)? Then nothing of ours is in
// <head> and nothing should be: a fully-read inbox adds no link at all.
const base = baseHref.current;
if (appliedHref.current === null || base === null) return;
if (appliedHref.current === base && ownLink()) return; // already showing the base: no thrash
setOwnLink(base);
appliedHref.current = base;
return;
}
const source = baseSource.current;
if (source === null) return; // still fetching; the fetch will call back
const next = renderBadgedFavicon(source, current);
if (!next) return;
if (next === appliedHref.current && ownLink()) return;
setOwnLink(next);
appliedHref.current = next;
}, []);
useEffect(() => {
if (disabled.current) return;
// Nothing to show and nothing applied: do not even fetch. A fully-read
// inbox — or the setting switched off before we ever badged — should cost
// no request.
if (effectiveCount <= 0 && baseSource.current === null && !fetchStarted.current) return;
// The base is fetched at most once, ever. Without this guard a StrictMode
// double-invoke issues two requests, and any count change while the fetch
// is in flight issues another.
if (baseSource.current !== null || fetchStarted.current) {
apply();
return;
}
// The one and only read of the base link. Its href is both what we fetch the
// source from and what we hand back to the browser when the badge clears.
const link = document.querySelector<HTMLLinkElement>(BASE_SELECTOR);
const href = link?.getAttribute('href');
if (!href) {
disabled.current = true;
return;
}
baseHref.current = href;
fetchStarted.current = true;
void (async () => {
try {
const response = await fetch(href);
if (!response.ok) throw new Error(`favicon fetch failed: ${response.status}`);
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('image/svg+xml')) {
throw new Error(`favicon is not SVG: ${contentType || 'unknown'}`);
}
baseSource.current = await response.text();
apply();
} catch (error) {
disabled.current = true;
debug.log('[favicon-badge] disabled:', error);
}
})();
}, [effectiveCount, apply]);
// Keep ours the last icon link in <head>.
//
// On a client-side navigation (Inbox -> Calendar) Next re-hoists the metadata
// <link rel="icon"> from app/(main)/layout.tsx into <head>. The re-inserted
// node lands *after* our badge link, the last-declared icon wins, and the
// badge vanishes. Coming back to the inbox did not bring it back either: the
// count is unchanged, so the effect above never re-ran and our link just sat
// there outranked. Watching <head> fixes both halves at once.
//
// Termination: moving our own link is itself a <head> mutation, so it feeds
// the observer a fresh record. The guard is `isLastIconLink` — on that second
// run ours *is* last, so we do nothing and the cascade stops. One move per
// foreign insertion, never two.
const keepOwnLinkLast = useCallback(() => {
if (unmounted.current || disabled.current) return;
// Ours must stay last in *both* states — badged, and showing the base href
// after a clear (`appliedHref` is only null when we have never badged, and
// then nothing of ours is in <head> to keep last). Gating this on the count
// instead would strand our base-href link behind a re-hoisted React icon,
// and the next badge would have to fight its way back on top.
if (appliedHref.current === null) return;
const own = ownLink();
if (!own) {
// React blew our link away with the rest of the head: re-apply from scratch.
apply();
return;
}
if (isLastIconLink(own)) return;
// Re-appending *our own* element is the only mutation we ever make; a node
// we did not create is never removed, moved or touched (see above).
document.head.appendChild(own);
}, [apply]);
useEffect(() => {
const observer = new MutationObserver(keepOwnLinkLast);
observer.observe(document.head, { childList: true });
return () => observer.disconnect();
}, [keepOwnLinkLast]);
}
+11
View File
@@ -0,0 +1,11 @@
# Credentials for the integration-test Stalwart mail server.
# Copy to `.env` (docker compose reads it automatically): cp .env.example .env
# Recovery admin (format user:password). Stays valid after bootstrap so the
# Stalwart admin UI on http://localhost:8025 remains reachable and stalwart-cli
# can be invoked via `docker exec`.
STALWART_RECOVERY_ADMIN=admin:bootstrap-secret
# Shared password for every test mailbox (alice/bob/carol @ example.org).
# The Playwright harness reads the same value from IT_ACCOUNT_PASSWORD.
TEST_ACCOUNT_PASSWORD=test-pass-123
+10
View File
@@ -0,0 +1,10 @@
# Local docker env (copied from .env.example)
.env
# Arch-specific stalwart-cli binary, fetched by stalwart/prepare-stalwart-cli.sh
stalwart/stalwart-cli
# Playwright/test artifacts
node_modules/
test-results/
playwright-report/
+123
View File
@@ -0,0 +1,123 @@
# Integration tests — webmail ⇆ Stalwart
End-to-end tests that run the **Bulwark webmail against a real Stalwart mail
server** in Docker and drive it with Playwright. The focus is the mail/folder
**synchronisation** behaviour that multi-account webmail clients get wrong:
unread/total counters, folder-list sync, and the account-scoped Unified Mailbox.
Everything here is self-contained and separate from the app's root
`playwright.config.ts` (which only smoke-tests the UI against `npm run dev`).
## What's in the stack
| Service | Image | Ports (host) | Purpose |
| --------- | --------------------------------------- | ---------------------------------- | -------------------------------------------------------- |
| `stalwart`| built from [`stalwart/`](stalwart/) | `8025` JMAP+admin, `1025` SMTP, `1143` IMAP | Real MTA, declaratively bootstrapped with test mailboxes |
| `webmail` | built from [`webmail.Dockerfile`](webmail.Dockerfile) | `3000` | The app under test (Next.js, **dev mode** — see below) |
Provisioned mailboxes (domain `example.org`, shared password `test-pass-123`):
`alice`, `bob`, `carol`. Admin: `admin` / `bootstrap-secret`.
### Two things worth knowing
- **The webmail runs in Next.js dev mode.** The browser talks JMAP *directly*
to Stalwart at `http://localhost:8025` (cross-origin, plain HTTP). The app's
production CSP pins `connect-src` to `'self' https:` and would block that;
dev mode widens it to allow `http:`. Dev mode also ships the test hooks from
source without a production rebuild. See the header of `webmail.Dockerfile`.
- **CORS.** Stalwart doesn't emit CORS headers by default. The bootstrap enables
`usePermissiveCors` (see `stalwart/plan-accounts.ndjson.tpl`) so the browser
origin (`:3000`) may call the JMAP origin (`:8025`).
## Running
```bash
# One-shot: brings the stack up and runs the whole suite in the Playwright
# container (browsers preinstalled, host networking to reach the stack).
integration/run-tests.sh
# A single spec:
integration/run-tests.sh 01-login
```
`run-tests.sh` is the recommended entry point because Playwright's browser
bundles can't always be downloaded/installed on the host; the official
`mcr.microsoft.com/playwright` image sidesteps that.
### Running against a host browser instead
If you *can* install Playwright browsers on your machine:
```bash
cd integration && cp .env.example .env
bash stalwart/prepare-stalwart-cli.sh
docker compose up -d --build --wait
npx playwright test -c playwright.integration.config.ts # from the repo root
```
The Playwright `globalSetup` brings the stack up for you (unless `IT_NO_DOCKER=1`).
## Layout
```
integration/
├── docker-compose.yml # stalwart + webmail
├── webmail.Dockerfile # dev-mode webmail image (built from repo source)
├── webmail-config/policy.json # enables the cross-account Unified Mailbox feature gate
├── run-tests.sh # bring up stack + run suite in the Playwright container
├── stalwart/ # bootstrap image (adapted from examples/docker/stalwart)
│ ├── Dockerfile
│ ├── entrypoint.sh # two-phase declarative bootstrap
│ ├── plan-bootstrap.ndjson # domain + datastore
│ ├── plan-accounts.ndjson.tpl # alice/bob/carol + listeners + CORS
│ └── prepare-stalwart-cli.sh # host-side fetch of stalwart-cli (offline-friendly build)
└── tests/
├── global-setup.ts / global-teardown.ts
├── helpers/
│ ├── config.ts # accounts, URLs, ports (env-overridable)
│ ├── smtp.ts # dependency-free SMTP submission client
│ ├── jmap.ts # JMAP client for seeding/inspecting server state
│ └── app.ts # login, add/switch account, folder-counter reads
├── 01-login.spec.ts
├── 02-mail-sync.spec.ts # single-account: receive/read/move/delete/folder-create
└── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
```
## How the tests work
- **Mutations** are made out-of-band — mail is injected over SMTP
(`helpers/smtp.ts`) and server-side reads/moves/deletes/folder-creates are
driven over JMAP (`helpers/jmap.ts`). Assertions are on the **rendered UI**,
so a test tells you whether the webmail *synced* the change.
- **Counters** are read from `data-unread` / `data-total` on the
`[data-testid="folder-counts"]` element, which makes assertions locale-
independent. These and the other `data-testid` hooks (`folder-row`,
`email-list-item`, `account-switcher`, `account-option`, `add-account`,
`email-composer`, …) were added to the app for these tests.
- **`forceSync(page)`** dispatches a `visibilitychange` to trigger the client's
`checkForStateChanges()` — the same reconcile a real user gets when tabbing
back. It makes external-mutation assertions deterministic instead of racing
the SSE push channel right after login.
## Environment knobs
| Var | Default | Effect |
| --------------- | ------------------ | ---------------------------------------------------------- |
| `IT_NO_DOCKER` | unset | `1` = don't manage docker in global-setup (stack already up) |
| `IT_TEARDOWN` | unset | `1` = `docker compose down -v` after the suite |
| `IT_WEBMAIL_URL`| `http://localhost:3000` | Webmail origin |
| `IT_JMAP_URL` | `http://localhost:8025` | Stalwart JMAP/admin base URL |
| `IT_SMTP_PORT` | `1025` | Stalwart submission port |
By default the stack is **left running** after the suite so re-runs are fast and
you can poke around (webmail on :3000, Stalwart admin on :8025). Tear it down
with `IT_TEARDOWN=1` or `docker compose -f integration/docker-compose.yml down -v`.
## Resetting
The Stalwart data lives in the `bulwark-it-stalwart-data` volume. To re-run the
bootstrap from scratch:
```bash
docker compose -f integration/docker-compose.yml down -v
```
+78
View File
@@ -0,0 +1,78 @@
name: bulwark-integration
# Integration-test backend for the Bulwark webmail. A single Stalwart mail
# server (JMAP + SMTP submission + IMAP), declaratively bootstrapped with the
# alice/bob/carol test mailboxes. The webmail itself is started by Playwright
# (webServer in playwright.integration.config.ts) so the dev-loop / debugger
# stays on the host; only the hard-to-provision mail backend is containerised.
services:
stalwart:
build:
context: stalwart
container_name: bulwark-it-stalwart
# JMAP + Webmail + admin on 8025, SMTP submission on 1025 (internal 587),
# IMAP on 1143 (internal 143). The browser talks JMAP to localhost:8025;
# the test harness submits mail over SMTP to localhost:1025.
ports:
- "8025:8080"
- "1025:587"
- "1143:143"
volumes:
- stalwart-data:/var/lib/stalwart
- stalwart-config:/etc/stalwart
environment:
STALWART_RECOVERY_ADMIN: ${STALWART_RECOVERY_ADMIN:?set in .env}
TEST_ACCOUNT_PASSWORD: ${TEST_ACCOUNT_PASSWORD:?set in .env}
healthcheck:
# /jmap/session answers 200 only once the account bootstrap has finished
# and the server is in normal mode.
test: ["CMD-SHELL", "curl -fsS -u alice@example.org:$${TEST_ACCOUNT_PASSWORD} http://127.0.0.1:8080/jmap/session >/dev/null || exit 1"]
interval: 5s
timeout: 5s
retries: 30
start_period: 30s
restart: unless-stopped
webmail:
build:
context: ..
dockerfile: integration/webmail.Dockerfile
container_name: bulwark-it-webmail
ports:
- "3000:3000"
volumes:
# Admin policy that turns on the cross-account Unified Mailbox feature
# gate (off by default), so the multi-account unified sync tests can
# exercise it. Read by /api/admin/policy -> usePolicyStore.isFeatureEnabled.
- ./webmail-config/policy.json:/app/data/admin/policy.json:ro
environment:
# Browser-facing JMAP URL. The browser (Playwright) reaches Stalwart on
# the host-published port; the webmail server never fetches this URL
# itself for trusted basic-auth logins, so it need not be container-
# reachable. Setting JMAP_SERVER_URL also puts the app in "env-managed"
# mode, which skips the first-run setup wizard.
JMAP_SERVER_URL: http://localhost:8025
STALWART_FEATURES: "true"
APP_NAME: "Bulwark Webmail (Integration)"
# Enables "Remember me" / settings-sync cookies. Not required for the
# sync tests but harmless and avoids noisy warnings.
SESSION_SECRET: integration-not-a-real-secret
LOG_LEVEL: info
depends_on:
stalwart:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 5s
retries: 30
# next dev compiles routes lazily; give the first boot ample runway.
start_period: 120s
restart: unless-stopped
volumes:
stalwart-data:
name: bulwark-it-stalwart-data
stalwart-config:
name: bulwark-it-stalwart-config
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Run the Playwright integration suite.
#
# Playwright's browser download host is often unreachable (and some host OSes
# aren't supported by the browser bundles), so the tests run inside the official
# Playwright container, which ships the browsers. The container uses host
# networking to reach the published stack ports (webmail :3000, Stalwart :8025).
#
# The docker stack itself is brought up here (on the host) and the in-container
# run is told to skip its own docker management via IT_NO_DOCKER=1.
#
# Usage:
# integration/run-tests.sh # whole suite
# integration/run-tests.sh 01-login # a single spec (grep on file name)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
INTEGRATION_DIR="${REPO_ROOT}/integration"
PW_IMAGE="mcr.microsoft.com/playwright:v1.59.1-noble"
cd "${INTEGRATION_DIR}"
[ -f .env ] || cp .env.example .env
echo "== bringing up stack =="
bash stalwart/prepare-stalwart-cli.sh
docker compose --env-file .env up -d --build --wait --wait-timeout 300
echo "== running Playwright in ${PW_IMAGE} =="
FILTER="${1:-}"
docker run --rm --network host \
--user "$(id -u):$(id -g)" \
-v "${REPO_ROOT}":/work -w /work \
-e IT_NO_DOCKER=1 \
-e HOME=/tmp \
"${PW_IMAGE}" \
npx playwright test -c playwright.integration.config.ts ${FILTER:+"$FILTER"}
+2
View File
@@ -0,0 +1,2 @@
prepare-stalwart-cli.sh
README.md
+29
View File
@@ -0,0 +1,29 @@
# Stalwart Mail Server with a declarative bootstrap for webmail integration
# testing.
#
# Extends the official image with stalwart-cli and an entrypoint that, on first
# container start, applies the bootstrap + account plans against the freshly
# started server. On subsequent starts the `.bootstrap-applied` marker short-
# circuits both phases and Stalwart boots straight into normal mode.
#
# NOTE: stalwart-cli is COPYed in rather than downloaded during the build. The
# binary is fetched by ./prepare-stalwart-cli.sh (run for you by the Playwright
# global-setup / integration runner). This keeps the build offline-friendly and
# avoids the base image's apt sources, which are unreachable in sandboxed CI.
FROM stalwartlabs/stalwart:v0.16
USER root
# Host-prefetched stalwart-cli matching the build architecture.
COPY stalwart-cli /usr/local/bin/stalwart-cli
RUN mkdir -p /etc/stalwart-bootstrap
COPY plan-bootstrap.ndjson /etc/stalwart-bootstrap/plan-bootstrap.ndjson
COPY plan-accounts.ndjson.tpl /etc/stalwart-bootstrap/plan-accounts.ndjson.tpl
COPY entrypoint.sh /usr/local/bin/stalwart-bootstrap-entrypoint.sh
RUN chmod +x /usr/local/bin/stalwart-cli /usr/local/bin/stalwart-bootstrap-entrypoint.sh
USER stalwart
ENTRYPOINT ["/usr/local/bin/stalwart-bootstrap-entrypoint.sh"]
+154
View File
@@ -0,0 +1,154 @@
#!/bin/sh
# Declarative bootstrap for the integration-test Stalwart Mail Server.
#
# Phase 1 (first start only, marker absent):
# - Stalwart starts in bootstrap mode (no config.json -> HTTP on :8080).
# - plan-bootstrap.ndjson is applied via stalwart-cli. This writes
# config.json, initialises RocksDB and creates the default domain +
# admin account.
# - Stalwart is restarted in normal mode (config.json now exists).
# - plan-accounts.ndjson.tpl is materialised with the resolved DOMAIN_ID
# and the shared TEST_ACCOUNT_PASSWORD, then applied (test accounts +
# submission/IMAP listeners + cleartext auth for the dev lanes).
# - Stalwart is stopped and the marker is written.
#
# Phase 2 (regular start, marker present):
# - exec stalwart as PID 1.
#
# Adapted from examples/docker/stalwart for webmail<->Stalwart integration
# testing: no ticket/service accounts, no Sieve, a single shared password for
# the alice/bob/carol test mailboxes.
set -eu
# stalwart-cli caches its schema under $HOME/.cache/stalwart-cli. The stalwart
# user has no home, so redirect to /tmp.
export HOME=/tmp
DATA_DIR=/var/lib/stalwart
MARKER="${DATA_DIR}/.bootstrap-applied"
PLAN_DIR=/etc/stalwart-bootstrap
STALWART_BIN=/usr/local/bin/stalwart
STALWART_CLI=/usr/local/bin/stalwart-cli
STALWART_CFG=/etc/stalwart/config.json
LOCAL_URL=http://127.0.0.1:8080
log() { printf '[stalwart-bootstrap] %s\n' "$*" >&2; }
wait_for_http() {
for _ in $(seq 1 60); do
if curl -fsS -u "admin:${ADMIN_PASS}" "${LOCAL_URL}/jmap/session" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
log "Stalwart HTTP on :8080 did not come up in time"
return 1
}
run_stalwart_bg() {
"${STALWART_BIN}" --config "${STALWART_CFG}" &
STALWART_PID=$!
}
stop_stalwart_bg() {
if [ -n "${STALWART_PID:-}" ]; then
kill -TERM "${STALWART_PID}" 2>/dev/null || true
wait "${STALWART_PID}" 2>/dev/null || true
STALWART_PID=
fi
}
if [ ! -f "${MARKER}" ]; then
: "${STALWART_RECOVERY_ADMIN:?must be set for first-run bootstrap}"
: "${TEST_ACCOUNT_PASSWORD:?must be set for first-run bootstrap}"
ADMIN_PASS=${STALWART_RECOVERY_ADMIN#*:}
log "Phase 1: starting Stalwart in bootstrap mode"
run_stalwart_bg
wait_for_http
log "Applying plan-bootstrap.ndjson"
STALWART_URL=${LOCAL_URL} \
STALWART_USER=admin \
STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" apply --file "${PLAN_DIR}/plan-bootstrap.ndjson" --quiet
log "Restarting Stalwart to leave bootstrap mode"
stop_stalwart_bg
run_stalwart_bg
wait_for_http
log "Resolving DOMAIN_ID for example.org"
DOMAIN_ID=$(STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" query Domain --json 2>/dev/null \
| head -1 \
| sed -E 's/.*"id":"([^"]+)".*/\1/')
if [ -z "${DOMAIN_ID}" ]; then
log "Could not resolve DOMAIN_ID after bootstrap"
stop_stalwart_bg
exit 1
fi
log "DOMAIN_ID=${DOMAIN_ID}"
# Materialise the account plan. gettext/envsubst is not in the base image,
# so substitute the two placeholders with sed. Passwords are escaped for the
# sed replacement (& and / are the only metacharacters that matter here).
PLAN_ACCOUNTS=/tmp/plan-accounts.ndjson
esc_pw=$(printf '%s' "${TEST_ACCOUNT_PASSWORD}" | sed -e 's/[&/\\]/\\&/g')
sed -e "s/\${DOMAIN_ID}/${DOMAIN_ID}/g" \
-e "s/\${TEST_ACCOUNT_PASSWORD}/${esc_pw}/g" \
"${PLAN_DIR}/plan-accounts.ndjson.tpl" > "${PLAN_ACCOUNTS}"
log "Applying plan-accounts.ndjson"
STALWART_URL=${LOCAL_URL} \
STALWART_USER=admin \
STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" apply --file "${PLAN_ACCOUNTS}" --quiet
rm -f "${PLAN_ACCOUNTS}"
# Make 'carol' a member of the 'team' group *before her first login*, so she
# already has the shared group mailbox (its folders show under "Shared") and
# the team@ send-as identity. This provisions the issue #569 scenario: the
# composer's From dropdown should then offer the group address. Membership is
# a set keyed by the group's server-assigned account id (see the User schema's
# memberGroupIds), so the ids are resolved here, mirroring DOMAIN_ID above.
# carol (not alice/bob) is used so the single-/multi-account sync specs, which
# drive alice and bob, keep a clean unshared environment.
q_account_id() {
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" query Account --json 2>/dev/null \
| grep "\"emailAddress\":\"$1\"" \
| sed -E 's/.*"id":"([^"]+)".*/\1/'
}
CAROL_ID=$(q_account_id "carol@example.org")
TEAM_ID=$(q_account_id "team@example.org")
if [ -n "${CAROL_ID}" ] && [ -n "${TEAM_ID}" ]; then
log "Adding carol (${CAROL_ID}) to the team group (${TEAM_ID}) [issue #569]"
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" update Account "${CAROL_ID}" \
--field "memberGroupIds={\"${TEAM_ID}\":true}" >/dev/null
else
log "WARNING: could not resolve account ids (carol='${CAROL_ID}' team='${TEAM_ID}'); skipping group membership"
fi
# Default inbound throttles (sender->recipient + sender-IP) otherwise trip
# 452 4.4.5 when a test blasts many messages. Stalwart re-seeds the defaults
# on every start when absent, so deleting is useless; disable them instead,
# which survives restarts.
for tid in $(STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" query MtaInboundThrottle --json 2>/dev/null \
| sed -E 's/.*"id":"([^"]+)".*/\1/'); do
log "Disabling MtaInboundThrottle ${tid}"
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
"${STALWART_CLI}" update MtaInboundThrottle "${tid}" --field enable=false >/dev/null
done
log "Stopping bootstrap instance, marking complete"
stop_stalwart_bg
touch "${MARKER}"
fi
log "Starting Stalwart (final, foreground)"
exec "${STALWART_BIN}" --config "${STALWART_CFG}"
@@ -0,0 +1,9 @@
{"@type":"create","object":"Account","value":{"alice":{"@type":"User","name":"alice","domainId":"${DOMAIN_ID}","description":"Integration test mailbox alice","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
{"@type":"create","object":"Account","value":{"bob":{"@type":"User","name":"bob","domainId":"${DOMAIN_ID}","description":"Integration test mailbox bob","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
{"@type":"create","object":"Account","value":{"carol":{"@type":"User","name":"carol","domainId":"${DOMAIN_ID}","description":"Integration test mailbox carol","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
{"@type":"create","object":"Account","value":{"team":{"@type":"Group","name":"team","domainId":"${DOMAIN_ID}","description":"Team shared mailbox"}}}
{"@type":"create","object":"NetworkListener","value":{"submission":{"name":"submission","protocol":"smtp","bind":{"[::]:587":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
{"@type":"create","object":"NetworkListener","value":{"imap":{"name":"imap","protocol":"imap","bind":{"[::]:143":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
{"@type":"update","object":"MtaStageAuth","value":{"saslMechanisms":{"match":{"0":{"if":"local_port != 25","then":"[plain, login, oauthbearer, xoauth2]"}},"else":"false"}}}
{"@type":"update","object":"Imap","value":{"allowPlainTextAuth":true}}
{"@type":"update","object":"Http","value":{"usePermissiveCors":true}}
@@ -0,0 +1 @@
{"@type":"update","object":"Bootstrap","value":{"serverHostname":"mail.example.org","defaultDomain":"example.org","generateDkimKeys":false,"requestTlsCertificate":false,"dataStore":{"@type":"RocksDb","path":"/var/lib/stalwart/data","blobSize":16834,"bufferSize":134217728}}}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Fetch the stalwart-cli binary used by the Stalwart bootstrap image.
#
# The Dockerfile COPYs ./stalwart-cli instead of downloading it during the
# build, because the base image's apt sources and the build network are
# unreachable in the sandboxed CI environment. This script does the fetch on
# the host (which has working outbound HTTPS) and extracts the binary with
# Python's lzma module (xz is not guaranteed to be installed).
#
# Idempotent: skips the download when a matching binary already exists.
set -euo pipefail
CLI_VERSION="${STALWART_CLI_VERSION:-1.0.6}"
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="${HERE}/stalwart-cli"
case "$(uname -m)" in
x86_64) TRIPLE=x86_64-unknown-linux-gnu ;;
aarch64|arm64) TRIPLE=aarch64-unknown-linux-gnu ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
if [ -x "${OUT}" ] && "${OUT}" --version 2>/dev/null | grep -q "${CLI_VERSION}"; then
echo "stalwart-cli ${CLI_VERSION} already present at ${OUT}"
exit 0
fi
URL="https://github.com/stalwartlabs/cli/releases/download/v${CLI_VERSION}/stalwart-cli-${TRIPLE}.tar.xz"
TARBALL="$(mktemp)"
trap 'rm -f "${TARBALL}"' EXIT
echo "Downloading ${URL}"
curl -sfL -o "${TARBALL}" "${URL}"
python3 - "${TARBALL}" "${OUT}" <<'PY'
import io, lzma, os, sys, tarfile
tarball, out = sys.argv[1], sys.argv[2]
with lzma.open(tarball) as f:
data = f.read()
tf = tarfile.open(fileobj=io.BytesIO(data))
member = next(m for m in tf.getmembers() if m.name.endswith("stalwart-cli"))
with open(out, "wb") as w:
w.write(tf.extractfile(member).read())
os.chmod(out, 0o755)
print(f"wrote {out}")
PY
"${OUT}" --version
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { login, folderRow, accountSwitcher, activeAccountEmail } from './helpers/app';
import { JmapClient } from './helpers/jmap';
test.describe('Login & session', () => {
test('logs in against Stalwart and loads the mailbox', async ({ page }) => {
await login(page, ACCOUNTS.alice);
// The Inbox folder row is a reliable "mailbox loaded" signal.
await expect(folderRow(page, { role: 'inbox' }).first()).toBeVisible();
// The active account in the switcher is alice. The account id is
// `${email}@${serverHost}`, so assert on the email it reports instead.
await expect(accountSwitcher(page)).toBeVisible();
expect(await activeAccountEmail(page)).toBe(ACCOUNTS.alice.email);
});
test('rejects invalid credentials', async ({ page }) => {
await page.goto('/');
await page.fill('#username', ACCOUNTS.alice.email);
await page.fill('#password', 'definitely-wrong');
await page.click('button[type="submit"]');
await expect(
page.locator('[role="alert"], .text-red-600, .text-destructive').first(),
).toBeVisible({ timeout: 15000 });
});
test('JMAP helper can reach every provisioned account', async () => {
for (const acct of Object.values(ACCOUNTS)) {
const client = await JmapClient.connect(acct.email, acct.password);
expect(client.accountId).toBeTruthy();
const inbox = await client.mailboxByRole('inbox');
expect(inbox, `${acct.email} has an inbox`).toBeTruthy();
}
});
});
+125
View File
@@ -0,0 +1,125 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
folderRow,
folderCounts,
expectFolderUnread,
expectFolderTotal,
emailItem,
expectEmailVisible,
forceSync,
} from './helpers/app';
/**
* Single-account mail & folder synchronisation.
*
* These exercise the webmail's ability to reflect *external* changes to the
* mailbox — new deliveries, server-side reads/moves/deletes, and folder
* creation — which is where "my counts are wrong / my folder didn't show up"
* sync bugs live. Mutations are made over SMTP/JMAP and the assertions are on
* the rendered UI.
*/
const alice = ACCOUNTS.alice;
// Unique subject per test run avoids cross-test contamination if a reset lags.
let seq = 0;
const subj = (label: string) => `IT ${label} ${Date.now()}-${seq++}`;
test.describe('Single-account sync', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('incoming mail appears and bumps the Inbox unread counter', async ({ page }) => {
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 0);
const subject = subj('incoming');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'hi' });
await expectFolderUnread(page, { role: 'inbox' }, 1);
await expectEmailVisible(page, subject);
});
test('opening a message clears its unread state (UI -> server -> counter)', async ({ page }) => {
const subject = subj('read');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'read me' });
await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 1);
await emailItem(page, subject).first().click();
await expectFolderUnread(page, { role: 'inbox' }, 0);
});
test('a folder created on the server shows up in the sidebar', async ({ page }) => {
await login(page, alice);
await expect(folderRow(page, { name: 'SyncFolder' })).toHaveCount(0);
await jmap.createMailbox('SyncFolder');
await expect(folderRow(page, { name: 'SyncFolder' }).first()).toBeVisible({ timeout: 20000 });
});
test('a server-side move updates both source and destination counters', async ({ page }) => {
const subject = subj('move');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'move me' });
const email = await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 1);
// Create destination + move the message there (server-side).
const destId = await jmap.createMailbox('Archive2');
const inbox = await jmap.mailboxByRole('inbox');
await jmap.request([
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
]);
await forceSync(page);
// Source Inbox drains, destination gains the message.
await expectFolderUnread(page, { role: 'inbox' }, 0);
await expectFolderTotal(page, { name: 'Archive2' }, 1);
expect(inbox).toBeTruthy();
});
test('a server-side delete drains the Inbox total', async ({ page }) => {
const subject = subj('delete');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'delete me' });
const email = await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderTotal(page, { role: 'inbox' }, 1);
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
await forceSync(page);
// The folder counter is the sync-critical signal and drains to zero. (The
// already-rendered list view is not re-queried on a background delete, so
// we don't assert on the row disappearing here.)
await expectFolderTotal(page, { role: 'inbox' }, 0);
await expectFolderUnread(page, { role: 'inbox' }, 0);
});
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 0);
const subjects = Array.from({ length: 3 }, (_, i) => subj(`burst-${i}`));
for (const s of subjects) {
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: s, body: 'burst' });
}
await expectFolderUnread(page, { role: 'inbox' }, 3);
const counts = await folderCounts(page, { role: 'inbox' });
expect(counts.total).toBe(3);
for (const s of subjects) await expectEmailVisible(page, s);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
addAccount,
switchAccount,
accountSwitcher,
seedUnifiedSettings,
folderRow,
expectFolderUnread,
expectFolderTotal,
forceSync,
} from './helpers/app';
/**
* Multi-account synchronisation — the account-scoped Unified Mailbox.
*
* Covers the two failure modes that dog multi-account webmail: counters
* bleeding between accounts, and the cross-account unified view mis-aggregating
* (or not updating when a background account receives mail).
*/
const { alice, bob } = ACCOUNTS;
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
async function send(to: typeof alice, subject: string) {
await sendMail({ from: to.email, authPass: to.password, to: to.email, subject, body: 'x' });
}
test.describe('Multi-account sync', () => {
test.beforeEach(async () => {
for (const a of [alice, bob]) {
const j = await JmapClient.connect(a.email, a.password);
await j.reset();
}
});
test('both accounts connect and their Inbox counters stay isolated', async ({ page }) => {
// Pre-seed: two unread for alice, one for bob.
await send(alice, subj('iso-a1'));
await send(alice, subj('iso-a2'));
await send(bob, subj('iso-b1'));
await login(page, alice);
// Active = alice: her own Inbox shows 2 unread.
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
await addAccount(page, bob);
await forceSync(page);
// Both accounts are now registered in the switcher.
await accountSwitcher(page).click();
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
await page.keyboard.press('Escape');
// Active = bob: his own Inbox shows 1 unread — alice's 2 don't leak in.
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
// Switch back to alice: her count is intact.
await switchAccount(page, alice.email);
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
});
test('the cross-account Unified Inbox aggregates unread across accounts', async ({ page }) => {
await send(alice, subj('agg-a'));
await send(bob, subj('agg-b'));
await seedUnifiedSettings(page);
await login(page, alice);
await addAccount(page, bob);
await forceSync(page);
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
// (bob) still reports just its own 1.
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
await expectFolderUnread(page, { name: 'unified-inbox' }, 2);
await expectFolderTotal(page, { name: 'unified-inbox' }, 2);
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
});
// fixme: the unified counter for a *background* (non-active) account is not
// updated live on this branch — the background-push counter fix lives in the
// unified-mailbox feature commits (single-source unified counters / keep
// unified counters current for shared accounts), which sit on
// feat/unified-mailbox-account-scope, not on this harness-only base branch.
test.fixme('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
await seedUnifiedSettings(page);
await login(page, alice);
await addAccount(page, bob); // bob is now the active account
await forceSync(page);
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
// Mail lands in alice's inbox while bob is the active account.
await send(alice, subj('bg'));
await forceSync(page);
// The unified counter reflects the background account's new mail.
await expectFolderUnread(page, { name: 'unified-inbox' }, 1);
// bob (active) own Inbox is unaffected.
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 0);
});
});
@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS, GROUP } from './helpers/config';
import { JmapClient } from './helpers/jmap';
import { login, forceSync, openComposer, composerFromOptions } from './helpers/app';
/**
* Issue #569 — the composer's "From" dropdown should include identities from
* shared/group accounts, not only the logged-in (connected) accounts.
*
* Scenario under test (the one expected to already work): a Stalwart *group*
* mailbox `team@example.org` is provisioned and `carol` is made a member of it
* *before her first login* (integration/stalwart/plan-accounts.ndjson.tpl +
* entrypoint.sh). As a member she gets the group's shared folders (shown under
* "Shared") and — per Stalwart — a `team@` send-as identity (Stalwart returns
* it among the member's own account identities). The composer should therefore
* offer `team@example.org` as a sender alongside her own address.
*/
const member = ACCOUNTS[GROUP.team.memberOf];
const { team } = GROUP;
test.describe('Composer From: shared/group identities (issue #569)', () => {
test('the pre-provisioned group account is reachable in the members JMAP session', async () => {
// Server-side guard for the UI expectation below: if this fails, the
// bootstrap group provisioning is broken (not the app). The member must see
// the group account in her session, and it must expose a team@ identity she
// can send as.
const memberClient = await JmapClient.connect(member.email, member.password);
expect(memberClient.sharedAccountNames()).toContain(team.email);
const groupAccountId = Object.entries(memberClient.accounts).find(
([, name]) => name === team.email,
)?.[0];
expect(groupAccountId).toBeTruthy();
const res = await memberClient.request([
['Identity/get', { accountId: groupAccountId! }, '0'],
]);
const groupIdentityEmails = (res.methodResponses[0][1].list as { email: string }[]).map(
(i) => i.email,
);
expect(groupIdentityEmails).toContain(team.email);
});
test('the composer From selector offers the group address', async ({ page }) => {
await login(page, member);
// Shared accounts/identities are discovered from the JMAP session; give the
// client a beat to settle them after the first render.
await forceSync(page);
await openComposer(page);
// The group address alice can send as should be one of the From choices.
// If #569 is unaddressed the control collapses to her own address only and
// this poll times out — which is the point: it pins the expected behaviour.
await expect
.poll(async () => (await composerFromOptions(page)).join(' | '), { timeout: 15000 })
.toContain(team.email);
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Brings the integration stack up before the suite runs:
* 1. fetch the arch-specific stalwart-cli (offline-friendly build input),
* 2. ensure integration/.env exists (compose credentials),
* 3. docker compose up -d --build --wait (Stalwart + webmail),
* 4. block until Stalwart JMAP and the webmail health endpoint answer.
*
* Set IT_NO_DOCKER=1 to skip container management entirely (useful when the
* stack is already running, e.g. during test authoring against `npm run dev`).
*/
import { execFileSync } from 'node:child_process';
import { existsSync, copyFileSync } from 'node:fs';
import path from 'node:path';
import { JMAP_URL, WEBMAIL_URL, ACCOUNTS, ACCOUNT_PASSWORD } from './helpers/config';
const INTEGRATION_DIR = path.resolve(__dirname, '..');
const COMPOSE_FILE = path.join(INTEGRATION_DIR, 'docker-compose.yml');
const ENV_FILE = path.join(INTEGRATION_DIR, '.env');
function run(cmd: string, args: string[], cwd = INTEGRATION_DIR): void {
execFileSync(cmd, args, { cwd, stdio: 'inherit' });
}
async function waitFor(label: string, url: string, check: (r: Response) => boolean, timeoutMs = 240000): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const res = await fetch(url, { headers: { Authorization: 'Basic ' + Buffer.from(`${ACCOUNTS.alice.email}:${ACCOUNT_PASSWORD}`).toString('base64') } });
if (check(res)) return;
} catch {
/* not up yet */
}
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label} at ${url}`);
await new Promise((r) => setTimeout(r, 2000));
}
}
export default async function globalSetup(): Promise<void> {
if (process.env.IT_NO_DOCKER === '1') {
console.log('[global-setup] IT_NO_DOCKER=1 — skipping docker compose management');
} else {
console.log('[global-setup] fetching stalwart-cli');
run('bash', [path.join(INTEGRATION_DIR, 'stalwart', 'prepare-stalwart-cli.sh')]);
if (!existsSync(ENV_FILE)) {
console.log('[global-setup] creating integration/.env from .env.example');
copyFileSync(path.join(INTEGRATION_DIR, '.env.example'), ENV_FILE);
}
console.log('[global-setup] docker compose up -d --build --wait');
run('docker', [
'compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE,
'up', '-d', '--build', '--wait', '--wait-timeout', '300',
]);
}
console.log('[global-setup] waiting for Stalwart JMAP');
await waitFor('Stalwart JMAP', `${JMAP_URL}/jmap/session`, (r) => r.ok);
console.log('[global-setup] waiting for webmail');
await waitFor('webmail', `${WEBMAIL_URL}/api/health`, (r) => r.ok, 240000);
console.log('[global-setup] stack ready');
}
+23
View File
@@ -0,0 +1,23 @@
/**
* By default the stack is left running after the suite so re-runs are fast and
* the state can be inspected (webmail on :3000, Stalwart admin on :8025).
* Set IT_TEARDOWN=1 to tear the containers (and volumes) down instead.
*/
import { execFileSync } from 'node:child_process';
import path from 'node:path';
const INTEGRATION_DIR = path.resolve(__dirname, '..');
const COMPOSE_FILE = path.join(INTEGRATION_DIR, 'docker-compose.yml');
const ENV_FILE = path.join(INTEGRATION_DIR, '.env');
export default async function globalTeardown(): Promise<void> {
if (process.env.IT_TEARDOWN !== '1' || process.env.IT_NO_DOCKER === '1') {
console.log('[global-teardown] leaving stack up (set IT_TEARDOWN=1 to remove it)');
return;
}
console.log('[global-teardown] docker compose down -v');
execFileSync('docker', ['compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE, 'down', '-v'], {
cwd: INTEGRATION_DIR,
stdio: 'inherit',
});
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Page-level helpers for driving the Bulwark webmail in integration tests.
*
* Selectors rely on the data-testid hooks added to the mail UI (sidebar folder
* rows + counters, account switcher, composer). Folder counters are read from
* the `data-unread` / `data-total` attributes on `[data-testid=folder-counts]`
* rather than parsing rendered text, so assertions are locale-independent.
*/
import { expect, type Page, type Locator } from '@playwright/test';
import type { TestAccount } from './config';
/**
* The account switcher renders twice (collapsed nav rail + expanded sidebar);
* both carry the same data-testid and state, so always target the first.
*/
export function accountSwitcher(page: Page): Locator {
return page.locator('[data-testid="account-switcher"]').first();
}
/**
* The Next.js dev-mode overlay (`<nextjs-portal>`) sits in the bottom-left
* corner and intercepts pointer events over the account switcher. Disable
* pointer events on the portal host (light DOM) so it can't swallow clicks.
* Registered as an init script so it survives navigations within the test.
*/
export async function neutralizeDevOverlay(page: Page): Promise<void> {
await page.addInitScript(() => {
const inject = () => {
const s = document.createElement('style');
s.textContent = 'nextjs-portal{pointer-events:none!important}';
document.documentElement.appendChild(s);
};
if (document.documentElement) inject();
else document.addEventListener('DOMContentLoaded', inject);
});
}
/**
* Enable the cross-account Unified Mailbox before the app boots by seeding the
* persisted settings store. Requires the `unifiedCrossAccountEnabled` admin
* feature gate (provided by integration/webmail-config/policy.json). Must be
* called before {@link login} so the init script is registered before the
* first navigation.
*/
export async function seedUnifiedSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
'settings-storage',
JSON.stringify({
state: { enableUnifiedMailbox: true, unifiedCrossAccount: true, includeGroupInUnified: true },
version: 7,
}),
);
});
}
/** Fill and submit the login form (works for first login and add-account). */
async function submitCredentials(page: Page, account: TestAccount): Promise<void> {
await page.locator('#username').waitFor({ state: 'visible', timeout: 30000 });
await page.fill('#username', account.email);
await page.fill('#password', account.password);
await page.click('button[type="submit"]');
}
/** Log in as `account` from a clean context and wait for the mailbox to load. */
export async function login(page: Page, account: TestAccount): Promise<void> {
await neutralizeDevOverlay(page);
await page.goto('/');
await submitCredentials(page, account);
// Landed in the app once the account switcher (sidebar chrome) is present.
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 30000 });
}
/** Add a second (or later) account via the account switcher + login form. */
export async function addAccount(page: Page, account: TestAccount): Promise<void> {
await accountSwitcher(page).click();
await page.locator('[data-testid="add-account"]').click();
await submitCredentials(page, account);
// Wait until the switcher reports the newly added account as active.
await expect
.poll(async () => activeAccountEmail(page), { timeout: 30000 })
.toBe(account.email);
}
/** Email of the currently active account, read from the switcher option list. */
export async function activeAccountEmail(page: Page): Promise<string | null> {
const switcher = accountSwitcher(page);
const id = await switcher.getAttribute('data-active-account-id');
if (!id) return null;
await switcher.click();
const email = await page
.locator(`[data-testid="account-option"][data-account-id="${id}"]`)
.first()
.getAttribute('data-account-email');
// Close the popover again.
await page.keyboard.press('Escape');
return email;
}
/** Switch the active account to the one matching `email`. */
export async function switchAccount(page: Page, email: string): Promise<void> {
await accountSwitcher(page).click();
await page.locator(`[data-testid="account-option"][data-account-email="${email}"]`).first().click();
await expect.poll(async () => activeAccountEmail(page), { timeout: 30000 }).toBe(email);
}
/**
* Nudge the app to reconcile mailbox state immediately.
*
* The JMAP client refetches on `visibilitychange` (tab focus) via
* checkForStateChanges(). Dispatching it makes reconciliation deterministic
* after an *external* mutation, sidestepping the small window right after
* login where a change can land before the SSE push channel has settled.
* Mirrors what happens when a real user tabs back to the mailbox.
*/
export async function forceSync(page: Page): Promise<void> {
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
}
export interface FolderSelector {
role?: string;
name?: string;
mailboxId?: string;
}
/** Locator for a sidebar folder row. */
export function folderRow(page: Page, sel: FolderSelector): Locator {
let s = '[data-testid="folder-row"]';
if (sel.role) s += `[data-folder-role="${sel.role}"]`;
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
return page.locator(s);
}
export interface FolderCounts {
unread: number;
total: number;
}
/**
* Read a folder's unread/total counts. When both are zero the counts element
* is not rendered, so a missing element is reported as {0,0}.
*/
export async function folderCounts(page: Page, sel: FolderSelector): Promise<FolderCounts> {
const row = folderRow(page, sel).first();
const counts = row.locator('[data-testid="folder-counts"]');
if ((await counts.count()) === 0) return { unread: 0, total: 0 };
const unread = await counts.getAttribute('data-unread');
const total = await counts.getAttribute('data-total');
return { unread: Number(unread ?? 0), total: Number(total ?? 0) };
}
/** Poll until a folder's unread count reaches `expected`. */
export async function expectFolderUnread(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
await expect
.poll(async () => (await folderCounts(page, sel)).unread, { timeout })
.toBe(expected);
}
/** Poll until a folder's total count reaches `expected`. */
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
await expect
.poll(async () => (await folderCounts(page, sel)).total, { timeout })
.toBe(expected);
}
/** Click a folder row to select it. */
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
await folderRow(page, sel).first().click();
}
/** Open the "New message" composer and wait for it to render. */
export async function openComposer(page: Page): Promise<Locator> {
await page.locator('[data-tour="compose-button"]').first().click();
const composer = page.locator('[data-testid="email-composer"]');
await composer.waitFor({ state: 'visible', timeout: 15000 });
return composer;
}
/**
* The sender addresses the composer's From control offers.
*
* With more than one identity the control is a <select> and each choice is an
* <option>; with a single identity it collapses to a static <span> that shows
* only that address. Returning the raw text of whichever is rendered lets a
* test assert on the *set of senders* without caring which shape it took.
*/
export async function composerFromOptions(page: Page): Promise<string[]> {
const from = page.locator('[data-testid="composer-from"]').first();
await from.waitFor({ state: 'visible', timeout: 10000 });
if ((await from.locator('option').count()) > 0) {
return from.locator('option').allTextContents();
}
return [await from.innerText()];
}
/** Locator for an email row by (exact) subject. */
export function emailItem(page: Page, subject: string): Locator {
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
}
/** Poll until an email with `subject` is present in the list. */
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Shared configuration for the integration tests. Values mirror the Stalwart
* bootstrap (integration/stalwart/*) and the docker-compose port mappings.
* Everything is overridable via env so the suite can run against a differently
* mapped stack (e.g. remote CI) without code changes.
*/
export const DOMAIN = process.env.IT_DOMAIN ?? 'example.org';
/** Shared password for every test mailbox (TEST_ACCOUNT_PASSWORD in .env). */
export const ACCOUNT_PASSWORD = process.env.IT_ACCOUNT_PASSWORD ?? 'test-pass-123';
/** Webmail app origin (containerised, published on the host). */
export const WEBMAIL_URL = process.env.IT_WEBMAIL_URL ?? 'http://localhost:3000';
/** Stalwart JMAP + admin base URL (host-published). */
export const JMAP_URL = process.env.IT_JMAP_URL ?? 'http://localhost:8025';
/** Stalwart SMTP submission listener (host-published, maps to container 587). */
export const SMTP_HOST = process.env.IT_SMTP_HOST ?? 'localhost';
export const SMTP_PORT = Number(process.env.IT_SMTP_PORT ?? 1025);
/** Recovery admin — `user:password`, used for stalwart-cli style admin JMAP. */
export const ADMIN_CREDENTIALS = process.env.IT_ADMIN ?? 'admin:bootstrap-secret';
export interface TestAccount {
/** Local part, e.g. "alice". */
user: string;
/** Full address, e.g. "alice@example.org". */
email: string;
password: string;
}
function acct(user: string): TestAccount {
return { user, email: `${user}@${DOMAIN}`, password: ACCOUNT_PASSWORD };
}
/** The mailboxes provisioned by the Stalwart bootstrap plan. */
export const ACCOUNTS = {
alice: acct('alice'),
bob: acct('bob'),
carol: acct('carol'),
} as const;
export type AccountKey = keyof typeof ACCOUNTS;
/**
* The shared *group* account provisioned by the bootstrap (a Stalwart Group
* principal, not a login). `carol` is made a member before her first login, so
* she sees the group's folders under "Shared" and can send as its address.
* Groups have no password of their own — access is via a member's session.
* (carol, rather than alice/bob, keeps the sync specs' accounts unshared.)
*/
export const GROUP = {
team: { user: 'team', email: `team@${DOMAIN}`, memberOf: 'carol' as AccountKey },
} as const;
+158
View File
@@ -0,0 +1,158 @@
/**
* Minimal JMAP client for test setup/inspection against Stalwart.
*
* Uses global fetch (Node 18+). Not a full JMAP implementation — just the
* pieces the integration tests need: authenticate, read/reset mailboxes,
* create folders, and poll for delivery. Assertions on *server* state (via
* this client) are kept separate from assertions on *UI* state (via the page),
* so a failing test can tell whether the bug is in delivery or in the webmail's
* sync.
*/
import { JMAP_URL } from './config';
const CORE = 'urn:ietf:params:jmap:core';
const MAIL = 'urn:ietf:params:jmap:mail';
// Identity/* lives under the submission capability, not mail.
const SUBMISSION = 'urn:ietf:params:jmap:submission';
interface JmapMailbox {
id: string;
name: string;
role: string | null;
parentId: string | null;
totalEmails: number;
unreadEmails: number;
}
type MethodCall = [string, Record<string, unknown>, string];
export class JmapClient {
private authHeader: string;
private apiUrl: string;
accountId = '';
/** Every account visible in this user's session (own + shared/group),
* keyed by accountId -> account name (its email address). */
accounts: Record<string, string> = {};
private constructor(private email: string, password: string) {
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
// Stalwart advertises apiUrl on its configured hostname (mail.example.org);
// rewrite onto the reachable origin, exactly as the app client does.
this.apiUrl = `${JMAP_URL}/jmap/`;
}
static async connect(email: string, password: string): Promise<JmapClient> {
const c = new JmapClient(email, password);
const res = await fetch(`${JMAP_URL}/jmap/session`, {
headers: { Authorization: c.authHeader },
});
if (!res.ok) throw new Error(`JMAP session failed for ${email}: ${res.status}`);
const session = await res.json();
const primary = session.primaryAccounts?.[MAIL];
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
c.accountId = primary;
c.accounts = Object.fromEntries(
Object.entries(session.accounts ?? {}).map(([id, a]) => [id, (a as { name: string }).name]),
);
return c;
}
/** Names (email addresses) of the shared/group accounts this user can access,
* i.e. everything in the session except the user's own primary account. */
sharedAccountNames(): string[] {
return Object.entries(this.accounts)
.filter(([id]) => id !== this.accountId)
.map(([, name]) => name);
}
async request(methodCalls: MethodCall[]): Promise<any> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ using: [CORE, MAIL, SUBMISSION], methodCalls }),
});
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
return res.json();
}
async mailboxes(): Promise<JmapMailbox[]> {
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
return r.methodResponses[0][1].list as JmapMailbox[];
}
async mailboxByRole(role: string): Promise<JmapMailbox | undefined> {
return (await this.mailboxes()).find((m) => m.role === role);
}
async mailboxByName(name: string): Promise<JmapMailbox | undefined> {
return (await this.mailboxes()).find((m) => m.name === name);
}
/** Create a folder (top-level) and return its id. Idempotent by name. */
async createMailbox(name: string, parentId: string | null = null): Promise<string> {
const existing = await this.mailboxByName(name);
if (existing) return existing.id;
const r = await this.request([
['Mailbox/set', { accountId: this.accountId, create: { new: { name, parentId } } }, '0'],
]);
const created = r.methodResponses[0][1].created?.new;
if (!created) throw new Error(`Mailbox/set create failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
async deleteMailboxByName(name: string): Promise<void> {
const mb = await this.mailboxByName(name);
if (!mb) return;
await this.request([
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: [mb.id] }, '0'],
]);
}
private async allEmailIds(): Promise<string[]> {
const r = await this.request([['Email/query', { accountId: this.accountId, limit: 5000 }, '0']]);
return r.methodResponses[0][1].ids as string[];
}
/**
* Reset a mailbox to a clean slate: destroy every message and delete any
* non-system (custom) folder. System folders (Inbox/Sent/Trash/…) are kept.
*/
async reset(): Promise<void> {
const ids = await this.allEmailIds();
if (ids.length) {
await this.request([['Email/set', { accountId: this.accountId, destroy: ids }, '0']]);
}
const custom = (await this.mailboxes()).filter((m) => !m.role);
if (custom.length) {
await this.request([
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: custom.map((m) => m.id) }, '0'],
]);
}
}
/** Look up an email id by subject within an optional mailbox. */
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
const filter: Record<string, unknown> = { subject };
if (mailboxId) filter.inMailbox = mailboxId;
const r = await this.request([
['Email/query', { accountId: this.accountId, filter }, '0'],
['Email/get', {
accountId: this.accountId,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
}, '1'],
]);
return r.methodResponses[1][1].list[0];
}
/** Poll until a message with `subject` is delivered (or throw on timeout). */
async waitForEmail(subject: string, opts: { mailboxId?: string; timeoutMs?: number } = {}): Promise<any> {
const deadline = Date.now() + (opts.timeoutMs ?? 15000);
for (;;) {
const found = await this.findEmailBySubject(subject, opts.mailboxId);
if (found) return found;
if (Date.now() > deadline) throw new Error(`Timed out waiting for email "${subject}" (${this.email})`);
await new Promise((r) => setTimeout(r, 500));
}
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Dependency-free SMTP submission client.
*
* Speaks just enough SMTP to authenticate against Stalwart's plaintext
* submission listener (AUTH LOGIN, no STARTTLS) and inject a message. Used to
* simulate real inbound mail so the webmail's sync behaviour can be observed.
* A raw socket keeps the test harness free of a nodemailer dependency.
*/
import net from 'node:net';
import { SMTP_HOST, SMTP_PORT } from './config';
interface SendOptions {
host?: string;
port?: number;
/** Envelope + auth sender, e.g. "alice@example.org". */
from: string;
/** Auth username; defaults to `from`. */
authUser?: string;
authPass: string;
/** One or more envelope recipients. */
to: string | string[];
subject: string;
/** Plain-text body. */
body: string;
/** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */
headers?: Record<string, string>;
}
class SmtpError extends Error {}
function crlf(s: string): string {
return s.replace(/\r?\n/g, '\r\n');
}
/**
* Submit a single message. Resolves once the server has accepted it (250 after
* end-of-DATA). Rejects on any non-2xx/3xx reply or socket error.
*/
export async function sendMail(opts: SendOptions): Promise<void> {
const host = opts.host ?? SMTP_HOST;
const port = opts.port ?? SMTP_PORT;
const recipients = Array.isArray(opts.to) ? opts.to : [opts.to];
const authUser = opts.authUser ?? opts.from;
const socket = net.createConnection({ host, port });
socket.setEncoding('utf8');
socket.setTimeout(15000);
let buffer = '';
let resolveLine: ((line: string) => void) | null = null;
let pendingError: Error | null = null;
socket.on('data', (chunk: string) => {
buffer += chunk;
// A complete reply ends with "<code> ...\r\n" (space, not hyphen, after code).
const lines = buffer.split('\r\n');
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (/^\d{3} /.test(line) && resolveLine) {
const r = resolveLine;
resolveLine = null;
buffer = lines.slice(i + 1).join('\r\n');
r(line);
return;
}
}
});
socket.on('timeout', () => { pendingError = new SmtpError('SMTP timeout'); socket.destroy(); });
socket.on('error', (e) => { pendingError = e; });
const waitReply = (expect: string): Promise<string> =>
new Promise((resolve, reject) => {
if (pendingError) return reject(pendingError);
resolveLine = (line) => {
if (!line.startsWith(expect)) {
reject(new SmtpError(`Expected ${expect}, got: ${line}`));
} else {
resolve(line);
}
};
});
const send = (line: string): void => { socket.write(line + '\r\n'); };
const b64 = (s: string) => Buffer.from(s).toString('base64');
try {
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
await waitReply('220');
send('EHLO integration-tests');
await waitReply('250');
send('AUTH LOGIN');
await waitReply('334');
send(b64(authUser));
await waitReply('334');
send(b64(opts.authPass));
await waitReply('235');
send(`MAIL FROM:<${opts.from}>`);
await waitReply('250');
for (const rcpt of recipients) {
send(`RCPT TO:<${rcpt}>`);
await waitReply('250');
}
send('DATA');
await waitReply('354');
const headers: Record<string, string> = {
From: opts.from,
To: recipients.join(', '),
Subject: opts.subject,
'Content-Type': 'text/plain; charset=utf-8',
...opts.headers,
};
const headerBlock = Object.entries(headers)
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n');
// Dot-stuff any line that begins with '.'
const safeBody = crlf(opts.body).replace(/\r\n\./g, '\r\n..');
send(`${headerBlock}\r\n\r\n${safeBody}\r\n.`);
await waitReply('250');
send('QUIT');
await waitReply('221').catch(() => { /* some servers drop before 221 */ });
} finally {
socket.destroy();
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"features": {
"unifiedCrossAccountEnabled": true,
"crossUnreadViewEnabled": true,
"crossAllViewEnabled": true
}
}
+33
View File
@@ -0,0 +1,33 @@
# Webmail image for integration testing — runs Next.js in DEVELOPMENT mode.
#
# Why dev mode rather than the production Dockerfile at the repo root?
# The browser talks JMAP directly to Stalwart at http://localhost:8025 (plain
# HTTP, cross-origin). The app's production Content-Security-Policy pins
# connect-src to `'self' https:`, which would block that plaintext cross-origin
# fetch. In development mode proxy.ts widens connect-src to `'self' http:
# https: ws: wss:` — exactly what a local, TLS-less Stalwart needs. Running
# from source also ships the integration-test data-testid hooks without a
# production rebuild.
#
# Build context is the repo root (see docker-compose.yml `context: ..`), so the
# root .dockerignore keeps examples/, integration/ and node_modules out.
FROM node:24-alpine
WORKDIR /app
# Install dependencies first for layer caching.
COPY package.json package-lock.json ./
RUN npm ci
# App source (data-testid hooks included).
COPY . .
ENV NODE_ENV=development
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
EXPOSE 3000
# Bind to 0.0.0.0 so the published port is reachable from the host/browser.
CMD ["npx", "next", "dev", "-H", "0.0.0.0", "-p", "3000"]
+59
View File
@@ -3,9 +3,11 @@ import DOMPurify from 'dompurify';
import {
sanitizeEmailHtml,
sanitizeSignatureHtml,
sanitizeSignatureHtmlForDisplay,
parseHtmlSafely,
hasRichFormatting,
plainTextToSafeHtml,
sanitizePlainTextRenderedHtml,
EMAIL_SANITIZE_CONFIG,
EMAIL_IFRAME_SANITIZE_CONFIG,
isExternalResourceUrl,
@@ -580,4 +582,61 @@ describe('email-sanitization', () => {
expect(result).toContain('javascript:alert(1)');
});
});
describe('sanitizeSignatureHtmlForDisplay', () => {
// Signatures render into the main document (identity-form preview, composer
// block), not the sandboxed iframe, so a target-less anchor navigates the
// whole app away and takes the unsaved draft/signature with it.
it('forces target=_blank and rel on signature links', () => {
const clean = sanitizeSignatureHtmlForDisplay('<p><a href="https://example.com">Site</a></p>');
expect(clean).toContain('target="_blank"');
expect(clean).toContain('rel="noopener noreferrer"');
});
it('overrides a target the user supplied themselves', () => {
const clean = sanitizeSignatureHtmlForDisplay('<a href="https://example.com" target="_top">x</a>');
expect(clean).toContain('target="_blank"');
expect(clean).not.toContain('_top');
});
it('keeps the image restrictions of the storage sanitizer', () => {
const clean = sanitizeSignatureHtmlForDisplay(
'<img src="http://insecure.example.com/l.png"><img src="https://cdn.example.com/l.png">',
);
expect(clean).not.toContain('insecure.example.com');
expect(clean).toContain('https://cdn.example.com/l.png');
});
it('does not leak target into the stored or sent signature', () => {
// sanitizeSignatureHtml feeds both storage and the outgoing message body.
const stored = sanitizeSignatureHtml('<p><a href="https://example.com">Site</a></p>');
expect(stored).toContain('href="https://example.com"');
expect(stored).not.toContain('target=');
});
it('handles empty input', () => {
expect(sanitizeSignatureHtmlForDisplay('')).toBe('');
expect(sanitizeSignatureHtmlForDisplay(' ')).toBe('');
});
});
describe('sanitizePlainTextRenderedHtml', () => {
// This branch renders into the main document, not the sandboxed iframe, so
// an anchor that loses target="_blank" navigates the whole app away.
it('preserves target and rel on links emitted by plainTextToSafeHtml', () => {
const rendered = sanitizePlainTextRenderedHtml(
plainTextToSafeHtml('see https://github.com/honzup/webmail/pull/560'),
);
expect(rendered).toContain('target="_blank"');
expect(rendered).toContain('rel="noopener noreferrer"');
});
it('still strips dangerous schemes and tags', () => {
const rendered = sanitizePlainTextRenderedHtml(
'<a href="javascript:alert(1)" target="_blank">x</a><script>alert(1)</script>',
);
expect(rendered).not.toContain('javascript:');
expect(rendered).not.toContain('<script');
});
});
});
+342
View File
@@ -0,0 +1,342 @@
import { describe, it, expect } from 'vitest';
import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
const BASE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clip1"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clip1)"><rect width="1000" height="1000" fill="#123456"/></g></svg>`;
function decode(dataUrl: string): string {
return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', ''));
}
/** The badge band: the last <rect> the renderer appends, identified by its fill. */
function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } {
const match =
/<rect[^>]*\bx="(-?[\d.]+)"[^>]*\by="(-?[\d.]+)"[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"[^>]*\brx="([\d.]+)"[^>]*fill="#ffffff"/.exec(
svg,
);
expect(match).not.toBeNull();
const [, x, y, w, h, rx] = match!.map(Number);
return { x, y, w, h, rx };
}
function fontSize(svg: string): number {
return Number(/<text[^>]*font-size="([\d.]+)"/.exec(svg)![1]);
}
function viewBoxOf(svg: string): { minX: number; minY: number; width: number; height: number } {
const [minX, minY, width, height] = /viewBox="([^"]+)"/
.exec(svg)![1]
.trim()
.split(/[\s,]+/)
.map(Number);
return { minX, minY, width, height };
}
describe('formatBadgeCount', () => {
it('returns an empty string for zero and below', () => {
expect(formatBadgeCount(0)).toBe('');
expect(formatBadgeCount(-3)).toBe('');
});
it('returns the count verbatim from 1 to 99', () => {
expect(formatBadgeCount(1)).toBe('1');
expect(formatBadgeCount(9)).toBe('9');
expect(formatBadgeCount(47)).toBe('47');
expect(formatBadgeCount(99)).toBe('99');
});
it('caps at 99+ above 99', () => {
// Gmail caps at 20; matching it was tried and reverted. A lower cap means a
// typical inbox needs three glyphs almost always, and three glyphs do not
// fit at the full font size — so "99+" rendered permanently smaller than a
// real two-digit count would have.
expect(formatBadgeCount(100)).toBe('99+');
expect(formatBadgeCount(133)).toBe('99+');
expect(formatBadgeCount(1000)).toBe('99+');
});
it('returns an empty string for non-finite input', () => {
expect(formatBadgeCount(Number.NaN)).toBe('');
expect(formatBadgeCount(Number.POSITIVE_INFINITY)).toBe('');
});
});
describe('renderBadgedFavicon', () => {
it('returns null when the count is zero', () => {
expect(renderBadgedFavicon(BASE_SVG, 0)).toBeNull();
});
it('returns null when the source is not SVG', () => {
expect(renderBadgedFavicon('this is not svg', 3)).toBeNull();
});
it('returns null when the root element is not <svg>', () => {
expect(renderBadgedFavicon('<html><body/></html>', 3)).toBeNull();
});
it('returns null when the root has no viewBox', () => {
const noViewBox = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>`;
expect(renderBadgedFavicon(noViewBox, 3)).toBeNull();
});
it('returns a percent-encoded svg data URL', () => {
const url = renderBadgedFavicon(BASE_SVG, 3);
expect(url).not.toBeNull();
expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
});
it('draws a badge band and the count text', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('<rect');
expect(svg).toContain('>3<');
});
it('renders 99+ for large counts', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(svg).toContain('>99+<');
});
it('preserves the base artwork and its clipPath id', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('id="_clip1"');
expect(svg).toContain('#123456');
});
it('overrides pt-unit width and height with unitless 16 and keeps the viewBox', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('width="16"');
expect(svg).toContain('height="16"');
expect(svg).toContain('viewBox="0 0 1000 1000"');
expect(svg).not.toContain('1000pt');
});
it('draws a white band with black digits, so the count stays legible over any base icon', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<rect[^>]*fill="#ffffff"/);
expect(svg).toMatch(/<text[^>]*fill="#000000"/);
});
it('shrinks the font as the label grows so three glyphs still fit', () => {
const one = decode(renderBadgedFavicon(BASE_SVG, 3)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(fontSize(three)).toBeLessThan(fontSize(one));
});
it('returns null rather than throwing when the source contains a lone surrogate', () => {
const bad = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>abc\uD800def</title></svg>`;
expect(() => renderBadgedFavicon(bad, 3)).not.toThrow();
expect(renderBadgedFavicon(bad, 3)).toBeNull();
});
it('sizes the band to the label, and only "99+" fills the full icon width', () => {
// The box is only as wide as its digits need — "5" must not squat on as much
// white as "99+". It is never wider than the icon, and three glyphs, whose
// font is budgeted against the full span, grow to exactly fill it.
const w = (count: number) => band(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
expect(w(7)).toBeLessThan(w(47));
expect(w(47)).toBeLessThan(w(250));
expect(w(250)).toBeCloseTo(1000, 5);
});
it('matches the geometry measured from Gmail\'s 16px favicon', () => {
// Ground truth, measured pixel-by-pixel off Gmail's tab icon and scaled to a
// 0 0 1000 1000 viewBox: band 10/16 of the icon (0.625), flush to the bottom
// edge, corners rounded by 0.1h, width fitted to the label, anchored right.
// Gmail's own single-digit badge sits hard right in a box about a third of
// the icon wide, so the box grows leftwards from the corner.
// w = label.length * 0.6 * font + 2 * 0.04 * 1000, x = 1000 - w.
const expected: Record<string, { x: number; w: number; font: number }> = {
'5': { x: 554, w: 446, font: 610 }, // textW = 1 * 0.6 * 610 = 366
'15': { x: 188, w: 812, font: 610 }, // textW = 2 * 0.6 * 610 = 732
'250': { x: 0, w: 1000, font: 920 / 1.8 }, // "99+": font = (1000 - 80) / (3 * 0.6)
};
for (const [count, want] of Object.entries(expected)) {
const svg = decode(renderBadgedFavicon(BASE_SVG, Number(count))!);
const { x, y, w, h, rx } = band(svg);
expect(x).toBeCloseTo(want.x, 5);
expect(w).toBeCloseTo(want.w, 5);
expect(fontSize(svg)).toBeCloseTo(want.font, 5);
expect(y).toBeCloseTo(375, 5);
expect(h).toBeCloseTo(625, 5);
expect(rx).toBeCloseTo(62.5, 5);
}
});
it('anchors the band to the right edge, including on a negative-origin viewBox', () => {
// Corner-anchored, not centred: the box grows leftwards from the bottom-right
// corner, so its right edge sits on minX + span whatever the label. Centring
// was rejected — at a single digit it lands under the middle of the mark.
const cases: [string, number, number][] = [
// [base svg, minX, span]
[BASE_SVG, 0, 1000],
[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 24 24"><rect x="-4" y="-4" width="24" height="24" fill="#123456"/></svg>`, -4, 24],
];
for (const [svgSource, minX, span] of cases) {
for (const count of [7, 47, 250]) {
const { x, w } = band(decode(renderBadgedFavicon(svgSource, count)!));
expect(x + w).toBeCloseTo(minX + span, 5);
expect(x).toBeGreaterThanOrEqual(minX);
}
}
});
it('renders 1- and 2-digit labels at the max font size, and shrinks only for "99+"', () => {
// The font is budgeted against the full icon span, not against the fitted
// box, so one or two glyphs always land at FONT_MAX; only three force a
// shrink — and their box then grows to fill the icon.
const FONT_MAX = 0.61 * 1000;
const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(fontSize(one)).toBeCloseTo(FONT_MAX, 5);
expect(fontSize(two)).toBeCloseTo(FONT_MAX, 5);
expect(fontSize(three)).toBeLessThan(FONT_MAX);
});
it('rounds the band corners slightly — neither an oval nor a hard square', () => {
// rx = h / 2 was the pill: at one digit it read as a circle, at two an oval,
// and "99+" was a smudge. rx = 0 is the other failure: Gmail's corners carry
// a visible ~1px round at 16px. Guard against a silent revert to either.
for (const count of [7, 47, 250]) {
const { h, rx } = band(decode(renderBadgedFavicon(BASE_SVG, count)!));
expect(rx).toBeCloseTo(0.1 * h, 5);
expect(rx).toBeGreaterThan(0);
expect(rx).toBeLessThan(h / 2);
}
});
it('draws the digits at font-weight 500, in both the attribute and the style', () => {
// 700 read visibly heavier than Gmail's equivalent badge.
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<text[^>]*font-weight="500"/);
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*500/);
});
it('keeps the badge band entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
// The band is flush to the bottom and, at three glyphs, to the left and
// right edges too — but it must never overflow any of them.
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { x, y, w, h } = band(svg);
expect(x).toBeGreaterThanOrEqual(0);
expect(y).toBeGreaterThanOrEqual(0);
expect(x + w).toBeLessThanOrEqual(1000);
expect(y + h).toBeLessThanOrEqual(1000);
}
});
// A previous version of this test used /\bx="([\d.]+)"/, which cannot match a
// negative number: dropping `minX +` from the anchoring passed it. Anchor
// against a viewBox whose origin is negative, where the band's own x is
// legitimately negative, so the offset is genuinely pinned.
it('anchors the band to the viewBox origin, including a negative origin', () => {
const NEGATIVE_ORIGIN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-40 -40 240 240"><rect x="-40" y="-40" width="240" height="240" fill="#123456"/></svg>`;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(NEGATIVE_ORIGIN, count)!);
const { x, y, w, h } = band(svg);
expect(x).toBeGreaterThanOrEqual(-40);
expect(y).toBeGreaterThanOrEqual(-40);
expect(x + w).toBeLessThanOrEqual(200);
expect(y + h).toBeLessThanOrEqual(200);
// Anchored to the bottom-right: in a viewBox running from -40 to 200, the
// band's bottom edge and its right edge both sit well past the midpoint.
expect(x + w).toBeGreaterThan(80);
expect(y + h).toBeGreaterThan(80);
}
});
it('fits the label inside the band, with padding, for every label length', () => {
// The core band invariant: textW + 2 * pad <= w, where the glyph advance and
// padding are the renderer's own published constants. PAD_FACTOR is a
// fraction of the icon span, not of the fitted box, so the padding is the
// same at every label length.
const GLYPH_ADV = 0.6;
const PAD_FACTOR = 0.04;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { w } = band(svg);
const label = count > 99 ? '99+' : String(count);
const textW = label.length * GLYPH_ADV * fontSize(svg);
const pad = PAD_FACTOR * 1000;
expect(textW + 2 * pad).toBeLessThanOrEqual(w + 1e-6);
}
});
it('percent-encodes the payload, so a "#" in a fill cannot truncate the data URL', () => {
const url = renderBadgedFavicon(BASE_SVG, 3)!;
// encodeURI leaves "#" bare, which the browser reads as a fragment
// delimiter: everything after the first colour would be silently dropped.
expect(url).toContain('%23');
expect(url).not.toContain('#');
});
it('returns an empty label, and no badge, for a fractional count below one', () => {
expect(formatBadgeCount(0.5)).toBe('');
expect(renderBadgedFavicon(BASE_SVG, 0.5)).toBeNull();
});
it('returns null when the root svg has no SVG namespace', () => {
// Non-null but unrenderable: a data URL built from this would show nothing.
const noNs = `<svg viewBox="0 0 100 100"><rect width="100" height="100"/></svg>`;
expect(renderBadgedFavicon(noNs, 3)).toBeNull();
});
it('beats a stylesheet in the base SVG, keeping the badge white-on-black', () => {
// Presentation attributes lose to any CSS rule in the document. A branded
// base carrying `rect { fill: #db2d54 }` would otherwise paint the band red
// and the digits red — exactly what the white band exists to prevent.
const STYLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><style>rect{fill:#db2d54}text{fill:#db2d54}</style><rect width="1000" height="1000"/></svg>`;
const svg = decode(renderBadgedFavicon(STYLED, 3)!);
expect(svg).toMatch(/<rect[^>]*style="[^"]*fill:\s*#ffffff/);
expect(svg).toMatch(/<text[^>]*style="[^"]*fill:\s*#000000/);
});
it('strips scripts, foreignObject and event handlers from the base SVG', () => {
// The base may be an admin-uploaded file, which upstream serves under a
// sandboxing CSP precisely because SVG can carry script. Re-emitting it as a
// same-origin data: URL would un-fence it, so sanitise before serialising.
const HOSTILE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" onload="alert(1)"><script>alert(2)</script><foreignObject width="100" height="100"><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject><rect width="100" height="100" onclick="alert(3)" ONMOUSEOVER="alert(4)" fill="#123456"/></svg>`;
const url = renderBadgedFavicon(HOSTILE, 3)!;
expect(url).not.toBeNull();
const svg = decode(url);
expect(svg).not.toContain('<script');
expect(svg).not.toContain('foreignObject');
expect(svg.toLowerCase()).not.toContain('onload');
expect(svg.toLowerCase()).not.toContain('onclick');
expect(svg.toLowerCase()).not.toContain('onmouseover');
expect(svg).not.toContain('alert');
// The legitimate artwork survives.
expect(svg).toContain('#123456');
});
it('normalises a non-square viewBox to a square, so the badge stays legible', () => {
// A 100x20 wordmark: span = min(w, h) = 20 previously produced a ~2px-tall
// smudge on a 16px icon. Squaring the viewBox first sizes the badge against
// the rendered box instead.
const WORDMARK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><rect width="100" height="20" fill="#123456"/></svg>`;
const svg = decode(renderBadgedFavicon(WORDMARK, 42)!);
const vb = viewBoxOf(svg);
expect(vb.width).toBe(100);
expect(vb.height).toBe(100);
expect(vb.minX).toBe(0);
expect(vb.minY).toBe(-40); // centred: (100 - 20) / 2 above and below
const { x, y, w, h } = band(svg);
// Sized against the square side (100), not the 20-unit short axis.
expect(h).toBeCloseTo(0.625 * 100, 5);
// Two glyphs at FONT_MAX (61) plus padding: 2 * 0.6 * 61 + 2 * 4 = 81.2,
// anchored to the right of the squared span.
expect(w).toBeCloseTo(81.2, 5);
expect(x + w).toBeCloseTo(100, 5);
// Still in bounds of the normalised viewBox.
expect(x).toBeGreaterThanOrEqual(vb.minX);
expect(y).toBeGreaterThanOrEqual(vb.minY);
expect(x + w).toBeLessThanOrEqual(vb.minX + vb.width);
expect(y + h).toBeLessThanOrEqual(vb.minY + vb.height);
});
it('leaves a square viewBox untouched', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('viewBox="0 0 1000 1000"');
});
});
+74 -3
View File
@@ -55,7 +55,7 @@ interface CapturedRequest {
* Mailbox/get → Identity/get → Email/set + EmailSubmission/set.
* Returns the captured request bodies for assertions.
*/
function mockSendEmailFlow() {
function mockSendEmailFlow(draftsId = 'mb-drafts', sentId = 'mb-sent') {
const captured: CapturedRequest[] = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
@@ -71,8 +71,8 @@ function mockSendEmailFlow() {
'Mailbox/get',
{
list: [
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
{ id: draftsId, name: 'Drafts', role: 'drafts' },
{ id: sentId, name: 'Sent', role: 'sent' },
],
},
'0',
@@ -264,3 +264,74 @@ describe('JMAPClient.sendEmail threading headers', () => {
]));
});
});
describe('JMAPClient post-send mailbox filing', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
function sentFilingPatch(captured: CapturedRequest[]): Record<string, unknown> {
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
expect(submissionCall).toBeDefined();
const onSuccess = (submissionCall![1] as {
onSuccessUpdateEmail: Record<string, Record<string, unknown>>;
}).onSuccessUpdateEmail;
return Object.values(onSuccess)[0];
}
it('files the sent message via a full mailboxIds replacement, never mailboxIds/<id> pointers', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'], 'subject', 'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const patch = sentFilingPatch(captured);
// A `mailboxIds/<id>` JSON-pointer whose token is purely numeric (e.g. a
// Drafts folder whose JMAP id is "0") is rejected by Stalwart, silently
// stranding already-delivered mail in Drafts. The move must use a full
// `mailboxIds` replacement, which has no per-id pointer token.
expect(Object.keys(patch).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(patch.mailboxIds).toEqual({ 'mb-sent': true });
expect(patch['keywords/$draft']).toBeNull();
});
it('files correctly when the Drafts mailbox id is a purely numeric string (Stalwart numeric-id bug)', async () => {
const client = createClient();
// Drafts id "0", Sent id "e": the old pointer form emitted `mailboxIds/0`,
// which Stalwart rejects with invalidProperties "Invalid patch value".
const captured = mockSendEmailFlow('0', 'e');
await client.sendEmail(
['recipient@example.com'], 'subject', 'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const patch = sentFilingPatch(captured);
expect(Object.keys(patch).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(patch.mailboxIds).toEqual({ e: true });
});
it('restoreEmailToDraft places the message in Drafts only via a full mailboxIds replacement', async () => {
const client = createClient();
let capturedUpdate: Record<string, unknown> | undefined;
vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { update?: Record<string, Record<string, unknown>> };
capturedUpdate = args.update?.['email-1'];
return { methodResponses: [['Email/set', { updated: { 'email-1': null } }, '0']] };
});
// Third arg (Sent mailbox id) is intentionally ignored — the message must
// end up in Drafts only, with no leftover Sent membership. Drafts id "0"
// also exercises the numeric-id path in the reverse direction.
await client.restoreEmailToDraft('email-1', '0', 'e');
expect(capturedUpdate).toBeDefined();
expect(Object.keys(capturedUpdate!).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(capturedUpdate!.mailboxIds).toEqual({ '0': true });
expect(capturedUpdate!['keywords/$draft']).toBe(true);
});
});
+83 -2
View File
@@ -46,7 +46,8 @@ describe('oauth/discovery', () => {
expect(result).toEqual(VALID_METADATA);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
'https://mail.example.com/.well-known/oauth-authorization-server'
'https://mail.example.com/.well-known/oauth-authorization-server',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
@@ -64,7 +65,8 @@ describe('oauth/discovery', () => {
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://fallback.example.com/.well-known/openid-configuration'
'https://fallback.example.com/.well-known/openid-configuration',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
@@ -176,4 +178,83 @@ describe('oauth/discovery', () => {
expect(second).toEqual(VALID_METADATA);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('bounds each discovery fetch with an AbortSignal timeout (no hang on unresponsive IdP)', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const fetchMock = vi.fn().mockRejectedValue(
Object.assign(new Error('The operation timed out'), { name: 'TimeoutError' }),
);
vi.stubGlobal('fetch', fetchMock);
const result = await discoverOAuth('https://unresponsive.example.com', { validateEndpoint });
expect(result).toBeNull();
// Every discovery fetch must carry an AbortSignal so an unresponsive IdP is
// aborted (DISCOVERY_TIMEOUT_MS) instead of hanging the request - and, with
// it, the login page's SSO button.
expect(fetchMock.mock.calls.length).toBeGreaterThan(0);
for (const call of fetchMock.mock.calls) {
expect(call[1]).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) }));
}
});
it('retries once when the first attempt fails, then succeeds', async () => {
// Attempt 1: both well-known URLs fail. Attempt 2: first URL succeeds.
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(VALID_METADATA) }));
const result = await discoverOAuth('https://flaky.example.com', { validateEndpoint });
expect(result).toEqual(VALID_METADATA);
// 2 failures (attempt 1) + 1 success (attempt 2 retry).
expect(fetch).toHaveBeenCalledTimes(3);
});
it('serves stale cached metadata when a refresh fails (keeps the SSO button up)', async () => {
vi.useFakeTimers();
try {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// First call succeeds and caches the metadata.
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(VALID_METADATA),
}));
const first = await discoverOAuth('https://stale.example.com', { validateEndpoint });
expect(first).toEqual(VALID_METADATA);
// Expire the cache (positive TTL is 10 min).
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
// Refresh now fails on every URL/attempt: the stale-but-usable value must
// be returned instead of null so the SSO button keeps rendering.
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
const pending = discoverOAuth('https://stale.example.com', { validateEndpoint });
await vi.advanceTimersByTimeAsync(1000); // fire the retry backoff timer
const second = await pending;
expect(second).toEqual(VALID_METADATA);
expect(warnSpy).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('negative-caches a total failure (no cached value) to avoid hammering the IdP', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'));
vi.stubGlobal('fetch', fetchMock);
const first = await discoverOAuth('https://down.example.com', { validateEndpoint });
const callsAfterFirst = fetchMock.mock.calls.length;
const second = await discoverOAuth('https://down.example.com', { validateEndpoint });
expect(first).toBeNull();
expect(second).toBeNull();
// The immediate second call is short-circuited by the negative cache, so no
// additional fetches are made.
expect(fetchMock).toHaveBeenCalledTimes(callsAfterFirst);
});
});
+17 -3
View File
@@ -166,6 +166,19 @@ export class DemoJMAPClient implements IJMAPClient {
return { emails, hasMore: position + limit < total, total };
}
async getSomeEmails(emailsId: string[], _accountId?: string): Promise<Email[]> {
if (!emailsId || emailsId.length === 0) {
return [];
}
const filtered = this.data.emails.filter(e => emailsId.includes(e.id));
filtered.sort((a, b) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
return filtered;
}
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
}
@@ -1083,11 +1096,12 @@ export class DemoJMAPClient implements IJMAPClient {
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
// Mirrors JMAPClient.restoreEmailToDraft: the third parameter is ignored and
// the message ends up in Drafts only (full mailboxIds replacement).
async restoreEmailToDraft(emailId: string, draftMailboxId: string, _sentMailboxId?: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds[draftMailboxId] = true;
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
email.mailboxIds = { [draftMailboxId]: true };
email.keywords.$draft = true;
this.recalcMailboxCounts();
}
+47 -6
View File
@@ -79,26 +79,61 @@ export const SIGNATURE_SANITIZE_CONFIG = {
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
};
/** Drop images whose src isn't https: or a base64 raster data: URI. */
function restrictSignatureImages(node: Element): void {
if (node.tagName !== 'IMG') return;
const src = node.getAttribute('src');
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
node.remove();
}
}
/**
* Sanitize HTML signature for storage and display.
* Sanitize an HTML signature for storage and for the outgoing message.
* img src is restricted to https: or base64-embedded raster data: URIs
* (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect
* bytes inside a data: URI. Images with a disallowed src are removed
* entirely so they don't render as broken-image icons.
*
* Deliberately does NOT force target="_blank": what we store, and what the
* recipient receives, should stay as the user wrote it. Use
* `sanitizeSignatureHtmlForDisplay` for anything rendered in our own DOM.
* @param html - User-provided HTML signature
* @returns Sanitized signature (no scripts, no external resources)
*/
export function sanitizeSignatureHtml(html: string): string {
if (!html?.trim()) return '';
DOMPurify.addHook('afterSanitizeAttributes', restrictSignatureImages);
try {
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
}
const SIGNATURE_DISPLAY_CONFIG = {
...SIGNATURE_SANITIZE_CONFIG,
ALLOWED_ATTR: [...SIGNATURE_SANITIZE_CONFIG.ALLOWED_ATTR, 'target', 'rel'],
};
/**
* Sanitize an HTML signature for rendering inside our own DOM — the identity
* form's live preview and the composer's signature block. Both inject into the
* main document rather than the sandboxed iframe used for message bodies, so a
* link without target="_blank" navigates the whole app away, taking any unsent
* draft or unsaved signature with it. Force every anchor to open a new tab.
*/
export function sanitizeSignatureHtmlForDisplay(html: string): string {
if (!html?.trim()) return '';
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName !== 'IMG') return;
const src = node.getAttribute('src');
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
node.remove();
restrictSignatureImages(node);
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
try {
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
return DOMPurify.sanitize(html, SIGNATURE_DISPLAY_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
@@ -132,6 +167,12 @@ export function sanitizeI18nHtml(html: string): string {
const PLAIN_TEXT_RENDERED_CONFIG = {
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
// DOMPurify URI-tests every attribute value not on its URI-safe list, so the
// strict ALLOWED_URI_REGEXP below would strip target="_blank" (and rel) —
// "_blank" is not a URI. This branch renders into the main document rather
// than the sandboxed iframe, so losing target turns every link into a
// whole-app navigation. Exempt the two from the URI check.
ADD_URI_SAFE_ATTR: ['target', 'rel'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|cid:|#)/i,
};
+206
View File
@@ -0,0 +1,206 @@
const SVG_NS = 'http://www.w3.org/2000/svg';
// A neutral white band with black digits, rather than the conventional red
// badge. The band guarantees contrast for the count whatever the base icon
// looks like, which matters because `faviconUrl` is admin-overridable and may
// be any artwork. A coloured badge cannot make that guarantee: Bulwark's own
// icon is rgb(219,45,84), so a red badge sat red-on-red.
const BADGE_FILL = '#ffffff';
const BADGE_TEXT_FILL = '#000000';
const BADGE_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
// The badge is a Gmail-style band across the bottom of the icon, sized as a
// fraction of the icon's own coordinate space so it lands correctly whatever
// viewBox the base declares.
//
// The fractions below are not invented: they are measured, pixel-by-pixel, off
// Gmail's real 16x16 tab favicon, which is the badge users actually compare this
// one against. Gmail's band is 10 of 16 px tall (0.625 of the icon span), its
// digits have a cap height of 7 of 16 px (0.44, i.e. a font-size of ~0.61 span),
// it is flush — edge to edge, and to the bottom, with no inset margin — and its
// corners carry a slight round, about 1px at 16px, which is roughly 0.1 of the
// band height. Not square, and emphatically not h/2.
//
// The box is sized to the label and centred, as Gmail's is: "5" must not squat
// on as much white as "99+" does.
//
// What keeps a three-glyph label legible is not the width — it is the small
// corner radius, plus budgeting the font against the FULL span rather than
// against the fitted box. The rounded-end pill that preceded this failed for the
// first reason: round ends (rx = h/2) squander their horizontal extent on the
// curve, which is exactly the space three glyphs need, so at 16px "99+" was an
// illegible smudge — and at one digit the same pill read as a plain circle. Do
// not reinstate rx = h/2. Because the font is budgeted against the full span,
// "99+" shrinks to the size that would fit edge to edge, and its box then grows
// to fill the icon width anyway; "9" and "47" render at the cap in a box that
// hugs them.
const BAND_HEIGHT = 0.625; // band height, as a fraction of the icon span
const FONT_MAX = 0.61; // font-size cap, as a fraction of the icon span
const PAD_FACTOR = 0.04; // horizontal padding, as a fraction of the icon span, each side
const CORNER_FACTOR = 0.1; // corner radius, as a fraction of band height
const GLYPH_ADV = 0.6; // advance width per glyph, in em, for the sans badge font
// Counts above this render as "99+". Gmail caps at 20, and matching it was
// tried and reverted: the cap decides how often the label needs three glyphs,
// and three glyphs do not fit at the full font size. Capping at 20 meant a
// typical inbox showed "20+" at 84% of the cap size essentially always, where
// capping at 99 shows a real two-digit count at full size. Bigger digits and a
// number you can act on beat parity with Gmail's ceiling.
const BADGE_MAX = 99;
/**
* Formats an unread count for display in the badge.
* Returns an empty string when there is nothing to show.
*/
export function formatBadgeCount(count: number): string {
// `< 1`, not `<= 0`: a fractional count such as 0.5 would otherwise floor to
// 0 and draw a "0" badge, since String(0) is truthy.
if (!Number.isFinite(count) || count < 1) return '';
const whole = Math.floor(count);
return whole > BADGE_MAX ? `${BADGE_MAX}+` : String(whole);
}
/**
* Strips anything active from the base SVG.
*
* The base may be an admin-uploaded file, which the branding route deliberately
* serves under a sandboxing CSP because SVG can carry script (see
* app/api/admin/branding/[filename]/route.ts). Re-emitting it verbatim as a
* same-origin `data:` URL inside our own document would un-fence exactly what
* that CSP fences, so remove script, foreignObject and every on* handler first.
*/
function sanitiseSvg(doc: Document): void {
doc.querySelectorAll('script, foreignObject').forEach((el) => el.remove());
doc.querySelectorAll('*').forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase().startsWith('on')) {
el.removeAttributeNS(attr.namespaceURI, attr.localName);
}
}
});
}
/**
* Composes an unread badge over an SVG favicon and returns it as a data URL.
*
* Returns null — meaning "leave the favicon alone" — when the count is zero,
* or when the source is not usable SVG. Never throws.
*/
export function renderBadgedFavicon(baseSvgSource: string, count: number): string | null {
const label = formatBadgeCount(count);
if (!label) return null;
try {
const doc = new DOMParser().parseFromString(baseSvgSource, 'image/svg+xml');
if (doc.querySelector('parsererror')) return null;
const root = doc.documentElement;
// The namespace, not just the tag name: an <svg> with no xmlns parses fine
// but renders as nothing, so it would yield a non-null, blank data URL.
if (!root || root.localName !== 'svg' || root.namespaceURI !== SVG_NS) return null;
const viewBox = root.getAttribute('viewBox');
if (!viewBox) return null;
const [rawMinX, rawMinY, rawWidth, rawHeight] = viewBox.trim().split(/[\s,]+/).map(Number);
if (
![rawMinX, rawMinY, rawWidth, rawHeight].every(Number.isFinite) ||
rawWidth <= 0 ||
rawHeight <= 0
) {
return null;
}
sanitiseSvg(doc);
// The base declares "1000pt"; point units in a favicon are unreliable.
// Unitless 16 with the viewBox retained lets the browser rasterise cleanly
// at any size it asks for.
root.setAttribute('width', '16');
root.setAttribute('height', '16');
// Normalise the viewBox to a square, centred on the original, before doing
// any badge maths. Sizing the badge off min(width, height) double-penalised
// a non-square base: a 100x20 wordmark produced a ~2px-tall smudge on a
// 16px icon. Squaring first sizes the badge against the box the icon is
// actually painted into. It is a no-op for a square viewBox (Bulwark's own
// is 0 0 1000 1000). Caveat: a base that pairs a non-square viewBox with
// preserveAspectRatio="none" will now letterbox rather than stretch — an
// acceptable, arguably better, trade for a favicon, which is always square.
const side = Math.max(rawWidth, rawHeight);
const minX = rawMinX - (side - rawWidth) / 2;
const minY = rawMinY - (side - rawHeight) / 2;
root.setAttribute('viewBox', `${minX} ${minY} ${side} ${side}`);
const span = side;
const h = BAND_HEIGHT * span;
const fontMax = FONT_MAX * span;
const pad = PAD_FACTOR * span;
// The font first, budgeted against the FULL span: the largest size that
// would still leave the padding intact if the box ran edge to edge. That is
// the cap for one or two glyphs and a modest shrink for "99+".
const font = Math.min(fontMax, (span - 2 * pad) / (label.length * GLYPH_ADV));
// The box then hugs the label — never wider than the icon, anchored to the
// bottom-right corner. A three-glyph label, whose font was budgeted against
// the whole span, fills that span exactly; shorter labels get a narrower
// box, leaving the left of the base mark uncovered so the artwork stays
// recognisable. Gmail's own badge does the same: measured off its 16px
// favicon, a single digit sits hard right in a box about a third of the
// icon wide. Centring was tried and rejected — at one digit the box lands
// under the middle of the mark and bites a hole out of it.
const textW = label.length * GLYPH_ADV * font;
const w = Math.min(span, textW + 2 * pad);
const x = minX + span - w;
const y = minY + span - h;
const rx = CORNER_FACTOR * h;
const bandRect = doc.createElementNS(SVG_NS, 'rect');
bandRect.setAttribute('x', String(x));
bandRect.setAttribute('y', String(y));
bandRect.setAttribute('width', String(w));
bandRect.setAttribute('height', String(h));
bandRect.setAttribute('rx', String(rx));
bandRect.setAttribute('ry', String(rx));
// Presentation attributes lose to any CSS rule in the same document, and a
// branded base is free to carry `<style>rect{fill:#db2d54}</style>` — which
// would paint the badge red-on-red, the exact failure the white band exists
// to prevent. A style attribute outranks a stylesheet rule, so set both: the
// attribute as the guarantee, the presentation attribute as the fallback.
bandRect.setAttribute('fill', BADGE_FILL);
bandRect.setAttribute('style', `fill:${BADGE_FILL}`);
const text = doc.createElementNS(SVG_NS, 'text');
text.setAttribute('x', String(x + w / 2));
text.setAttribute('y', String(y + h / 2));
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('font-family', BADGE_FONT);
// 500, not 700: at true 16px a bold count read visibly heavier than the
// equivalent badge in Gmail's tab, which is the thing users compare it to.
text.setAttribute('font-weight', '500');
text.setAttribute('font-size', String(font));
text.setAttribute('fill', BADGE_TEXT_FILL);
text.setAttribute(
'style',
`fill:${BADGE_TEXT_FILL};font-family:${BADGE_FONT};font-weight:500;font-size:${font}px`,
);
text.textContent = label;
root.appendChild(bandRect);
root.appendChild(text);
const serialised = new XMLSerializer().serializeToString(doc);
// Percent-encoding rather than base64: btoa throws on any character outside
// Latin-1, which a branded SVG may well contain. encodeURIComponent itself
// throws on an unpaired surrogate, so this whole tail is guarded. It must be
// encodeURIComponent, not encodeURI: the latter leaves "#" bare, and a bare
// "#" in a colour truncates the data URL at the first fill.
return `data:image/svg+xml,${encodeURIComponent(serialised)}`;
} catch {
return null;
}
}
+2
View File
@@ -83,6 +83,7 @@ export interface IJMAPClient {
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
advancedSearchEmails(
@@ -188,6 +189,7 @@ export interface IJMAPClient {
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>;
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
/** `sentMailboxId` is accepted for backwards compatibility but ignored: the message is placed in Drafts only. */
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
sendImipReply(opts: {
+97 -17
View File
@@ -15,6 +15,41 @@ function parseRecipientString(s: string): { name?: string; email: string } {
return { email: trimmed };
}
/**
* Build the `mailboxIds` portion of an `Email/set` PatchObject as a full-property
* replacement — `{ mailboxIds: { <id>: true, ... } }` — instead of per-id
* `mailboxIds/<id>` JSON-Pointer patches.
*
* Two reasons:
* 1. It states the actual intent of a post-send / undo-send move: the message
* should belong to *exactly* the given mailbox(es).
* 2. It avoids per-id JSON-Pointer tokens entirely. Stalwart (observed on
* 0.15.5) rejects an `Email/set` PatchObject whose pointer token is a
* purely-numeric string — e.g. `mailboxIds/0` for a mailbox whose JMAP id is
* "0" — with `invalidProperties: "Invalid patch value"` (it treats the digits
* as a JSON-Pointer array index even though `mailboxIds` is a JSON object;
* cf. RFC 6901 §4, and RFC 8620 §1.2's warning against interop-hostile ids).
* That silently stranded already-delivered mail in Drafts for accounts whose
* Drafts/Sent mailbox id happened to be all digits (a full member of `0`,
* `1`, … `9`, `10`, … was verified rejected; ids containing a letter work).
* Stalwart fixed the parsing in 0.16.5 (stalwartlabs/stalwart@175f34ea,
* jmap-tools 0.1.5), but earlier deployments remain in the wild — and not
* emitting interop-hostile pointer tokens is the safer shape regardless.
*
* This is a *replacement*: it drops any other mailbox membership the message
* had, so callers must know the complete target set. Do NOT also place a
* `mailboxIds/<id>` pointer key in the same PatchObject — a pointer whose prefix
* is another key in the object is illegal (RFC 8620 §5.3).
*/
function mailboxIdsReplacement(
mailboxId: string,
...moreMailboxIds: string[]
): { mailboxIds: Record<string, true> } {
const mailboxIds: Record<string, true> = { [mailboxId]: true };
for (const id of moreMailboxIds) mailboxIds[id] = true;
return { mailboxIds };
}
export class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
@@ -432,6 +467,17 @@ function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
// Generate a Message-ID for outgoing mail (bare msg-id, no angle brackets, per
// RFC 8621 §4.1.2.3). Without one the server synthesizes it from its OS
// hostname, which leaks internal names (e.g. @ip-10-0-12-97.ec2.internal) into
// headers — an anti-spam signal and an information disclosure. Use the sender's
// domain instead, matching what receivers expect a Message-ID to look like.
function generateMessageId(fromEmail: string): string {
const at = fromEmail.lastIndexOf('@');
const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost';
return `${Date.now().toString(36)}.${crypto.randomUUID()}@${domain}`;
}
/**
* Build a CalendarEvent/query filter restricting results to the given
* calendars. Stalwart implements the singular `inCalendar` condition (one
@@ -550,6 +596,43 @@ export class JMAPClient implements IJMAPClient {
this.authHeader = `Bearer ${token}`;
}
async getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]> {
try {
const targetAccountId = accountId || this.accountId;
if (!emailsId || emailsId.length === 0) {
return [];
}
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: emailsId,
properties: [...EMAIL_LIST_PROPERTIES],
}, "0"],
]);
const getResponse = response.methodResponses?.[0]?.[1];
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
const emails = (getResponse.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return emails;
}
return [];
} catch (error) {
console.error('Failed to get specific emails:', error);
return [];
}
}
/** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void {
this.authMode = 'bearer';
@@ -2430,6 +2513,7 @@ export class JMAPClient implements IJMAPClient {
cc: cc?.length ? cc.map(parseRecipientString) : undefined,
bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined,
subject,
messageId: [generateMessageId(fromEmail || this.username)],
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
references: normalizedReferences?.length ? normalizedReferences : undefined,
keywords: { "$seen": true, "$draft": true },
@@ -2473,8 +2557,7 @@ export class JMAPClient implements IJMAPClient {
// issues with servers that encrypt on append (e.g. Stalwart). See #188.
const onSuccessUpdateEmail = {
"#1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
"keywords/$draft": null,
},
};
@@ -2748,8 +2831,7 @@ export class JMAPClient implements IJMAPClient {
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
"keywords/$draft": null,
},
},
@@ -2934,8 +3016,7 @@ export class JMAPClient implements IJMAPClient {
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
"keywords/$draft": null,
},
},
@@ -3089,8 +3170,7 @@ export class JMAPClient implements IJMAPClient {
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
"keywords/$draft": null,
},
},
@@ -6200,8 +6280,7 @@ export class JMAPClient implements IJMAPClient {
...(draftMailboxId ? {
onSuccessUpdateEmail: {
'#raw-submit': {
[`mailboxIds/${draftMailboxId}`]: null,
[`mailboxIds/${sentMailboxId}`]: true,
...mailboxIdsReplacement(sentMailboxId),
'keywords/$draft': null,
},
},
@@ -6368,8 +6447,7 @@ export class JMAPClient implements IJMAPClient {
...(draftsMailbox && sentMailbox ? {
onSuccessUpdateEmail: {
'#replacement': {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
'keywords/$draft': null,
},
},
@@ -6401,15 +6479,17 @@ export class JMAPClient implements IJMAPClient {
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
// The third parameter is intentionally unused: this restores an undo-send /
// canceled-scheduled message to be a draft, so it should live in Drafts *only*.
// A full mailboxIds replacement (rather than mailboxIds/<id> pointer patches)
// both drops the Sent copy without needing its id and stays safe for numeric
// mailbox ids — see mailboxIdsReplacement().
async restoreEmailToDraft(emailId: string, draftMailboxId: string, _sentMailboxId?: string): Promise<void> {
const update: Record<string, unknown> = {
[`mailboxIds/${draftMailboxId}`]: true,
...mailboxIdsReplacement(draftMailboxId),
'keywords/$draft': true,
'keywords/$seen': true,
};
if (sentMailboxId) {
update[`mailboxIds/${sentMailboxId}`] = null;
}
const response = await this.request([
['Email/set', {
accountId: this.accountId,
+85 -19
View File
@@ -21,6 +21,26 @@ const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 64;
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
// --- Discovery hardening ---------------------------------------------------
// The login page's "Sign in with SSO" button is gated on OIDC discovery
// succeeding. An un-timed, un-retried fetch that dropped its cached value on
// failure let a single transient blip to the IdP silently hide the button.
// A per-fetch timeout, one retry, and serving stale-but-usable metadata on
// failure keep the button up through a transient blip.
const DISCOVERY_TIMEOUT_MS = 4000;
const DISCOVERY_RETRIES = 1;
const DISCOVERY_RETRY_DELAY_MS = 300;
// When discovery fails, remember the outcome briefly so repeated login-page
// loads during an outage don't hammer the IdP. Also throttles re-discovery
// while serving stale metadata. Kept short so recovery is fast.
const DISCOVERY_FAILURE_TTL_MS = 15 * 1000;
// Records recent failures for serverUrls that have no cached metadata to serve.
const negativeCache = new Map<string, number>();
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
// Bound the cache so callers that can supply arbitrary serverUrl values
// (e.g. unauthenticated routes that fall back to user input) cannot
@@ -33,6 +53,16 @@ function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
}
function rememberFailure(serverUrl: string): void {
// Bound like metadataCache: a user-supplied serverUrl must not grow this map
// without limit.
if (negativeCache.size >= CACHE_MAX_ENTRIES) {
const oldest = negativeCache.keys().next().value;
if (oldest !== undefined) negativeCache.delete(oldest);
}
negativeCache.set(serverUrl, Date.now() + DISCOVERY_FAILURE_TTL_MS);
}
// Endpoints come from an attacker-controllable JSON document when callers pass
// a user-supplied serverUrl (e.g. /api/auth/totp-token-exchange under
// allowCustomJmapEndpoint). Without a validator, a malicious metadata document
@@ -52,24 +82,18 @@ async function endpointsArePublic(
return true;
}
export async function discoverOAuth(
serverUrl: string,
options?: DiscoverOAuthOptions,
// One pass over the well-known documents. Returns usable metadata, or null
// (pushing diagnostics into `errors`) when neither URL yields a public,
// complete document. Each fetch is bounded by a timeout so an unresponsive IdP
// can never hang the request (and, with it, the login page's SSO button).
async function attemptDiscovery(
urls: string[],
validate: EndpointValidator | undefined,
errors: string[],
): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
if (cached) metadataCache.delete(serverUrl);
const urls = [
`${serverUrl}/.well-known/oauth-authorization-server`,
`${serverUrl}/.well-known/openid-configuration`,
];
const errors: string[] = [];
for (const url of urls) {
try {
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
if (!response.ok) {
errors.push(`${url} returned HTTP ${response.status}`);
continue;
@@ -82,20 +106,18 @@ export async function discoverOAuth(
data.token_endpoint,
data.revocation_endpoint,
data.end_session_endpoint,
], options?.validateEndpoint);
], validate);
if (!allPublic) {
errors.push(`${url} returned non-public or invalid endpoint URL`);
continue;
}
const metadata: OAuthMetadata = {
return {
issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint,
token_endpoint: data.token_endpoint,
revocation_endpoint: data.revocation_endpoint,
end_session_endpoint: data.end_session_endpoint,
};
rememberMetadata(serverUrl, metadata);
return metadata;
}
errors.push(`${url} response missing required endpoints`);
} catch (err) {
@@ -103,7 +125,51 @@ export async function discoverOAuth(
continue;
}
}
return null;
}
export async function discoverOAuth(
serverUrl: string,
options?: DiscoverOAuthOptions,
): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
// A stale entry is deliberately retained (not deleted) so it can be served
// as a fallback below if the refresh fails - this keeps the SSO button up
// through a transient IdP blip.
// Nothing to serve and we failed recently: skip hammering the IdP.
if (!cached) {
const retryAfter = negativeCache.get(serverUrl);
if (retryAfter !== undefined && retryAfter > Date.now()) return null;
}
const urls = [
`${serverUrl}/.well-known/oauth-authorization-server`,
`${serverUrl}/.well-known/openid-configuration`,
];
const errors: string[] = [];
for (let attempt = 0; attempt <= DISCOVERY_RETRIES; attempt++) {
if (attempt > 0) await sleep(DISCOVERY_RETRY_DELAY_MS);
const metadata = await attemptDiscovery(urls, options?.validateEndpoint, errors);
if (metadata) {
rememberMetadata(serverUrl, metadata);
negativeCache.delete(serverUrl);
return metadata;
}
}
// Every attempt failed. Prefer stale-but-usable metadata over nothing so the
// login page keeps rendering the SSO button during the outage; throttle the
// next re-discovery so we don't retry on every request.
if (cached) {
cached.expiresAt = Date.now() + DISCOVERY_FAILURE_TTL_MS;
console.warn(`[OAuth] Discovery refresh failed for ${serverUrl}; serving stale metadata: ${errors.join('; ')}`);
return cached.metadata;
}
rememberFailure(serverUrl);
console.error(`[OAuth] Discovery failed for ${serverUrl}: ${errors.join('; ')}`);
return null;
}
+4
View File
@@ -272,6 +272,10 @@ export const emailHooks = {
// normally. This is the send-takeover hook used by the S/MIME plugin to
// replace the former native sign+encrypt+sendRaw pipeline.
onComposeSend: new HookBus(),
// Transform hook - receive Email[] or ScheduledEmail[] just after there are fetched to
// lets plugin edit emails before they are shown in row. Used to populate preview
// field for encryption plugins.
onEmailsFetched: new HookBus(),
};
// §7.2 Calendar Hooks
+1
View File
@@ -86,6 +86,7 @@ const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' },
'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' },
'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' },
'ui:download-file': { title: 'Download files', body: 'Download custom files generated by the plugin.' },
};
export function describePermission(perm: string): { title: string; body: string } {
+137
View File
@@ -22,6 +22,7 @@ const PRIVILEGED_ONLY_METHODS = new Set<string>([
'jmap.fetchBlob',
'jmap.sendRaw',
'upfiles.get',
'webauthn.getOrCreate',
'upfiles.set',
]);
@@ -47,6 +48,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
// To just read, use jmap.fetchBlob.
'upfiles.get' : 'email:blob-write',
'upfiles.save' : 'email:blob-write',
'webauthn.getOrCreate': 'crypto:full',
// admin
'admin.getConfig': 'admin:config',
'admin.getAllConfig': 'admin:config',
@@ -58,6 +60,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'ui.prompt': null,
'ui.rerenderEmail': null,
'ui.openExternalUrl': null,
'ui.downloadFile': 'ui:download-file'
};
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
@@ -274,6 +277,117 @@ async function doJmapSendRaw(
);
}
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
// This salt acts as a constant context identifier for key derivation.
// While hardcoded, security is maintained because the WebAuthn PRF extension
// mixes this salt with the device's unique, hardware-bound private key.
// Changing this string will result in a completely different derived secret.
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1");
/**
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
* This secret is typically used as a local master encryption key.
*/
async function doGetOrCreatePRF(
masterCredentialIdBytes: number[] | undefined,
name?: string,
displayName?: string
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
const credentialId = new Uint8Array(masterCredentialIdBytes).buffer;
// Request an assertion (login) while evaluating the PRF salt
const assertion = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
allowCredentials: [{ type: "public-key", id: credentialId }],
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
extensions: { prf: { eval: { first: PRF_SALT } } } as any
}
}) as PublicKeyCredential;
// Extract the derived symmetric key from the authenticator's output
const outputs = assertion.getClientExtensionResults();
const prfSecret = (outputs as any).prf?.results?.first;
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
return {
credentialId: masterCredentialIdBytes,
prfSecret: Array.from(new Uint8Array(prfSecret))
};
}
// ─── CASE 2: No masterCredentialIdBytes passed, create a new key (Registration) ──────────
else if (name && displayName) {
// Create the new passkey credential
const credential = await navigator.credentials.create({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: { name: "Bulwark Webmail", id: window.location.hostname },
user: {
id: crypto.getRandomValues(new Uint8Array(16)),
name: name,
displayName: displayName
},
// Supported cryptographic algorithms
pubKeyCredParams: [
{ type: "public-key" as const, alg: -7 }, // ES256 (Recommended)
{ type: "public-key" as const, alg: -257 } // RS256 (Compatibility fallback)
],
authenticatorSelection: {
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
userVerification: "required"
},
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
}
}) as PublicKeyCredential;
const outputs = credential.getClientExtensionResults();
// Ensure the authenticator successfully enabled and supports the PRF extension
const isPrfEnabled = (outputs as any).prf?.enabled;
if (!isPrfEnabled) {
return 'The authenticator does not support or has rejected the PRF extension.';
}
// Note: Since many authenticators do not return the PRF evaluation results
// directly during creation, we immediately run an assertion (get) to fetch the initial secret.
const assertion = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
allowCredentials: [{
type: "public-key",
id: credential.rawId
}],
userVerification: "required",
extensions: {
prf: { eval: { first: PRF_SALT } }
} as any
}
}) as PublicKeyCredential;
const assertionOutputs = assertion.getClientExtensionResults();
const prfSecret = (assertionOutputs as any).prf?.results?.first;
if (!prfSecret) {
return 'Cannot get PRF secret from existing credential.';
}
return {
credentialId: Array.from(new Uint8Array(credential.rawId)),
prfSecret: Array.from(new Uint8Array(prfSecret))
};
}
// ─── CASE 3: Insufficient parameters provided ───────────────────────────
else {
throw new Error("Provide name and display name if you want to create a new PRF.");
}
}
// ─── Uploaded files in IndexedDB (privileged tier) ──────────────────────────
async function getFile(fileID:string): Promise<File | null> {
@@ -287,6 +401,24 @@ async function saveFile(formerFileID:string, file: File): Promise<string> {
return fileId;
}
// ─── Download files generated by the plugin. This is not user's files or attachments ──────────────────────────
async function downloadFile(args: { content: string; filename: string; contentType?: string }): Promise<void> {
const { content, filename, contentType = 'application/json' } = args;
try {
const url = URL.createObjectURL(new Blob([content], { type: contentType }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
throw new Error(`Failed to download file: ${error}`);
}
}
// ─── admin config (same as before) ────────────────────────────
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
@@ -361,6 +493,7 @@ export async function dispatchApiCall(
);
case 'upfiles.get' : return getFile(args[0] as string);
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined);
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
case 'admin.getAllConfig': return adminGetAll(plugin.id);
@@ -433,6 +566,10 @@ export async function dispatchApiCall(
window.open(parsed.toString(), '_blank', 'noopener,noreferrer');
return undefined;
}
case 'ui.downloadFile': {
const opts = args[0] as { content: string; filename: string; contentType?: string };
return downloadFile(opts);
}
default:
throw new Error(`Unhandled method "${method}"`);
+3 -1
View File
@@ -241,10 +241,12 @@ export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged';
export const API_METHODS = [
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
'http.post', 'http.fetch',
'webauthn.getOrCreate',
'jmap.fetchBlob', 'jmap.sendRaw',
'upfiles.get', 'upfiles.save',
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl',
'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl', 'ui.downloadFile'
] as const;
export type ApiMethod = (typeof API_METHODS)[number];
+6
View File
@@ -165,6 +165,9 @@ function buildPluginApi(manifest: PluginManifest) {
version: manifest.version,
settings: { ...manifest.settings },
},
webauthn: {
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
},
storage: {
get: (key: string) => callApi('storage.get', [key]),
set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
@@ -229,6 +232,9 @@ function buildPluginApi(manifest: PluginManifest) {
/** Opens an http/https URL in a new tab via host `window.open`. */
openExternalUrl: (url: string, target?: string) =>
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
/** Downloads a file generated by the plugin. Not a user's file or attachment. */
downloadFile: (opts: { content: string; filename: string; contentType?: string }) =>
callApi('ui.downloadFile', [opts]) as Promise<void>,
},
admin: {
getConfig: (key: string) => callApi('admin.getConfig', [key]),
+1
View File
@@ -925,6 +925,7 @@ export const ALL_PERMISSIONS = [
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
'ui:email-details',
'ui:download-file',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
+4
View File
@@ -949,6 +949,10 @@
"label": "Zobrazit celkový počet zpráv",
"description": "Zobrazí celkový počet zpráv vedle složek a štítků spolu s počtem nepřečtených. Vypněte pro zobrazení pouze nepřečtených."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro rozhraní (experimentální)",
"description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Vis samlet antal beskeder",
"description": "Viser det samlede antal beskeder ud for mapper og tags sammen med antallet af ulæste. Slå fra for kun at vise ulæste."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-grænseflade (eksperimentel)",
"description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Gesamtzahl der Nachrichten anzeigen",
"description": "Zeigt neben Ordnern und Tags die Gesamtzahl der Nachrichten zusätzlich zur Anzahl ungelesener Nachrichten an. Deaktivieren, um nur ungelesene Nachrichten anzuzeigen."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-Oberfläche (experimentell)",
"description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Show Total Message Count",
"description": "Show the total message count next to folders and tags, alongside the unread count. Disable to show only unread counts."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro Interface (Experimental)",
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostrar el número total de mensajes",
"description": "Muestra el número total de mensajes junto a las carpetas y etiquetas, además del número de mensajes no leídos. Desactívalo para mostrar solo los no leídos."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interfaz Pro (experimental)",
"description": "Diseño de escritorio para usuarios avanzados con exploración de mensajes en varias pestañas y flujos de trabajo entre cuentas. La interfaz estándar no se ve afectada; puedes volver en cualquier momento.",
+4
View File
@@ -952,6 +952,10 @@
"label": "نمایش تعداد کل پیام‌ها",
"description": "تعداد کل پیام‌ها را در کنار پوشه‌ها و برچسب‌ها، همراه با تعداد خوانده‌نشده‌ها نمایش می‌دهد. برای نمایش فقط تعداد خوانده‌نشده‌ها غیرفعال کنید."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "رابط حرفه‌ای (آزمایشی)",
"description": "چیدمان قدرت-کاربری فقط دسکتاپ",
+4
View File
@@ -949,6 +949,10 @@
"label": "Afficher le nombre total de messages",
"description": "Affiche le nombre total de messages à côté des dossiers et des étiquettes, en plus du nombre de messages non lus. Désactivez pour n'afficher que les messages non lus."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interface Pro (expérimental)",
"description": "Disposition pour utilisateurs avancés (bureau uniquement) avec navigation des messages multi-onglets et flux de travail multi-comptes. L'interface standard n'est pas affectée ; vous pouvez revenir à tout moment.",
+4
View File
@@ -914,6 +914,10 @@
"label": "הצגת מספר ההודעות הכולל",
"description": "מציג את מספר ההודעות הכולל לצד תיקיות ותגיות, לצד מספר ההודעות שלא נקראו. כבו כדי להציג רק את מספר ההודעות שלא נקראו."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "ממשק Pro (ניסיוני)",
"description": "פריסה של משתמש כוח לשולחן עבודה בלבד עם גלישה מרובת-הודעות וזרימות עבודה חוצות-חשבונות. הממשק הסטנדרטי אינו מושפע; תוכל להחזור בכל עת.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Összes üzenet számának megjelenítése",
"description": "Megjeleníti az üzenetek teljes számát a mappák és címkék mellett, az olvasatlanok számán túl. Kapcsolja ki, ha csak az olvasatlanok számát szeretné látni."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro felület (Kísérleti)",
"description": "Asztali számítógépes erőfelhasználói elrendezés több lapos üzenetböngészéssel és fiókok közötti munkafolyamatokkal. A szabványos felületet nem érinti; bármikor visszaválthatsz.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostra il numero totale di messaggi",
"description": "Mostra il numero totale di messaggi accanto a cartelle ed etichette, insieme al conteggio dei non letti. Disattiva per mostrare solo i non letti."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interfaccia Pro (sperimentale)",
"description": "Layout per utenti esperti solo desktop con esplorazione messaggi a più schede e flussi tra account. L'interfaccia standard non è influenzata; puoi tornare indietro in qualsiasi momento.",
+4
View File
@@ -949,6 +949,10 @@
"label": "メッセージの総数を表示",
"description": "フォルダーやタグの横に、未読数に加えてメッセージの総数を表示します。未読数のみを表示するには無効にします。"
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro インターフェイス(実験的)",
"description": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。",
+4
View File
@@ -949,6 +949,10 @@
"label": "전체 메시지 수 표시",
"description": "읽지 않은 수와 함께 폴더 및 태그 옆에 전체 메시지 수를 표시합니다. 읽지 않은 수만 표시하려면 비활성화하세요."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro 인터페이스 (실험적)",
"description": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Rādīt kopējo ziņojumu skaitu",
"description": "Rāda kopējo ziņojumu skaitu blakus mapēm un tagiem, kā arī nelasīto ziņojumu skaitu. Atspējojiet, lai rādītu tikai nelasītos."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro saskarne (eksperimentāla)",
"description": "Tikai darbvirsmas pieredzējušu lietotāju izkārtojums ar ziņojumu pārlūkošanu vairākās cilnēs un kontu pārvaldību. Standarta saskarne netiek ietekmēta; varat jebkurā brīdī pārslēgties atpakaļ.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Totaal aantal berichten tonen",
"description": "Toont het totale aantal berichten naast mappen en labels, naast het aantal ongelezen berichten. Schakel uit om alleen ongelezen aantallen te tonen."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-interface (experimenteel)",
"description": "Power user-indeling alleen voor desktop met meertabs berichtweergave en accountoverschrijdende workflows. De standaardinterface blijft onveranderd; u kunt op elk moment terugkeren.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Pokaż łączną liczbę wiadomości",
"description": "Pokazuje łączną liczbę wiadomości obok folderów i etykiet, obok liczby nieprzeczytanych. Wyłącz, aby pokazywać tylko nieprzeczytane."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interfejs Pro (eksperymentalny)",
"description": "Układ dla zaawansowanych użytkowników (tylko na komputerze) z przeglądaniem wiadomości w wielu kartach i obiegami pracy między kontami. Standardowy interfejs pozostaje nietknięty; możesz wrócić w dowolnej chwili.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Mostrar contagem total de mensagens",
"description": "Mostra a contagem total de mensagens ao lado de pastas e etiquetas, além da contagem de não lidas. Desative para mostrar apenas as não lidas."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interface Pro (experimental)",
"description": "Layout para utilizadores avançados apenas em desktop com navegação de mensagens em múltiplos separadores e fluxos entre contas. A interface padrão não é afetada; pode voltar a qualquer momento.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Afișează numărul total de mesaje",
"description": "Afișează numărul total de mesaje lângă foldere și etichete, pe lângă numărul celor necitite. Dezactivează pentru a afișa doar mesajele necitite."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Interfață Pro (experimentală)",
"description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Показывать общее количество сообщений",
"description": "Показывает общее количество сообщений рядом с папками и метками, наряду с количеством непрочитанных. Отключите, чтобы показывать только непрочитанные."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-интерфейс (экспериментальный)",
"description": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.",
+4
View File
@@ -952,6 +952,10 @@
"label": "Zobraziť celkový počet správ",
"description": "Zobraziť celkový počet správ vedľa priečinkov a štítkov spolu s počtom neprečítaných."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro rozhranie (experimentálne)",
"description": "Rozloženie pre pokročilých používateľov iba pre stolné počítače.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Toplam mesaj sayısını göster",
"description": "Klasörlerin ve etiketlerin yanında, okunmamış sayısının yanı sıra toplam mesaj sayısını gösterir. Yalnızca okunmamışları göstermek için devre dışı bırakın."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro Arayüz (Deneysel)",
"description": "Yalnızca masaüstü için güçlü kullanıcı düzeni: çoklu sekmede ileti gezme ve hesaplar arası iş akışları. Standart arayüz etkilenmez; istediğiniz zaman geri dönebilirsiniz.",
+4
View File
@@ -949,6 +949,10 @@
"label": "Показувати загальну кількість повідомлень",
"description": "Показує загальну кількість повідомлень поруч із теками та мітками, разом із кількістю непрочитаних. Вимкніть, щоб показувати лише непрочитані."
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro-інтерфейс (експериментальний)",
"description": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.",
+4
View File
@@ -949,6 +949,10 @@
"label": "显示邮件总数",
"description": "在文件夹和标签旁边显示邮件总数,以及未读数量。停用后仅显示未读数量。"
},
"favicon_unread_badge": {
"label": "Unread Count on Tab Icon",
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
},
"pro_interface": {
"label": "Pro 界面(实验性)",
"description": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。",
+1
View File
@@ -28,6 +28,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts",
"test:integration": "bash integration/run-tests.sh",
"prepare": "husky",
"typecheck": "tsc --noEmit"
},
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from '@playwright/test';
/**
* Integration test config: drives the containerised webmail (+ Stalwart) stack
* managed by integration/tests/global-setup.ts. Distinct from the root
* playwright.config.ts (fast UI smoke tests against `npm run dev`).
*
* Run: npx playwright test -c playwright.integration.config.ts
*/
const WEBMAIL_URL = process.env.IT_WEBMAIL_URL ?? 'http://localhost:3000';
export default defineConfig({
testDir: './integration/tests',
// next dev compiles routes lazily and each test logs in fresh, so give
// individual tests and their polling assertions generous headroom.
timeout: 90_000,
expect: { timeout: 20_000 },
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [['list'], ['html', { open: 'never', outputFolder: 'integration/playwright-report' }]],
outputDir: 'integration/test-results',
globalSetup: './integration/tests/global-setup.ts',
globalTeardown: './integration/tests/global-teardown.ts',
use: {
baseURL: WEBMAIL_URL,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
});
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { applyPreferredIdentity, useAuthStore } from '../auth-store';
import { useIdentityStore } from '../identity-store';
import { useAccountStore } from '../account-store';
import { useSettingsStore } from '../settings-store';
import type { Identity } from '@/lib/jmap/types';
const makeIdentity = (overrides: Partial<Identity> = {}): Identity => ({
id: 'id-1',
name: 'Test User',
email: 'test@example.com',
mayDelete: true,
...overrides,
});
const IDS = [
makeIdentity({ id: 'id-1', name: 'Alice', email: 'alice@example.com' }),
makeIdentity({ id: 'id-2', name: 'Bob', email: 'bob@example.com' }),
makeIdentity({ id: 'id-3', name: 'Carol', email: 'carol@example.com' }),
];
/**
* applyPreferredIdentity() is the single mechanism that honours the synced,
* per-account default sender identity (#507). These tests drive the real
* zustand stores directly (as the other auth-store tests do).
*/
describe('applyPreferredIdentity (issue #507)', () => {
beforeEach(() => {
useIdentityStore.setState({ identities: [...IDS], preferredPrimaryId: null });
useAuthStore.setState({ identities: [...IDS], primaryIdentity: IDS[0] });
useAccountStore.setState({ activeAccountId: 'acc-1' });
useSettingsStore.setState({ preferredIdentityIds: {} });
});
it('reorders the active account so the synced preferred identity is primary', () => {
useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-3' } });
applyPreferredIdentity('acc-1');
expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-3', 'id-1', 'id-2']);
expect(useAuthStore.getState().primaryIdentity?.id).toBe('id-3');
expect(useIdentityStore.getState().preferredPrimaryId).toBe('id-3');
});
it('defaults to the active account when no accountId is passed', () => {
useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-2' } });
applyPreferredIdentity();
expect(useAuthStore.getState().identities[0].id).toBe('id-2');
});
it('is a no-op when the target is not the active account', () => {
useSettingsStore.setState({ preferredIdentityIds: { 'acc-2': 'id-3' } });
applyPreferredIdentity('acc-2');
// active account's live ordering must be untouched
expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-1', 'id-2', 'id-3']);
});
it('is a no-op when the account has no synced default and no local fallback', () => {
applyPreferredIdentity('acc-1');
expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-1', 'id-2', 'id-3']);
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({});
});
it('migrates the pre-#507 browser-local default into the synced map, keyed by accountId', () => {
// No synced entry, but a local (identity-storage) preferred primary exists.
useIdentityStore.setState({ preferredPrimaryId: 'id-2' });
applyPreferredIdentity('acc-1');
// adopted, persisted per account, and applied to the live ordering
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acc-1': 'id-2' });
expect(useAuthStore.getState().identities[0].id).toBe('id-2');
});
it('prefers the synced value over the local fallback', () => {
useIdentityStore.setState({ preferredPrimaryId: 'id-2' });
useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-3' } });
applyPreferredIdentity('acc-1');
expect(useAuthStore.getState().identities[0].id).toBe('id-3');
// the synced value is not overwritten by the migration
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acc-1': 'id-3' });
});
});
@@ -0,0 +1,257 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useAuthStore } from '../auth-store';
import { useSettingsStore } from '../settings-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// Sidebar tag badges render from `tagCounts`, which is fetched from the server
// (`fetchTagCounts` -> `client.getTagCounts`) rather than derived from
// `state.emails`. Read/unread mutations therefore have to keep it in step the
// same way they keep `mailboxes[].unreadEmails` in step, or the tag unread
// count (and the bold tag name) stays stale until a full page reload.
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
return {
id: 'inbox',
name: 'Inbox',
role: 'inbox',
sortOrder: 0,
totalEmails: 10,
unreadEmails: 5,
totalThreads: 10,
unreadThreads: 5,
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: { inbox: true },
...overrides,
} as Email;
}
function makeClient() {
return {
markAsRead: vi.fn().mockResolvedValue(undefined),
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
markMailboxAsRead: vi.fn().mockResolvedValue(3),
getTagCounts: vi.fn().mockResolvedValue({}),
getAccountId: vi.fn().mockReturnValue('account-a'),
} as unknown as IJMAPClient;
}
describe('email-store tag counts stay in step with read state', () => {
let client: IJMAPClient;
beforeEach(() => {
client = makeClient();
useAuthStore.setState({
activeAccountId: 'account-a',
getClientForAccount: (() => client) as never,
} as never);
useSettingsStore.setState({
emailKeywords: [
{ id: 'ingsel', label: 'Ingsel', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
],
} as never);
useEmailStore.setState({
isUnifiedView: false,
viewingAccountId: null,
selectedMailbox: 'inbox',
mailboxes: [makeMailbox()],
accountMailboxes: {},
emails: [],
selectedEmail: null,
selectedEmailIds: new Set(),
processingReadStatus: new Set(),
threadEmailsCache: new Map(),
tagCounts: {
ingsel: { total: 1658, unread: 47 },
work: { total: 200, unread: 9 },
},
} as never);
});
it('decrements only the matching tag when a tagged email is marked read', async () => {
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true } })],
} as never);
await useEmailStore.getState().markAsRead(client, 'email-1', true);
expect(useEmailStore.getState().tagCounts).toEqual({
ingsel: { total: 1658, unread: 46 },
work: { total: 200, unread: 9 },
});
});
it('increments the tag again when the email is marked unread', async () => {
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true, $seen: true } })],
} as never);
await useEmailStore.getState().markAsRead(client, 'email-1', false);
expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 1658, unread: 48 });
});
it('updates both tags when an email carries two tags', async () => {
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true, '$label:work': true } })],
} as never);
await useEmailStore.getState().markAsRead(client, 'email-1', true);
expect(useEmailStore.getState().tagCounts).toEqual({
ingsel: { total: 1658, unread: 46 },
work: { total: 200, unread: 8 },
});
});
it('leaves tag counts alone for an untagged email', async () => {
useEmailStore.setState({ emails: [makeEmail({ keywords: {} })] } as never);
await useEmailStore.getState().markAsRead(client, 'email-1', true);
expect(useEmailStore.getState().tagCounts).toEqual({
ingsel: { total: 1658, unread: 47 },
work: { total: 200, unread: 9 },
});
});
it('does not double-decrement when an already-read email is marked read', async () => {
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true, $seen: true } })],
} as never);
await useEmailStore.getState().markAsRead(client, 'email-1', true);
expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 1658, unread: 47 });
});
it('never drives a tag unread count negative', async () => {
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true } })],
tagCounts: { ingsel: { total: 3, unread: 0 } },
} as never);
await useEmailStore.getState().markAsRead(client, 'email-1', true);
expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 3, unread: 0 });
});
it('never alters `total` on a read-state change', async () => {
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true } })],
} as never);
await useEmailStore.getState().markAsRead(client, 'email-1', true);
await useEmailStore.getState().markAsRead(client, 'email-1', false);
expect(useEmailStore.getState().tagCounts.ingsel.total).toBe(1658);
expect(useEmailStore.getState().tagCounts.work.total).toBe(200);
});
describe('batchMarkAsRead', () => {
it('applies the delta once per tag per changed email', async () => {
useEmailStore.setState({
emails: [
makeEmail({ id: 'e1', keywords: { '$label:ingsel': true } }),
makeEmail({ id: 'e2', keywords: { '$label:ingsel': true, '$label:work': true } }),
// Already read: must not contribute a delta.
makeEmail({ id: 'e3', keywords: { '$label:work': true, $seen: true } }),
// Untagged: must not contribute a delta.
makeEmail({ id: 'e4', keywords: {} }),
],
selectedEmailIds: new Set(['e1', 'e2', 'e3', 'e4']),
} as never);
await useEmailStore.getState().batchMarkAsRead(client, true);
expect(useEmailStore.getState().tagCounts).toEqual({
ingsel: { total: 1658, unread: 45 },
work: { total: 200, unread: 8 },
});
});
});
describe('markMailboxAsRead', () => {
it('refetches tag counts from the server rather than applying a local delta', async () => {
(client.getTagCounts as ReturnType<typeof vi.fn>).mockResolvedValue({
ingsel: { total: 1658, unread: 0 },
work: { total: 200, unread: 4 },
});
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true } })],
} as never);
const count = await useEmailStore.getState().markMailboxAsRead(client, 'inbox');
expect(count).toBe(3);
// The server bulk-marks emails that are not in `state.emails`, so a local
// delta would under-count: it has to refetch.
expect(client.getTagCounts).toHaveBeenCalledWith(['ingsel', 'work']);
await vi.waitFor(() => {
expect(useEmailStore.getState().tagCounts).toEqual({
ingsel: { total: 1658, unread: 0 },
work: { total: 200, unread: 4 },
});
});
});
});
describe('setEmailKeywordsLocal', () => {
it('adjusts tag unread counts when the local keyword patch flips $seen', () => {
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true } })],
} as never);
useEmailStore.getState().setEmailKeywordsLocal('email-1', {
'$label:ingsel': true,
$seen: true,
});
expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 1658, unread: 46 });
});
it('leaves tag unread counts alone when $seen is unchanged', () => {
useEmailStore.setState({
emails: [makeEmail({ keywords: { '$label:ingsel': true } })],
} as never);
// Pin toggle: labels/pin change, read state does not.
useEmailStore.getState().setEmailKeywordsLocal('email-1', {
'$label:ingsel': true,
$pinned: true,
});
expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 1658, unread: 47 });
});
});
});
@@ -0,0 +1,22 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useSettingsStore } from '../settings-store';
describe('settings-store favicon unread badge', () => {
beforeEach(() => {
useSettingsStore.getState().resetToDefaults();
});
it('defaults to on', () => {
expect(useSettingsStore.getState().faviconUnreadBadge).toBe(true);
});
it('includes the favicon unread badge in exported settings', () => {
useSettingsStore.getState().updateSetting('faviconUnreadBadge', false);
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
faviconUnreadBadge?: boolean;
};
expect(exported.faviconUnreadBadge).toBe(false);
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSettingsStore } from '../settings-store';
describe('settings-store per-account preferredIdentityIds (issue #507)', () => {
beforeEach(() => {
useSettingsStore.setState({ preferredIdentityIds: {} });
});
it('defaults to an empty record (no account has a synced default)', () => {
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({});
});
it('keeps each account default independent', () => {
useSettingsStore.setState({
preferredIdentityIds: { 'acct-1': 'b', 'acct-2': 'c' },
});
const map = useSettingsStore.getState().preferredIdentityIds;
expect(map['acct-1']).toBe('b');
expect(map['acct-2']).toBe('c');
expect(map['acct-3']).toBeUndefined();
});
it('round-trips through export -> import so the choice survives clearing site data', () => {
useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } });
const json = useSettingsStore.getState().exportSettings();
// Appears in exported JSON (issue #507 acceptance criterion).
expect(JSON.parse(json).preferredIdentityIds).toEqual({ 'acct-1': 'b' });
// Simulate a fresh browser: clear, then import the exported settings.
useSettingsStore.setState({ preferredIdentityIds: {} });
expect(useSettingsStore.getState().importSettings(json)).toBe(true);
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' });
});
describe('importSettings non-record guard', () => {
it('ignores a legacy array shape', () => {
useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } });
const ok = useSettingsStore.getState().importSettings(
JSON.stringify({ preferredIdentityIds: ['b'] }),
);
expect(ok).toBe(true);
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' });
});
it('ignores a null value', () => {
useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } });
useSettingsStore.getState().importSettings(JSON.stringify({ preferredIdentityIds: null }));
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' });
});
it('accepts a proper per-account record', () => {
useSettingsStore.getState().importSettings(
JSON.stringify({ preferredIdentityIds: { 'acct-9': 'a' } }),
);
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-9': 'a' });
});
});
});
+65 -39
View File
@@ -49,7 +49,6 @@ interface AuthState {
clearError: () => void;
syncIdentities: () => void;
refreshIdentities: () => Promise<void>;
applyPreferredIdentityOrdering: () => void;
getClientForAccount: (accountId: string) => JMAPClient | undefined;
getAllConnectedClients: () => Map<string, JMAPClient>;
}
@@ -198,25 +197,16 @@ function sortIdentities(rawIdentities: Identity[], username: string): Identity[]
}
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
const settings = useSettingsStore.getState();
const preferredMap = settings.preferredIdentityIds || {};
let preferredPrimaryId = preferredMap[username] ?? null;
// One-time migration: builds before #507 stored the preferred identity only
// in the browser-local identity-storage (never synced). If the synced
// settings have no entry for this account yet, adopt that legacy local value
// and write it into the synced settings so it persists across devices.
if (preferredPrimaryId == null) {
const legacy = useIdentityStore.getState().preferredPrimaryId;
if (legacy) {
preferredPrimaryId = legacy;
settings.updateSetting('preferredIdentityIds', { ...preferredMap, [username]: legacy });
}
}
// The synced per-account default sender identity (#507) is keyed by
// AccountEntry.id and re-applied by applyPreferredIdentity() once
// loadFromServer resolves (the accountId isn't known here). At load time we
// only honour the browser-local fallback (identity-storage) so the ordering
// is stable before - or entirely without - settings sync.
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
const identities = sortIdentities(rawIdentities, username);
// If user has a preferred primary, move it to front
// If a local preferred primary is set, move it to the front.
if (preferredPrimaryId) {
const idx = identities.findIndex((id) => id.id === preferredPrimaryId);
if (idx > 0) {
@@ -227,12 +217,60 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti
const primaryIdentity = identities[0] ?? null;
useIdentityStore.getState().setIdentities(identities);
// Mirror the resolved choice into the identity store so the identity-manager
// UI (the ⭐ marker) reflects the active account's preferred identity.
useIdentityStore.setState({ preferredPrimaryId });
return { identities, primaryIdentity };
}
/**
* Re-apply the per-account default sender identity once synced settings are
* available (issue #507). The choice is stored server-side in the settings
* store (`preferredIdentityIds`, keyed by AccountEntry.id), so it can only be
* applied after `loadFromServer` resolves. It reorders the account's identities
* so the preferred one is primary - the composer defaults its `From` to
* identities[0]. No-op when nothing is configured for the account.
*
* Also performs the one-time migration of the pre-#507 browser-local default
* (identity-storage) into the synced per-account map, keyed by accountId.
*
* @param accountId The account to apply for; defaults to the active account.
*/
export function applyPreferredIdentity(accountId?: string | null): void {
const targetId = accountId ?? useAccountStore.getState().activeAccountId;
if (!targetId) return;
const idStore = useIdentityStore.getState();
// Only touch the live identity store when it currently holds this account's
// identities (true for the active account). Switching snapshots/restores the
// ordering per account, so a background account's order is restored later.
// The local fallback below also belongs to the active account, so gate first.
if (useAccountStore.getState().activeAccountId !== targetId) return;
let preferred = useSettingsStore.getState().preferredIdentityIds[targetId] ?? null;
// One-time migration: before #507 the default lived only in the browser-local
// identity-storage (never synced). If the synced map has no entry for this
// account yet, adopt that local value and persist it (keyed by accountId) so
// it follows the user across devices.
if (!preferred) {
const legacy = idStore.preferredPrimaryId;
if (legacy) {
preferred = legacy;
const current = useSettingsStore.getState().preferredIdentityIds;
useSettingsStore.getState().updateSetting('preferredIdentityIds', { ...current, [targetId]: legacy });
}
}
if (!preferred) return;
idStore.setPreferredPrimary(preferred);
const ids = [...idStore.identities];
const idx = ids.findIndex((i) => i.id === preferred);
if (idx > 0) {
const [p] = ids.splice(idx, 1);
ids.unshift(p);
idStore.setIdentities(ids);
}
useAuthStore.setState({ identities: ids, primaryIdentity: ids[0] ?? null });
}
function getLocaleLoginPath(): string {
if (typeof window === 'undefined') return '/en/login';
@@ -638,6 +676,7 @@ export const useAuthStore = create<AuthState>()(
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
applyPreferredIdentity(accountId);
});
}).catch(() => {});
@@ -840,6 +879,7 @@ export const useAuthStore = create<AuthState>()(
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
applyPreferredIdentity(accountId);
});
}).catch(() => {});
@@ -977,6 +1017,7 @@ export const useAuthStore = create<AuthState>()(
if (!cfg.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => {
useSettingsStore.getState().enableSync(username, ssoServerUrl);
applyPreferredIdentity(accountId);
});
}).catch(() => {});
@@ -1363,6 +1404,7 @@ export const useAuthStore = create<AuthState>()(
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
applyPreferredIdentity(targetAccount.id);
});
}).catch(() => {});
},
@@ -1534,6 +1576,7 @@ export const useAuthStore = create<AuthState>()(
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
applyPreferredIdentity(targetAccount.id);
});
}).catch(() => {});
return;
@@ -1647,6 +1690,7 @@ export const useAuthStore = create<AuthState>()(
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
useSettingsStore.getState().enableSync(state.username || '', state.serverUrl!);
applyPreferredIdentity(accountId);
});
}).catch(() => {});
return;
@@ -1718,6 +1762,7 @@ export const useAuthStore = create<AuthState>()(
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
useSettingsStore.getState().enableSync(username, serverUrl);
applyPreferredIdentity(accountId);
});
}).catch(() => {});
return;
@@ -1761,25 +1806,6 @@ export const useAuthStore = create<AuthState>()(
set({ identities, primaryIdentity });
},
// Re-sort the already-loaded identities to honor the active account's
// synced preferred-primary identity, without a network round-trip. Used
// after settings load from the server so a fresh browser reflects the
// synced default (#507).
applyPreferredIdentityOrdering: () => {
const { username, identities } = get();
if (!username || identities.length === 0) return;
const preferredId = useSettingsStore.getState().preferredIdentityIds?.[username] ?? null;
useIdentityStore.setState({ preferredPrimaryId: preferredId });
if (!preferredId) return;
const idx = identities.findIndex((id) => id.id === preferredId);
if (idx <= 0) return; // already first, or not present
const reordered = [...identities];
const [preferred] = reordered.splice(idx, 1);
reordered.unshift(preferred);
useIdentityStore.getState().setIdentities(reordered);
set({ identities: reordered, primaryIdentity: reordered[0] ?? null });
},
refreshIdentities: async () => {
const { client, username } = get();
if (!client || !username) return;
+171 -89
View File
@@ -584,6 +584,37 @@ function applyBatchMailboxCounterUpdate(
return { mailboxes, accountMailboxes };
}
// Sidebar tag badges render from `tagCounts`, which is *fetched from the server*
// (`fetchTagCounts` -> `getTagCounts`) rather than derived from `state.emails`.
// So a read/unread mutation has to keep it in step locally, exactly as it does
// for `mailboxes[].unreadEmails` - otherwise the tag unread count (and the bold
// tag name) stays stale until a full page reload.
//
// `changes` carries one entry per email whose read state *actually changed*
// (callers already compute that), with delta -1 when it became read and +1 when
// it became unread. Only `unread` moves: read state never changes tag
// membership, so `total` is left alone.
function applyTagCountReadDelta(
tagCounts: Record<string, { total: number; unread: number }>,
changes: Array<{ keywords?: Record<string, boolean>; delta: number }>,
): Record<string, { total: number; unread: number }> {
const keywordIds = useSettingsStore.getState().emailKeywords.map(k => k.id);
if (keywordIds.length === 0) return tagCounts;
let next: Record<string, { total: number; unread: number }> | null = null;
for (const { keywords, delta } of changes) {
if (!keywords || delta === 0) continue;
for (const id of keywordIds) {
if (!keywords[`$label:${id}`]) continue;
const current = (next ?? tagCounts)[id];
if (!current) continue; // Tag not in the fetched counts yet; nothing to adjust.
next = next ?? { ...tagCounts };
next[id] = { total: current.total, unread: Math.max(0, current.unread + delta) };
}
}
return next ?? tagCounts;
}
// Per-mailbox counter map (for applyBatchMailboxCounterUpdate) for removing a
// group of emails from a folder: decrement total (and unread for unseen) for
// each group email that lives in the mailbox.
@@ -918,8 +949,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
for (const email of result.emails) {
email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes);
}
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
@@ -946,8 +978,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// When filtering by tag, omit the mailbox constraint so emails across
// all folders that carry the tag are returned.
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter, true);
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
// Clear thread caches since the email list was fully replaced
@@ -991,8 +1024,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
set({
emails: [...currentEmails, ...newEmails],
emails: [...currentEmails, ...enrichedNewEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
@@ -1034,8 +1068,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
set({
emails: [...currentEmails, ...newEmails],
emails: [...currentEmails, ...enrichedNewEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
@@ -1127,14 +1162,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id));
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
set({
emails: [...currentEmails, ...newEmails],
emails: [...currentEmails, ...enrichedNewEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false
});
// Fetch full thread counts for newly loaded threads in the background
if (newEmails.length > 0) {
if (enrichedNewEmails.length > 0) {
void get().fetchThreadEmailCounts(client);
}
} catch (error) {
@@ -1416,6 +1452,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
: state.selectedEmail,
...mailboxPatch,
// Same delta, applied to every tag this email carries, so the sidebar
// tag badges track the folder counters instead of going stale.
tagCounts: applyTagCountReadDelta(state.tagCounts, [
{ keywords: emailInState.keywords, delta },
]),
processingReadStatus: newProcessingSet,
// Also update threadEmailsCache so expanded dropdowns reflect the change
threadEmailsCache: (() => {
@@ -1754,60 +1795,66 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try {
const { isUnifiedView, unifiedRole, crossView } = get();
const { isUnifiedView, unifiedRole, crossView, selectedMailbox, searchFilters } = get();
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
let result;
let accountId;
let unifiedErrors;
if (isUnifiedView && crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
if (isUnifiedView && unifiedRole) {
result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0);
unifiedErrors = result.errors;
} else if (isUnifiedView && unifiedRole) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
unifiedErrors = result.errors;
} else {
// Get the current mailbox to scope the search. In the All Mail view the
// search spans every folder of the account (no inMailbox constraint).
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
}
// Get the current mailbox to scope the search. In the All Mail view the
// search spans every folder of the account (no inMailbox constraint).
const selectedMailbox = get().selectedMailbox;
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
const hookEdit = await emailHooks.onSearchResults.transform({
newEmailIds: [] as string[],
result: result,
query: query,
filters: searchFilters
});
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
result = hookEdit.result;
if (hookEdit.newEmailIds.length > 0) {
// in unified, accountId will be undefined and we will use the default.
const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId);
result.emails.push(...newEmails);
result.total += newEmails.length;
}
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
query,
filters: searchFilters
});
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
isLoading: false,
...(unifiedErrors ? { unifiedErrors } : {})
});
} catch (error) {
set({
@@ -1841,60 +1888,64 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
let result;
let accountId;
let unifiedErrors;
if (isUnifiedView && crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0);
unifiedErrors = result.errors;
if (isUnifiedView && unifiedRole) {
} else if (isUnifiedView && unifiedRole) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = await advancedSearchUnifiedEmails(
result = await advancedSearchUnifiedEmails(
built,
unifiedRole,
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
emailsPerPage,
0,
);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
searchAbortController: null,
unifiedErrors: result.errors,
});
return;
unifiedErrors = result.errors;
} else {
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
}
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
const hookEdit = await emailHooks.onSearchResults.transform({
newEmailIds: [] as string[],
result: result,
query: searchQuery,
filters: searchFilters
});
result = hookEdit.result;
if (hookEdit.newEmailIds.length > 0) {
const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId);
result.emails.push(...newEmails);
result.total += newEmails.length;
}
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
query: searchQuery,
filters: searchFilters
});
if (controller.signal.aborted) return;
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals,
@@ -1902,6 +1953,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
totalEmails: result.total,
isLoading: false,
searchAbortController: null,
...(unifiedErrors ? { unifiedErrors } : {})
});
} catch (error) {
if (controller.signal.aborted) return;
@@ -1960,14 +2012,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
setEmailKeywordsLocal: (emailId, keywords) => {
set((state) => ({
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...keywords } }
: state.selectedEmail,
}));
set((state) => {
// This patch replaces the whole keyword map, so it can flip $seen as well
// as labels. Only a genuine read-state change moves the tag unread counts.
const previous = state.emails.find(e => e.id === emailId) ?? state.selectedEmail;
const wasRead = previous?.keywords?.$seen ?? false;
const isRead = keywords.$seen ?? false;
const delta = wasRead === isRead ? 0 : (isRead ? -1 : 1);
return {
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...keywords } }
: state.selectedEmail,
tagCounts: applyTagCountReadDelta(state.tagCounts, [{ keywords, delta }]),
};
});
},
// Batch operations
@@ -2025,9 +2087,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
};
});
// Tag badges follow the same delta as the folder counters, counting only
// the emails whose read state actually changed.
const tagCounts = applyTagCountReadDelta(
get().tagCounts,
affectedEmails
.filter(email => (email.keywords?.$seen ?? false) !== read)
.map(email => ({ keywords: email.keywords, delta: read ? -1 : 1 })),
);
set({
emails: updatedEmails,
...mailboxPatch,
tagCounts,
selectedEmailIds: new Set(),
isLoading: false
});
@@ -3142,6 +3214,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
),
}));
// Tag counts are refetched here rather than adjusted with a local delta
// (as markAsRead/batchMarkAsRead do). This is a server-side bulk operation
// over the *whole* mailbox, so it also marks emails that were never loaded
// into `state.emails` - a local delta would only see the loaded page and
// would leave the tag counts drifting high. Fire-and-forget: the folder
// counters above already update instantly.
void get().fetchTagCounts(client);
return count;
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to mark folder as read' });
@@ -3284,6 +3364,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getScheduledEmails(emailsPerPage, 0);
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
const scheduledEmailIds = new Set(result.emails.map(email => email.id));
const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, {
submissionId: email.emailSubmissionId,
@@ -3324,6 +3405,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition);
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))];
const pendingUndoSend = get().pendingUndoSend;
set({
+8 -1
View File
@@ -123,7 +123,14 @@ export const useIdentityStore = create<IdentityStore>()(
}),
{
name: 'identity-storage',
// Only persist sub-addressing data, not identities (they're server-side)
// Only persist sub-addressing data, not identities (they're server-side).
// The default sender identity (`preferredPrimaryId`) is the per-account
// value for the *active* account; it is kept here purely as a local
// fallback so the choice survives a reload when settings sync is off.
// The durable, cross-device, exportable source of truth is the synced
// settings store, keyed per account (`preferredIdentityIds`), which is
// re-applied via applyPreferredIdentity() once server settings load and
// overrides this value per account (issue #507).
partialize: (state) => ({
subAddress: state.subAddress,
preferredPrimaryId: state.preferredPrimaryId,
+23 -22
View File
@@ -178,13 +178,6 @@ interface SettingsState {
requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer
readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests
// Identities
// Per-account default ("preferred primary") sender identity, keyed by
// username (the same key settings sync uses). A JMAP identity id is only
// meaningful within its own account, so this must be account-scoped. Synced
// so the choice survives a new browser / cleared site data (#507).
preferredIdentityIds: Record<string, string | null>;
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
trustedSenders: string[]; // Email addresses that can load external content
@@ -258,11 +251,18 @@ interface SettingsState {
// explicit [] = "no folders". (Replaced the legacy global string[] | null.)
allMailFolderIds: Record<string, string[]>;
// Per-account default sender identity, keyed by AccountEntry.id -> JMAP
// Identity id. Synced (and exported) so the chosen default survives clearing
// site data and follows the user across browsers/devices (issue #507). Kept
// per account because JMAP identity ids are account-scoped and would collide.
preferredIdentityIds: Record<string, string>;
// Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
senderFavicons: boolean;
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
faviconUnreadBadge: boolean; // Badge the browser-tab icon with the inbox unread count
// Sidebar
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
@@ -396,9 +396,6 @@ const DEFAULT_SETTINGS = {
requestReadReceiptDefault: false,
readReceiptResponse: 'ask' as ReadReceiptResponse,
// Identities
preferredIdentityIds: {} as Record<string, string | null>,
// Privacy & Security
sessionTimeout: 0, // Never
trustedSenders: [] as string[],
@@ -452,6 +449,7 @@ const DEFAULT_SETTINGS = {
// All Mail view (gated)
enableAllMailView: false,
allMailFolderIds: {} as Record<string, string[]>,
preferredIdentityIds: {} as Record<string, string>,
enableCrossUnreadView: false,
enableCrossStarredView: false,
@@ -462,6 +460,7 @@ const DEFAULT_SETTINGS = {
senderFavicons: true,
showAvatarsInJunk: false,
faviconUnreadBadge: true,
// Sidebar
colorfulSidebarIcons: true,
@@ -609,7 +608,6 @@ export const useSettingsStore = create<SettingsState>()(
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
requestReadReceiptDefault: state.requestReadReceiptDefault,
readReceiptResponse: state.readReceiptResponse,
preferredIdentityIds: state.preferredIdentityIds,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,
@@ -637,11 +635,13 @@ export const useSettingsStore = create<SettingsState>()(
includeGroupInUnified: state.includeGroupInUnified,
enableAllMailView: state.enableAllMailView,
allMailFolderIds: state.allMailFolderIds,
preferredIdentityIds: state.preferredIdentityIds,
enableCrossUnreadView: state.enableCrossUnreadView,
enableCrossStarredView: state.enableCrossStarredView,
enableCrossAllView: state.enableCrossAllView,
senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk,
faviconUnreadBadge: state.faviconUnreadBadge,
colorfulSidebarIcons: state.colorfulSidebarIcons,
tintListRowsByTag: state.tintListRowsByTag,
showFolderTotalCount: state.showFolderTotalCount,
@@ -694,8 +694,8 @@ export const useSettingsStore = create<SettingsState>()(
if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) {
return;
}
// Defensive: a non-record (e.g. a legacy scalar) would break the
// per-account map lookups - ignore it.
// Per-account map (accountId -> identityId); ignore any legacy
// global/non-record value rather than corrupting the map.
if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) {
return;
}
@@ -877,14 +877,10 @@ export const useSettingsStore = create<SettingsState>()(
get().importSettings(JSON.stringify(settings));
isLoadingFromServer = false;
syncLog('Settings loaded from server successfully');
// Re-apply the (possibly server-updated) per-account preferred
// sender identity to the already-loaded identities, so a fresh
// browser reflects the synced default without waiting for the next
// identity refresh. Dynamic import avoids a static import cycle
// (auth-store imports this store). (#507)
import('./auth-store')
.then(({ useAuthStore }) => useAuthStore.getState().applyPreferredIdentityOrdering())
.catch(() => {});
// The per-account preferred sender identity (#507) is re-applied by
// applyPreferredIdentity() in auth-store, invoked from the
// loadFromServer().finally() of every login / switch / restore path,
// so no extra hook is needed here.
return true;
}
return false;
@@ -897,7 +893,7 @@ export const useSettingsStore = create<SettingsState>()(
}),
{
name: 'settings-storage',
version: 5,
version: 6,
migrate: (persisted, version) => {
const state = persisted as Record<string, unknown>;
if (version < 2 && state.listDensity) {
@@ -925,6 +921,11 @@ export const useSettingsStore = create<SettingsState>()(
if (version < 5 || !isPlainRecord(state.allMailFolderIds)) {
state.allMailFolderIds = {};
}
// v6: introduced the per-account default-identity map (issue #507).
// Coerce any missing/legacy value to an empty record.
if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) {
state.preferredIdentityIds = {};
}
return state as unknown as SettingsState;
},
onRehydrateStorage: () => {
+3 -1
View File
@@ -37,6 +37,8 @@
],
"exclude": [
"node_modules",
"repos"
"repos",
"examples",
"integration"
]
}