Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -79,6 +79,8 @@ export default [
|
||||
"e2e/**",
|
||||
"local-data/**/*.mjs",
|
||||
"benchmark/**",
|
||||
"examples/**",
|
||||
"integration/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
Executable
+36
@@ -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"}
|
||||
@@ -0,0 +1,2 @@
|
||||
prepare-stalwart-cli.sh
|
||||
README.md
|
||||
@@ -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"]
|
||||
Executable
+154
@@ -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}}}
|
||||
Executable
+48
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 member’s 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"features": {
|
||||
"unifiedCrossAccountEnabled": true,
|
||||
"crossUnreadViewEnabled": true,
|
||||
"crossAllViewEnabled": true
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "چیدمان قدرت-کاربری فقط دسکتاپ",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "פריסה של משתמש כוח לשולחן עבודה בלבד עם גלישה מרובת-הודעות וזרימות עבודה חוצות-חשבונות. הממשק הסטנדרטי אינו מושפע; תוכל להחזור בכל עת.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。",
|
||||
|
||||
@@ -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": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.",
|
||||
|
||||
@@ -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ļ.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.",
|
||||
|
||||
@@ -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": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -37,6 +37,8 @@
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"repos"
|
||||
"repos",
|
||||
"examples",
|
||||
"integration"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user