# 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
+2
View File
@@ -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
+2
View File
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
<TourProvider>
<ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
<ImpersonationReconciler />
{children}
<PluginDialogHost />
<PluginConsentDialog />
+107 -17
View File
@@ -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,
@@ -78,6 +79,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from
import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { getEffectiveLocale } from '@/i18n/detect-locale';
import type { QuoteHeader } from "@/lib/plugin-types";
@@ -785,7 +787,15 @@ export default function Home() {
// This makes the Pro composer behave like Thunderbird's pop-out window.
useEffect(() => {
if (!isEmbedded || !showComposer) return;
const replyTo = selectedEmail ? {
// pendingDraft.replyTo, when set, was built by the opener (e.g.
// handleForwardAsAttachment) with intent that must survive the hop into
// the Pro tab - mirrors the same precedence the non-embedded render path
// uses just below (`replyTo={pendingDraft !== null ? pendingDraft.replyTo
// : ...}`). Building fresh from selectedEmail unconditionally here would
// silently drop that intent (e.g. the synthetic message/rfc822
// attachment "Forward as attachment" stages), falling back to a normal
// quoted forward instead.
const replyTo = pendingDraft?.replyTo ?? (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
@@ -801,7 +811,7 @@ export default function Home() {
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
} : undefined;
} : undefined);
const effectiveMode = pendingDraft?.mode ?? composerMode;
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
@@ -1540,6 +1550,77 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
// Forward the original message as a message/rfc822 attachment instead of
// inline-quoted text - e.g. for reporting spam to an upstream gateway
// that expects the raw original as an attachment, or preserving exact
// formatting/headers the recipient needs to see untouched. Reuses the
// same attachment-carry-forward mechanism native Forward already uses
// for a forwarded message's own attachments (see the `attachments`
// useState initializer in email-composer.tsx) - we just add one more
// synthetic entry representing the whole original message, referenced
// by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are
// account-scoped, not per-email). Skips prepareComposerQuoteHeader
// entirely, so the body starts blank instead of quoting the original.
// Takes an explicit `email` (defaulting to selectedEmail), same pattern
// handleDelete uses just below, rather than always reading selectedEmail
// from this closure - callers that just called selectEmail(email) and
// invoke this synchronously in the same tick would otherwise see the
// PRE-update value (the Zustand store updates immediately, but this
// render's selectedEmail closure doesn't until the next render),
// forwarding the previously selected message or no-op'ing on an
// unselected row. See the list context-menu wiring below.
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
if (!email) return;
// Same filename options "Export as .eml" uses (see emailFilenameOptions
// in email-viewer.tsx), so the two actions produce consistent filenames
// for the same message rather than the synthetic attachment silently
// ignoring the user's configured naming template.
const {
emailDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
} = useSettingsStore.getState();
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
template: emailDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
});
if (!payload) return;
const ok = await emailHooks.onBeforeForward.intercept({
originalEmailId: email.id,
originalEmail: emailToReadView(email),
mode: 'forward' as const,
});
if (!ok) return;
startFreshComposerSession();
setPendingDraft({
to: "",
cc: "",
bcc: "",
subject: payload.subject,
body: "",
showCc: false,
showBcc: false,
selectedIdentityId: null,
subAddressTag: "",
mode: "forward",
draftId: null,
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
},
});
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
};
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !emailToDelete) return;
@@ -1754,7 +1835,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;
@@ -1778,31 +1859,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;
}
}
@@ -1828,7 +1913,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);
}
};
@@ -3226,6 +3311,10 @@ export default function Home() {
selectEmail(email);
handleForward();
}}
onForwardAsAttachment={(email) => {
selectEmail(email);
handleForwardAsAttachment(email);
}}
onMarkAsRead={async (email, read) => {
if (client) {
await markAsRead(client, email.id, read);
@@ -3245,8 +3334,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) {
@@ -3456,6 +3545,7 @@ export default function Home() {
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onForwardAsAttachment={handleForwardAsAttachment}
onDelete={() => {
// Deleting the open message returns to the list (Gmail-style),
// not the next email — unless the user turned the setting off.
@@ -3469,7 +3559,7 @@ export default function Home() {
}}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onSetTag={handleSetTag}
onMarkAsSpam={() => handleMarkAsSpam()}
onUndoSpam={() => handleUndoSpam()}
onMarkAsRead={async (emailId, read) => {
+3 -2
View File
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
* Master-user impersonation via signed JWT. The token carries the target
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/" and the
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
* ImpersonationReconciler, GH #646) and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
@@ -136,6 +137,6 @@ export async function GET(request: NextRequest) {
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
return new NextResponse(null, {
status: 303,
headers: { Location: '/' },
headers: { Location: '/?impersonated=1' },
});
}
+6 -6
View File
@@ -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: 'Pierre Dubois', email: 'pierre@dubois.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
@@ -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', email: 'notifications@github.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: '[bulwark-webmail] Theme choice is ignored after an OS theme change (#42)',
@@ -180,7 +180,7 @@ const emails: MockEmail[] = [
},
// Newsletter with a full HTML body
{
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: 'Sidenote', email: 'post@sidenote.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Sidenote 47: the Component Model shipped and nobody has to care yet',
@@ -346,7 +346,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: 'JMAP-342 is up: vCard import',
@@ -373,7 +373,7 @@ const emails: MockEmail[] = [
},
},
{
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 8900, receivedAt: daysAgo(1),
id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work': true }, size: 8900, receivedAt: daysAgo(1),
from: [{ name: 'Nordhost GmbH', email: 'rechnung@nordhost.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Invoice NH-2026-0284 for February',
@@ -522,7 +522,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: 'Mollie Developers', email: 'developers@mollie.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'API version 2023-10 stops working on 15 April',
+35
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:where(.dark, .dark *));
@@ -558,6 +559,40 @@ body {
overscroll-behavior: none;
}
/* Shake animation (for rejected input) */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.4s ease-in-out;
}
/* Fade in animation (for popovers) */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
/* Slide in from right animation (for mobile views) */
@keyframes slide-in-from-right {
from {
@@ -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');
});
});
+23 -61
View File
@@ -23,8 +23,6 @@ import {
Archive,
FolderInput,
Tag,
X,
Check,
Inbox,
Send,
File,
@@ -34,10 +32,12 @@ import {
EditIcon,
CalendarClock,
XCircle,
Paperclip,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
interface Position {
x: number;
@@ -59,12 +59,13 @@ interface EmailContextMenuProps {
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onForwardAsAttachment?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onTogglePinned?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onSetTag?: (tagId: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
@@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => {
}
};
// Get all active label/color tag IDs from email keywords
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return tags;
};
export function EmailContextMenu({
email,
position,
@@ -127,12 +114,13 @@ export function EmailContextMenu({
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMoveToMailbox,
onMarkAsSpam,
onUndoSpam,
@@ -149,13 +137,12 @@ export function EmailContextMenu({
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const _tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tEmailViewer = useTranslations("email_viewer");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentColors = getCurrentColors(email.keywords);
const currentTagIds = getEmailTagIds(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
@@ -164,13 +151,6 @@ export function EmailContextMenu({
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
}));
// Build mailbox tree for move-to submenu with proper hierarchy
const moveTargetIds = new Set(
mailboxes
@@ -277,6 +257,12 @@ export function EmailContextMenu({
onClick={() => handleAction(onForward!)}
disabled={!onForward}
/>
<ContextMenuItem
icon={Paperclip}
label={tEmailViewer("forward_as_attachment")}
onClick={() => handleAction(onForwardAsAttachment!)}
disabled={!onForwardAsAttachment || !email.blobId}
/>
<ContextMenuSeparator />
</>
)}
@@ -370,37 +356,13 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
</>
)}
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
<div className="w-56 max-w-[18rem]">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => onSetTag?.(tagId)}
/>
</div>
</ContextMenuSubMenu>
)}
+3 -3
View File
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onSetTag?: (tagId: string | null) => void;
onMarkAsSpam?: () => void;
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
// the spam quick-action flips to "not spam".
@@ -76,7 +76,7 @@ export function EmailHoverActions({
onMarkAsRead,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMarkAsSpam,
isInJunk = false,
onUndoSpam,
@@ -112,7 +112,7 @@ export function EmailHoverActions({
onArchive?.();
break;
case "tag":
onSetColorTag?.(null);
onSetTag?.(null);
break;
case "spam":
if (isInJunk) onUndoSpam?.();
-351
View File
@@ -1,351 +0,0 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback } from "react";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps {
email: Email;
selected?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
// In aggregate role-views the selected mailbox is virtual → fall back to the
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isMobile = useUIStore((state) => state.isMobile);
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
email,
sourceMailboxId: selectedMailbox,
});
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
// Shift-click extends the selection from the anchor to here, like
// shift-clicking the row (the checkbox stops propagation, so the
// row's shift handler never runs — replicate it here).
selectRangeEmails(email.id);
} else {
toggleEmailSelection(email.id);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
? "bg-selection"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-selection/60",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
// Long press visual feedback
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email.id);
} else {
if (selectedEmailIds.size > 0) clearSelection();
onClick?.();
}
}}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{/* Unread indicator */}
{isUnread && (
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{/* Avatar */}
{density !== 'extra-compact' && (
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={() => toggleEmailSelection(email.id)}
selectLabel={tBatch('select')}
/>
)}
{/* Content */}
<div className="flex-1 min-w-0">
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || t('no_subject')}
</span>
{inlinePreview && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
) : (
<>
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || t('no_preview_available')}
</p>
)}
</>
)}
</div>
</div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
</div>
);
}
+38 -22
View File
@@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { useVirtualizer } from "@tanstack/react-virtual";
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { SearchChips } from "@/components/search/search-chips";
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
@@ -33,12 +34,13 @@ interface EmailListProps {
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onForwardAsAttachment?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onToggleStar?: (email: Email) => void;
onTogglePinned?: (email: Email) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
@@ -63,12 +65,13 @@ export function EmailList({
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
@@ -130,10 +133,20 @@ export function EmailList({
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
/**
* The row the menu was opened on, as the list currently has it. The menu holds
* the message it was handed when it opened, but tags can be applied from
* inside it without dismissing it, so what it draws has to keep up.
*/
const contextMenuEmail = contextMenu.data
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
: null;
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [isProcessing, setIsProcessing] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
// One tag treatment for the whole list, measured from the scroll container.
const tagDisplay = useMeasuredTagDisplay(parentRef);
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -330,6 +343,7 @@ export function EmailList({
}, [density, isFocusedMailLayout, showPreview]);
return (
<TagDisplayContext.Provider value={tagDisplay}>
<div className={cn("flex flex-col min-h-0", className)}>
{/* Batch Actions Toolbar */}
<div
@@ -541,7 +555,7 @@ export function EmailList({
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
onDelete={onDelete ? (email) => onDelete(email) : undefined}
onArchive={onArchive ? (email) => onArchive(email) : undefined}
onSetColorTag={onSetColorTag}
onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
/>
@@ -568,9 +582,9 @@ export function EmailList({
</div>
{/* Context Menu */}
{contextMenu.data && (
{contextMenuEmail && (
<EmailContextMenu
email={contextMenu.data}
email={contextMenuEmail}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
@@ -578,24 +592,25 @@ export function EmailList({
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
selectedCount={selectedEmailIds.size}
onReply={() => onReply?.(contextMenu.data!)}
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
onForward={() => onForward?.(contextMenu.data!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
onDelete={() => onDelete?.(contextMenu.data!)}
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
onReply={() => onReply?.(contextMenuEmail!)}
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
onForward={() => onForward?.(contextMenuEmail!)}
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
onDelete={() => onDelete?.(contextMenuEmail!)}
onArchive={() => onArchive?.(contextMenuEmail!)}
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
@@ -642,5 +657,6 @@ export function EmailList({
<ConfirmDialog {...confirmDialogProps} />
</div>
</TagDisplayContext.Provider>
);
}
+80 -158
View File
@@ -12,6 +12,11 @@ import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker";
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getEmailTagIds } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
import { generateEmailSource } from "@/lib/email-source";
@@ -19,6 +24,7 @@ import {
Reply,
ReplyAll,
Forward,
Paperclip,
Trash2,
Archive,
Star,
@@ -73,7 +79,7 @@ import {
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
@@ -109,11 +115,12 @@ interface EmailViewerProps {
onReply?: (draftText?: string) => void;
onReplyAll?: () => void;
onForward?: () => void;
onForwardAsAttachment?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onToggleStar?: () => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
@@ -198,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment';
};
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return tags;
};
// Helper function to format recipients with contextual display
const _formatRecipients = (
recipients: Array<{ name?: string; email: string }> | undefined,
@@ -621,11 +615,12 @@ export function EmailViewer({
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onDelete,
onArchive,
onToggleStar,
onMarkAsRead,
onSetColorTag,
onSetTag,
onDownloadAttachment,
onQuickReply,
onMarkAsSpam,
@@ -664,6 +659,7 @@ export function EmailViewer({
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { sortTagIds, tagColor } = useKeywordFormat();
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -706,12 +702,6 @@ export function EmailViewer({
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
}));
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
@@ -816,8 +806,13 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
const currentTagIds = getEmailTagIds(email?.keywords);
const sortedTagIds = sortTagIds(currentTagIds);
// The header spans the reading pane, so it measures its own width rather than
// inheriting the message list's answer.
const headerTagsRef = useRef<HTMLDivElement>(null);
const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef);
const currentColor = currentTagIds[0] ?? null;
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
@@ -1015,7 +1010,7 @@ export function EmailViewer({
showToolbarLabels,
isLoading,
moveTree.length,
colorOptions.length,
emailKeywords.length,
currentColor,
isInJunkFolder,
isTablet,
@@ -2994,64 +2989,18 @@ export function EmailViewer({
<div ref={tagMenuRef} className="relative">
<button
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
title={t('set_tag')}
>
{currentColors.length > 0 ? (
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
<Tag className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
</button>
{tagMenuOpen && (
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
</>
)}
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
)}
</div>
@@ -3243,7 +3192,7 @@ export function EmailViewer({
</div>
)}
{/* Overflow: tag - submenu */}
{colorOptions.length > 0 && (
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')}
onMouseLeave={() => setMoreMenuSub(null)}
@@ -3257,36 +3206,11 @@ export function EmailViewer({
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'tag' && (
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
</>
)}
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
)}
</div>
@@ -3340,6 +3264,16 @@ export function EmailViewer({
</button>
)}
<div className="h-px bg-border my-1" />
{/* Forward as attachment */}
{onForwardAsAttachment && email?.blobId && (
<button
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Paperclip className="w-4 h-4" />
{t('forward_as_attachment')}
</button>
)}
{/* Export email */}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
@@ -3420,19 +3354,21 @@ export function EmailViewer({
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button>
{/* Tag (opens sub-view) */}
{colorOptions.length > 0 && (
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<button
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && (
{currentTagIds.length > 0 && (
<div className="flex -space-x-1 me-1">
{currentColors.slice(0, 3).map((c) => {
const opt = colorOptions.find((o) => o.value === c);
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
})}
{sortedTagIds.slice(0, 3).map((tagId) => (
<span
key={tagId}
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
/>
))}
</div>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
@@ -3462,6 +3398,15 @@ export function EmailViewer({
</button>
)}
<div className="h-px bg-border my-1" />
{onForwardAsAttachment && email?.blobId && (
<button
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Paperclip className="w-5 h-5" />
{t('forward_as_attachment')}
</button>
)}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
@@ -3519,35 +3464,12 @@ export function EmailViewer({
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
<>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
)}
</>
{moreMenuSub === 'tag' && (
<TagPicker
touch
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
)}
</div>
</div>
@@ -3605,24 +3527,24 @@ export function EmailViewer({
)} />
</button>
)}
{/* Color tag dots */}
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
);
})}
</span>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
</span>
)}
</div>
{sortedTagIds.length > 0 && (
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
{sortedTagIds.map((tagId) => (
<TagBadge
key={tagId}
tagId={tagId}
variant={headerTagVariant}
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
/>
))}
</div>
)}
</div>
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="hidden sm:block flex-shrink-0 text-end">
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useShortenedText } from "@/hooks/use-shortened-text";
/**
* How much room the surface has for a tag.
* - `badge` names the tag; `dot` only identifies it by colour.
*/
export type TagBadgeVariant = "badge" | "dot";
/**
* The lozenge shape, shared so anything standing next to a tag lines up with
* it rather than approximating its padding and text size.
*/
export const TAG_LOZENGE_CLASS =
"inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium";
/**
* The row a group of tags sits in. Using it for neighbouring lozenges too keeps
* the spacing between them the same as the spacing within them - a wider gap on
* one side is what makes a neighbour look indented.
*/
export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1";
/**
* A tag, drawn the one way tags are drawn.
*
* The lozenge carries the colour in its border and text rather than pairing a
* swatch with plain text: the name is the tag, and the colour is how you pick
* it out of a row at a glance. That also matches every other coloured pill in
* the app, all of which set a text colour alongside the background.
*
* A deep name shortens to fit its own box (`Work/../Acme`) before the browser
* clips it, so the outermost and innermost levels survive.
*/
export function TagBadge({
tagId,
variant,
onRemove,
className,
}: {
tagId: string;
variant: TagBadgeVariant;
/**
* Takes the tag off the message. Only the named form offers it - a dot is the
* size of the control it would have to hold.
*/
onRemove?: () => void;
className?: string;
}) {
const t = useTranslations("email_viewer");
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
const color = tagColor(tagId);
const name = tagName(tagId);
if (variant === "dot") {
return (
<span
className={cn("h-2.5 w-2.5 shrink-0 rounded-full", color.dot, className)}
title={name}
aria-label={name}
/>
);
}
return (
<span
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[12rem] border",
color.fill,
color.border,
color.text,
className,
)}
title={name}
>
<span ref={labelRef} className="min-w-0 truncate">
{shortenedName}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
title={t("remove_tag")}
aria-label={t("remove_tag")}
>
<X className="w-3 h-3" />
</button>
)}
</span>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
/** Below this many tags a filter box costs more room than it saves. */
const SEARCH_THRESHOLD = 10;
/**
* The list of tags to apply to a message.
*
* Shared by all four places one appears - the toolbar popover, the overflow
* flyout, the mobile sheet and the context menu - because they had drifted into
* four different dot sizes, check alignments and separators, and only one of
* them capped its height.
*
* Nested tags are drawn as a tree rather than repeating the parent's name on
* every child. Filtering flattens it: with a query the hierarchy is noise, and
* the full path is what gets matched.
*/
export function TagPicker({
selectedIds,
onToggle,
touch = false,
}: {
selectedIds: string[];
onToggle: (tagId: string) => void;
/** Larger hit areas for the mobile sheet. */
touch?: boolean;
}) {
const t = useTranslations("email_viewer");
const keywords = useSettingsStore((state) => state.emailKeywords);
const nestedTags = useSettingsStore((state) => state.nestedTags);
const { tagName, tagColor } = useKeywordFormat();
const [query, setQuery] = useState("");
const trimmedQuery = query.trim().toLowerCase();
/**
* Tags on the message this client has no definition for - set from another
* client, or outliving the tag they were made with. Listing them is the only
* way to take one off, and they leave the list as they are deselected because
* nothing but the message itself records that they exist.
*/
const unknownIds = useMemo(
() =>
selectedIds
.filter((id) => !keywords.some((keyword) => keyword.id === id))
.sort((a, b) => tagName(a).localeCompare(tagName(b))),
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
[selectedIds, keywords, tagName],
);
const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD;
const matches = useMemo(
() =>
trimmedQuery
? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) =>
tagName(id).toLowerCase().includes(trimmedQuery),
)
: [],
[keywords, unknownIds, trimmedQuery, tagName],
);
const tree = useMemo(
() => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))),
[keywords, nestedTags],
);
const rowClass = cn(
"w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm",
);
const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3";
const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5";
const renderRow = (id: string, label: string) => {
const isActive = selectedIds.includes(id);
return (
<button
key={id}
type="button"
role="menuitemcheckbox"
aria-checked={isActive}
onClick={() => onToggle(id)}
className={cn(rowClass, isActive && "bg-accent font-medium")}
title={tagName(id)}
>
<span className={cn("rounded-full flex-shrink-0", dotClass, tagColor(id).dot)} />
<span className="flex-1 min-w-0 truncate">{label}</span>
{isActive && <Check className={cn("ms-auto flex-shrink-0 text-foreground", checkClass)} />}
</button>
);
};
const renderBranch = (nodes: KeywordNode[]) =>
nodes.map((node) => (
<div key={node.id}>
{renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)}
{node.children.length > 0 && <div className="ps-4">{renderBranch(node.children)}</div>}
</div>
));
return (
<>
{showSearch && (
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("tag_filter_placeholder")}
aria-label={t("tag_filter_placeholder")}
className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
)}
<div className="max-h-[min(20rem,60vh)] overflow-y-auto">
{trimmedQuery ? (
matches.length > 0 ? (
matches.map((id) => renderRow(id, tagName(id)))
) : (
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
)
) : (
<>
{renderBranch(tree)}
{unknownIds.length > 0 && (
<>
{keywords.length > 0 && <div className="h-px bg-border my-1" />}
{unknownIds.map((id) => renderRow(id, tagName(id)))}
</>
)}
</>
)}
</div>
</>
);
}
+12
View File
@@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge } from "./tag-badge";
interface ThreadEmailItemProps {
email: Email;
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { sortTagIds } = useKeywordFormat();
const { variant: tagVariant } = useTagDisplay();
// A message inside an expanded thread carries its own tags; the collapsed
// header pools them, so without this they disappear on the way in.
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density);
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
{email.hasAttachment && (
<Paperclip className="w-3 h-3 text-muted-foreground" />
)}
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</div>
{/* Preview snippet */}
+136 -102
View File
@@ -6,11 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
import { useAccountStore } from "@/stores/account-store";
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
@@ -34,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) {
);
}
/**
* How many messages a collapsed thread stands for.
*
* Built from the tag lozenge so it lines up with the tags it sits next to: the
* same shape, and the same group spacing.
*/
function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) {
return (
<span
className={cn(
TAG_LOZENGE_CLASS,
"gap-0.5",
hasUnread ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
title={title}
>
<MessageSquare className="w-3 h-3" />
{count}
</span>
);
}
interface ThreadListItemProps {
thread: ThreadGroup;
isExpanded: boolean;
@@ -50,7 +75,7 @@ interface ThreadListItemProps {
onMarkAsRead?: (email: Email, read: boolean) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
}
@@ -62,18 +87,18 @@ interface SingleEmailItemProps {
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
rowTint: string | null;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onSetTag?: (tagId: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const isUnread = !email.keywords?.$seen;
@@ -89,7 +114,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { sortTagIds, tagColor } = useKeywordFormat();
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -110,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? formatDateTime(email.scheduledSendAt, timeFormat)
: null;
// Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
})();
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
const { dragHandlers, isDragging } = useEmailDrag({
email,
@@ -172,19 +192,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
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 : (
resolvedRowTint ? resolvedRowTint : (
selected
? "bg-accent"
: "bg-background"
),
selected && !resolvedColorTag && "shadow-sm",
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
selected && !resolvedRowTint && "shadow-sm",
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedRowTint && "bg-accent/30",
isChecked && "ring-2 ring-primary/20",
isChecked && !resolvedRowTint && "bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
isPressed && "scale-[0.98] ring-2 ring-primary/30",
isPressed && !resolvedRowTint && "bg-muted"
)}
onClick={handleClick}
onDoubleClick={(e) => {
@@ -258,6 +280,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
{tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
@@ -281,9 +310,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -322,6 +348,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
{tagPlacement === 'sender' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
@@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -378,13 +402,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
"min-w-0 flex-1 truncate text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</span>
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{!email.isScheduled && (
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
@@ -440,7 +473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsRead,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMarkAsSpam,
onUndoSpam,
}, ref) {
@@ -497,11 +530,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
);
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const { sortTagIds, tagColor } = useKeywordFormat();
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// A collapsed row speaks for every message under it, so it carries their tags too.
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
@@ -518,12 +552,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
rowTint={rowTint}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
/>
@@ -597,19 +631,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{...threadLongPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
colorTag ? colorTag : (
rowTint ? rowTint : (
isSelected
? "bg-accent"
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isSelected && !rowTint && "shadow-sm",
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
rowTint && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !rowTint && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
isChecked && "ring-2 ring-primary/20",
isChecked && !rowTint && "bg-accent/40",
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
isThreadPressed && !rowTint && "bg-muted"
)}
onClick={handleHeaderClick}
onDoubleClick={(e) => {
@@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/>
)}
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44',
// Matches SingleEmailItem: the sender column sets where
// every row's tags and subject begin, so the two have to
// agree or thread rows sit 1rem further right.
'w-32 shrink-0 truncate text-sm lg:w-40',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{displayNames.join(', ')}
</span>
<span
className={cn(
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={TAG_GROUP_CLASS}>
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
<span className={cn(
'min-w-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
@@ -745,9 +784,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</>
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -786,17 +822,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}>
{displayNames.join(", ")}
</span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
<span className={TAG_GROUP_CLASS}>
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
{tagPlacement === 'sender' && tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
<div className="flex items-center gap-1.5">
{hasPinned && (
@@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -854,13 +879,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
"min-w-0 flex-1 truncate text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</span>
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!latestEmail.isScheduled && (
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
backgroundClassName={rowTint ? rowTint : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
+3 -1
View File
@@ -18,6 +18,7 @@ import type {
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -89,6 +90,7 @@ export function FilterRuleModal({
const t = useTranslations("settings.filters");
const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagName } = useKeywordFormat();
const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -477,7 +479,7 @@ export function FilterRuleModal({
>
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
<option key={kw.id} value={kw.id}>{kw.label}</option>
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
))}
</select>
)}
@@ -0,0 +1,61 @@
'use client';
import { useEffect } from 'react';
import { evictAll } from '@/lib/account-state-manager';
/**
* After a master-user impersonation handoff (`GET /api/auth/impersonate`), the
* server swaps the slot-0 session cookie but the client's *persisted* account
* registry still lists the PREVIOUS account so the top-left account chip keeps
* showing the old mailbox even though the message list is correctly the new one.
* Only a manual sign-out (which clears `account-registry` / `auth-storage`) fixes
* it, because that state lives in localStorage and the impersonation redirect
* never reconciles it. (Reported downstream: jabali-panel #646.)
*
* The impersonate route now redirects to `/?impersonated=1`. Here we drop the
* stale persisted account + auth state (and the server-derived caches) and
* reload to a clean URL, so the app rehydrates empty and re-derives the single
* account from the fresh session cookie the same result as the manual
* sign-out-then-reopen, done automatically. Cookies are untouched, so the
* just-granted impersonation session survives the reload.
*/
const STALE_KEYS = [
'account-registry',
'auth-storage',
'identity-storage',
'contact-storage',
'calendar-storage',
'calendar-notification-storage',
];
export function ImpersonationReconciler() {
useEffect(() => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
if (params.get('impersonated') !== '1') return;
try {
evictAll();
} catch {
/* in-memory snapshots are best-effort */
}
for (const key of STALE_KEYS) {
try {
window.localStorage.removeItem(key);
} catch {
/* ignore storage access errors */
}
}
// Reload to a clean URL (drop the marker) so the now-empty persisted stores
// rehydrate and the app reconnects + re-derives the impersonated account
// from the session cookie. The marker is gone on the second load, so this
// runs exactly once.
params.delete('impersonated');
const query = params.toString();
window.location.replace(window.location.pathname + (query ? `?${query}` : ''));
}, []);
return null;
}
+174 -54
View File
@@ -35,9 +35,19 @@ import {
BellOff,
Mails,
MailOpen,
MoreHorizontal,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
buildKeywordTree,
countKeywordNodes,
filterKeywordTree,
hasChildKeywords,
type KeywordNode,
} from "@/lib/keyword-nesting";
import { useShortenedText } from "@/hooks/use-shortened-text";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -51,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
@@ -241,6 +251,9 @@ function SidebarRowCounts({
interface SidebarRowProps {
icon: ReactNode;
label: string;
/** Progressively shorter renderings of `label`, longest first. The widest one
* that fits the row is shown; without this the full label is used. */
labelCandidates?: string[];
depth?: number;
isSelected?: boolean;
isVirtual?: boolean;
@@ -266,6 +279,7 @@ interface SidebarRowProps {
function SidebarRow({
icon,
label,
labelCandidates,
depth = 0,
isSelected = false,
isVirtual = false,
@@ -288,6 +302,7 @@ function SidebarRow({
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
return (
<div
@@ -353,7 +368,7 @@ function SidebarRow({
</span>
{!isCollapsed && (
<>
<span className="flex-1 truncate">{label}</span>
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
<SidebarRowCounts
unread={unread}
total={total}
@@ -542,78 +557,120 @@ function MailboxTreeItem({
);
}
const TAG_ICON_COLOR: Record<string, string> = {
red: "text-red-600/75 dark:text-red-400/75",
orange: "text-orange-600/75 dark:text-orange-400/75",
yellow: "text-yellow-600/75 dark:text-yellow-400/75",
green: "text-green-600/75 dark:text-green-400/75",
blue: "text-blue-600/75 dark:text-blue-400/75",
purple: "text-purple-600/75 dark:text-purple-400/75",
pink: "text-pink-600/75 dark:text-pink-400/75",
teal: "text-teal-600/75 dark:text-teal-400/75",
cyan: "text-cyan-600/75 dark:text-cyan-400/75",
indigo: "text-indigo-600/75 dark:text-indigo-400/75",
amber: "text-amber-600/75 dark:text-amber-400/75",
lime: "text-lime-600/75 dark:text-lime-400/75",
gray: "text-gray-500",
};
function ShowAllTagsRow({
hiddenCount,
showAll,
onToggle,
isCollapsed,
}: {
hiddenCount: number;
showAll: boolean;
onToggle: () => void;
isCollapsed: boolean;
}) {
const t = useTranslations('sidebar');
return (
<SidebarRow
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })}
depth={0}
onClick={onToggle}
isCollapsed={isCollapsed}
/>
);
}
function TagItem({
kw,
isSelected,
node,
selectedKeyword,
expandedTags,
isCollapsed,
onTagSelect,
totalCount,
unreadCount,
onToggleExpand,
tagCounts,
colorful,
}: {
kw: KeywordDefinition;
isSelected: boolean;
node: KeywordNode;
selectedKeyword: string | null;
expandedTags: Set<string>;
isCollapsed: boolean;
onTagSelect?: (keywordId: string | null) => void;
totalCount: number;
unreadCount: number;
onToggleExpand: (keywordId: string) => void;
tagCounts: Record<string, { total: number; unread: number }>;
colorful: boolean;
}) {
const t = useTranslations('notifications');
const palette = KEYWORD_PALETTE[kw.color];
const { tagNameCandidates, tagColor } = useKeywordFormat();
const palette = tagColor(node.id);
const hasChildren = node.children.length > 0;
const isExpanded = expandedTags.has(node.id);
const isSelected = selectedKeyword === node.id;
// Nested rows are placed by their indentation, so they show their own name.
// A root spells out its path, which matters when an intermediate tag is
// missing from this client's settings and the row would otherwise read as a
// bare leaf name.
const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label];
const label = labelCandidates[0];
// Toasts have the room for the whole thing, and no indentation to lean on,
// so they always spell out the full path - otherwise two leaves with the
// same name in different branches (e.g. "Personal/Receipts" and
// "Work/Receipts") would read as the same tag.
const fullLabel = tagNameCandidates(node.id)[0];
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget } = useTagDrop({
tagId: kw.id,
onSuccess: (count, _tagLabel) => {
tagId: node.id,
onSuccess: (count) => {
if (count === 1) {
toast.success(t('email_tagged'), kw.label);
toast.success(t('email_tagged'), fullLabel);
} else {
toast.success(t('emails_tagged', { count }), kw.label);
toast.success(t('emails_tagged', { count }), fullLabel);
}
},
onError: () => {
toast.error(t('tag_failed'), kw.label);
toast.error(t('tag_failed'), fullLabel);
},
});
const tagIcon = colorful ? (
<Tag
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
fill="currentColor"
/>
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
) : (
<span className={cn("w-3 h-3 rounded-full", palette?.dot || "bg-gray-400")} />
<span className={cn("w-3 h-3 rounded-full", palette.dot)} />
);
return (
<SidebarRow
icon={tagIcon}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={unreadCount}
total={totalCount}
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
<>
<SidebarRow
icon={tagIcon}
label={label}
labelCandidates={labelCandidates}
depth={node.depth}
isSelected={isSelected}
unread={tagCounts[node.id]?.unread ?? 0}
total={tagCounts[node.id]?.total ?? 0}
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
hasChildren={hasChildren}
isExpanded={isExpanded}
onExpandToggle={() => onToggleExpand(node.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
<TagItem
key={child.id}
node={child}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
onToggleExpand={onToggleExpand}
tagCounts={tagCounts}
colorful={colorful}
/>
))}
</>
);
}
@@ -737,6 +794,8 @@ export function Sidebar({
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
const [showAllTags, setShowAllTags] = useState(false);
const [foldersExpanded, setFoldersExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarFoldersExpanded');
@@ -779,6 +838,7 @@ export function Sidebar({
return new Set();
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const nestedTags = useSettingsStore(s => s.nestedTags);
const isEmbedded = useIsEmbedded();
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
@@ -842,6 +902,37 @@ export function Sidebar({
});
};
useEffect(() => {
const stored = localStorage.getItem('expandedTags');
if (stored) {
try {
const parsed = JSON.parse(stored);
setExpandedTags(new Set(parsed));
} catch (e) {
debug.error('Failed to parse expanded tags:', e);
}
} else {
setExpandedTags(
new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id))
);
}
}, [emailKeywords]);
const handleToggleTagExpand = (keywordId: string) => {
setExpandedTags((prev) => {
const next = new Set(prev);
if (next.has(keywordId)) {
next.delete(keywordId);
} else {
next.add(keywordId);
}
try {
localStorage.setItem('expandedTags', JSON.stringify(Array.from(next)));
} catch { /* storage full or unavailable */ }
return next;
});
};
// When the app renders its own virtual "Scheduled" folder (for delayed
// sends, driven by EmailSubmission), hide the server-provided scheduled
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
@@ -852,6 +943,26 @@ export function Sidebar({
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
// With nesting off every tag is its own root, so the same rows render through
// one path whether or not the ids describe a hierarchy.
const tagTree: KeywordNode[] = nestedTags
? buildKeywordTree(emailKeywords)
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
// Counts arrive from a separate JMAP round trip; until they land, treat every
// "show if unread" tag as visible rather than blanking the section and
// filling it back in.
const tagCountsLoaded = Object.keys(tagCounts).length > 0;
const isTagVisible = (node: KeywordNode) => {
if (showAllTags || node.id === selectedKeyword) return true;
const visibility = getKeywordVisibility(node);
if (visibility === 'hide') return false;
if (visibility === 'unread') return !tagCountsLoaded || (tagCounts[node.id]?.unread ?? 0) > 0;
return true;
};
const visibleTagTree = filterKeywordTree(tagTree, isTagVisible);
const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree);
// Multi-account mode (Pro shell): render every connected account as its
// own collapsible group. The active account's tree comes from the
// `mailboxes` prop (which is the live email-store value); other accounts
@@ -1265,18 +1376,27 @@ export function Sidebar({
/>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
<>
{emailKeywords.map((kw) => (
{visibleTagTree.map((node) => (
<TagItem
key={kw.id}
kw={kw}
isSelected={selectedKeyword === kw.id}
key={node.id}
node={node}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
totalCount={tagCounts[kw.id]?.total ?? 0}
unreadCount={tagCounts[kw.id]?.unread ?? 0}
onToggleExpand={handleToggleTagExpand}
tagCounts={tagCounts}
colorful={colorfulSidebarIcons}
/>
))}
{(hiddenTagCount > 0 || showAllTags) && (
<ShowAllTagsRow
hiddenCount={hiddenTagCount}
showAll={showAllTags}
onToggle={() => setShowAllTags((prev) => !prev)}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</div>
+68 -12
View File
@@ -15,6 +15,8 @@ import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/st
import type { Email } from "@/lib/jmap/types";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { getQuoteBodies } from "@/lib/email-composer-utils";
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
interface ProEmailTabBodyProps {
tabId: string;
@@ -56,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
const mailboxes = useEmailStore((s) => s.mailboxes);
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
const identities = useIdentityStore((s) => s.identities);
const multiAccountIdentities = useProMultiAccountIdentities();
@@ -136,6 +137,50 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
});
}, [email, openComposeTab, t]);
// Mirrors handleForward, but attaches the original as a message/rfc822
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
// This is a separate, self-contained render path from the main Mail
// tab's EmailViewer (page.tsx) - Pro tabs fetch their own `email` and
// open compose tabs directly via useProTabStore, not through
// page.tsx's pendingDraft/selectedEmail plumbing - so it needed its own
// wiring rather than falling out of the page.tsx fix automatically.
const handleForwardAsAttachment = useCallback(() => {
if (!email) return;
const {
emailDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
} = useSettingsStore.getState();
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
template: emailDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
});
if (!payload) return;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'forward',
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
},
sourceEmailId: email.id,
// payload.subject is intentionally blank for a subject-less email (to
// match normal Forward's *composer* subject behavior - see
// buildForwardAsAttachmentPayload). The Pro tab *title* is a separate
// UI label that still needs a sensible fallback, same as handleForward
// above uses - reusing payload.subject here would give the tab an
// empty title instead of e.g. "Fwd: New message".
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
});
}, [email, openComposeTab, t]);
const handleDelete = useCallback(async () => {
if (!client || !email) return;
try {
@@ -191,21 +236,31 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
}
}, [client, markAsRead]);
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
if (!email || email.id !== emailId) return;
// Drop existing color keywords, optionally add the new one. Matches the
// mail page's local optimistic update.
// Toggle one tag, or clear them all. Matches the mail page's local
// optimistic update, down to reaching tags this client cannot name.
const keywords = { ...(email.keywords ?? {}) };
for (const kw of settingsKeywords) {
delete keywords[`$label:${kw.id}`];
}
if (color) {
const def = settingsKeywords.find((k) => k.color === color);
if (def) keywords[`$label:${def.id}`] = true;
if (tagId === null) {
for (const key of Object.keys(keywords)) {
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
keywords[key] = false;
}
}
} else {
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
.filter(key => keywords[key]);
if (activeKeys.length > 0) {
for (const key of activeKeys) {
keywords[key] = false;
}
} else {
keywords[KEYWORD_PREFIX + tagId] = true;
}
}
setEmailKeywordsLocal(emailId, keywords);
setEmail({ ...email, keywords });
}, [email, settingsKeywords, setEmailKeywordsLocal]);
}, [email, setEmailKeywordsLocal]);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
@@ -283,11 +338,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onForwardAsAttachment={handleForwardAsAttachment}
onDelete={handleDelete}
onArchive={handleArchive}
onToggleStar={handleToggleStar}
onMarkAsRead={handleMarkAsRead}
onSetColorTag={handleSetColorTag}
onSetTag={handleSetTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onEditDraft={handleEditDraft}
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { KeywordSettings } from '../keyword-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
// Mock SettingsSection to just render children
vi.mock('../settings-section', () => ({
// Mock SettingsSection to just render children, keeping the real controls
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('KeywordSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
});
it('renders all default keywords', () => {
@@ -31,11 +32,6 @@ describe('KeywordSettings', () => {
expect(screen.getByText('add_keyword')).toBeInTheDocument();
});
it('renders reset defaults button', () => {
render(<KeywordSettings />);
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
});
it('shows add form when add button clicked', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
@@ -114,18 +110,6 @@ describe('KeywordSettings', () => {
expect(kw?.label).toBe('Crimson');
});
it('resets to defaults when reset button clicked', () => {
// Modify keywords first
useSettingsStore.getState().removeKeyword('red');
useSettingsStore.getState().removeKeyword('blue');
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
render(<KeywordSettings />);
fireEvent.click(screen.getByText('reset_defaults'));
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
});
it('normalizes label to id correctly', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
@@ -139,4 +123,90 @@ describe('KeywordSettings', () => {
expect(added.id).toBe('my-custom-tag');
expect(added.label).toBe('My Custom Tag!');
});
it('offers no parent picker while nesting is off', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
});
it('nests a new tag under the selected parent', () => {
useSettingsStore.setState({
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
});
it('shows nested tags by their full path', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
});
it('rejects a path that would exceed the keyword length limit', () => {
const deepId = 'a'.repeat(240);
useSettingsStore.setState({
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
expect(screen.getByText('too_long')).toBeInTheDocument();
expect(screen.getByText('add').closest('button')).toBeDisabled();
});
it('locks the name and the delete action of a tag that has nested tags', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
fireEvent.click(screen.getAllByTitle('edit')[0]);
expect(screen.getByDisplayValue('Work')).toBeDisabled();
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
});
it('defaults every tag to always visible in the sidebar', () => {
render(<KeywordSettings />);
const pickers = screen.getAllByLabelText('visibility_field');
expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length);
pickers.forEach((picker) => expect(picker).toHaveValue('show'));
});
it('stores the visibility chosen for a tag', () => {
render(<KeywordSettings />);
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
});
});
+154 -43
View File
@@ -2,15 +2,35 @@
import React, { useState } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import {
useSettingsStore,
KEYWORD_PALETTE,
KEYWORD_PALETTE_ROWS,
getKeywordVisibility,
type KeywordDefinition,
type KeywordVisibility,
} from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { SettingsSection } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
import {
buildKeywordTree,
composeKeywordId,
getParentKeywordId,
hasChildKeywords,
isKeywordDescendant,
keywordLevels,
type KeywordNode,
MAX_KEYWORD_ID_LENGTH,
} from "@/lib/keyword-nesting";
import { formatKeyword, keywordRenderings } from "@/lib/keyword-format";
import { useShortenedText } from "@/hooks/use-shortened-text";
import { TagBadge } from "@/components/email/tag-badge";
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
/** Lighter, base and darker shade of each hue, one row per shade. */
function KeywordColorPicker({
value,
onChange,
@@ -19,19 +39,23 @@ function KeywordColorPicker({
onChange: (color: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{PALETTE_KEYS.map((colorKey) => (
<button
key={colorKey}
type="button"
onClick={() => onChange(colorKey)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
KEYWORD_PALETTE[colorKey].dot,
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
aria-label={colorKey}
/>
<div className="space-y-1.5">
{KEYWORD_PALETTE_ROWS.map((row, index) => (
<div key={index} className="flex flex-wrap gap-1.5">
{row.map((colorKey) => (
<button
key={colorKey}
type="button"
onClick={() => onChange(colorKey)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
KEYWORD_PALETTE[colorKey].dot,
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
aria-label={colorKey}
/>
))}
</div>
))}
</div>
);
@@ -39,8 +63,11 @@ function KeywordColorPicker({
function KeywordRow({
keyword,
keywords,
nestedTags,
onEdit,
onDelete,
onVisibilityChange,
onDragStart,
onDragOver,
onDrop,
@@ -49,8 +76,11 @@ function KeywordRow({
isDragging,
}: {
keyword: KeywordDefinition;
keywords: KeywordDefinition[];
nestedTags: boolean;
onEdit: () => void;
onDelete: () => void;
onVisibilityChange: (visibility: KeywordVisibility) => void;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
@@ -59,7 +89,16 @@ function KeywordRow({
isDragging: boolean;
}) {
const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color];
const hasChildren = hasChildKeywords(keyword.id, keywords);
// Measured with the prefix attached, since that is what occupies the column.
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
.map((rendering) => KEYWORD_PREFIX + rendering);
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
const visibilityOptions = [
{ value: "show", label: t("visibility.show") },
{ value: "unread", label: t("visibility.unread") },
{ value: "hide", label: t("visibility.hide") },
];
return (
<div
@@ -75,9 +114,23 @@ function KeywordRow({
)}
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
<div className="flex min-w-0 flex-1">
<TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
</div>
<span
ref={keywordRef}
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
title={KEYWORD_PREFIX + keyword.id}
>
{shortenedKeyword}
</span>
<Select
value={getKeywordVisibility(keyword)}
onChange={(value) => onVisibilityChange(value as KeywordVisibility)}
options={visibilityOptions}
ariaLabel={t("visibility_field")}
className="text-xs py-1"
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
@@ -90,8 +143,9 @@ function KeywordRow({
<button
type="button"
onClick={onDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t("delete")}
disabled={hasChildren}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
title={hasChildren ? t("has_children_delete") : t("delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -102,37 +156,74 @@ function KeywordRow({
function KeywordEditForm({
initial,
keywords,
existingIds,
nestedTags,
onSave,
onCancel,
}: {
initial?: KeywordDefinition;
keywords: KeywordDefinition[];
existingIds: string[];
nestedTags: boolean;
onSave: (keyword: KeywordDefinition) => void;
onCancel: () => void;
}) {
const t = useTranslations("settings.keywords");
const [label, setLabel] = useState(initial?.label || "");
const [color, setColor] = useState(initial?.color || "blue");
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
const isEditing = !!initial;
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
// Renaming or re-parenting a tag rewrites the keyword on every message below
// it, and this client only knows about the tags in its own settings - the
// server may hold nested keywords created elsewhere. Freeze the identity of a
// tag that has children and allow the color to change.
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH;
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
// Every tag is a candidate parent except the one being edited and anything
// already below it, which would detach the branch from its own root.
const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }];
const collectParentOptions = (nodes: KeywordNode[]) => {
for (const node of nodes) {
if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue;
parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) });
collectParentOptions(node.children);
}
};
collectParentOptions(buildKeywordTree(keywords));
const handleSave = () => {
if (!isValid) return;
if (isLocked && initial) {
onSave({ ...initial, color });
return;
}
onSave({ id: normalizedId, label: label.trim(), color });
};
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
{nestedTags && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("parent_field")}
</label>
<Select
value={parentId}
onChange={setParentId}
options={parentOptions}
disabled={isLocked}
ariaLabel={t("parent_field")}
className="w-full"
/>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("label_field")}
@@ -141,15 +232,29 @@ function KeywordEditForm({
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isLocked}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
placeholder={t("label_placeholder")}
autoFocus
maxLength={30}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{nestedTags && normalizedId.length > 0 && (
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
{KEYWORD_PREFIX + normalizedId}
</p>
)}
{isLocked && (
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
)}
{isDuplicate && (
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
)}
{isTooLong && (
<p className="text-xs text-destructive mt-1">
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
</p>
)}
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
@@ -182,7 +287,7 @@ function KeywordEditForm({
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
@@ -260,12 +365,19 @@ export function KeywordSettings() {
removeKeyword(id);
};
const handleResetDefaults = () => {
reorderKeywords(DEFAULT_KEYWORDS);
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
updateKeyword(id, { visibility });
};
return (
<SettingsSection title={t("title")} description={t("description")}>
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
<ToggleSwitch
checked={nestedTags}
onChange={(checked) => updateSetting("nestedTags", checked)}
/>
</SettingItem>
<div className="space-y-2">
{isMigrating && (
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
@@ -278,7 +390,9 @@ export function KeywordSettings() {
<KeywordEditForm
key={keyword.id}
initial={keyword}
keywords={emailKeywords}
existingIds={existingIds.filter((id) => id !== keyword.id)}
nestedTags={nestedTags}
onSave={handleEdit}
onCancel={() => setEditingId(null)}
/>
@@ -286,11 +400,14 @@ export function KeywordSettings() {
<KeywordRow
key={keyword.id}
keyword={keyword}
keywords={emailKeywords}
nestedTags={nestedTags}
onEdit={() => {
setEditingId(keyword.id);
setIsAdding(false);
}}
onDelete={() => handleDelete(keyword.id)}
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
@@ -303,7 +420,9 @@ export function KeywordSettings() {
{isAdding ? (
<KeywordEditForm
keywords={emailKeywords}
existingIds={existingIds}
nestedTags={nestedTags}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
@@ -320,14 +439,6 @@ export function KeywordSettings() {
<Plus className="w-3.5 h-3.5" />
{t("add_keyword")}
</button>
<button
type="button"
onClick={handleResetDefaults}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("reset_defaults")}
</button>
</div>
)}
</div>
+11 -2
View File
@@ -111,15 +111,24 @@ interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
ariaLabel?: string;
}
export function Select({ value, onChange, options }: SelectProps) {
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label={ariaLabel}
dir="auto"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
className={cn(
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
className
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
@@ -0,0 +1,87 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { useKeywordFormat } from '../use-keyword-format';
import { useSettingsStore, KEYWORD_PALETTE, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
{ id: 'archive', label: 'Archive', color: 'red-dark' },
];
describe('useKeywordFormat', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
describe('tagColor', () => {
it('resolves a tag to its palette entry, including the new shades', () => {
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('work')).toBe(KEYWORD_PALETTE.blue);
expect(result.current.tagColor('archive')).toBe(KEYWORD_PALETTE['red-dark']);
});
it('falls back to grey for a keyword this client has no definition for', () => {
// Set on the message by another client, or its tag was deleted here.
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('never-heard-of-it')).toBe(KEYWORD_PALETTE.gray);
});
it('falls back to grey for a colour that is not in the palette', () => {
useSettingsStore.setState({ emailKeywords: [{ id: 'odd', label: 'Odd', color: 'chartreuse' }] });
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE.gray);
});
});
describe('sortTagIds', () => {
it('follows the order the user arranged in settings', () => {
// Settings order is work, work/clients, archive - drag-reorderable, and
// deliberately not alphabetical.
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['archive', 'work/clients', 'work'])).toEqual([
'work',
'work/clients',
'archive',
]);
});
it('is stable however the keywords happen to arrive', () => {
const { result } = renderHook(() => useKeywordFormat());
const expected = ['work', 'work/clients', 'archive'];
expect(result.current.sortTagIds(['work', 'archive', 'work/clients'])).toEqual(expected);
expect(result.current.sortTagIds(['archive', 'work', 'work/clients'])).toEqual(expected);
});
it('follows a reordering of the settings list', () => {
useSettingsStore.setState({ emailKeywords: [TAGS[2], TAGS[0], TAGS[1]] });
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['work', 'archive'])).toEqual(['archive', 'work']);
});
it('puts a tag with no local definition last, ordered by name', () => {
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['zz-unknown', 'work', 'aa-unknown'])).toEqual([
'work',
'aa-unknown',
'zz-unknown',
]);
});
it("leaves the caller's array alone", () => {
const { result } = renderHook(() => useKeywordFormat());
const input = ['archive', 'work'];
result.current.sortTagIds(input);
expect(input).toEqual(['archive', 'work']);
});
});
});
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { useShortenedText } from '../use-shortened-text';
const CANDIDATES = ['Work/Clients/Acme/Sales', 'Work/../Acme/Sales', 'Work/.../Sales'];
/**
* Reports `width` for the observed element and measures text at 10px per
* character, so a width of N*10 fits any candidate of N characters or fewer.
*/
function stubMeasurement(width: number) {
// Implementing the interface rather than passing an anonymous class keeps the
// members the hook never calls from reading as dead code.
class StubResizeObserver implements ResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
/** The hook observes once on mount; hand it `width` straight back. */
observe(target: Element) {
this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', StubResizeObserver);
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: (text: string) => ({ width: text.length * 10 }),
} as unknown as CanvasRenderingContext2D);
}
function Probe({ candidates }: { candidates: string[] }) {
const [ref, text] = useShortenedText(candidates);
return <span ref={ref} data-testid="probe">{text}</span>;
}
function renderProbe(candidates: string[]): string {
render(<Probe candidates={candidates} />);
return screen.getByTestId('probe').textContent ?? '';
}
describe('useShortenedText', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns the longest candidate where the DOM cannot be measured', () => {
// No ResizeObserver: server rendering, and jsdom by default. Showing the
// whole path beats shortening it on a guess.
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
});
it('keeps the full path when the element is wide enough', () => {
stubMeasurement(230);
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
});
it('steps down only as far as the width requires', () => {
stubMeasurement(200);
expect(renderProbe(CANDIDATES)).toBe('Work/../Acme/Sales');
});
it('falls back to the shortest candidate when none of them fit', () => {
stubMeasurement(40);
expect(renderProbe(CANDIDATES)).toBe('Work/.../Sales');
});
});
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useMemo } from "react";
import {
useSettingsStore,
KEYWORD_PALETTE,
FALLBACK_KEYWORD_COLOR,
type KeywordColor,
} from "@/stores/settings-store";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
/**
* Names and colours tags for the screen, bound to the user's tag settings.
*
* Resolving the definitions and the nesting setting here rather than at every
* call site means no caller can forget the setting and render a nested name to
* someone who never asked for nesting. Subscribing to them also keeps tags in
* step the moment either changes: reading the store inside the formatter would
* leave every list stale until something else happened to re-render it.
*/
export function useKeywordFormat() {
const keywords = useSettingsStore((state) => state.emailKeywords);
const nested = useSettingsStore((state) => state.nestedTags);
return useMemo(
() => ({
/** The tag's display name. */
tagName: (id: string) => formatKeyword(id, keywords, nested),
/** Its progressively shorter forms, longest first, for `useShortenedText`. */
tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)),
/**
* The tag's colour. Falls back to grey for a keyword this client has no
* definition for - one created on another device, or whose tag was
* deleted - so such a tag still shows rather than silently vanishing.
*/
tagColor: (id: string): KeywordColor => {
const color = keywords.find((keyword) => keyword.id === id)?.color;
return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR];
},
/**
* Tag ids in the order the user arranged them in settings.
*
* The keywords on a message arrive as an unordered JMAP map, so without
* this the same two tags can swap places between rows. A tag with no
* local definition has no place in that order, so it sorts last, by name.
*/
sortTagIds: (ids: string[]): string[] => {
const rank = (id: string) => {
const index = keywords.findIndex((keyword) => keyword.id === id);
return index === -1 ? keywords.length : index;
};
return [...ids].sort(
(a, b) =>
rank(a) - rank(b) ||
formatKeyword(a, keywords, nested).localeCompare(formatKeyword(b, keywords, nested)),
);
},
}),
[keywords, nested],
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useMemo, useState } from "react";
/**
* Measures text the way the browser will, using the font the element actually
* renders with. One canvas is reused for every measurement.
*/
let measureContext: CanvasRenderingContext2D | null | undefined;
function measureText(text: string, font: string): number {
if (measureContext === undefined) {
measureContext = document.createElement("canvas").getContext("2d");
}
if (!measureContext) return 0;
measureContext.font = font;
return measureContext.measureText(text).width;
}
/**
* Picks the first of `candidates` that fits the element the returned ref is
* attached to, remeasuring whenever that element is resized.
*
* Candidates run longest first, so the result is the most complete one there is
* room for. A character budget cannot do this job: the columns this is used in
* are resized by the user and share their row with controls whose width depends
* on the locale, so any fixed number is either so generous that it never
* triggers or so tight that it shortens text that would have fit.
*
* Attach the ref to an element whose width does *not* depend on its own text -
* a flex child that is allowed to shrink, i.e. one with `truncate` or
* `min-w-0`. On anything else, picking a shorter candidate would change the
* width that picked it and the two would oscillate.
*
* Where measurement is unavailable - server rendering, and jsdom under test -
* this returns the first candidate, so the text is complete rather than
* arbitrarily shortened.
*/
export function useShortenedText(
candidates: string[],
): [(node: HTMLElement | null) => void, string] {
const [element, setElement] = useState<HTMLElement | null>(null);
const [box, setBox] = useState<{ width: number; font: string } | null>(null);
useEffect(() => {
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const style = window.getComputedStyle(element);
setBox({
width: entry.contentRect.width,
font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
});
});
observer.observe(element);
return () => observer.disconnect();
}, [element]);
// Candidates are rebuilt on every render, so key the choice on their content.
// They must not contain a newline, which keeps this join unambiguous.
const key = candidates.join("\n");
return [
setElement,
useMemo(() => {
const options = key.split("\n");
if (!box || box.width === 0) return options[0];
return (
options.find((option) => measureText(option, box.font) <= box.width)
?? options[options.length - 1]
);
}, [key, box]),
];
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState, type RefObject } from "react";
import type { TagBadgeVariant } from "@/components/email/tag-badge";
/**
* Below this, a named tag beside the subject would leave the subject nothing to
* occupy, so tags move up to the sender line instead. The split list runs
* 240-600px wide and defaults to 384, so it reads that way until widened, while
* the full-width focus and bottom-pane layouts keep tags with the subject.
*/
const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560;
/**
* Below this there is no room to name a tag anywhere on the row, and colour
* alone has to carry it. Well under the split list's default, because the
* sender line still has room for a name long after the subject line does not.
*/
const TAG_NAME_MIN_WIDTH = 320;
export interface TagDisplay {
/** Whether a tag is named or shown as colour alone. */
variant: TagBadgeVariant;
/** Which line of a multi-line row the tags belong on. */
placement: "subject" | "sender";
}
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" };
/**
* How message rows should draw their tags.
*
* One value for the whole list, never per row: rows are all the same width, so
* measuring each would burn a `ResizeObserver` per virtualised row and, worse,
* let neighbours disagree - one naming its tags while the next showed dots.
*/
export const TagDisplayContext = createContext<TagDisplay>(NAMED_BESIDE_SUBJECT);
export function useTagDisplay(): TagDisplay {
return useContext(TagDisplayContext);
}
/**
* Watches a container and reports what its rows have room for. Falls back to
* naming tags beside the subject where measurement is unavailable - server
* rendering, and jsdom under test - since that is the most informative form.
*/
export function useMeasuredTagDisplay(ref: RefObject<HTMLElement | null>): TagDisplay {
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
const element = ref.current;
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const measured = entries[0]?.contentRect.width;
if (measured !== undefined) setWidth(measured);
});
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return useMemo(() => {
if (width === null) return NAMED_BESIDE_SUBJECT;
return {
variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot",
placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender",
};
}, [width]);
}
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildForwardAsAttachmentPayload } from '@/lib/forward-as-attachment';
import type { Email } from '@/lib/jmap/types';
// Pin TZ so the local-time date rendering in the filename test is deterministic,
// restoring it after so this doesn't leak into other test files in the same worker.
let originalTZ: string | undefined;
beforeAll(() => {
originalTZ = process.env.TZ;
process.env.TZ = 'UTC';
});
afterAll(() => {
// process.env coerces to strings, so `= undefined` would leave the literal
// string "undefined" behind when TZ was originally unset - delete instead.
if (originalTZ === undefined) delete process.env.TZ;
else process.env.TZ = originalTZ;
});
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'e1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
size: 12345,
receivedAt: '2026-07-26T22:25:22Z',
subject: 'Your waste service day is changing',
hasAttachment: false,
blobId: 'blob123',
...overrides,
};
}
describe('buildForwardAsAttachmentPayload', () => {
it('returns null when the email has no blobId', () => {
const email = makeEmail({ blobId: undefined });
expect(buildForwardAsAttachmentPayload(email, 'Fwd:')).toBeNull();
});
it('prefixes the subject using the given forward prefix', () => {
const email = makeEmail({ subject: 'Missed spam example' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.subject).toBe('Fwd: Missed spam example');
});
it('builds a message/rfc822 attachment referencing the email\'s own blobId, not a new upload', () => {
const email = makeEmail({ blobId: 'the-real-blob-id', size: 26489 });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.attachment).toEqual({
blobId: 'the-real-blob-id',
name: expect.stringMatching(/\.eml$/),
type: 'message/rfc822',
size: 26489,
});
});
it('is idempotent - repeated forwarding does not stack prefixes', () => {
const email = makeEmail({ subject: 'Fwd: already forwarded once' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.subject).toBe('Fwd: already forwarded once');
});
it('leaves the subject blank (not just the bare prefix) for a subject-less message, matching normal Forward', () => {
const email = makeEmail({ subject: undefined });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.subject).toBe('');
});
it('applies user space/case transforms but ignores a custom filename template, unlike "Export as .eml"', () => {
const email = makeEmail({ subject: 'Missed spam example' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:', {
template: 'custom-{subject}',
lowercase: true,
spaceReplacement: 'dash',
});
expect(payload?.attachment.name).toBe('2026-07-26-22.25.22-missed-spam-example.eml');
});
it('uses a dash between date and subject by default', () => {
const email = makeEmail({ subject: 'Missed spam example' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.attachment.name).toBe('2026-07-26 22.25.22-Missed spam example.eml');
});
it('never includes from/to in the filename, even with the default template, to avoid leaking names to the recipient', () => {
const email = makeEmail({
subject: 'Missed spam example',
from: [{ name: 'Alice Sender', email: 'alice@example.com' }],
to: [{ name: "'Bobby'", email: 'bob@example.com' }],
});
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.attachment.name).not.toContain('Alice');
expect(payload?.attachment.name).not.toContain('Bobby');
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
import type { KeywordDefinition } from "@/stores/settings-store";
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
// Work
// Clients
// Acme
// Personal
const KEYWORDS: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/personal", "Personal"),
];
describe("formatKeyword with nesting on", () => {
it("joins the display name of every level", () => {
expect(formatKeyword("work/clients/acme", KEYWORDS, true)).toBe("Work/Clients/Acme");
});
it("returns the plain display name for a tag with one level", () => {
expect(formatKeyword("work", KEYWORDS, true)).toBe("Work");
});
it("falls back to the raw level for one this client does not know", () => {
expect(formatKeyword("work/archive/2026", KEYWORDS, true)).toBe("Work/archive/2026");
expect(formatKeyword("unknown", [], true)).toBe("unknown");
});
});
describe("formatKeyword with nesting off", () => {
it("names a tag by its own label, leaving a slash in the id uninterpreted", () => {
// The setting says a slash means nothing, so an id that happens to contain
// one - from before it was turned off, or from another client - is a single
// opaque token rather than a hierarchy.
expect(formatKeyword("work/clients/acme", KEYWORDS, false)).toBe("Acme");
expect(formatKeyword("work", KEYWORDS, false)).toBe("Work");
});
it("falls back to the whole id when the tag has no definition", () => {
expect(formatKeyword("work/archive/2026", KEYWORDS, false)).toBe("work/archive/2026");
});
it("offers no shortening, leaving the markup to clip", () => {
expect(keywordRenderings(formatKeywordLabels("work/clients/acme", KEYWORDS, false)))
.toEqual(["Acme"]);
});
});
describe("keywordRenderings", () => {
it("shortens by one intermediate level at a time, outermost first", () => {
expect(keywordRenderings(["Work", "Clients", "Acme", "EU", "Sales"])).toEqual([
"Work/Clients/Acme/EU/Sales",
"Work/../Acme/EU/Sales",
"Work/.../EU/Sales",
"Work/.../Sales",
]);
});
it("collapses to a single ... as soon as the run covers more than one level", () => {
expect(keywordRenderings(["Work", "Clients", "Acme", "Sales"])).toEqual([
"Work/Clients/Acme/Sales",
"Work/../Acme/Sales",
"Work/.../Sales",
]);
});
it("uses .. for a lone intermediate level, never ...", () => {
expect(keywordRenderings(["Work", "Clients", "Acme"])).toEqual([
"Work/Clients/Acme",
"Work/../Acme",
]);
});
it("has nothing to shorten without an intermediate level", () => {
expect(keywordRenderings(["Work", "Acme"])).toEqual(["Work/Acme"]);
expect(keywordRenderings(["Work"])).toEqual(["Work"]);
});
it("drops a rendering that would not come out shorter", () => {
// "../" costs as much as the level it replaces, so shortening buys nothing.
expect(keywordRenderings(["a", "it", "b"])).toEqual(["a/it/b"]);
expect(keywordRenderings(["a", "x", "b"])).toEqual(["a/x/b"]);
});
});
// How the components use the two together: resolve a tag to its display names,
// then hand the ladder to `useShortenedText` to pick a rung.
describe("keywordRenderings over formatKeywordLabels", () => {
it("shortens a display name by the same ladder as an id", () => {
const deep: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/clients/acme/eu", "Europe"),
];
expect(keywordRenderings(formatKeywordLabels("work/clients/acme/eu", deep, true))).toEqual([
"Work/Clients/Acme/Europe",
"Work/../Acme/Europe",
"Work/.../Europe",
]);
});
it("treats a slash inside one display name as part of that name, not a level", () => {
const slashed: KeywordDefinition[] = [kw("work", "Work"), kw("work/acme-r-d", "Acme/R&D")];
// Two levels, so there is no intermediate level to shorten.
expect(keywordRenderings(formatKeywordLabels("work/acme-r-d", slashed, true))).toEqual([
"Work/Acme/R&D",
]);
});
});
+183
View File
@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import {
MAX_KEYWORD_ID_LENGTH,
buildKeywordTree,
composeKeywordId,
countKeywordNodes,
filterKeywordTree,
getParentKeywordId,
hasChildKeywords,
isKeywordDescendant,
keywordLevels,
normalizeKeywordLevel,
} from "@/lib/keyword-nesting";
import type { KeywordDefinition } from "@/stores/settings-store";
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
// Work
// Clients
// Acme
// Personal
const KEYWORDS: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/personal", "Personal"),
];
describe("normalizeKeywordLevel", () => {
it("lowercases and folds unsupported characters into single dashes", () => {
expect(normalizeKeywordLevel("My Custom Tag!")).toBe("my-custom-tag");
expect(normalizeKeywordLevel(" Spaced Out ")).toBe("spaced-out");
expect(normalizeKeywordLevel("--Trimmed--")).toBe("trimmed");
});
it("treats a slash as part of the name, not as a level", () => {
expect(normalizeKeywordLevel("Acme/R&D")).toBe("acme-r-d");
});
it("returns an empty string when nothing usable is left", () => {
expect(normalizeKeywordLevel(" ")).toBe("");
expect(normalizeKeywordLevel("!!!")).toBe("");
});
});
describe("composeKeywordId", () => {
it("returns a bare slug at the top level", () => {
expect(composeKeywordId(null, "Work")).toBe("work");
expect(composeKeywordId("", "Work")).toBe("work");
});
it("appends the slug below the parent", () => {
expect(composeKeywordId("work/clients", "Acme")).toBe("work/clients/acme");
});
it("never produces a trailing separator for an unusable name", () => {
expect(composeKeywordId("work", "!!!")).toBe("");
});
});
describe("keywordLevels", () => {
it("splits an id into its levels", () => {
expect(keywordLevels("work/clients/acme")).toEqual(["work", "clients", "acme"]);
expect(keywordLevels("work")).toEqual(["work"]);
});
});
describe("getParentKeywordId", () => {
it("drops the last level", () => {
expect(getParentKeywordId("work/clients/acme")).toBe("work/clients");
});
it("returns null for a top-level tag", () => {
expect(getParentKeywordId("work")).toBeNull();
});
});
describe("isKeywordDescendant", () => {
it("matches anything below the ancestor", () => {
expect(isKeywordDescendant("work/clients/acme", "work")).toBe(true);
expect(isKeywordDescendant("work/clients", "work")).toBe(true);
});
it("does not match the ancestor itself or a shared name prefix", () => {
expect(isKeywordDescendant("work", "work")).toBe(false);
expect(isKeywordDescendant("workshop/tools", "work")).toBe(false);
});
});
describe("hasChildKeywords", () => {
it("reports whether any defined tag sits below the given one", () => {
expect(hasChildKeywords("work", KEYWORDS)).toBe(true);
expect(hasChildKeywords("work/clients", KEYWORDS)).toBe(true);
expect(hasChildKeywords("work/clients/acme", KEYWORDS)).toBe(false);
});
});
describe("MAX_KEYWORD_ID_LENGTH", () => {
it("leaves room for the `$label:` prefix within the 255-character keyword limit", () => {
expect(MAX_KEYWORD_ID_LENGTH).toBe(248);
expect("$label:".length + MAX_KEYWORD_ID_LENGTH).toBe(255);
});
});
describe("buildKeywordTree", () => {
it("nests each tag under its parent and records the depth", () => {
const [work] = buildKeywordTree(KEYWORDS);
expect(work.id).toBe("work");
expect(work.depth).toBe(0);
expect(work.children.map((c) => c.id)).toEqual(["work/clients", "work/personal"]);
const clients = work.children[0];
expect(clients.depth).toBe(1);
expect(clients.children.map((c) => c.id)).toEqual(["work/clients/acme"]);
expect(clients.children[0].depth).toBe(2);
});
it("keeps the manual order within a level", () => {
const reordered = [KEYWORDS[0], KEYWORDS[3], KEYWORDS[1], KEYWORDS[2]];
const [work] = buildKeywordTree(reordered);
expect(work.children.map((c) => c.id)).toEqual(["work/personal", "work/clients"]);
});
it("keeps a tag whose parent is not defined at the root", () => {
const orphan = buildKeywordTree([kw("work/clients/acme", "Acme")]);
expect(orphan).toHaveLength(1);
expect(orphan[0].id).toBe("work/clients/acme");
expect(orphan[0].depth).toBe(0);
});
it("returns every tag as a root when no id describes a hierarchy", () => {
const flat = buildKeywordTree([kw("red", "Red"), kw("blue", "Blue")]);
expect(flat.map((n) => n.id)).toEqual(["red", "blue"]);
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
});
});
describe("filterKeywordTree", () => {
const tree = buildKeywordTree(KEYWORDS);
it("drops the nodes the predicate rejects", () => {
const kept = filterKeywordTree(tree, (node) => node.id !== "work/personal");
const [work] = kept;
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
});
it("keeps a rejected node when a descendant survives, so nothing is stranded", () => {
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
const [work] = kept;
expect(work.id).toBe("work");
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
expect(work.children[0].children.map((c) => c.id)).toEqual(["work/clients/acme"]);
});
it("keeps the depth of a surviving node so its indentation does not shift", () => {
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
expect(kept[0].children[0].children[0].depth).toBe(2);
});
it("returns nothing when the predicate rejects everything", () => {
expect(filterKeywordTree(tree, () => false)).toEqual([]);
});
it("leaves the original tree untouched", () => {
filterKeywordTree(tree, (node) => node.id === "work");
expect(countKeywordNodes(tree)).toBe(4);
});
});
describe("countKeywordNodes", () => {
it("counts every level, not just the roots", () => {
expect(countKeywordNodes(buildKeywordTree(KEYWORDS))).toBe(4);
expect(countKeywordNodes([])).toBe(0);
});
});
+87 -30
View File
@@ -4,8 +4,10 @@ import {
sortThreadGroups,
getThreadParticipants,
mergeThreadEmails,
getEmailColorTag,
getThreadColorTag,
getEmailTagId,
getEmailTagIds,
getThreadTagId,
getThreadTagIds,
} from '../thread-utils';
import type { Email, ThreadGroup } from '../jmap/types';
@@ -245,47 +247,71 @@ describe('mergeThreadEmails', () => {
});
});
describe('getEmailColorTag', () => {
it('returns label from $label: keyword', () => {
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
describe('getEmailTagIds', () => {
it('gathers every tag set on the message', () => {
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
.toEqual(['red', 'work']);
});
it('returns label from legacy $color: keyword', () => {
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
it('reads the legacy prefix alongside the current one', () => {
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
});
it('returns null when no color keyword', () => {
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
});
it('returns null for undefined keywords', () => {
expect(getEmailColorTag(undefined)).toBeNull();
it('reports a tag written under both prefixes once', () => {
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
});
it('ignores keywords set to false', () => {
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']);
});
it('prefers $label: over $color: when both exist', () => {
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
});
it('handles custom keyword ids', () => {
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
});
it('returns null for empty keywords object', () => {
expect(getEmailColorTag({})).toBeNull();
it('is empty for an untagged message or none at all', () => {
expect(getEmailTagIds({ $seen: true })).toEqual([]);
expect(getEmailTagIds(undefined)).toEqual([]);
});
});
describe('getThreadColorTag', () => {
describe('getEmailTagId', () => {
it('returns label from $label: keyword', () => {
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
});
it('returns label from legacy $color: keyword', () => {
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
});
it('returns null when no color keyword', () => {
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
});
it('returns null for undefined keywords', () => {
expect(getEmailTagId(undefined)).toBeNull();
});
it('ignores keywords set to false', () => {
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
});
it('prefers $label: over $color: when both exist', () => {
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
});
it('handles custom keyword ids', () => {
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
});
it('returns null for empty keywords object', () => {
expect(getEmailTagId({})).toBeNull();
});
});
describe('getThreadTagId', () => {
it('returns first color found across thread emails', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
];
expect(getThreadColorTag(emails)).toBe('blue');
expect(getThreadTagId(emails)).toBe('blue');
});
it('returns null when no emails have color tags', () => {
@@ -293,7 +319,7 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
];
expect(getThreadColorTag(emails)).toBeNull();
expect(getThreadTagId(emails)).toBeNull();
});
it('returns first tag from earliest tagged email', () => {
@@ -301,7 +327,7 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
];
expect(getThreadColorTag(emails)).toBe('red');
expect(getThreadTagId(emails)).toBe('red');
});
it('returns legacy tag from thread emails', () => {
@@ -309,10 +335,41 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
];
expect(getThreadColorTag(emails)).toBe('green');
expect(getThreadTagId(emails)).toBe('green');
});
it('returns null for empty email array', () => {
expect(getThreadColorTag([])).toBeNull();
expect(getThreadTagId([])).toBeNull();
});
});
describe('getThreadTagIds', () => {
it('gathers the tags of every message in the thread', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }),
];
expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']);
});
it('reports a tag shared by several messages once', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
];
expect(getThreadTagIds(emails)).toEqual(['red']);
});
it('reads the legacy prefix alongside the current one', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$color:green': true } }),
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
];
expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']);
});
it('is empty for an untagged or empty thread', () => {
expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]);
expect(getThreadTagIds([])).toEqual([]);
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { Email } from "@/lib/jmap/types";
import { buildForwardSubject } from "@/lib/subject-prefix";
import { emailExportFilename, type EmailFilenameOptions } from "@/lib/download-filename";
export interface ForwardAsAttachmentEntry {
blobId: string;
name: string;
type: "message/rfc822";
size: number;
}
export interface ForwardAsAttachmentPayload {
subject: string;
attachment: ForwardAsAttachmentEntry;
}
/**
* Build the subject and synthetic attachment entry for forwarding a
* message as a message/rfc822 attachment instead of inline-quoted text
* (e.g. reporting spam to an upstream gateway that expects the raw
* original as an attachment, or preserving exact formatting/headers).
*
* Referenced by blobId, not re-uploaded - JMAP blobs are account-scoped,
* not per-email, so the same blobId a message already has can be attached
* to a brand new outgoing email directly.
*
* `filenameOptions`, when passed, carries the user's configured space/case/
* diacritics transforms (see useSettingsStore's filenameSpaceReplacement
* and friends) for consistency with "Export as .eml" / drag-out. Its
* `template`, if any, is ignored: this attachment goes out to a possibly
* external recipient (spam gateway, another person), so the filename is
* always just "{date}-{subject}.eml" - never the user's own from/to naming
* template, which could otherwise leak sender/recipient names into an
* attachment filename visible to that recipient.
*
* Returns null when the email has no blobId (nothing to reference).
*/
export function buildForwardAsAttachmentPayload(
email: Email,
forwardPrefix: string,
filenameOptions?: EmailFilenameOptions,
): ForwardAsAttachmentPayload | null {
if (!email.blobId) return null;
return {
// Match the normal Forward flow's getInitialSubject(), which leaves the
// subject blank rather than prefix-only when the original has none -
// buildForwardSubject("", prefix) would otherwise return just the bare
// prefix (e.g. "Fwd:") for a subject-less message.
subject: email.subject ? buildForwardSubject(email.subject, forwardPrefix) : "",
attachment: {
blobId: email.blobId,
name: emailExportFilename(email, { ...filenameOptions, template: "{date}-{subject}" }),
type: "message/rfc822",
size: email.size,
},
};
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Naming a tag on screen.
*
* A nested tag is written out level by level - `Work/Clients/Acme` - and a flat
* one is simply its own name, so nothing here asks the caller which kind it
* has. `keywordRenderings` additionally offers progressively shorter forms for
* a name with nowhere to fit, which `useShortenedText` measures against the
* room actually available.
*/
import type { KeywordDefinition } from "@/stores/settings-store";
import { KEYWORD_SEPARATOR, keywordLevels } from "./keyword-nesting";
/** Stands in for one level left out of a name. */
export const KEYWORD_SHORTENED_LEVEL = "..";
/** Stands in for a run of more than one level left out of a name. */
export const KEYWORD_SHORTENED_RUN = "...";
/**
* The display name of a tag, one entry per level, outermost first. A tag with
* one level yields a single entry, so callers need not care either way.
*
* `nested` is the user's setting. With nesting off a slash carries no meaning,
* so the id is one opaque token and the tag is named by its own label - nobody
* who left the setting alone should find their tags rewritten because an id
* happens to contain a slash, which can outlast turning nesting off, or arrive
* through settings sync or another client.
*
* With nesting on, each level resolves to that tag's display name, falling back
* to the raw level of the id when it has no definition - the settings list only
* describes the tags this client knows about. Levels stay separate entries
* because a display name may itself contain a slash, which is part of that one
* name rather than a level of its own.
*/
export function formatKeywordLabels(
id: string,
keywords: KeywordDefinition[],
nested: boolean,
): string[] {
const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label;
if (!nested) return [label(id) ?? id];
const levels = keywordLevels(id);
return levels.map((level, index) =>
label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level,
);
}
/**
* The display name of a tag: `Work/Clients/Acme` for a nested one, its own name
* otherwise. The general way to name a tag on screen.
*/
export function formatKeyword(
id: string,
keywords: KeywordDefinition[],
nested: boolean,
): string {
return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR);
}
/**
* Every way a name can be written, longest first: in full, then with an ever
* longer run of intermediate levels replaced by `..`, collapsing to a single
* `...` as soon as that run covers more than one level.
*
* The outermost and innermost levels always survive - between them they say
* which branch a tag belongs to and which tag it is, which is exactly what a
* trailing ellipsis destroys. A rendering that would not actually come out
* shorter than the one before it (levels named `it`, say) is dropped, so
* walking the list never makes the text grow.
*/
export function keywordRenderings(levels: string[]): string[] {
const renderings = [levels.join(KEYWORD_SEPARATOR)];
for (let shortened = 1; shortened <= levels.length - 2; shortened++) {
const marker = shortened === 1 ? KEYWORD_SHORTENED_LEVEL : KEYWORD_SHORTENED_RUN;
const rendering = [levels[0], marker, ...levels.slice(shortened + 1)]
.join(KEYWORD_SEPARATOR);
if (rendering.length < renderings[renderings.length - 1].length) {
renderings.push(rendering);
}
}
return renderings;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Tag nesting.
*
* A tag is stored on the server as the JMAP keyword `$label:<id>`, where `id`
* is a slug derived from the display name. Nesting reuses that single id: the
* levels are joined with a forward slash, so `$label:work/clients` is the child
* of `$label:work`. Keeping the hierarchy inside the id means the server stays
* the source of truth for tag membership and existing lookups by keyword keep
* working.
*
* RFC 8621 section 4.1.1 allows a keyword of 1-255 characters from the ASCII
* range %x21-%x7e minus `( ) { ] % * " \`, so the separator is legal but the
* length of a deep id is not free - `MAX_KEYWORD_ID_LENGTH` is the budget a
* composed id has to stay within.
*
* Turning any of this into text for the screen lives in `keyword-format`.
*/
import type { KeywordDefinition } from "@/stores/settings-store";
import { KEYWORD_PREFIX } from "./thread-utils";
/** Separates parent from child inside a tag id. */
export const KEYWORD_SEPARATOR = "/";
/** Longest keyword a JMAP server has to accept (RFC 8621, section 4.1.1). */
export const MAX_KEYWORD_LENGTH = 255;
/** What is left for the id once the `$label:` prefix is spent. */
export const MAX_KEYWORD_ID_LENGTH = MAX_KEYWORD_LENGTH - KEYWORD_PREFIX.length;
/** A tag definition placed in the hierarchy its id describes. */
export interface KeywordNode extends KeywordDefinition {
children: KeywordNode[];
depth: number;
}
/**
* Reduces a display name to one level of an id: lowercase, and everything
* outside `[a-z0-9_-]` folded to a single dash. The separator is not exempt -
* a slash typed into the name is a literal part of that name, not a level.
* The only slug function for tag ids; keep it the only one.
*/
export function normalizeKeywordLevel(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/** Builds the id a tag named `name` gets under `parentId` (null = top level). */
export function composeKeywordId(parentId: string | null, name: string): string {
const level = normalizeKeywordLevel(name);
if (!parentId || !level) return level;
return `${parentId}${KEYWORD_SEPARATOR}${level}`;
}
/** Splits `work/clients/acme` into `["work", "clients", "acme"]`. */
export function keywordLevels(id: string): string[] {
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
}
/** The id of the tag one level up, or null for a top-level tag. */
export function getParentKeywordId(id: string): string | null {
const index = id.lastIndexOf(KEYWORD_SEPARATOR);
return index === -1 ? null : id.slice(0, index);
}
/** True when `candidateId` sits anywhere below `ancestorId`. */
export function isKeywordDescendant(candidateId: string, ancestorId: string): boolean {
return candidateId.startsWith(`${ancestorId}${KEYWORD_SEPARATOR}`);
}
/** True when any defined tag sits below `id`. */
export function hasChildKeywords(id: string, keywords: KeywordDefinition[]): boolean {
return keywords.some((keyword) => isKeywordDescendant(keyword.id, id));
}
/**
* Arranges tag definitions into the tree their ids describe, preserving the
* user's manual order within each level.
*
* A tag whose direct parent is not defined stays at the root rather than being
* hidden or grafted onto a grandparent; callers name such a root in full so the
* missing level is still visible.
*/
export function buildKeywordTree(keywords: KeywordDefinition[]): KeywordNode[] {
const nodes = new Map<string, KeywordNode>();
for (const keyword of keywords) {
nodes.set(keyword.id, { ...keyword, children: [], depth: 0 });
}
const roots: KeywordNode[] = [];
for (const keyword of keywords) {
const node = nodes.get(keyword.id)!;
const parentId = getParentKeywordId(keyword.id);
const parent = parentId ? nodes.get(parentId) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
const setDepth = (node: KeywordNode, depth: number) => {
node.depth = depth;
node.children.forEach((child) => setDepth(child, depth + 1));
};
roots.forEach((root) => setDepth(root, 0));
return roots;
}
/**
* Prunes a tag tree down to the nodes worth showing.
*
* A node survives when the predicate accepts it or when any of its descendants
* survives, so hiding a parent never strands the children below it. Depths are
* left untouched: a kept node keeps the indentation of its original level even
* when the level above it is only there to carry it.
*/
export function filterKeywordTree(
nodes: KeywordNode[],
isVisible: (node: KeywordNode) => boolean,
): KeywordNode[] {
const kept: KeywordNode[] = [];
for (const node of nodes) {
const children = filterKeywordTree(node.children, isVisible);
if (children.length > 0 || isVisible(node)) {
kept.push({ ...node, children });
}
}
return kept;
}
/** Total number of nodes in a tag tree, at every level. */
export function countKeywordNodes(nodes: KeywordNode[]): number {
return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0);
}
+15 -16
View File
@@ -430,21 +430,20 @@ async function doContactCreate(contact: ContactCard): Promise<ContactCard> {
// ─── 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,
pluginId: string,
name?: string,
displayName?: string
displayName?: string,
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
// To avoid a privileged plugin to access secret created from another privileged plugin,
// we add the pluginID from manifest in salt.
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1" + pluginId)
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
@@ -456,18 +455,18 @@ async function doGetOrCreatePRF(
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
extensions: { prf: { eval: { first: PRF_SALT } } }
}
}) as PublicKeyCredential;
// Extract the derived symmetric key from the authenticator's output
const outputs = assertion.getClientExtensionResults();
const prfSecret = (outputs as any).prf?.results?.first;
const prfSecret = (outputs).prf?.results?.first;
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
return {
credentialId: masterCredentialIdBytes,
prfSecret: Array.from(new Uint8Array(prfSecret))
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
};
}
@@ -492,14 +491,14 @@ async function doGetOrCreatePRF(
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
extensions: { prf: {} } // 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;
const isPrfEnabled = (outputs).prf?.enabled;
if (!isPrfEnabled) {
return 'The authenticator does not support or has rejected the PRF extension.';
}
@@ -516,20 +515,20 @@ async function doGetOrCreatePRF(
userVerification: "required",
extensions: {
prf: { eval: { first: PRF_SALT } }
} as any
}
}
}) as PublicKeyCredential;
const assertionOutputs = assertion.getClientExtensionResults();
const prfSecret = (assertionOutputs as any).prf?.results?.first;
const prfSecret = (assertionOutputs).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))
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
};
}
@@ -738,7 +737,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 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string, args[2] as string | undefined, args[3] as string | undefined);
case 'contact.get': return doContactGet(args[0] as string);
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
+1 -1
View File
@@ -167,7 +167,7 @@ function buildPluginApi(manifest: PluginManifest) {
settings: { ...manifest.settings },
},
webauthn: {
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, manifest.id, name, displayName], 0)
},
storage: {
get: (key: string) => callApi('storage.get', [key]),
+30 -12
View File
@@ -168,41 +168,59 @@ export const KEYWORD_PREFIX = "$label:";
export const KEYWORD_PREFIX_LEGACY = "$color:";
/**
* Gets all active label/color tag IDs from email keywords.
* Gets every tag id set on a message.
* Reads both the current $label: prefix and the legacy $color: prefix.
* A tag written under both spellings is one tag, so it is returned once.
*/
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return [];
const tags: string[] = [];
const tags = new Set<string>();
for (const key of Object.keys(keywords)) {
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
tags.push(
tags.add(
key.startsWith(KEYWORD_PREFIX)
? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length)
);
}
}
return tags;
return [...tags];
}
/**
* Gets label/color tag from email keywords (if any).
* Gets the first tag id set on a message, if any.
* Reads both the current $label: prefix and the legacy $color: prefix.
* @deprecated Use getEmailColorTags for multi-tag support.
* @deprecated Use getEmailTagIds for multi-tag support.
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailColorTags(keywords);
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailTagIds(keywords);
return tags.length > 0 ? tags[0] : null;
}
/**
* Checks if a thread has any color tag (returns first found).
* The first tag id found anywhere in a thread, if any.
*/
export function getThreadColorTag(emails: Email[]): string | null {
export function getThreadTagId(emails: Email[]): string | null {
for (const email of emails) {
const color = getEmailColorTag(email.keywords);
const color = getEmailTagId(email.keywords);
if (color) return color;
}
return null;
}
/**
* Every tag anywhere in a thread, deduplicated.
*
* A collapsed thread row stands in for all its messages, so it has to account
* for all their tags - showing only the first message's would hide the rest
* with nothing to indicate they exist.
*/
export function getThreadTagIds(emails: Email[]): string[] {
const tags = new Set<string>();
for (const email of emails) {
for (const tag of getEmailTagIds(email.keywords)) {
tags.add(tag);
}
}
return [...tags];
}
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "إعادة تعيين",
"demo_tour": "جولة",
"tags": "الوسوم",
"show_all_tags": "إظهار الكل ({count})",
"show_fewer_tags": "إظهار أقل",
"folders": "المجلدات",
"shared": "مشترك",
"mail": "البريد",
@@ -298,6 +300,7 @@
"print": "طباعة",
"view_source": "عرض المصدر",
"export_email": "تصدير كملف .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "استيراد ملف .eml أو .zip",
"keyboard_shortcuts": "اختصارات لوحة المفاتيح (؟)",
"email_source": "مصدر الرسالة",
@@ -324,13 +327,15 @@
"view_contact": "عرض جهة الاتصال",
"message_details": "تفاصيل الرسالة",
"more_reply_options": "خيارات رد إضافية",
"set_color": "تعيين وسم",
"set_tag": "تعيين وسم",
"tag": "وسم",
"more_actions": "المزيد من الإجراءات",
"previous": "السابق",
"next": "التالي",
"move_to": "نقل إلى...",
"remove_color": "إزالة الوسم",
"remove_tag": "إزالة الوسم",
"tag_filter_placeholder": "تصفية الوسوم",
"tag_no_matches": "لا توجد وسوم مطابقة",
"more_count": "+{count} أخرى",
"characters_count": "{count} حرفًا",
"quick_reply_placeholder": "اكتب ردًا سريعًا...",
@@ -424,17 +429,6 @@
"message_id": "معرّف الرسالة",
"list_info": "معلومات القائمة"
},
"color_tag": {
"title": "وسم لوني",
"red": "أحمر",
"orange": "برتقالي",
"yellow": "أصفر",
"green": "أخضر",
"blue": "أزرق",
"purple": "بنفسجي",
"pink": "وردي",
"none": "بلا"
},
"tooltips": {
"reply": "رد (r)",
"reply_all": "الرد على الجميع (a)",
@@ -1018,7 +1012,6 @@
"title": "وسوم البريد",
"description": "عرّف وسومًا لتنظيم رسائلك بالألوان. تُخزَّن هذه ككلمات مفتاحية JMAP على الخادم.",
"add_keyword": "إضافة وسم",
"reset_defaults": "إعادة التعيين للافتراضي",
"label_field": "الاسم المعروض",
"label_placeholder": "مثال: عمل، شخصي، عاجل",
"id_field": "معرّف الوسم",
@@ -1031,7 +1024,22 @@
"add": "إضافة",
"cancel": "إلغاء",
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
"migration_error": "فشل تحديث الوسم على الرسائل الحالية"
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
"nesting": {
"label": "وسوم متداخلة",
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
},
"parent_field": "الوسم الأصل",
"no_parent": "بدون وسم أصل",
"too_long": "مسار الوسم طويل جدًا ({max} حرفًا على الأكثر)",
"has_children_locked": "توجد وسوم أخرى متداخلة تحت هذا الوسم، لذا فإن اسمه ووسمه الأصل مقفلان. انقلها أو احذفها أولًا.",
"has_children_delete": "احذف أولًا الوسوم المتداخلة تحت هذا الوسم",
"visibility_field": "الظهور في الشريط الجانبي",
"visibility": {
"show": "إظهار",
"unread": "إظهار عند وجود غير مقروء",
"hide": "إخفاء"
}
},
"notifications": {
"test_sound": "اختبار صوت الإشعار",
@@ -2033,8 +2041,7 @@
"delete": "حذف",
"mark_as_spam": "الإبلاغ عن بريد مزعج",
"not_spam": "ليس مزعجًا",
"color_tag": "وسم",
"remove_color": "إزالة الوسم",
"tag": "وسم",
"items_selected": "{count} رسالة محددة",
"edit_draft": "تعديل المسودة",
"cancel_scheduled_send": "إلغاء الإرسال",
+56 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Reinicia",
"demo_tour": "Visita guiada",
"tags": "Etiquetes",
"show_all_tags": "Mostra-ho tot ({count})",
"show_fewer_tags": "Mostra'n menys",
"folders": "Carpetes",
"shared": "Compartit",
"mail": "Correu",
@@ -298,6 +300,7 @@
"print": "Imprimeix",
"view_source": "Mostra el codi font",
"export_email": "Exporta com a .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importa .eml o .zip",
"keyboard_shortcuts": "Dreceres de teclat (?)",
"email_source": "Codi font del correu",
@@ -324,13 +327,15 @@
"view_contact": "Mostra el contacte",
"message_details": "Detalls del missatge",
"more_reply_options": "Més opcions de resposta",
"set_color": "Estableix l'etiqueta",
"set_tag": "Estableix l'etiqueta",
"tag": "Etiqueta",
"more_actions": "Més accions",
"previous": "Anterior",
"next": "Següent",
"move_to": "Mou a...",
"remove_color": "Elimina l'etiqueta",
"remove_tag": "Elimina l'etiqueta",
"tag_filter_placeholder": "Filtra les etiquetes",
"tag_no_matches": "Cap etiqueta coincident",
"more_count": "+{count} més",
"characters_count": "{count} caràcters",
"quick_reply_placeholder": "Escriviu una resposta ràpida...",
@@ -424,17 +429,6 @@
"message_id": "ID del missatge",
"list_info": "Informació de la llista"
},
"color_tag": {
"title": "Etiqueta de color",
"red": "Vermell",
"orange": "Taronja",
"yellow": "Groc",
"green": "Verd",
"blue": "Blau",
"purple": "Lila",
"pink": "Rosa",
"none": "Cap"
},
"tooltips": {
"reply": "Respon (r)",
"reply_all": "Respon a tots (a)",
@@ -686,6 +680,38 @@
"recipient_name_placeholder": "Nom mostrat",
"autocomplete_search_server": "Cerca al servidor",
"autocomplete_searching": "Cercant...",
"toolbar": {
"bold": "Negreta",
"italic": "Cursiva",
"underline": "Subratllat",
"strikethrough": "Ratllat",
"text_color": "Color del text",
"remove_color": "Elimina el color",
"heading_1": "Encapçalament 1",
"heading_2": "Encapçalament 2",
"bullet_list": "Llista de pics",
"ordered_list": "Llista numerada",
"quote": "Cita",
"code_block": "Bloc de codi",
"align_left": "Alinea a l'esquerra",
"align_center": "Centra",
"align_right": "Alinea a la dreta",
"text_direction": "Direcció del text (RTL/LTR)",
"link": "Enllaç",
"table": "Taula",
"clear_formatting": "Neteja el format",
"undo": "Desfés",
"redo": "Refés",
"add_row_above": "Afegeix una fila a sobre",
"add_row_below": "Afegeix una fila a sota",
"add_column_before": "Afegeix una columna abans",
"add_column_after": "Afegeix una columna després",
"delete_row": "Elimina la fila",
"delete_column": "Elimina la columna",
"toggle_header_row": "Commuta la fila de capçalera",
"delete_table": "Elimina la taula",
"pick_size": "Tria la mida"
},
"send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet."
},
"confirm_dialog": {
@@ -986,7 +1012,6 @@
"title": "Etiquetes de correu",
"description": "Definiu etiquetes per organitzar els correus amb colors. Es desen com a paraules clau JMAP al servidor.",
"add_keyword": "Afegeix una etiqueta",
"reset_defaults": "Restableix als valors per defecte",
"label_field": "Nom mostrat",
"label_placeholder": "p. ex. Feina, Personal, Urgent",
"id_field": "ID de l'etiqueta",
@@ -999,7 +1024,22 @@
"add": "Afegeix",
"cancel": "Cancel·la",
"migrating": "Actualitzant l'etiqueta als correus existents…",
"migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents"
"migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents",
"nesting": {
"label": "Etiquetes imbricades",
"description": "Imbrica etiquetes sota altres etiquetes i mostra-les com un arbre a la barra lateral."
},
"parent_field": "Etiqueta principal",
"no_parent": "Sense etiqueta principal",
"too_long": "Aquest camí d'etiqueta és massa llarg (com a màxim {max} caràcters)",
"has_children_locked": "Hi ha altres etiquetes imbricades sota aquesta, per això el seu nom i la seva etiqueta principal estan bloquejats. Mou-les o elimina-les primer.",
"has_children_delete": "Elimina primer les etiquetes imbricades sota aquesta",
"visibility_field": "Visibilitat a la barra lateral",
"visibility": {
"show": "Mostra",
"unread": "Mostra si hi ha no llegits",
"hide": "Amaga"
}
},
"notifications": {
"test_sound": "Prova el so de notificació",
@@ -2001,8 +2041,7 @@
"delete": "Suprimeix",
"mark_as_spam": "Denuncia com a brossa",
"not_spam": "No és brossa",
"color_tag": "Etiqueta",
"remove_color": "Elimina l'etiqueta",
"tag": "Etiqueta",
"items_selected": "{count} correus seleccionats",
"edit_draft": "Edita l'esborrany",
"cancel_scheduled_send": "Cancel·la l'enviament",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetovat",
"demo_tour": "Průvodce",
"tags": "Štítky",
"show_all_tags": "Zobrazit vše ({count})",
"show_fewer_tags": "Zobrazit méně",
"folders": "Složky",
"shared": "Sdílené",
"mail": "Pošta",
@@ -298,6 +300,7 @@
"print": "Tisk",
"view_source": "Zobrazit zdrojový kód",
"export_email": "Exportovat jako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importovat .eml nebo .zip",
"keyboard_shortcuts": "Klávesové zkratky (?)",
"email_source": "Zdrojový kód zprávy",
@@ -324,13 +327,15 @@
"view_contact": "Zobrazit kontakt",
"message_details": "Podrobnosti zprávy",
"more_reply_options": "Další možnosti odpovědi",
"set_color": "Nastavit štítek",
"set_tag": "Nastavit štítek",
"tag": "Štítek",
"more_actions": "Další akce",
"previous": "Předchozí",
"next": "Další",
"move_to": "Přesunout do...",
"remove_color": "Odebrat štítek",
"remove_tag": "Odebrat štítek",
"tag_filter_placeholder": "Filtrovat štítky",
"tag_no_matches": "Žádné odpovídající štítky",
"more_count": "+{count} dalších",
"characters_count": "{count} znaků",
"quick_reply_placeholder": "Napsat rychlou odpověď...",
@@ -399,17 +404,6 @@
"message_id": "ID zprávy",
"list_info": "Informace o konferenci"
},
"color_tag": {
"title": "Barevný štítek",
"red": "Červený",
"orange": "Oranžový",
"yellow": "Žlutý",
"green": "Zelený",
"blue": "Modrý",
"purple": "Fialový",
"pink": "Růžový",
"none": "Žádný"
},
"tooltips": {
"reply": "Odpovědět (r)",
"reply_all": "Odpovědět všem (a)",
@@ -1015,7 +1009,6 @@
"title": "E-mailové štítky",
"description": "Definujte štítky pro organizaci e-mailů pomocí barev. Ukládají se jako klíčová slova JMAP na serveru.",
"add_keyword": "Přidat štítek",
"reset_defaults": "Obnovit výchozí",
"label_field": "Zobrazovaný název",
"label_placeholder": "např. Práce, Osobní, Naliehavé",
"id_field": "ID štítku",
@@ -1028,7 +1021,22 @@
"add": "Přidat",
"cancel": "Zrušit",
"migrating": "Aktualizace štítku v existujících e-mailech…",
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech"
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech",
"nesting": {
"label": "Vnořené štítky",
"description": "Vnořujte štítky pod jiné štítky a zobrazujte je v postranním panelu jako strom."
},
"parent_field": "Nadřazený štítek",
"no_parent": "Bez nadřazeného štítku",
"too_long": "Tato cesta štítku je příliš dlouhá (nejvýše {max} znaků)",
"has_children_locked": "Pod tímto štítkem jsou vnořeny další štítky, proto jsou jeho název a nadřazený štítek uzamčeny. Nejprve je přesuňte nebo odeberte.",
"has_children_delete": "Nejprve odeberte štítky vnořené pod tímto",
"visibility_field": "Viditelnost v postranním panelu",
"visibility": {
"show": "Zobrazit",
"unread": "Zobrazit při nepřečtených",
"hide": "Skrýt"
}
},
"notifications": {
"test_sound": "Otestovat zvuk oznámení",
@@ -2033,8 +2041,7 @@
"delete": "Odstranit",
"mark_as_spam": "Nahlásit spam",
"not_spam": "Není spam",
"color_tag": "Štítek",
"remove_color": "Odebrat štítek",
"tag": "Štítek",
"items_selected": "{count} vybraných zpráv",
"edit_draft": "Upravit koncept",
"cancel_scheduled_send": "Zrušit odeslání",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Nulstil",
"demo_tour": "Rundvisning",
"tags": "Tags",
"show_all_tags": "Vis alle ({count})",
"show_fewer_tags": "Vis færre",
"folders": "Mapper",
"shared": "Delt",
"mail": "Mail",
@@ -298,6 +300,7 @@
"print": "Udskriv",
"view_source": "Vis kilde",
"export_email": "Eksportér som .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importér .eml eller .zip",
"keyboard_shortcuts": "Tastaturgenveje (?)",
"email_source": "E-mail-kilde",
@@ -324,13 +327,15 @@
"view_contact": "Vis kontakt",
"message_details": "Beskeddetaljer",
"more_reply_options": "Flere svar-muligheder",
"set_color": "Sæt tag",
"set_tag": "Sæt tag",
"tag": "Tag",
"more_actions": "Flere handlinger",
"previous": "Forrige",
"next": "Næste",
"move_to": "Flyt til...",
"remove_color": "Fjern tag",
"remove_tag": "Fjern tag",
"tag_filter_placeholder": "Filtrer tags",
"tag_no_matches": "Ingen matchende tags",
"more_count": "+{count} mere",
"characters_count": "{count} tegn",
"quick_reply_placeholder": "Skriv et hurtigt svar...",
@@ -424,17 +429,6 @@
"message_id": "Besked-ID",
"list_info": "Listeinformation"
},
"color_tag": {
"title": "Farvetag",
"red": "Rød",
"orange": "Orange",
"yellow": "Gul",
"green": "Grøn",
"blue": "Blå",
"purple": "Lilla",
"pink": "Pink",
"none": "Ingen"
},
"tooltips": {
"reply": "Svar (r)",
"reply_all": "Svar alle (a)",
@@ -1018,7 +1012,6 @@
"title": "E-mail-tags",
"description": "Definér tags til at organisere dine e-mails med farver. Disse gemmes som JMAP-nøgleord på serveren.",
"add_keyword": "Tilføj tag",
"reset_defaults": "Nulstil til standard",
"label_field": "Visningsnavn",
"label_placeholder": "f.eks. Arbejde, Privat, Vigtigt",
"id_field": "Tag-ID",
@@ -1031,7 +1024,22 @@
"add": "Tilføj",
"cancel": "Annuller",
"migrating": "Opdaterer tag på eksisterende e-mails…",
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails"
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails",
"nesting": {
"label": "Indlejrede tags",
"description": "Indlejr tags under andre tags og vis dem som et træ i sidepanelet."
},
"parent_field": "Overordnet tag",
"no_parent": "Intet overordnet tag",
"too_long": "Denne tagsti er for lang (højst {max} tegn)",
"has_children_locked": "Andre tags er indlejret under dette, så dets navn og overordnede tag er låst. Flyt eller fjern dem først.",
"has_children_delete": "Fjern først de tags, der er indlejret under dette",
"visibility_field": "Synlighed i sidepanel",
"visibility": {
"show": "Vis",
"unread": "Vis ved ulæste",
"hide": "Skjul"
}
},
"notifications": {
"test_sound": "Test notifikationslyd",
@@ -2033,8 +2041,7 @@
"delete": "Slet",
"mark_as_spam": "Rapportér spam",
"not_spam": "Ikke spam",
"color_tag": "Tag",
"remove_color": "Fjern tag",
"tag": "Tag",
"items_selected": "{count} e-mails valgt",
"edit_draft": "Redigér kladde",
"cancel_scheduled_send": "Annuller afsendelse",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Zurücksetzen",
"demo_tour": "Tour",
"tags": "Tags",
"show_all_tags": "Alle anzeigen ({count})",
"show_fewer_tags": "Weniger anzeigen",
"folders": "Ordner",
"mail": "E-Mail",
"nav_label": "Navigation",
@@ -298,6 +300,7 @@
"print": "Drucken",
"view_source": "Quelltext anzeigen",
"export_email": "Als .eml exportieren",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml oder .zip importieren",
"keyboard_shortcuts": "Tastaturkürzel (?)",
"email_source": "E-Mail-Quelltext",
@@ -324,11 +327,13 @@
"view_contact": "Kontakt anzeigen",
"message_details": "Nachrichtendetails",
"more_reply_options": "Weitere Antwortoptionen",
"set_color": "Label setzen",
"set_tag": "Label setzen",
"tag": "Label",
"more_actions": "Weitere Aktionen",
"move_to": "Verschieben nach...",
"remove_color": "Label entfernen",
"remove_tag": "Label entfernen",
"tag_filter_placeholder": "Labels filtern",
"tag_no_matches": "Keine passenden Labels",
"more_count": "+{count} weitere",
"characters_count": "{count} Zeichen",
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
@@ -397,17 +402,6 @@
"message_id": "Nachrichten-ID",
"list_info": "Listeninformationen"
},
"color_tag": {
"title": "Farb-Tag",
"red": "Rot",
"orange": "Orange",
"yellow": "Gelb",
"green": "Grün",
"blue": "Blau",
"purple": "Violett",
"pink": "Rosa",
"none": "Keine"
},
"tooltips": {
"reply": "Antworten",
"reply_all": "Allen antworten (a)",
@@ -1015,7 +1009,6 @@
"title": "E-Mail-Labels",
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
"add_keyword": "Label hinzufügen",
"reset_defaults": "Auf Standard zurücksetzen",
"label_field": "Anzeigename",
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
"id_field": "Label-ID",
@@ -1028,7 +1021,22 @@
"add": "Hinzufügen",
"cancel": "Abbrechen",
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden"
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden",
"nesting": {
"label": "Verschachtelte Labels",
"description": "Labels unter anderen Labels verschachteln und als Baum in der Seitenleiste anzeigen."
},
"parent_field": "Übergeordnetes Label",
"no_parent": "Kein übergeordnetes Label",
"too_long": "Dieser Label-Pfad ist zu lang (höchstens {max} Zeichen)",
"has_children_locked": "Unter diesem Label sind andere Labels verschachtelt, daher sind Name und übergeordnetes Label gesperrt. Verschieben oder entfernen Sie diese zuerst.",
"has_children_delete": "Entfernen Sie zuerst die Labels, die unter diesem verschachtelt sind",
"visibility_field": "Sichtbarkeit in der Seitenleiste",
"visibility": {
"show": "Anzeigen",
"unread": "Bei Ungelesenen anzeigen",
"hide": "Ausblenden"
}
},
"notifications": {
"test_sound": "Benachrichtigungston testen",
@@ -2033,8 +2041,7 @@
"delete": "Löschen",
"mark_as_spam": "Spam melden",
"not_spam": "Kein Spam",
"color_tag": "Label",
"remove_color": "Label entfernen",
"tag": "Label",
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten",
"cancel_scheduled_send": "Senden abbrechen",
+25 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Reset",
"demo_tour": "Tour",
"tags": "Tags",
"show_all_tags": "Show all ({count})",
"show_fewer_tags": "Show less",
"folders": "Folders",
"shared": "Shared",
"mail": "Mail",
@@ -298,6 +300,7 @@
"print": "Print",
"view_source": "View source",
"export_email": "Export as .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Import .eml or .zip",
"keyboard_shortcuts": "Keyboard shortcuts (?)",
"email_source": "Email Source",
@@ -324,13 +327,15 @@
"view_contact": "View contact",
"message_details": "Message Details",
"more_reply_options": "More reply options",
"set_color": "Set tag",
"set_tag": "Set tag",
"tag": "Tag",
"more_actions": "More actions",
"previous": "Prev",
"next": "Next",
"move_to": "Move to...",
"remove_color": "Remove tag",
"remove_tag": "Remove tag",
"tag_filter_placeholder": "Filter tags",
"tag_no_matches": "No matching tags",
"more_count": "+{count} more",
"characters_count": "{count} characters",
"quick_reply_placeholder": "Write a quick reply...",
@@ -424,17 +429,6 @@
"message_id": "Message ID",
"list_info": "List Information"
},
"color_tag": {
"title": "Color Tag",
"red": "Red",
"orange": "Orange",
"yellow": "Yellow",
"green": "Green",
"blue": "Blue",
"purple": "Purple",
"pink": "Pink",
"none": "None"
},
"tooltips": {
"reply": "Reply (r)",
"reply_all": "Reply All (a)",
@@ -1016,9 +1010,8 @@
},
"keywords": {
"title": "Email Tags",
"description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.",
"description": "Define tags to organize your emails. These are stored as JMAP keywords on the server.",
"add_keyword": "Add Tag",
"reset_defaults": "Reset to Defaults",
"label_field": "Display Name",
"label_placeholder": "e.g. Work, Personal, Urgent",
"id_field": "Tag ID",
@@ -1031,7 +1024,22 @@
"add": "Add",
"cancel": "Cancel",
"migrating": "Updating tag on existing emails…",
"migration_error": "Failed to update tag on existing emails"
"migration_error": "Failed to update tag on existing emails",
"nesting": {
"label": "Nested Tags",
"description": "Nest tags underneath other tags and show them as a tree in the sidebar."
},
"parent_field": "Parent Tag",
"no_parent": "No parent",
"too_long": "This tag path is too long (at most {max} characters)",
"has_children_locked": "Other tags are nested under this one, so its name and parent are locked. Move or remove them first.",
"has_children_delete": "Remove the tags nested under this one first",
"visibility_field": "Sidebar visibility",
"visibility": {
"show": "Show",
"unread": "Show if unread",
"hide": "Hide"
}
},
"notifications": {
"test_sound": "Test notification sound",
@@ -2033,8 +2041,7 @@
"delete": "Delete",
"mark_as_spam": "Report spam",
"not_spam": "Not spam",
"color_tag": "Tag",
"remove_color": "Remove tag",
"tag": "Tag",
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft",
"cancel_scheduled_send": "Cancel send",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Restablecer",
"demo_tour": "Tour",
"tags": "Etiquetas",
"show_all_tags": "Mostrar todo ({count})",
"show_fewer_tags": "Mostrar menos",
"folders": "Carpetas",
"mail": "Correo",
"nav_label": "Navegación",
@@ -298,6 +300,7 @@
"print": "Imprimir",
"view_source": "Ver código fuente",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importar .eml o .zip",
"keyboard_shortcuts": "Atajos de teclado (?)",
"email_source": "Código Fuente del Correo",
@@ -324,11 +327,13 @@
"view_contact": "Ver contacto",
"message_details": "Detalles del Mensaje",
"more_reply_options": "Más opciones de respuesta",
"set_color": "Establecer etiqueta",
"set_tag": "Establecer etiqueta",
"tag": "Etiqueta",
"more_actions": "Más acciones",
"move_to": "Mover a...",
"remove_color": "Eliminar etiqueta",
"remove_tag": "Eliminar etiqueta",
"tag_filter_placeholder": "Filtrar etiquetas",
"tag_no_matches": "No hay etiquetas coincidentes",
"more_count": "+{count} más",
"characters_count": "{count} caracteres",
"quick_reply_placeholder": "Escriba una respuesta rápida...",
@@ -397,17 +402,6 @@
"message_id": "ID del Mensaje",
"list_info": "Información de Lista"
},
"color_tag": {
"title": "Etiqueta de Color",
"red": "Rojo",
"orange": "Naranja",
"yellow": "Amarillo",
"green": "Verde",
"blue": "Azul",
"purple": "Morado",
"pink": "Rosa",
"none": "Ninguno"
},
"tooltips": {
"reply": "Responder",
"reply_all": "Responder a todos (a)",
@@ -1015,7 +1009,6 @@
"title": "Etiquetas de correo",
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
"add_keyword": "Añadir etiqueta",
"reset_defaults": "Restablecer valores predeterminados",
"label_field": "Nombre para mostrar",
"label_placeholder": "ej. Trabajo, Personal, Urgente",
"id_field": "ID de etiqueta",
@@ -1028,7 +1021,22 @@
"add": "Añadir",
"cancel": "Cancelar",
"migrating": "Actualizando etiqueta en correos existentes…",
"migration_error": "Error al actualizar la etiqueta en correos existentes"
"migration_error": "Error al actualizar la etiqueta en correos existentes",
"nesting": {
"label": "Etiquetas anidadas",
"description": "Anida etiquetas debajo de otras etiquetas y muéstralas como un árbol en la barra lateral."
},
"parent_field": "Etiqueta principal",
"no_parent": "Sin etiqueta principal",
"too_long": "Esta ruta de etiqueta es demasiado larga (máximo {max} caracteres)",
"has_children_locked": "Hay otras etiquetas anidadas bajo esta, por lo que su nombre y su etiqueta principal están bloqueados. Muévelas o elimínalas primero.",
"has_children_delete": "Elimina primero las etiquetas anidadas bajo esta",
"visibility_field": "Visibilidad en la barra lateral",
"visibility": {
"show": "Mostrar",
"unread": "Mostrar si hay no leídos",
"hide": "Ocultar"
}
},
"notifications": {
"test_sound": "Probar sonido de notificación",
@@ -2033,8 +2041,7 @@
"delete": "Eliminar",
"mark_as_spam": "Reportar spam",
"not_spam": "No es spam",
"color_tag": "Etiqueta",
"remove_color": "Eliminar etiqueta",
"tag": "Etiqueta",
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador",
"cancel_scheduled_send": "Cancelar envío",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "بازنشانی",
"demo_tour": "تور",
"tags": "برچسب‌ها",
"show_all_tags": "نمایش همه ({count})",
"show_fewer_tags": "نمایش کمتر",
"folders": "پوشه‌ها",
"shared": "اشتراکی",
"mail": "ایمیل",
@@ -298,6 +300,7 @@
"print": "چاپ",
"view_source": "مشاهده منبع",
"export_email": "خروجی .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "وارد کردن .eml یا .zip",
"keyboard_shortcuts": "میانبرهای صفحه کلید (?)",
"email_source": "منبع ایمیل",
@@ -324,13 +327,15 @@
"view_contact": "مشاهده مخاطب",
"message_details": "جزئیات پیام",
"more_reply_options": "گزینه‌های بیشتر پاسخ",
"set_color": "تنظیم برچسب",
"set_tag": "تنظیم برچسب",
"tag": "برچسب",
"more_actions": "عملیات بیشتر",
"previous": "قبلی",
"next": "بعدی",
"move_to": "انتقال به...",
"remove_color": "حذف برچسب",
"remove_tag": "حذف برچسب",
"tag_filter_placeholder": "فیلتر برچسب‌ها",
"tag_no_matches": "برچسب مطابقی یافت نشد",
"more_count": "+{count} بیشتر",
"characters_count": "{count} کاراکتر",
"quick_reply_placeholder": "پاسخ سریع بنویسید...",
@@ -424,17 +429,6 @@
"message_id": "شناسه پیام",
"list_info": "اطلاعات لیست"
},
"color_tag": {
"title": "برچسب رنگی",
"red": "قرمز",
"orange": "نارنجی",
"yellow": "زرد",
"green": "سبز",
"blue": "آبی",
"purple": "بنفش",
"pink": "صورتی",
"none": "هیچکدام"
},
"tooltips": {
"reply": "پاسخ (r)",
"reply_all": "پاسخ به همه (a)",
@@ -1018,7 +1012,6 @@
"title": "برچسب‌های ایمیل",
"description": "برچسب‌ها را برای سازمان‌دهی ایمیل‌ها تعریف کنید",
"add_keyword": "افزودن برچسب",
"reset_defaults": "بازنشانی به پیش‌فرض",
"label_field": "نام نمایشی",
"label_placeholder": "مثال: کاری، شخصی، فوری",
"id_field": "شناسه برچسب",
@@ -1031,7 +1024,22 @@
"add": "افزودن",
"cancel": "انصراف",
"migrating": "در حال به‌روزرسانی برچسب روی ایمیل‌های موجود…",
"migration_error": "به‌روزرسانی برچسب ناموفق بود"
"migration_error": "به‌روزرسانی برچسب ناموفق بود",
"nesting": {
"label": "برچسب‌های تودرتو",
"description": "برچسب‌ها را زیر برچسب‌های دیگر قرار دهید و آن‌ها را به‌صورت درختی در نوار کناری نمایش دهید."
},
"parent_field": "برچسب والد",
"no_parent": "بدون برچسب والد",
"too_long": "این مسیر برچسب خیلی طولانی است (حداکثر {max} نویسه)",
"has_children_locked": "برچسب‌های دیگری زیر این برچسب قرار دارند، بنابراین نام و برچسب والد آن قفل است. ابتدا آن‌ها را جابه‌جا یا حذف کنید.",
"has_children_delete": "ابتدا برچسب‌های زیرمجموعهٔ این برچسب را حذف کنید",
"visibility_field": "نمایش در نوار کناری",
"visibility": {
"show": "نمایش",
"unread": "نمایش در صورت وجود خوانده‌نشده",
"hide": "پنهان کردن"
}
},
"notifications": {
"test_sound": "تست صدای اعلان",
@@ -2033,8 +2041,7 @@
"delete": "حذف",
"mark_as_spam": "گزارش هرزنامه",
"not_spam": "هرزنامه نیست",
"color_tag": "برچسب",
"remove_color": "حذف برچسب",
"tag": "برچسب",
"items_selected": "{count} ایمیل انتخاب شده",
"edit_draft": "ویرایش پیش‌نویس",
"cancel_scheduled_send": "لغو ارسال",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Réinitialiser",
"demo_tour": "Visite",
"tags": "Étiquettes",
"show_all_tags": "Tout afficher ({count})",
"show_fewer_tags": "Afficher moins",
"folders": "Dossiers",
"mail": "Messagerie",
"nav_label": "Navigation",
@@ -298,6 +300,7 @@
"print": "Imprimer",
"view_source": "Voir la source",
"export_email": "Exporter en .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importer .eml ou .zip",
"keyboard_shortcuts": "Raccourcis clavier (?)",
"email_source": "Source de l'email",
@@ -324,11 +327,13 @@
"view_contact": "Voir le contact",
"message_details": "Détails du message",
"more_reply_options": "Plus d'options de réponse",
"set_color": "Définir l'étiquette",
"set_tag": "Définir l'étiquette",
"tag": "Étiquette",
"more_actions": "Plus d'actions",
"move_to": "Déplacer vers...",
"remove_color": "Retirer l'étiquette",
"remove_tag": "Retirer l'étiquette",
"tag_filter_placeholder": "Filtrer les étiquettes",
"tag_no_matches": "Aucune étiquette correspondante",
"more_count": "+{count} de plus",
"characters_count": "{count} caractères",
"quick_reply_placeholder": "Écrivez une réponse rapide...",
@@ -397,17 +402,6 @@
"message_id": "ID du message",
"list_info": "Information de liste"
},
"color_tag": {
"title": "Étiquette de couleur",
"red": "Rouge",
"orange": "Orange",
"yellow": "Jaune",
"green": "Vert",
"blue": "Bleu",
"purple": "Violet",
"pink": "Rose",
"none": "Aucune"
},
"tooltips": {
"reply": "Répondre",
"reply_all": "Répondre à tous (a)",
@@ -1015,7 +1009,6 @@
"title": "Étiquettes de messagerie",
"description": "Définissez des étiquettes pour organiser vos e-mails avec des couleurs. Elles sont stockées sous forme de mots-clés JMAP sur le serveur.",
"add_keyword": "Ajouter une étiquette",
"reset_defaults": "Réinitialiser par défaut",
"label_field": "Nom d'affichage",
"label_placeholder": "ex. Travail, Personnel, Urgent",
"id_field": "ID d'étiquette",
@@ -1028,7 +1021,22 @@
"add": "Ajouter",
"cancel": "Annuler",
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants"
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants",
"nesting": {
"label": "Étiquettes imbriquées",
"description": "Imbriquez des étiquettes sous d'autres étiquettes et affichez-les sous forme d'arborescence dans la barre latérale."
},
"parent_field": "Étiquette parente",
"no_parent": "Aucune étiquette parente",
"too_long": "Ce chemin d'étiquette est trop long ({max} caractères au maximum)",
"has_children_locked": "D'autres étiquettes sont imbriquées sous celle-ci, son nom et son étiquette parente sont donc verrouillés. Déplacez-les ou supprimez-les d'abord.",
"has_children_delete": "Retirez d'abord les étiquettes imbriquées sous celle-ci",
"visibility_field": "Visibilité dans la barre latérale",
"visibility": {
"show": "Afficher",
"unread": "Afficher si non lus",
"hide": "Masquer"
}
},
"notifications": {
"test_sound": "Tester le son de notification",
@@ -2033,8 +2041,7 @@
"delete": "Supprimer",
"mark_as_spam": "Signaler comme spam",
"not_spam": "Pas un spam",
"color_tag": "Étiquette",
"remove_color": "Supprimer l'étiquette",
"tag": "Étiquette",
"items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon",
"cancel_scheduled_send": "Annuler lenvoi",
+24 -17
View File
@@ -122,6 +122,8 @@
"demo_reset": "אִתחוּל",
"demo_tour": "סִיוּר",
"tags": "תגים",
"show_all_tags": "הצג הכל ({count})",
"show_fewer_tags": "הצג פחות",
"folders": "תיקיות",
"mail": "דוֹאַר",
"nav_label": "ניווט",
@@ -245,6 +247,7 @@
"print": "הדפס",
"view_source": "צפה במקור",
"export_email": "ייצא כ-.eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "ייבוא .eml",
"keyboard_shortcuts": "קיצורי מקשים (?)",
"email_source": "מקור דוא\"ל",
@@ -271,13 +274,15 @@
"view_contact": "הצג איש קשר",
"message_details": "פרטי הודעה",
"more_reply_options": "אפשרויות תשובה נוספות",
"set_color": "הגדר תג",
"set_tag": "הגדר תג",
"tag": "תג",
"more_actions": "עוד פעולות",
"previous": "הקודם",
"next": "הבא",
"move_to": "העבר ל...",
"remove_color": "הסר תג",
"remove_tag": "הסר תג",
"tag_filter_placeholder": "סינון תגים",
"tag_no_matches": "אין תגים תואמים",
"more_count": "+{count}נוספים",
"characters_count": "{count} תווים",
"quick_reply_placeholder": "תשובה מהירה",
@@ -346,17 +351,6 @@
"message_id": "מזהה הודעה",
"list_info": "רשימת מידע"
},
"color_tag": {
"title": "תג צבע",
"red": "אדום",
"orange": "כתום",
"yellow": "צהוב",
"green": "ירוק",
"blue": "כחול",
"purple": "סגול",
"pink": "ורוד",
"none": "אין"
},
"tooltips": {
"reply": "תשובה (ר)",
"reply_all": "השב לכולם (א)",
@@ -980,7 +974,6 @@
"title": "מילות מפתח בדוא\"ל",
"description": "הגדר מילות מפתח (תוויות/תגים) כדי לארגן את המיילים שלך עם צבעים. אלו מאוחסנות כמילות מפתח JMAP בשרת.",
"add_keyword": "הוסף מילת מפתח",
"reset_defaults": "אפס לברירות מחדל",
"label_field": "שם תצוגה",
"label_placeholder": "למשל עבודה, אישי, דחוף",
"id_field": "מזהה מילת מפתח",
@@ -993,7 +986,22 @@
"add": "לְהוֹסִיף",
"cancel": "לְבַטֵל",
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות"
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות",
"nesting": {
"label": "תגים מקוננים",
"description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד."
},
"parent_field": "תג אב",
"no_parent": "ללא תג אב",
"too_long": "נתיב התג ארוך מדי (עד {max} תווים)",
"has_children_locked": "תגים אחרים מקוננים תחת תג זה, ולכן שמו ותג האב שלו נעולים. העבר או הסר אותם תחילה.",
"has_children_delete": "הסר תחילה את התגים המקוננים תחת תג זה",
"visibility_field": "הצגה בסרגל הצד",
"visibility": {
"show": "הצג",
"unread": "הצג כשיש שלא נקראו",
"hide": "הסתר"
}
},
"notifications": {
"test_sound": "צליל הודעת בדיקה",
@@ -1999,8 +2007,7 @@
"delete": "לִמְחוֹק",
"mark_as_spam": "דווח על ספאם",
"not_spam": "לא ספאם",
"color_tag": "תווית",
"remove_color": "הסר תווית",
"tag": "תווית",
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
"edit_draft": "ערוך טיוטה",
"cancel_scheduled_send": "ביטול שליחה",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Visszaállítás",
"demo_tour": "Bemutató",
"tags": "Címkék",
"show_all_tags": "Összes megjelenítése ({count})",
"show_fewer_tags": "Kevesebb megjelenítése",
"folders": "Mappák",
"shared": "Megosztott",
"mail": "Levelek",
@@ -298,6 +300,7 @@
"print": "Nyomtatás",
"view_source": "Forrás megtekintése",
"export_email": "Exportálás .eml-ként",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importálás .eml vagy .zip fájlból",
"keyboard_shortcuts": "Billentyűparancsok (?)",
"email_source": "E-mail forrás",
@@ -324,13 +327,15 @@
"view_contact": "Névjegy megtekintése",
"message_details": "Üzenet részletei",
"more_reply_options": "További válasz opciók",
"set_color": "Címke beállítása",
"set_tag": "Címke beállítása",
"tag": "Címke",
"more_actions": "További műveletek",
"previous": "Előző",
"next": "Következő",
"move_to": "Áthelyezés ide...",
"remove_color": "Címke eltávolítása",
"remove_tag": "Címke eltávolítása",
"tag_filter_placeholder": "Címkék szűrése",
"tag_no_matches": "Nincs találat a címkék közt",
"more_count": "+{count} további",
"characters_count": "{count} karakter",
"quick_reply_placeholder": "Gyors válasz írása...",
@@ -424,17 +429,6 @@
"message_id": "Üzenet azonosító",
"list_info": "Lista információk"
},
"color_tag": {
"title": "Színes címke",
"red": "Piros",
"orange": "Narancs",
"yellow": "Sárga",
"green": "Zöld",
"blue": "Kék",
"purple": "Lila",
"pink": "Rózsaszín",
"none": "Nincs"
},
"tooltips": {
"reply": "Válasz (r)",
"reply_all": "Válasz mindenkinek (a)",
@@ -1018,7 +1012,6 @@
"title": "E-mail címkék",
"description": "Címkék definiálása az e-mailek színekkel történő rendezéséhez. Ezek JMAP kulcsszavakként tárolódnak a szerveren.",
"add_keyword": "Címke hozzáadása",
"reset_defaults": "Alapértelmezettek visszaállítása",
"label_field": "Megjelenített név",
"label_placeholder": "pl. Munka, Személyes, Sürgős",
"id_field": "Címke azonosító",
@@ -1031,7 +1024,22 @@
"add": "Hozzáadás",
"cancel": "Mégse",
"migrating": "Címke frissítése a meglévő e-maileken...",
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken"
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken",
"nesting": {
"label": "Beágyazott címkék",
"description": "Ágyazzon címkéket más címkék alá, és jelenítse meg őket fastruktúraként az oldalsávon."
},
"parent_field": "Szülőcímke",
"no_parent": "Nincs szülőcímke",
"too_long": "Ez a címkeútvonal túl hosszú (legfeljebb {max} karakter)",
"has_children_locked": "Más címkék vannak beágyazva ez alá, ezért a neve és a szülőcímkéje zárolva van. Előbb helyezze át vagy távolítsa el őket.",
"has_children_delete": "Előbb távolítsa el az ez alá beágyazott címkéket",
"visibility_field": "Láthatóság az oldalsávon",
"visibility": {
"show": "Megjelenítés",
"unread": "Megjelenítés olvasatlanoknál",
"hide": "Elrejtés"
}
},
"notifications": {
"test_sound": "Értesítési hang tesztelése",
@@ -2033,8 +2041,7 @@
"delete": "Törlés",
"mark_as_spam": "Spam jelentése",
"not_spam": "Nem spam",
"color_tag": "Címke",
"remove_color": "Címke eltávolítása",
"tag": "Címke",
"items_selected": "{count} e-mail kijelölve",
"edit_draft": "Piszkozat szerkesztése",
"cancel_scheduled_send": "Küldés megszakítása",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Reimposta",
"demo_tour": "Tour",
"tags": "Etichette",
"show_all_tags": "Mostra tutto ({count})",
"show_fewer_tags": "Mostra meno",
"folders": "Cartelle",
"mail": "Posta",
"nav_label": "Navigazione",
@@ -298,6 +300,7 @@
"print": "Stampa",
"view_source": "Visualizza sorgente",
"export_email": "Esporta come .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importa .eml o .zip",
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
"email_source": "Sorgente del messaggio",
@@ -324,11 +327,13 @@
"view_contact": "Visualizza contatto",
"message_details": "Dettagli del messaggio",
"more_reply_options": "Più opzioni di risposta",
"set_color": "Imposta etichetta",
"set_tag": "Imposta etichetta",
"tag": "Etichetta",
"more_actions": "Altre azioni",
"move_to": "Sposta in...",
"remove_color": "Rimuovi etichetta",
"remove_tag": "Rimuovi etichetta",
"tag_filter_placeholder": "Filtra etichette",
"tag_no_matches": "Nessuna etichetta corrispondente",
"more_count": "+{count} altri",
"characters_count": "{count} caratteri",
"quick_reply_placeholder": "Scrivi una risposta veloce...",
@@ -397,17 +402,6 @@
"message_id": "ID messaggio",
"list_info": "Informazioni lista"
},
"color_tag": {
"title": "Etichetta colore",
"red": "Rosso",
"orange": "Arancione",
"yellow": "Giallo",
"green": "Verde",
"blue": "Blu",
"purple": "Viola",
"pink": "Rosa",
"none": "Nessuno"
},
"tooltips": {
"reply": "Rispondi",
"reply_all": "Rispondi a tutti (a)",
@@ -1015,7 +1009,6 @@
"title": "Etichette e-mail",
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
"add_keyword": "Aggiungi etichetta",
"reset_defaults": "Ripristina predefiniti",
"label_field": "Nome visualizzato",
"label_placeholder": "es. Lavoro, Personale, Urgente",
"id_field": "ID etichetta",
@@ -1028,7 +1021,22 @@
"add": "Aggiungi",
"cancel": "Annulla",
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti"
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti",
"nesting": {
"label": "Etichette nidificate",
"description": "Nidifica le etichette sotto altre etichette e mostrale come un albero nella barra laterale."
},
"parent_field": "Etichetta principale",
"no_parent": "Nessuna etichetta principale",
"too_long": "Questo percorso di etichetta è troppo lungo (al massimo {max} caratteri)",
"has_children_locked": "Altre etichette sono nidificate sotto questa, quindi il suo nome e la sua etichetta principale sono bloccati. Spostale o rimuovile prima.",
"has_children_delete": "Rimuovi prima le etichette nidificate sotto questa",
"visibility_field": "Visibilità nella barra laterale",
"visibility": {
"show": "Mostra",
"unread": "Mostra se non letti",
"hide": "Nascondi"
}
},
"notifications": {
"test_sound": "Testa il suono di notifica",
@@ -2033,8 +2041,7 @@
"delete": "Elimina",
"mark_as_spam": "Segnala come spam",
"not_spam": "Non spam",
"color_tag": "Etichetta",
"remove_color": "Rimuovi etichetta",
"tag": "Etichetta",
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza",
"cancel_scheduled_send": "Annulla invio",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "リセット",
"demo_tour": "ツアー",
"tags": "タグ",
"show_all_tags": "すべて表示({count}",
"show_fewer_tags": "表示を減らす",
"folders": "フォルダ",
"mail": "メール",
"nav_label": "ナビゲーション",
@@ -298,6 +300,7 @@
"print": "印刷",
"view_source": "ソースを表示",
"export_email": ".emlとしてエクスポート",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml または .zip をインポート",
"keyboard_shortcuts": "キーボードショートカット (?)",
"email_source": "メールソース",
@@ -324,11 +327,13 @@
"view_contact": "連絡先を表示",
"message_details": "メッセージの詳細",
"more_reply_options": "その他の返信オプション",
"set_color": "ラベルを設定",
"set_tag": "ラベルを設定",
"tag": "ラベル",
"more_actions": "その他の操作",
"move_to": "移動...",
"remove_color": "ラベルを削除",
"remove_tag": "ラベルを削除",
"tag_filter_placeholder": "ラベルを絞り込む",
"tag_no_matches": "一致するラベルがありません",
"more_count": "他{count}件",
"characters_count": "{count}文字",
"quick_reply_placeholder": "クイック返信を入力...",
@@ -397,17 +402,6 @@
"message_id": "メッセージID",
"list_info": "リスト情報"
},
"color_tag": {
"title": "カラータグ",
"red": "赤",
"orange": "オレンジ",
"yellow": "黄色",
"green": "緑",
"blue": "青",
"purple": "紫",
"pink": "ピンク",
"none": "なし"
},
"tooltips": {
"reply": "返信",
"reply_all": "全員に返信 (a)",
@@ -1015,7 +1009,6 @@
"title": "メールラベル",
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
"add_keyword": "ラベルを追加",
"reset_defaults": "デフォルトに戻す",
"label_field": "表示名",
"label_placeholder": "例:仕事、個人、緊急",
"id_field": "ラベルID",
@@ -1028,7 +1021,22 @@
"add": "追加",
"cancel": "キャンセル",
"migrating": "既存のメールのラベルを更新中…",
"migration_error": "既存のメールのラベルの更新に失敗しました"
"migration_error": "既存のメールのラベルの更新に失敗しました",
"nesting": {
"label": "ネストされたラベル",
"description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。"
},
"parent_field": "親ラベル",
"no_parent": "親ラベルなし",
"too_long": "このラベルのパスが長すぎます(最大{max}文字)",
"has_children_locked": "このラベルの下に他のラベルがネストされているため、名前と親ラベルは変更できません。先に移動または削除してください。",
"has_children_delete": "先にこのラベルの下にネストされたラベルを削除してください",
"visibility_field": "サイドバーでの表示",
"visibility": {
"show": "表示する",
"unread": "未読がある場合に表示",
"hide": "表示しない"
}
},
"notifications": {
"test_sound": "通知音をテスト",
@@ -2033,8 +2041,7 @@
"delete": "削除",
"mark_as_spam": "迷惑メールを報告",
"not_spam": "迷惑メールでない",
"color_tag": "ラベル",
"remove_color": "ラベルを削除",
"tag": "ラベル",
"items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集",
"cancel_scheduled_send": "送信をキャンセル",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "초기화",
"demo_tour": "둘러보기",
"tags": "태그",
"show_all_tags": "전체 보기({count})",
"show_fewer_tags": "간략히 보기",
"folders": "폴더",
"mail": "메일",
"nav_label": "내비게이션",
@@ -298,6 +300,7 @@
"print": "인쇄",
"view_source": "원본 보기",
"export_email": ".eml 파일로 내보내기",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml 또는 .zip 가져오기",
"keyboard_shortcuts": "단축키 (?)",
"email_source": "메일 원본",
@@ -324,13 +327,15 @@
"view_contact": "연락처 보기",
"message_details": "메시지 상세 정보",
"more_reply_options": "답장 옵션 더보기",
"set_color": "태그 설정",
"set_tag": "태그 설정",
"tag": "태그",
"more_actions": "작업 더보기",
"previous": "이전",
"next": "다음",
"move_to": "이동...",
"remove_color": "태그 제거",
"remove_tag": "태그 제거",
"tag_filter_placeholder": "태그 검색",
"tag_no_matches": "일치하는 태그 없음",
"more_count": "+{count}개 더보기",
"characters_count": "{count}자",
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
@@ -399,17 +404,6 @@
"message_id": "메시지 ID",
"list_info": "목록 정보"
},
"color_tag": {
"title": "색상 태그",
"red": "빨간색",
"orange": "주황색",
"yellow": "노란색",
"green": "초록색",
"blue": "파란색",
"purple": "보라색",
"pink": "분홍색",
"none": "없음"
},
"tooltips": {
"reply": "답장 (r)",
"reply_all": "전체 답장 (a)",
@@ -1015,7 +1009,6 @@
"title": "이메일 태그",
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
"add_keyword": "태그 추가",
"reset_defaults": "기본값으로 초기화",
"label_field": "표시 이름",
"label_placeholder": "예: 업무, 개인, 긴급",
"id_field": "태그 ID",
@@ -1028,7 +1021,22 @@
"add": "추가",
"cancel": "취소",
"migrating": "기존 이메일의 태그 업데이트 중…",
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다"
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다",
"nesting": {
"label": "중첩 태그",
"description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다."
},
"parent_field": "상위 태그",
"no_parent": "상위 태그 없음",
"too_long": "이 태그 경로가 너무 깁니다(최대 {max}자)",
"has_children_locked": "이 태그 아래에 다른 태그가 중첩되어 있어 이름과 상위 태그가 잠겨 있습니다. 먼저 옮기거나 삭제하세요.",
"has_children_delete": "이 태그 아래에 중첩된 태그를 먼저 삭제하세요",
"visibility_field": "사이드바 표시",
"visibility": {
"show": "표시",
"unread": "읽지 않음이 있을 때 표시",
"hide": "숨기기"
}
},
"notifications": {
"test_sound": "알림음 테스트",
@@ -2033,8 +2041,7 @@
"delete": "삭제",
"mark_as_spam": "스팸 신고",
"not_spam": "정상 메일",
"color_tag": "태그",
"remove_color": "태그 제거",
"tag": "태그",
"items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정",
"cancel_scheduled_send": "보내기 취소",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Atiestatīt",
"demo_tour": "Ekskursija",
"tags": "Tagi",
"show_all_tags": "Rādīt visus ({count})",
"show_fewer_tags": "Rādīt mazāk",
"folders": "Mapes",
"mail": "Pasts",
"nav_label": "Navigācija",
@@ -298,6 +300,7 @@
"print": "Drukāt",
"view_source": "Skatīt avota kodu",
"export_email": "Eksportēt kā .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importēt .eml vai .zip",
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
"email_source": "Vēstules avota kods",
@@ -324,13 +327,15 @@
"view_contact": "Skatīt kontaktu",
"message_details": "Informācija par ziņojumu",
"more_reply_options": "Papildu atbildēšanas iespējas",
"set_color": "Iestatīt tagu",
"set_tag": "Iestatīt tagu",
"tag": "Tags",
"more_actions": "Citas darbības",
"previous": "Iepr.",
"next": "Nāk.",
"move_to": "Pārvietot uz...",
"remove_color": "Noņemt tagu",
"remove_tag": "Noņemt tagu",
"tag_filter_placeholder": "Filtrēt tagus",
"tag_no_matches": "Nav atbilstošu tagu",
"more_count": "+vairāk {count}",
"characters_count": "{count} rakstzīmes",
"quick_reply_placeholder": "Rakstīt ātru atbildi...",
@@ -399,17 +404,6 @@
"message_id": "Ziņojuma ID",
"list_info": "Informācija par adresātu sarakstu"
},
"color_tag": {
"title": "Krāsu tags",
"red": "Sarkans",
"orange": "Oranžs",
"yellow": "Dzeltens",
"green": "Zaļš",
"blue": "Zils",
"purple": "Violets",
"pink": "Rozā",
"none": "Nav"
},
"tooltips": {
"reply": "Atbildēt (r)",
"reply_all": "Atbildēt visiem (a)",
@@ -1015,7 +1009,6 @@
"title": "E-pasta tagi",
"description": "Definējiet tagus, lai organizētu e-pastus ar krāsām. Tie tiek saglabāti kā JMAP atslēgvārdi serverī.",
"add_keyword": "Pievienot tagu",
"reset_defaults": "Atiestatīt noklusējumu",
"label_field": "Redzamais nosaukums",
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
"id_field": "Taga identifikators",
@@ -1028,7 +1021,22 @@
"add": "Pievienot",
"cancel": "Atcelt",
"migrating": "Taga atjaunināšana esošajos e-pastos…",
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos"
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos",
"nesting": {
"label": "Ligzdoti tagi",
"description": "Ligzdojiet tagus zem citiem tagiem un rādiet tos sānjoslā kā koku."
},
"parent_field": "Vecāktags",
"no_parent": "Nav vecāktaga",
"too_long": "Šis taga ceļš ir pārāk garš (ne vairāk kā {max} rakstzīmes)",
"has_children_locked": "Zem šī taga ir ligzdoti citi tagi, tāpēc tā nosaukums un vecāktags ir bloķēti. Vispirms pārvietojiet vai noņemiet tos.",
"has_children_delete": "Vispirms noņemiet zem šī ligzdotos tagus",
"visibility_field": "Redzamība sānjoslā",
"visibility": {
"show": "Rādīt",
"unread": "Rādīt, ja ir nelasīti",
"hide": "Slēpt"
}
},
"notifications": {
"test_sound": "Pārbaudīt paziņojuma skaņu",
@@ -2033,8 +2041,7 @@
"delete": "Dzēst",
"mark_as_spam": "Atzīmēt kā mēstuli",
"not_spam": "Nav mēstule",
"color_tag": "Tags",
"remove_color": "Noņemt tagu",
"tag": "Tags",
"items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu",
"cancel_scheduled_send": "Atcelt sūtīšanu",
+25 -18
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetten",
"demo_tour": "Rondleiding",
"tags": "Labels",
"show_all_tags": "Alles tonen ({count})",
"show_fewer_tags": "Minder tonen",
"folders": "Mappen",
"mail": "E-mail",
"nav_label": "Navigatie",
@@ -298,6 +300,7 @@
"print": "Afdrukken",
"view_source": "Bron bekijken",
"export_email": "Exporteren als .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml of .zip importeren",
"keyboard_shortcuts": "Sneltoetsen (?)",
"email_source": "E-mailbron",
@@ -324,11 +327,13 @@
"view_contact": "Contact bekijken",
"message_details": "Berichtdetails",
"more_reply_options": "Meer antwoordopties",
"set_color": "Label instellen",
"set_tag": "Label instellen",
"tag": "Label",
"more_actions": "Meer acties",
"move_to": "Verplaatsen naar...",
"remove_color": "Label verwijderen",
"remove_tag": "Label verwijderen",
"tag_filter_placeholder": "Labels filteren",
"tag_no_matches": "Geen overeenkomende labels",
"more_count": "+{count} meer",
"characters_count": "{count} tekens",
"quick_reply_placeholder": "Schrijf een snel antwoord...",
@@ -397,17 +402,6 @@
"message_id": "Bericht-ID",
"list_info": "Lijstinformatie"
},
"color_tag": {
"title": "Kleurtag",
"red": "Rood",
"orange": "Oranje",
"yellow": "Geel",
"green": "Groen",
"blue": "Blauw",
"purple": "Paars",
"pink": "Roze",
"none": "Geen"
},
"tooltips": {
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden (a)",
@@ -1013,9 +1007,8 @@
},
"keywords": {
"title": "E-maillabels",
"description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
"description": "Definieer labels om uw e-mails te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
"add_keyword": "Label toevoegen",
"reset_defaults": "Standaardwaarden herstellen",
"label_field": "Weergavenaam",
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
"id_field": "Label-ID",
@@ -1028,7 +1021,22 @@
"add": "Toevoegen",
"cancel": "Annuleren",
"migrating": "Label bijwerken op bestaande e-mails…",
"migration_error": "Label bijwerken op bestaande e-mails mislukt"
"migration_error": "Label bijwerken op bestaande e-mails mislukt",
"nesting": {
"label": "Geneste labels",
"description": "Nest labels onder andere labels en toon ze als een boomstructuur in de zijbalk."
},
"parent_field": "Bovenliggend label",
"no_parent": "Geen bovenliggend label",
"too_long": "Dit labelpad is te lang (maximaal {max} tekens)",
"has_children_locked": "Er vallen andere labels onder dit label, dus de naam en het bovenliggende label liggen vast. Verplaats of verwijder ze eerst.",
"has_children_delete": "Verwijder eerst de labels die hieronder vallen",
"visibility_field": "Zichtbaarheid in de zijbalk",
"visibility": {
"show": "Tonen",
"unread": "Tonen bij ongelezen",
"hide": "Verbergen"
}
},
"notifications": {
"test_sound": "Meldingsgeluid testen",
@@ -2033,8 +2041,7 @@
"delete": "Verwijderen",
"mark_as_spam": "Spam melden",
"not_spam": "Geen spam",
"color_tag": "Label",
"remove_color": "Label verwijderen",
"tag": "Label",
"items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken",
"cancel_scheduled_send": "Verzenden annuleren",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetuj",
"demo_tour": "Przewodnik",
"tags": "Etykiety",
"show_all_tags": "Pokaż wszystkie ({count})",
"show_fewer_tags": "Pokaż mniej",
"folders": "Foldery",
"mail": "Poczta",
"nav_label": "Nawigacja",
@@ -298,6 +300,7 @@
"print": "Drukuj",
"view_source": "Pokaż źródło",
"export_email": "Eksportuj jako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importuj .eml lub .zip",
"keyboard_shortcuts": "Skróty klawiszowe (?)",
"email_source": "Źródło wiadomości",
@@ -324,13 +327,15 @@
"view_contact": "Pokaż kontakt",
"message_details": "Szczegóły wiadomości",
"more_reply_options": "Więcej opcji odpowiedzi",
"set_color": "Ustaw etykietę",
"set_tag": "Ustaw etykietę",
"tag": "Etykieta",
"more_actions": "Więcej działań",
"previous": "Poprz.",
"next": "Nast.",
"move_to": "Przenieś do...",
"remove_color": "Usuń etykietę",
"remove_tag": "Usuń etykietę",
"tag_filter_placeholder": "Filtruj etykiety",
"tag_no_matches": "Brak pasujących etykiet",
"more_count": "+{count} więcej",
"characters_count": "{count} znaków",
"quick_reply_placeholder": "Napisz szybką odpowiedź...",
@@ -399,17 +404,6 @@
"message_id": "ID wiadomości",
"list_info": "Informacje o liście"
},
"color_tag": {
"title": "Kolorowa etykieta",
"red": "Czerwony",
"orange": "Pomarańczowy",
"yellow": "Żółty",
"green": "Zielony",
"blue": "Niebieski",
"purple": "Fioletowy",
"pink": "Różowy",
"none": "Brak"
},
"tooltips": {
"reply": "Odpowiedz (r)",
"reply_all": "Odpowiedz wszystkim (a)",
@@ -1015,7 +1009,6 @@
"title": "Etykiety e-mail",
"description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
"add_keyword": "Dodaj etykietę",
"reset_defaults": "Przywróć domyślne",
"label_field": "Nazwa wyświetlana",
"label_placeholder": "np. Praca, Osobiste, Pilne",
"id_field": "ID etykiety",
@@ -1028,7 +1021,22 @@
"add": "Dodaj",
"cancel": "Anuluj",
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach"
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach",
"nesting": {
"label": "Zagnieżdżone etykiety",
"description": "Zagnieżdżaj etykiety pod innymi etykietami i wyświetlaj je w panelu bocznym jako drzewo."
},
"parent_field": "Etykieta nadrzędna",
"no_parent": "Brak etykiety nadrzędnej",
"too_long": "Ta ścieżka etykiety jest za długa (maksymalnie {max} znaków)",
"has_children_locked": "Pod tą etykietą zagnieżdżone są inne etykiety, więc jej nazwa i etykieta nadrzędna są zablokowane. Najpierw je przenieś lub usuń.",
"has_children_delete": "Najpierw usuń etykiety zagnieżdżone pod tą",
"visibility_field": "Widoczność w panelu bocznym",
"visibility": {
"show": "Pokaż",
"unread": "Pokaż przy nieprzeczytanych",
"hide": "Ukryj"
}
},
"notifications": {
"test_sound": "Przetestuj dźwięk powiadomienia",
@@ -2033,8 +2041,7 @@
"delete": "Usuń",
"mark_as_spam": "Zgłoś spam",
"not_spam": "To nie spam",
"color_tag": "Etykieta",
"remove_color": "Usuń etykietę",
"tag": "Etykieta",
"items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic",
"cancel_scheduled_send": "Anuluj wysyłkę",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Repor",
"demo_tour": "Tour",
"tags": "Etiquetas",
"show_all_tags": "Mostrar tudo ({count})",
"show_fewer_tags": "Mostrar menos",
"folders": "Pastas",
"mail": "E-mail",
"nav_label": "Navegação",
@@ -298,6 +300,7 @@
"print": "Imprimir",
"view_source": "Ver código-fonte",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importar .eml ou .zip",
"keyboard_shortcuts": "Atalhos de teclado (?)",
"email_source": "Código-fonte do E-mail",
@@ -324,11 +327,13 @@
"view_contact": "Ver contato",
"message_details": "Detalhes da Mensagem",
"more_reply_options": "Mais opções de resposta",
"set_color": "Definir etiqueta",
"set_tag": "Definir etiqueta",
"tag": "Etiqueta",
"more_actions": "Mais ações",
"move_to": "Mover para...",
"remove_color": "Remover etiqueta",
"remove_tag": "Remover etiqueta",
"tag_filter_placeholder": "Filtrar etiquetas",
"tag_no_matches": "Nenhuma etiqueta correspondente",
"more_count": "+{count} mais",
"characters_count": "{count} caracteres",
"quick_reply_placeholder": "Escreva uma resposta rápida...",
@@ -397,17 +402,6 @@
"message_id": "ID da Mensagem",
"list_info": "Informações da Lista"
},
"color_tag": {
"title": "Etiqueta de Cor",
"red": "Vermelho",
"orange": "Laranja",
"yellow": "Amarelo",
"green": "Verde",
"blue": "Azul",
"purple": "Roxo",
"pink": "Rosa",
"none": "Nenhuma"
},
"tooltips": {
"reply": "Responder",
"reply_all": "Responder a todos (a)",
@@ -1015,7 +1009,6 @@
"title": "Etiquetas de e-mail",
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
"add_keyword": "Adicionar etiqueta",
"reset_defaults": "Restaurar padrões",
"label_field": "Nome de exibição",
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
"id_field": "ID da etiqueta",
@@ -1028,7 +1021,22 @@
"add": "Adicionar",
"cancel": "Cancelar",
"migrating": "A atualizar etiqueta nos e-mails existentes…",
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes",
"nesting": {
"label": "Etiquetas aninhadas",
"description": "Aninhe etiquetas sob outras etiquetas e mostre-as como uma árvore na barra lateral."
},
"parent_field": "Etiqueta principal",
"no_parent": "Sem etiqueta principal",
"too_long": "Este caminho de etiqueta é demasiado longo (no máximo {max} caracteres)",
"has_children_locked": "Existem outras etiquetas aninhadas sob esta, por isso o seu nome e a sua etiqueta principal estão bloqueados. Mova-as ou remova-as primeiro.",
"has_children_delete": "Remova primeiro as etiquetas aninhadas sob esta",
"visibility_field": "Visibilidade na barra lateral",
"visibility": {
"show": "Mostrar",
"unread": "Mostrar se não lidas",
"hide": "Ocultar"
}
},
"notifications": {
"test_sound": "Testar som de notificação",
@@ -2033,8 +2041,7 @@
"delete": "Excluir",
"mark_as_spam": "Reportar spam",
"not_spam": "Não é spam",
"color_tag": "Etiqueta",
"remove_color": "Remover etiqueta",
"tag": "Etiqueta",
"items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho",
"cancel_scheduled_send": "Cancelar envio",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetare",
"demo_tour": "Tur de prezentare",
"tags": "Etichete",
"show_all_tags": "Afișează tot ({count})",
"show_fewer_tags": "Afișează mai puține",
"folders": "Dosare",
"shared": "Partajat",
"mail": "E-mail",
@@ -298,6 +300,7 @@
"print": "Imprimare",
"view_source": "Vizualizați sursa",
"export_email": "Exportați ca fișier .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importați fișiere .eml sau .zip",
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
"email_source": "Sursa e-mailului",
@@ -324,13 +327,15 @@
"view_contact": "Vizualizare contact",
"message_details": "Detalii mesaj",
"more_reply_options": "Mai multe opțiuni de răspuns",
"set_color": "Setați eticheta",
"set_tag": "Setați eticheta",
"tag": "Etichetă",
"more_actions": "Alte acțiuni",
"previous": "Anterior",
"next": "Următorul",
"move_to": "Mergi la...",
"remove_color": "Eliminați eticheta",
"remove_tag": "Eliminați eticheta",
"tag_filter_placeholder": "Filtrează etichetele",
"tag_no_matches": "Nicio etichetă corespunzătoare",
"more_count": "+{count} mai multe",
"characters_count": "{count} caractere",
"quick_reply_placeholder": "Scrie un răspuns rapid...",
@@ -424,17 +429,6 @@
"message_id": "IDul mesajelor",
"list_info": "Informații despre listă"
},
"color_tag": {
"title": "Etichetă de culoare",
"red": "Roșu",
"orange": "Portocaliu",
"yellow": "Galben",
"green": "Verde",
"blue": "Albastru",
"purple": "Violet",
"pink": "Roz",
"none": "Niciunul"
},
"tooltips": {
"reply": "Răspunde (r)",
"reply_all": "Răspunde tuturor (a)",
@@ -1018,7 +1012,6 @@
"title": "Etichete de e-mail",
"description": "Definiți etichete pentru a vă organiza e-mailurile pe culori. Acestea sunt stocate pe server sub formă de cuvinte-cheie dJMAP.",
"add_keyword": "Adăugați etichetă",
"reset_defaults": "Resezare la setările implicite",
"label_field": "Nume afișat",
"label_placeholder": "de ex. Serviciu, Personal, Urgent",
"id_field": "EtichetăID",
@@ -1031,7 +1024,22 @@
"add": "Adăugați",
"cancel": "Anulează",
"migrating": "Actualizarea etichetei pentru e-mailurile existente…",
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente"
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente",
"nesting": {
"label": "Etichete imbricate",
"description": "Imbricați etichete sub alte etichete și afișați-le ca un arbore în bara laterală."
},
"parent_field": "Etichetă părinte",
"no_parent": "Fără etichetă părinte",
"too_long": "Această cale de etichetă este prea lungă (cel mult {max} caractere)",
"has_children_locked": "Alte etichete sunt imbricate sub aceasta, așa că numele și eticheta părinte sunt blocate. Mutați-le sau eliminați-le mai întâi.",
"has_children_delete": "Eliminați mai întâi etichetele imbricate sub aceasta",
"visibility_field": "Vizibilitate în bara laterală",
"visibility": {
"show": "Afișează",
"unread": "Afișează dacă sunt necitite",
"hide": "Ascunde"
}
},
"notifications": {
"test_sound": "Testați sunetul de notificare",
@@ -2033,8 +2041,7 @@
"delete": "Șterge",
"mark_as_spam": "Raportează spamul",
"not_spam": "Nu este spam",
"color_tag": "Etichetă",
"remove_color": "Eliminați eticheta",
"tag": "Etichetă",
"items_selected": "{count} e-mailuri selectate",
"edit_draft": "Editează schița",
"cancel_scheduled_send": "Anulează trimiterea",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Сбросить",
"demo_tour": "Тур",
"tags": "Теги",
"show_all_tags": "Показать все ({count})",
"show_fewer_tags": "Показать меньше",
"folders": "Папки",
"mail": "Почта",
"nav_label": "Навигация",
@@ -298,6 +300,7 @@
"print": "Распечатать",
"view_source": "Просмотреть исходный код",
"export_email": "Экспортировать как .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Импортировать .eml или .zip",
"keyboard_shortcuts": "Сочетания клавиш (?)",
"email_source": "Исходный код письма",
@@ -324,13 +327,15 @@
"view_contact": "Просмотреть контакт",
"message_details": "Детали сообщения",
"more_reply_options": "Дополнительные параметры ответа",
"set_color": "Установить тег",
"set_tag": "Установить тег",
"tag": "Тег",
"more_actions": "Другие действия",
"previous": "Пред.",
"next": "След.",
"move_to": "Переместить в...",
"remove_color": "Удалить тег",
"remove_tag": "Удалить тег",
"tag_filter_placeholder": "Фильтр тегов",
"tag_no_matches": "Подходящих тегов нет",
"more_count": "+{count} ещё",
"characters_count": "{count} символов",
"quick_reply_placeholder": "Написать быстрый ответ...",
@@ -399,17 +404,6 @@
"message_id": "Идентификатор сообщения",
"list_info": "Информация о рассылке"
},
"color_tag": {
"title": "Цветной тег",
"red": "Красный",
"orange": "Оранжевый",
"yellow": "Жёлтый",
"green": "Зелёный",
"blue": "Синий",
"purple": "Фиолетовый",
"pink": "Розовый",
"none": "Нет"
},
"tooltips": {
"reply": "Ответить (r)",
"reply_all": "Ответить всем (a)",
@@ -1015,7 +1009,6 @@
"title": "Теги электронной почты",
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
"add_keyword": "Добавить тег",
"reset_defaults": "Сбросить по умолчанию",
"label_field": "Отображаемое название",
"label_placeholder": "напр., Работа, Личное, Срочно",
"id_field": "Идентификатор тега",
@@ -1028,7 +1021,22 @@
"add": "Добавить",
"cancel": "Отмена",
"migrating": "Обновление тега в существующих письмах…",
"migration_error": "Не удалось обновить тег в существующих письмах"
"migration_error": "Не удалось обновить тег в существующих письмах",
"nesting": {
"label": "Вложенные теги",
"description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева."
},
"parent_field": "Родительский тег",
"no_parent": "Без родительского тега",
"too_long": "Этот путь тега слишком длинный (не более {max} символов)",
"has_children_locked": "В этот тег вложены другие теги, поэтому его имя и родительский тег заблокированы. Сначала переместите или удалите их.",
"has_children_delete": "Сначала удалите теги, вложенные в этот",
"visibility_field": "Видимость в боковой панели",
"visibility": {
"show": "Показывать",
"unread": "Показывать при непрочитанных",
"hide": "Скрывать"
}
},
"notifications": {
"test_sound": "Проверить звук уведомления",
@@ -2033,8 +2041,7 @@
"delete": "Удалить",
"mark_as_spam": "Отметить как спам",
"not_spam": "Не спам",
"color_tag": "Тег",
"remove_color": "Удалить тег",
"tag": "Тег",
"items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик",
"cancel_scheduled_send": "Отменить отправку",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Resetovať",
"demo_tour": "Sprievodca",
"tags": "Štítky",
"show_all_tags": "Zobraziť všetko ({count})",
"show_fewer_tags": "Zobraziť menej",
"folders": "Priečinky",
"shared": "Zdieľané",
"mail": "Pošta",
@@ -298,6 +300,7 @@
"print": "Tlačiť",
"view_source": "Zobraziť zdrojový kód",
"export_email": "Exportovať ako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importovať .eml alebo .zip",
"keyboard_shortcuts": "Klávesové skratky (?)",
"email_source": "Zdrojový kód e-mailu",
@@ -324,13 +327,15 @@
"view_contact": "Zobraziť kontakt",
"message_details": "Podrobnosti správy",
"more_reply_options": "Viac možností odpovede",
"set_color": "Nastaviť štítok",
"set_tag": "Nastaviť štítok",
"tag": "Štítok",
"more_actions": "Viac akcií",
"previous": "Predchádzajúci",
"next": "Ďalší",
"move_to": "Presunúť do...",
"remove_color": "Odstrániť štítok",
"remove_tag": "Odstrániť štítok",
"tag_filter_placeholder": "Filtrovať štítky",
"tag_no_matches": "Žiadne zodpovedajúce štítky",
"more_count": "+{count} ďalších",
"characters_count": "{count} znakov",
"quick_reply_placeholder": "Napísať rýchlu odpoveď...",
@@ -424,17 +429,6 @@
"message_id": "ID správy",
"list_info": "Informácie o zozname"
},
"color_tag": {
"title": "Farebný štítok",
"red": "Červený",
"orange": "Oranžový",
"yellow": "Žltý",
"green": "Zelený",
"blue": "Modrý",
"purple": "Fialový",
"pink": "RŪžový",
"none": "Žiadny"
},
"tooltips": {
"reply": "Odpovedať (r)",
"reply_all": "Odpovedať všetkým (a)",
@@ -1018,7 +1012,6 @@
"title": "E-mailové štítky",
"description": "Definujte štítky na organizáciu e-mailov s farbami.",
"add_keyword": "Pridať štítok",
"reset_defaults": "Obnoviť predvolené",
"label_field": "Zobrazovaný názov",
"label_placeholder": "napr. Práca, Osobné, Naliehavé",
"id_field": "ID štítku",
@@ -1031,7 +1024,22 @@
"add": "Pridať",
"cancel": "Zrušiť",
"migrating": "Aktualizácia štítku v existujúcich e-mailoch…",
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch"
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch",
"nesting": {
"label": "Vnorené štítky",
"description": "Vnorujte štítky pod iné štítky a zobrazujte ich v bočnom paneli ako strom."
},
"parent_field": "Nadradený štítok",
"no_parent": "Bez nadradeného štítku",
"too_long": "Táto cesta štítku je príliš dlhá (najviac {max} znakov)",
"has_children_locked": "Pod týmto štítkom sú vnorené ďalšie štítky, preto sú jeho názov a nadradený štítok uzamknuté. Najprv ich presuňte alebo odstráňte.",
"has_children_delete": "Najprv odstráňte štítky vnorené pod týmto",
"visibility_field": "Viditeľnosť v bočnom paneli",
"visibility": {
"show": "Zobraziť",
"unread": "Zobraziť pri neprečítaných",
"hide": "Skryť"
}
},
"notifications": {
"test_sound": "Otestovať zvuk oznámenia",
@@ -2033,8 +2041,7 @@
"delete": "Odstrániť",
"mark_as_spam": "Nahlásiť spam",
"not_spam": "Nie je spam",
"color_tag": "Štítok",
"remove_color": "Odstrániť štítok",
"tag": "Štítok",
"items_selected": "{count} vybraných e-mailov",
"edit_draft": "Upraviť koncept",
"cancel_scheduled_send": "Zrušiť odoslanie",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Sıfırla",
"demo_tour": "Tur",
"tags": "Etiketler",
"show_all_tags": "Tümünü göster ({count})",
"show_fewer_tags": "Daha az göster",
"folders": "Klasörler",
"shared": "Paylaşılan",
"mail": "Posta",
@@ -298,6 +300,7 @@
"print": "Yazdır",
"view_source": "Kaynağı görüntüle",
"export_email": ".eml olarak dışa aktar",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml veya .zip içe aktar",
"keyboard_shortcuts": "Klavye kısayolları (?)",
"email_source": "E-posta Kaynağı",
@@ -324,13 +327,15 @@
"view_contact": "Kişiyi görüntüle",
"message_details": "İleti Ayrıntıları",
"more_reply_options": "Daha fazla yanıt seçeneği",
"set_color": "Etiket ayarla",
"set_tag": "Etiket ayarla",
"tag": "Etiket",
"more_actions": "Diğer işlemler",
"previous": "Önceki",
"next": "Sonraki",
"move_to": "Şuraya taşı...",
"remove_color": "Etiketi kaldır",
"remove_tag": "Etiketi kaldır",
"tag_filter_placeholder": "Etiketleri filtrele",
"tag_no_matches": "Eşleşen etiket yok",
"more_count": "+{count} daha",
"characters_count": "{count} karakter",
"quick_reply_placeholder": "Hızlı yanıt yazın...",
@@ -399,17 +404,6 @@
"message_id": "İleti Kimliği",
"list_info": "Liste Bilgisi"
},
"color_tag": {
"title": "Renk Etiketi",
"red": "Kırmızı",
"orange": "Turuncu",
"yellow": "Sarı",
"green": "Yeşil",
"blue": "Mavi",
"purple": "Mor",
"pink": "Pembe",
"none": "Yok"
},
"tooltips": {
"reply": "Yanıtla (r)",
"reply_all": "Tümünü Yanıtla (a)",
@@ -1015,7 +1009,6 @@
"title": "E-posta Etiketleri",
"description": "E-postalarınızı renklerle düzenlemek için etiketler tanımlayın. Bunlar sunucuda JMAP anahtar sözcükleri olarak saklanır.",
"add_keyword": "Etiket Ekle",
"reset_defaults": "Varsayılanlara Sıfırla",
"label_field": "Görünen Ad",
"label_placeholder": "ör. İş, Kişisel, Acil",
"id_field": "Etiket Kimliği",
@@ -1028,7 +1021,22 @@
"add": "Ekle",
"cancel": "İptal",
"migrating": "Mevcut e-postalardaki etiket güncelleniyor…",
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi"
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi",
"nesting": {
"label": "İç içe etiketler",
"description": "Etiketleri başka etiketlerin altına yerleştirin ve kenar çubuğunda ağaç olarak gösterin."
},
"parent_field": "Üst etiket",
"no_parent": "Üst etiket yok",
"too_long": "Bu etiket yolu çok uzun (en fazla {max} karakter)",
"has_children_locked": "Bunun altında başka etiketler var, bu nedenle adı ve üst etiketi kilitli. Önce onları taşıyın veya kaldırın.",
"has_children_delete": "Önce bunun altındaki etiketleri kaldırın",
"visibility_field": "Kenar çubuğunda görünürlük",
"visibility": {
"show": "Göster",
"unread": "Okunmamış varsa göster",
"hide": "Gizle"
}
},
"notifications": {
"test_sound": "Bildirim sesini test et",
@@ -2033,8 +2041,7 @@
"delete": "Sil",
"mark_as_spam": "Spam bildir",
"not_spam": "Spam değil",
"color_tag": "Etiket",
"remove_color": "Etiketi kaldır",
"tag": "Etiket",
"items_selected": "{count} e-posta seçildi",
"edit_draft": "Taslağı Düzenle",
"cancel_scheduled_send": "Göndermeyi iptal et",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "Скинути",
"demo_tour": "Тур",
"tags": "Теги",
"show_all_tags": "Показати всі ({count})",
"show_fewer_tags": "Показати менше",
"folders": "Папки",
"mail": "Пошта",
"nav_label": "Навігація",
@@ -298,6 +300,7 @@
"print": "Роздрукувати",
"view_source": "Переглянути джерело",
"export_email": "Експортувати як .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Імпорт .eml або .zip",
"keyboard_shortcuts": "Комбінації клавіш (?)",
"email_source": "Джерело електронної пошти",
@@ -324,13 +327,15 @@
"view_contact": "Переглянути контакт",
"message_details": "Деталі повідомлення",
"more_reply_options": "Більше варіантів відповіді",
"set_color": "Встановити тег",
"set_tag": "Встановити тег",
"tag": "Тег",
"more_actions": "Більше дій",
"previous": "попередня",
"next": "Далі",
"move_to": "Перейти до...",
"remove_color": "Видалити тег",
"remove_tag": "Видалити тег",
"tag_filter_placeholder": "Фільтр тегів",
"tag_no_matches": "Немає відповідних тегів",
"more_count": "+ ще {count}",
"characters_count": "{count} символів",
"quick_reply_placeholder": "Напишіть швидку відповідь...",
@@ -399,17 +404,6 @@
"message_id": "ID повідомлення",
"list_info": "Інформація про список"
},
"color_tag": {
"title": "Кольоровий тег",
"red": "Червоний",
"orange": "Помаранчевий",
"yellow": "Жовтий",
"green": "Зелений",
"blue": "Синій",
"purple": "Фіолетовий",
"pink": "Рожевий",
"none": "Жодного"
},
"tooltips": {
"reply": "Відповісти (р)",
"reply_all": "Відповісти всім (а)",
@@ -1015,7 +1009,6 @@
"title": "Ключові слова електронної пошти",
"description": "Визначте ключові слова (мітки/теги), щоб упорядкувати свої листи за кольорами. Вони зберігаються як ключові слова JMAP на сервері.",
"add_keyword": "Додати ключове слово",
"reset_defaults": "Скинути до значень за замовчуванням",
"label_field": "Відображуване ім'я",
"label_placeholder": "напр. Робота, Особиста, Терміново",
"id_field": "ID ключового слова",
@@ -1028,7 +1021,22 @@
"add": "додати",
"cancel": "Скасувати",
"migrating": "Оновлення ключового слова в наявних електронних листах…",
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах"
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах",
"nesting": {
"label": "Вкладені теги",
"description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева."
},
"parent_field": "Батьківський тег",
"no_parent": "Без батьківського тега",
"too_long": "Цей шлях тега задовгий (щонайбільше {max} символів)",
"has_children_locked": "У цей тег вкладено інші теги, тому його назву та батьківський тег заблоковано. Спочатку перемістіть або видаліть їх.",
"has_children_delete": "Спочатку видаліть теги, вкладені в цей",
"visibility_field": "Видимість на бічній панелі",
"visibility": {
"show": "Показувати",
"unread": "Показувати за непрочитаних",
"hide": "Приховувати"
}
},
"notifications": {
"test_sound": "Тестовий звук сповіщення",
@@ -2033,8 +2041,7 @@
"delete": "Видалити",
"mark_as_spam": "Повідомити про спам",
"not_spam": "Не спам",
"color_tag": "Мітка",
"remove_color": "Видалити мітку",
"tag": "Мітка",
"items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку",
"cancel_scheduled_send": "Скасувати надсилання",
+24 -17
View File
@@ -130,6 +130,8 @@
"demo_reset": "重置",
"demo_tour": "引导",
"tags": "标签",
"show_all_tags": "显示全部({count}",
"show_fewer_tags": "收起",
"folders": "文件夹",
"mail": "邮件",
"nav_label": "导航",
@@ -298,6 +300,7 @@
"print": "打印",
"view_source": "查看源码",
"export_email": "导出为 .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "导入 .eml 或 .zip",
"keyboard_shortcuts": "键盘快捷键(?)",
"email_source": "邮件源码",
@@ -324,13 +327,15 @@
"view_contact": "查看联系人",
"message_details": "邮件详情",
"more_reply_options": "更多回复选项",
"set_color": "设置颜色标签",
"set_tag": "设置颜色标签",
"tag": "标签",
"more_actions": "更多操作",
"previous": "上一封",
"next": "下一封",
"move_to": "移动到…",
"remove_color": "删除标签",
"remove_tag": "删除标签",
"tag_filter_placeholder": "筛选标签",
"tag_no_matches": "没有匹配的标签",
"more_count": "+{count} 更多",
"characters_count": "{count} 个字符",
"quick_reply_placeholder": "快速回复...",
@@ -399,17 +404,6 @@
"message_id": "消息 ID",
"list_info": "邮件列表信息"
},
"color_tag": {
"title": "颜色标签",
"red": "红色",
"orange": "橙色",
"yellow": "黄色",
"green": "绿色",
"blue": "蓝色",
"purple": "紫色",
"pink": "粉色",
"none": "无"
},
"tooltips": {
"reply": "回复 (r)",
"reply_all": "全部回复 (a)",
@@ -1015,7 +1009,6 @@
"title": "电子邮件标签",
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
"add_keyword": "添加标签",
"reset_defaults": "重置为默认值",
"label_field": "显示名称",
"label_placeholder": "例如工作、个人、紧急",
"id_field": "标签ID",
@@ -1028,7 +1021,22 @@
"add": "添加",
"cancel": "取消",
"migrating": "正在更新现有邮件的标签…",
"migration_error": "更新现有邮件的标签失败"
"migration_error": "更新现有邮件的标签失败",
"nesting": {
"label": "嵌套标签",
"description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。"
},
"parent_field": "上级标签",
"no_parent": "无上级标签",
"too_long": "此标签路径过长(最多 {max} 个字符)",
"has_children_locked": "此标签下嵌套了其他标签,因此其名称和上级标签已锁定。请先移动或删除它们。",
"has_children_delete": "请先删除嵌套在此标签下的标签",
"visibility_field": "侧边栏显示",
"visibility": {
"show": "显示",
"unread": "有未读时显示",
"hide": "隐藏"
}
},
"notifications": {
"test_sound": "测试通知声音",
@@ -2033,8 +2041,7 @@
"delete": "删除",
"mark_as_spam": "举报垃圾邮件",
"not_spam": "不是垃圾邮件",
"color_tag": "标签",
"remove_color": "删除标签",
"tag": "标签",
"items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿",
"cancel_scheduled_send": "取消发送",
+1
View File
@@ -59,6 +59,7 @@ const nextConfig: NextConfig = {
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
NEXT_PUBLIC_APP_VERSION: appVersion,
NEXT_PUBLIC_BASE_PATH: basePath,
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
},
};
+11 -12
View File
@@ -73,6 +73,7 @@
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vitest": "^4.1.5"
}
@@ -6279,7 +6280,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -7976,17 +7976,6 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -9666,6 +9655,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+1
View File
@@ -97,6 +97,7 @@
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vitest": "^4.1.5"
},
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE } from '../settings-store';
import { useSettingsStore, DEFAULT_KEYWORDS, DEV_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store';
import type { KeywordDefinition } from '../settings-store';
describe('settings-store keywords', () => {
@@ -16,7 +16,7 @@ describe('settings-store keywords', () => {
DEFAULT_KEYWORDS.forEach((kw) => {
expect(KEYWORD_PALETTE[kw.color]).toBeDefined();
expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy();
expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy();
expect(KEYWORD_PALETTE[kw.color].fill).toBeTruthy();
});
});
@@ -24,17 +24,67 @@ describe('settings-store keywords', () => {
const ids = DEFAULT_KEYWORDS.map((k) => k.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('ships no nested tag, which is opt-in', () => {
DEFAULT_KEYWORDS.forEach((kw) => expect(kw.id).not.toContain('/'));
});
});
describe('DEV_KEYWORDS', () => {
it('every nested tag has its parent defined, so the tree has no gaps', () => {
const ids = new Set(DEV_KEYWORDS.map((k) => k.id));
DEV_KEYWORDS.forEach((kw) => {
const cut = kw.id.lastIndexOf('/');
if (cut > 0) expect(ids, `orphan: ${kw.id}`).toContain(kw.id.slice(0, cut));
});
});
it('nests deeply enough to exercise the tree', () => {
const depths = DEV_KEYWORDS.map((k) => k.id.split('/').length);
expect(Math.max(...depths)).toBeGreaterThanOrEqual(3);
});
it('each dev keyword has a valid palette color and a unique id', () => {
const ids = DEV_KEYWORDS.map((k) => k.id);
expect(new Set(ids).size).toBe(ids.length);
DEV_KEYWORDS.forEach((kw) => expect(KEYWORD_PALETTE[kw.color]).toBeDefined());
});
});
describe('KEYWORD_PALETTE', () => {
it('has 13 colors', () => {
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13);
it('has a lighter, base and darker shade of every hue', () => {
expect(KEYWORD_PALETTE_ROWS).toHaveLength(3);
KEYWORD_PALETTE_ROWS.forEach((row) => expect(row).toHaveLength(13));
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(39);
});
it('each color has dot and bg classes', () => {
it('lays every row out in the same hue order', () => {
const [light, base, dark] = KEYWORD_PALETTE_ROWS;
expect(light).toEqual(base.map((key) => `${key}-light`));
expect(dark).toEqual(base.map((key) => `${key}-dark`));
});
it('keeps the bare hue name on the base row, so saved tags still resolve', () => {
// A tag stored as `red` predates the lighter and darker rows.
expect(KEYWORD_PALETTE_ROWS[1]).toContain('red');
expect(KEYWORD_PALETTE.red).toBeDefined();
});
it('spells every class out so Tailwind can find it', () => {
// A composed class name would compile to nothing, so none may be built
// at runtime and each has to carry its own utility prefix.
Object.values(KEYWORD_PALETTE).forEach((entry) => {
expect(entry.dot).toMatch(/^bg-/);
expect(entry.bg).toMatch(/^bg-/);
expect(entry.fill).toMatch(/^bg-/);
expect(entry.border).toMatch(/^border-/);
expect(entry.text).toMatch(/^text-.* dark:text-/);
expect(entry.rowTint).toMatch(/^bg-.* dark:bg-/);
});
});
it('resolves every row key', () => {
KEYWORD_PALETTE_ROWS.flat().forEach((key) => {
expect(KEYWORD_PALETTE[key]).toBeDefined();
});
});
});
@@ -156,4 +206,30 @@ describe('settings-store keywords', () => {
expect(kw?.label).toBe('Scarlet');
});
});
describe('getKeywordVisibility', () => {
it('treats a tag stored before visibility was configurable as always shown', () => {
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red' })).toBe('show');
});
it('returns the stored choice when there is one', () => {
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red', visibility: 'unread' })).toBe('unread');
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red', visibility: 'hide' })).toBe('hide');
});
});
describe('nestedTags', () => {
it('is off by default', () => {
useSettingsStore.getState().resetToDefaults();
expect(useSettingsStore.getState().nestedTags).toBe(false);
});
it('is included in exported settings', () => {
useSettingsStore.getState().updateSetting('nestedTags', true);
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
nestedTags?: boolean;
};
expect(exported.nestedTags).toBe(true);
});
});
});
+102 -16
View File
@@ -101,10 +101,19 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
{ id: 'contacts', labelKey: 'contacts' },
];
/** Whether a tag shows in the sidebar always, only when it has unread mail, or never. */
export type KeywordVisibility = 'show' | 'hide' | 'unread';
export interface KeywordDefinition {
id: string; // Used as JMAP keyword suffix: $label:<id>
label: string; // Display name
color: string; // Key from KEYWORD_PALETTE
visibility?: KeywordVisibility; // Absent on tags stored before this was configurable
}
/** Resolves the sidebar visibility of a tag, defaulting to always shown. */
export function getKeywordVisibility(keyword: KeywordDefinition): KeywordVisibility {
return keyword.visibility ?? 'show';
}
export interface SidebarApp {
@@ -116,23 +125,86 @@ export interface SidebarApp {
showOnMobile: boolean;
}
// Available color palette for keywords
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = {
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' },
orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' },
yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' },
green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' },
blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' },
purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' },
pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' },
teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' },
cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' },
indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' },
amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' },
lime: { dot: 'bg-lime-500', bg: 'bg-lime-50 dark:bg-lime-950/30' },
gray: { dot: 'bg-gray-500', bg: 'bg-gray-50 dark:bg-gray-950/30' },
export interface KeywordColor {
/** Solid swatch: the dot form and the settings swatches. */
dot: string;
/** The same solid colour as `dot`, for glyphs that take a text colour. */
icon: string;
/** Lozenge background. */
fill: string;
/** Lozenge border. */
border: string;
/** Lozenge text. */
text: string;
/** Full-row wash when `tintListRowsByTag` is on. */
rowTint: string;
}
/**
* Tag colours, written out literally.
*
* Tailwind v4 scans this file, but only for classes that appear verbatim -
* a composed `bg-${hue}-500` would compile to nothing. Every shade a tag can
* take therefore has to be spelled out, which is why this map is long.
*
* Three shades per hue: the middle one keeps the bare hue name, so a tag
* saved before the lighter and darker rows existed still resolves.
*/
export const KEYWORD_PALETTE: Record<string, KeywordColor> = {
// light
'red-light': { dot: 'bg-red-300', icon: 'text-red-300', fill: 'bg-red-300/10', border: 'border-red-300/30', text: 'text-red-600 dark:text-red-200', rowTint: 'bg-red-50/60 dark:bg-red-950/20' },
'orange-light': { dot: 'bg-orange-300', icon: 'text-orange-300', fill: 'bg-orange-300/10', border: 'border-orange-300/30', text: 'text-orange-600 dark:text-orange-200', rowTint: 'bg-orange-50/60 dark:bg-orange-950/20' },
'amber-light': { dot: 'bg-amber-300', icon: 'text-amber-300', fill: 'bg-amber-300/10', border: 'border-amber-300/30', text: 'text-amber-600 dark:text-amber-200', rowTint: 'bg-amber-50/60 dark:bg-amber-950/20' },
'yellow-light': { dot: 'bg-yellow-300', icon: 'text-yellow-300', fill: 'bg-yellow-300/10', border: 'border-yellow-300/30', text: 'text-yellow-600 dark:text-yellow-200', rowTint: 'bg-yellow-50/60 dark:bg-yellow-950/20' },
'lime-light': { dot: 'bg-lime-300', icon: 'text-lime-300', fill: 'bg-lime-300/10', border: 'border-lime-300/30', text: 'text-lime-600 dark:text-lime-200', rowTint: 'bg-lime-50/60 dark:bg-lime-950/20' },
'green-light': { dot: 'bg-green-300', icon: 'text-green-300', fill: 'bg-green-300/10', border: 'border-green-300/30', text: 'text-green-600 dark:text-green-200', rowTint: 'bg-green-50/60 dark:bg-green-950/20' },
'teal-light': { dot: 'bg-teal-300', icon: 'text-teal-300', fill: 'bg-teal-300/10', border: 'border-teal-300/30', text: 'text-teal-600 dark:text-teal-200', rowTint: 'bg-teal-50/60 dark:bg-teal-950/20' },
'cyan-light': { dot: 'bg-cyan-300', icon: 'text-cyan-300', fill: 'bg-cyan-300/10', border: 'border-cyan-300/30', text: 'text-cyan-600 dark:text-cyan-200', rowTint: 'bg-cyan-50/60 dark:bg-cyan-950/20' },
'blue-light': { dot: 'bg-blue-300', icon: 'text-blue-300', fill: 'bg-blue-300/10', border: 'border-blue-300/30', text: 'text-blue-600 dark:text-blue-200', rowTint: 'bg-blue-50/60 dark:bg-blue-950/20' },
'indigo-light': { dot: 'bg-indigo-300', icon: 'text-indigo-300', fill: 'bg-indigo-300/10', border: 'border-indigo-300/30', text: 'text-indigo-600 dark:text-indigo-200', rowTint: 'bg-indigo-50/60 dark:bg-indigo-950/20' },
'purple-light': { dot: 'bg-purple-300', icon: 'text-purple-300', fill: 'bg-purple-300/10', border: 'border-purple-300/30', text: 'text-purple-600 dark:text-purple-200', rowTint: 'bg-purple-50/60 dark:bg-purple-950/20' },
'pink-light': { dot: 'bg-pink-300', icon: 'text-pink-300', fill: 'bg-pink-300/10', border: 'border-pink-300/30', text: 'text-pink-600 dark:text-pink-200', rowTint: 'bg-pink-50/60 dark:bg-pink-950/20' },
'gray-light': { dot: 'bg-gray-300', icon: 'text-gray-300', fill: 'bg-gray-300/10', border: 'border-gray-300/30', text: 'text-gray-600 dark:text-gray-200', rowTint: 'bg-gray-50/60 dark:bg-gray-950/20' },
// base
red: { dot: 'bg-red-500', icon: 'text-red-500', fill: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-700 dark:text-red-300', rowTint: 'bg-red-50 dark:bg-red-950/30' },
orange: { dot: 'bg-orange-500', icon: 'text-orange-500', fill: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-700 dark:text-orange-300', rowTint: 'bg-orange-50 dark:bg-orange-950/30' },
amber: { dot: 'bg-amber-500', icon: 'text-amber-500', fill: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-700 dark:text-amber-300', rowTint: 'bg-amber-50 dark:bg-amber-950/30' },
yellow: { dot: 'bg-yellow-500', icon: 'text-yellow-500', fill: 'bg-yellow-500/10', border: 'border-yellow-500/30', text: 'text-yellow-700 dark:text-yellow-300', rowTint: 'bg-yellow-50 dark:bg-yellow-950/30' },
lime: { dot: 'bg-lime-500', icon: 'text-lime-500', fill: 'bg-lime-500/10', border: 'border-lime-500/30', text: 'text-lime-700 dark:text-lime-300', rowTint: 'bg-lime-50 dark:bg-lime-950/30' },
green: { dot: 'bg-green-500', icon: 'text-green-500', fill: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-700 dark:text-green-300', rowTint: 'bg-green-50 dark:bg-green-950/30' },
teal: { dot: 'bg-teal-500', icon: 'text-teal-500', fill: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-700 dark:text-teal-300', rowTint: 'bg-teal-50 dark:bg-teal-950/30' },
cyan: { dot: 'bg-cyan-500', icon: 'text-cyan-500', fill: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-700 dark:text-cyan-300', rowTint: 'bg-cyan-50 dark:bg-cyan-950/30' },
blue: { dot: 'bg-blue-500', icon: 'text-blue-500', fill: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-700 dark:text-blue-300', rowTint: 'bg-blue-50 dark:bg-blue-950/30' },
indigo: { dot: 'bg-indigo-500', icon: 'text-indigo-500', fill: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-700 dark:text-indigo-300', rowTint: 'bg-indigo-50 dark:bg-indigo-950/30' },
purple: { dot: 'bg-purple-500', icon: 'text-purple-500', fill: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-700 dark:text-purple-300', rowTint: 'bg-purple-50 dark:bg-purple-950/30' },
pink: { dot: 'bg-pink-500', icon: 'text-pink-500', fill: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-700 dark:text-pink-300', rowTint: 'bg-pink-50 dark:bg-pink-950/30' },
gray: { dot: 'bg-gray-500', icon: 'text-gray-500', fill: 'bg-gray-500/10', border: 'border-gray-500/30', text: 'text-gray-700 dark:text-gray-300', rowTint: 'bg-gray-50 dark:bg-gray-950/30' },
// dark
'red-dark': { dot: 'bg-red-700', icon: 'text-red-700', fill: 'bg-red-700/10', border: 'border-red-700/30', text: 'text-red-800 dark:text-red-400', rowTint: 'bg-red-100 dark:bg-red-950/50' },
'orange-dark': { dot: 'bg-orange-700', icon: 'text-orange-700', fill: 'bg-orange-700/10', border: 'border-orange-700/30', text: 'text-orange-800 dark:text-orange-400', rowTint: 'bg-orange-100 dark:bg-orange-950/50' },
'amber-dark': { dot: 'bg-amber-700', icon: 'text-amber-700', fill: 'bg-amber-700/10', border: 'border-amber-700/30', text: 'text-amber-800 dark:text-amber-400', rowTint: 'bg-amber-100 dark:bg-amber-950/50' },
'yellow-dark': { dot: 'bg-yellow-700', icon: 'text-yellow-700', fill: 'bg-yellow-700/10', border: 'border-yellow-700/30', text: 'text-yellow-800 dark:text-yellow-400', rowTint: 'bg-yellow-100 dark:bg-yellow-950/50' },
'lime-dark': { dot: 'bg-lime-700', icon: 'text-lime-700', fill: 'bg-lime-700/10', border: 'border-lime-700/30', text: 'text-lime-800 dark:text-lime-400', rowTint: 'bg-lime-100 dark:bg-lime-950/50' },
'green-dark': { dot: 'bg-green-700', icon: 'text-green-700', fill: 'bg-green-700/10', border: 'border-green-700/30', text: 'text-green-800 dark:text-green-400', rowTint: 'bg-green-100 dark:bg-green-950/50' },
'teal-dark': { dot: 'bg-teal-700', icon: 'text-teal-700', fill: 'bg-teal-700/10', border: 'border-teal-700/30', text: 'text-teal-800 dark:text-teal-400', rowTint: 'bg-teal-100 dark:bg-teal-950/50' },
'cyan-dark': { dot: 'bg-cyan-700', icon: 'text-cyan-700', fill: 'bg-cyan-700/10', border: 'border-cyan-700/30', text: 'text-cyan-800 dark:text-cyan-400', rowTint: 'bg-cyan-100 dark:bg-cyan-950/50' },
'blue-dark': { dot: 'bg-blue-700', icon: 'text-blue-700', fill: 'bg-blue-700/10', border: 'border-blue-700/30', text: 'text-blue-800 dark:text-blue-400', rowTint: 'bg-blue-100 dark:bg-blue-950/50' },
'indigo-dark': { dot: 'bg-indigo-700', icon: 'text-indigo-700', fill: 'bg-indigo-700/10', border: 'border-indigo-700/30', text: 'text-indigo-800 dark:text-indigo-400', rowTint: 'bg-indigo-100 dark:bg-indigo-950/50' },
'purple-dark': { dot: 'bg-purple-700', icon: 'text-purple-700', fill: 'bg-purple-700/10', border: 'border-purple-700/30', text: 'text-purple-800 dark:text-purple-400', rowTint: 'bg-purple-100 dark:bg-purple-950/50' },
'pink-dark': { dot: 'bg-pink-700', icon: 'text-pink-700', fill: 'bg-pink-700/10', border: 'border-pink-700/30', text: 'text-pink-800 dark:text-pink-400', rowTint: 'bg-pink-100 dark:bg-pink-950/50' },
'gray-dark': { dot: 'bg-gray-700', icon: 'text-gray-700', fill: 'bg-gray-700/10', border: 'border-gray-700/30', text: 'text-gray-800 dark:text-gray-400', rowTint: 'bg-gray-100 dark:bg-gray-950/50' },
} as const;
/** Palette laid out as the settings picker shows it: lighter, base, darker. */
export const KEYWORD_PALETTE_ROWS: string[][] = [
['red-light', 'orange-light', 'amber-light', 'yellow-light', 'lime-light', 'green-light', 'teal-light', 'cyan-light', 'blue-light', 'indigo-light', 'purple-light', 'pink-light', 'gray-light'],
['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray'],
['red-dark', 'orange-dark', 'amber-dark', 'yellow-dark', 'lime-dark', 'green-dark', 'teal-dark', 'cyan-dark', 'blue-dark', 'indigo-dark', 'purple-dark', 'pink-dark', 'gray-dark'],
];
/** The colour a tag falls back to when its definition is gone. */
export const FALLBACK_KEYWORD_COLOR = 'gray';
export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
{ id: 'red', label: 'Red', color: 'red' },
{ id: 'orange', label: 'Orange', color: 'orange' },
@@ -143,6 +215,17 @@ export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
{ id: 'pink', label: 'Pink', color: 'pink' },
];
export const DEV_KEYWORDS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'teal' },
{ id: 'work/clients/acme', label: 'Acme', color: 'green' },
{ id: 'personal', label: 'Personal', color: 'purple' },
{ id: 'personal/finance', label: 'Finance', color: 'amber' },
{ id: 'receipts', label: 'Receipts', color: 'gray' },
];
const USING_MOCK_SERVER = process.env.NEXT_PUBLIC_DEV_MOCK_JMAP === 'true';
interface SettingsState {
// Appearance
fontSize: FontSize;
@@ -287,6 +370,7 @@ interface SettingsState {
// Keywords (labels/tags)
emailKeywords: KeywordDefinition[];
nestedTags: boolean; // Treat "/" in a tag id as a parent/child separator
// Attachment Reminder
attachmentReminderEnabled: boolean;
@@ -484,7 +568,8 @@ const DEFAULT_SETTINGS = {
folderIcons: {} as Record<string, string>,
// Keywords
emailKeywords: DEFAULT_KEYWORDS,
emailKeywords: USING_MOCK_SERVER ? DEV_KEYWORDS : DEFAULT_KEYWORDS,
nestedTags: USING_MOCK_SERVER,
// Attachment Reminder
attachmentReminderEnabled: true,
@@ -661,6 +746,7 @@ export const useSettingsStore = create<SettingsState>()(
showFolderTotalCount: state.showFolderTotalCount,
folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords,
nestedTags: state.nestedTags,
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
hideInlineImageAttachments: state.hideInlineImageAttachments,
-40
View File
@@ -1,40 +0,0 @@
import type { Config } from "tailwindcss";
export default {
darkMode: 'class',
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./stores/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: [
"-apple-system",
"BlinkMacSystemFont",
"Inter",
"system-ui",
"sans-serif",
],
mono: ["JetBrains Mono", "monospace"],
},
animation: {
"fade-in": "fade-in 0.2s ease-out",
"slide-in": "slide-in 0.3s ease-out",
},
keyframes: {
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
"slide-in": {
"0%": { transform: "translateY(-10px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
},
},
},
plugins: [],
} satisfies Config;