Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
- Multi-select for batch archive, delete, move, and tag
- Archive directly, by year, or by month
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
- Each tag can be configured to show always, only when there are unread mails or always be hidden
- Star or unstar, with a configurable mark-as-read delay
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
Expand Down
35 changes: 20 additions & 15 deletions app/(main)/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import {
ErrorBoundary,
SidebarErrorFallback,
Expand Down Expand Up @@ -1833,7 +1834,7 @@ export default function Home() {
keywords['$pinned'] = true;
}

// Same unified-view routing as color tags: write to the email's own
// Same unified-view routing as tags: write to the email's own
// account via the login it is reachable through. (#281)
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
Expand All @@ -1857,31 +1858,35 @@ export default function Home() {
}
};

const handleSetColorTag = async (emailId: string, color: string | null) => {
const handleSetTag = async (emailId: string, tagId: string | null) => {
if (!client) return;

try {
// Remove any existing label/color tags
// Remove any existing tag keywords
const email = emails.find(e => e.id === emailId);
if (!email) return;

const keywords = { ...email.keywords };

if (color === null) {
// Remove all label/color tags
if (tagId === null) {
// Remove all tag keywords
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
keywords[key] = false;
}
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey]) {
// Toggle off if already active
keywords[jmapKey] = false;
// Both prefixes name the same tag when read, so taking one off has to
// clear whichever spellings are actually set.
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
.filter(key => keywords[key]);
if (activeKeys.length > 0) {
activeKeys.forEach(key => {
keywords[key] = false;
});
} else {
// Add the tag without disturbing others
keywords[jmapKey] = true;
keywords[KEYWORD_PREFIX + tagId] = true;
}
}

Expand All @@ -1907,7 +1912,7 @@ export default function Home() {
// Refresh tag counts
fetchTagCounts(client);
} catch (error) {
console.error("Failed to set color tag:", error);
console.error("Failed to set tag:", error);
}
};

Expand Down Expand Up @@ -3309,8 +3314,8 @@ export default function Home() {
onArchive={async (email) => {
await handleArchive(email);
}}
onSetColorTag={(emailId, color) => {
handleSetColorTag(emailId, color);
onSetTag={(emailId, color) => {
handleSetTag(emailId, color);
}}
onMoveToMailbox={async (emailId, mailboxId) => {
if (client) {
Expand Down Expand Up @@ -3534,7 +3539,7 @@ export default function Home() {
}}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onSetTag={handleSetTag}
onMarkAsSpam={() => handleMarkAsSpam()}
onUndoSpam={() => handleUndoSpam()}
onMarkAsRead={async (emailId, read) => {
Expand Down
12 changes: 6 additions & 6 deletions app/api/dev-jmap/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work/clients/acme': true }, size: 5100, receivedAt: daysAgo(1),
from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }],
Expand Down Expand Up @@ -152,7 +152,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0),
id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:work/clients': true, '$label:receipts': true }, size: 6200, receivedAt: daysAgo(0),
from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: '[bulwark-webmail] New issue: Add dark mode toggle (#42)',
Expand Down Expand Up @@ -181,7 +181,7 @@ const emails: MockEmail[] = [
},
// Newsletter with full HTML
{
id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0),
id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal/finance': true }, size: 18200, receivedAt: daysAgo(0),
from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Launchpad Weekly #47 - The future of the open web',
Expand Down Expand Up @@ -228,7 +228,7 @@ const emails: MockEmail[] = [
],
},
{
id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3),
id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal': true }, size: 4100, receivedAt: hoursAgo(3),
from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Code review request: JMAP-342 contact import',
Expand All @@ -255,7 +255,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1),
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work': true }, size: 4700, receivedAt: daysAgo(1),
from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Your Hetzner invoice is available - February 2026',
Expand Down Expand Up @@ -355,7 +355,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6),
id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$color:work/archived': true }, size: 4100, receivedAt: daysAgo(6),
from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Action required: API v2023-10 deprecation on April 15, 2026',
Expand Down
154 changes: 0 additions & 154 deletions components/email/__tests__/email-list-item.test.tsx

This file was deleted.

41 changes: 41 additions & 0 deletions components/email/__tests__/tag-badge.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading