# Conflicts:
#	app/api/dev-jmap/[...path]/route.ts
This commit is contained in:
Linus Rath
2026-07-30 19:17:43 +02:00
71 changed files with 3682 additions and 1551 deletions
@@ -1,154 +0,0 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EmailListItem } from '../email-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import type { Email } from '@/lib/jmap/types';
// Mock the drag hook
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
// Mock identity badge
vi.mock('../email-identity-badge', () => ({
EmailIdentityBadge: () => null,
}));
// Mock auth store
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
describe('EmailListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show tag badge when email has no label keyword', () => {
const email = makeEmail({ keywords: { $seen: true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
// No keyword label should appear
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows tag badge with label when email has $label: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows tag badge for legacy $color: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('shows a gray fallback badge when keyword id is not in settings', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
render(<EmailListItem email={email} />);
// Unknown tags fall back to the raw id as label with a gray dot
// (see email-list-item.tsx: keywordDefs fallback).
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
});
it('shows custom keyword label', () => {
useSettingsStore.setState({
emailKeywords: [
...DEFAULT_KEYWORDS,
{ id: 'work', label: 'Work', color: 'teal' },
],
});
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('updates badge when keyword definition changes', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
// Update label name
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
rerender(<EmailListItem email={email} />);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
it('renders subject even without tag', () => {
const email = makeEmail({ subject: 'Hello World' });
render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders inline preview text in focused mail layout', () => {
useSettingsStore.setState({
showPreview: true,
mailLayout: 'focus',
});
const email = makeEmail({ preview: 'Inline preview content' });
const { container } = render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
expect(container.querySelector('p')).toBeNull();
});
});
describe('EmailListItem shift-range checkbox', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
});
it('shift-clicking the checkbox extends the selection from the anchor', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
// selection mode active (so the checkbox renders), anchor on e1
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedMailbox: 'inbox',
});
render(<EmailListItem email={e3} />);
// the checkbox is the first button in the row (shown in selection mode)
const checkbox = screen.getAllByRole('button')[0];
act(() => {
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const sel = useEmailStore.getState().selectedEmailIds;
expect(sel.has('e1')).toBe(true);
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
expect(sel.has('e3')).toBe(true);
});
});
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TagBadge } from '../tag-badge';
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
];
describe('TagBadge', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
it('names the tag by its full path', () => {
render(<TagBadge tagId="work/clients" variant="badge" />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
});
it('names a tag it has no definition for by its id', () => {
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
});
it('offers removal only when asked to', () => {
const onRemove = vi.fn();
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
expect(onRemove).toHaveBeenCalledOnce();
});
it('leaves the dot alone, having nowhere to put the control', () => {
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByLabelText('Work')).toBeInTheDocument();
});
});
@@ -0,0 +1,117 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TagPicker } from '../tag-picker';
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
{ id: 'work/clients/acme', label: 'Acme', color: 'red' },
{ id: 'personal', label: 'Personal', color: 'purple' },
];
/** Ten tags is the point at which the filter box appears. */
const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({
id: `tag-${i}`,
label: i === 0 ? 'Invoices' : `Tag ${i}`,
color: 'blue',
}));
describe('TagPicker', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
it('names a nested tag by its own label, not the whole path', () => {
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
// The tree conveys the hierarchy, so a child needs only its own name.
expect(screen.getByText('Clients')).toBeInTheDocument();
expect(screen.getByText('Acme')).toBeInTheDocument();
expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument();
});
it('indents each level below its parent', () => {
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
const acme = screen.getByText('Acme');
// Two levels down: two nested indent wrappers between it and the list.
const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4');
expect(indents).not.toBeNull();
expect(container.querySelectorAll('.ps-4').length).toBe(2);
});
it('marks the applied tags and reports toggles by id', () => {
const onToggle = vi.fn();
render(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
const row = screen.getByText('Clients').closest('button')!;
expect(row).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false');
fireEvent.click(row);
expect(onToggle).toHaveBeenCalledWith('work/clients');
});
it('lists a tag it has no definition for, so it can be taken off', () => {
const onToggle = vi.fn();
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
const row = screen.getByText('from-elsewhere').closest('button')!;
expect(row).toHaveAttribute('aria-checked', 'true');
fireEvent.click(row);
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
// Nothing but the message says it exists, so deselecting is the last of it.
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
});
it('counts undefined tags towards the filter box, and matches them', () => {
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
expect(within(container).getByText('stray-3')).toBeInTheDocument();
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
});
it('hides the filter box until the list is long enough to need one', () => {
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0);
});
it('flattens to matches while filtering, and says so when there are none', () => {
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } });
expect(within(container).getByText('Invoices')).toBeInTheDocument();
expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } });
expect(within(container).getByText('tag_no_matches')).toBeInTheDocument();
});
it('matches the full path, so a child is reachable by its parent name', () => {
useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } });
// Filtered rows are flat, so they carry the whole path.
expect(within(container).getByText('Work/Clients')).toBeInTheDocument();
});
it('lists tags flat when nesting is off', () => {
useSettingsStore.setState({ nestedTags: false });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(container.querySelectorAll('.ps-4').length).toBe(0);
expect(screen.getByText('Clients')).toBeInTheDocument();
});
});
@@ -0,0 +1,290 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThreadListItem } from '../thread-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { groupEmailsByThread } from '@/lib/thread-utils';
import type { Email } from '@/lib/jmap/types';
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
/**
* A one-message thread, built through the real grouping so the fixture cannot
* drift from what the list actually feeds this component. `ThreadListItem`
* delegates to `SingleEmailItem` at that size, which is what draws every
* single-message row in the app.
*/
function renderRow(email: Email) {
const [thread] = groupEmailsByThread([email]);
return render(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
describe('ThreadListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show a tag badge when the email has no label keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true } }));
expect(screen.getByText('Test Subject')).toBeInTheDocument();
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows a tag badge for a $label: keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows a tag badge for the legacy $color: keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('falls back to the raw id when the tag is not in settings', () => {
// A keyword created by another client, or one whose definition was deleted.
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
});
it('shows a custom tag label', () => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
});
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('follows a renamed tag definition', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = renderRow(email);
expect(screen.getByText('Red')).toBeInTheDocument();
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
const [thread] = groupEmailsByThread([email]);
rerender(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
});
describe('ThreadListItem multi-message thread', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
function renderThread(emails: Email[], expanded = false) {
const [thread] = groupEmailsByThread(emails);
return render(
<ThreadListItem
thread={thread}
isExpanded={expanded}
expandedEmails={expanded ? emails : undefined}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
it('carries the tags of every message, not just the first', () => {
// A collapsed row stands in for the whole thread, so a tag applied only to
// a later message still has to surface.
renderThread([
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
]);
expect(screen.getByText('Red')).toBeInTheDocument();
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('names a tag shared by several messages once', () => {
renderThread([
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
]);
expect(screen.getAllByText('Red')).toHaveLength(1);
});
it('shows each message its own tags once the thread is expanded', () => {
renderThread(
[
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
],
true,
);
// Once on the header and once on the message that carries it.
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
});
});
describe('ThreadListItem row content', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('renders the subject without a tag', () => {
renderRow(makeEmail({ subject: 'Hello World' }));
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders preview text inline in the focused layout', () => {
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
// Focused rows are one line: the preview shares the subject's element
// rather than getting a paragraph of its own.
expect(container.querySelector('p')).toBeNull();
});
});
describe('ThreadListItem shift-range checkbox', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
});
it('shift-clicking the checkbox extends the selection from the anchor', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
// Selection mode active so the checkbox renders, with the anchor on e1.
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedMailbox: 'inbox',
});
renderRow(e3);
const checkbox = screen.getAllByRole('button')[0];
act(() => {
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const selected = useEmailStore.getState().selectedEmailIds;
expect(selected.has('e1')).toBe(true);
expect(selected.has('e2')).toBe(true); // the row in between got filled in
expect(selected.has('e3')).toBe(true);
});
});
describe('ThreadListItem row tint', () => {
const rowClasses = (container: HTMLElement) =>
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
tintListRowsByTag: true,
});
useEmailStore.setState({
selectedEmailIds: new Set(['email-1']),
selectedMailbox: 'inbox',
});
});
it('keeps a checked row tinted, and says so to either theme', () => {
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
const classes = rowClasses(container);
expect(classes).toContain('bg-red-50');
expect(classes).toContain('dark:bg-red-950/30');
expect(classes).not.toContain('bg-accent/40');
expect(classes).toContain('ring-primary/20');
});
it('washes a checked row that has no tint to keep', () => {
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
const classes = rowClasses(container);
expect(classes).toContain('bg-accent/40');
expect(classes).toContain('ring-primary/20');
});
it('leaves the tint alone when the setting is off', () => {
useSettingsStore.setState({ tintListRowsByTag: false });
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
const classes = rowClasses(container);
expect(classes).not.toContain('bg-red-50');
expect(classes).toContain('bg-accent/40');
});
});