toggleSection(`shared-${group.accountId}`)}
- className="flex items-center gap-1 flex-1 min-w-0 text-left"
+ className="flex items-center gap-1 flex-1 min-w-0 text-start"
>
{collapsed[`shared-${group.accountId}`] ? (
@@ -779,7 +779,7 @@ function CategoryItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
- "w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
+ "w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -789,7 +789,7 @@ function CategoryItem({
>
{keyword}
-
+
{count}
@@ -847,7 +847,7 @@ function AddressBookItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
- "w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
+ "w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -858,11 +858,11 @@ function AddressBookItem({
{book.name}
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
-
+
)}
0) && "ml-auto"
+ !(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
)}>
{contactCount}
diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx
index fb08be4a..6e2c017a 100644
--- a/components/email/__tests__/calendar-invitation-banner.test.tsx
+++ b/components/email/__tests__/calendar-invitation-banner.test.tsx
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
mocks.clientMock,
'event-8',
expect.objectContaining({
- replyTo: { imip: 'mailto:organizer@example.com' },
+ // The stored event lacks an ORGANIZER, so the RSVP repair writes
+ // organizerCalendarAddress (replyTo is retired in jscalendarbis).
+ organizerCalendarAddress: 'mailto:organizer@example.com',
participants: expect.objectContaining({
attendee: expect.objectContaining({
participationStatus: 'accepted',
diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx
index 2a6101fd..72d41e35 100644
--- a/components/email/__tests__/email-list-item.test.tsx
+++ b/components/email/__tests__/email-list-item.test.tsx
@@ -121,3 +121,34 @@ describe('EmailListItem tag badge', () => {
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(
);
+ // 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);
+ });
+});
diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx
index 461b4452..f3138b38 100644
--- a/components/email/__tests__/recipient-chip-drag.test.tsx
+++ b/components/email/__tests__/recipient-chip-drag.test.tsx
@@ -148,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
-vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
+vi.mock('@/lib/reply-identity', () => ({
+ resolveReplyFrom: () => null,
+ findComposeIdentityId: () => null,
+}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
@@ -227,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => {
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
- expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
+ expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
@@ -241,7 +244,7 @@ describe('RecipientChipInput drag and drop', () => {
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
- expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
+ expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
});
it('onDragEnd clears the opacity class on the chip', async () => {
@@ -340,4 +343,121 @@ describe('RecipientChipInput drag and drop', () => {
const ccLabel = await screen.findByText('cc_label');
expect(ccLabel).toBeInTheDocument();
});
+
+ // ─── Reordering within / across fields (#593) ─────────────────────────────────
+ // jsdom ignores `clientX` in fireEvent's init for drag events (it's a
+ // read-only MouseEvent getter) and gives every element a zero-size rect at
+ // (0,0). So we dispatch events with `clientX` forced via defineProperty; with
+ // the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
+
+ const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
+
+ const chipByText = async (text: string) =>
+ (await screen.findByText(text)).closest('[draggable]') as HTMLElement;
+
+ /** Dispatch a drag event with a real clientX (fireEvent init drops it). */
+ const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
+ const e = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperty(e, 'clientX', { value: clientX });
+ Object.defineProperty(e, 'dataTransfer', { value: dt });
+ act(() => { fireEvent(el, e); });
+ };
+ const BEFORE = -100;
+ const AFTER = 100;
+
+ /** Ordered chip labels of the field-container that holds `anchorText`. */
+ const orderIn = (anchorText: string) => {
+ const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
+ const c = containers.find(el =>
+ Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
+ ) as HTMLElement;
+ return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
+ };
+
+ /** All draggable chips (across fields) whose label contains `text`. */
+ const draggableChipsWith = (text: string) =>
+ Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
+
+ it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
+ render(
);
+ await screen.findByText('alice@example.com');
+ const alice = await chipByText('alice@example.com');
+ const carol = await chipByText('carol@example.com');
+
+ const dt = new MockDataTransfer();
+ fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
+ fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
+ fireDnd('drop', carol, dt, AFTER);
+
+ expect(orderIn('bob@example.com')).toEqual([
+ 'bob@example.com', 'carol@example.com', 'alice@example.com',
+ ]);
+ });
+
+ it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
+ render(
);
+ await screen.findByText('carol@example.com');
+ const carol = await chipByText('carol@example.com');
+ const alice = await chipByText('alice@example.com');
+
+ const dt = new MockDataTransfer();
+ fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
+ fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
+ fireDnd('drop', alice, dt, BEFORE);
+
+ expect(orderIn('alice@example.com')).toEqual([
+ 'carol@example.com', 'alice@example.com', 'bob@example.com',
+ ]);
+ });
+
+ it('dropping a chip onto its own position leaves the order unchanged', async () => {
+ render(
);
+ await screen.findByText('bob@example.com');
+ const bob = await chipByText('bob@example.com');
+
+ const dt = new MockDataTransfer();
+ fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
+ fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
+ fireDnd('drop', bob, dt, BEFORE);
+
+ expect(orderIn('bob@example.com')).toEqual([
+ 'alice@example.com', 'bob@example.com', 'carol@example.com',
+ ]);
+ });
+
+ it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
+ render(
);
+ await screen.findByText('alice@example.com');
+ const alice = await chipByText('alice@example.com'); // To
+ const y = await chipByText('y@example.com'); // Cc
+
+ const dt = new MockDataTransfer();
+ fireEvent.dragStart(alice, { dataTransfer: dt });
+ fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
+ fireDnd('drop', y, dt, BEFORE);
+
+ // alice lands between x and y; To no longer holds it (count only real chips,
+ // not the leftover jsdom drag-preview element)
+ expect(orderIn('x@example.com')).toEqual([
+ 'x@example.com', 'alice@example.com', 'y@example.com',
+ ]);
+ expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
+ });
+
+ it('shows a drop caret only while a chip is dragged over the field', async () => {
+ render(
);
+ await screen.findByText('alice@example.com');
+ const alice = await chipByText('alice@example.com');
+ const bob = await chipByText('bob@example.com');
+
+ const dt = new MockDataTransfer();
+ fireEvent.dragStart(alice, { dataTransfer: dt });
+ expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
+
+ fireDnd('dragover', bob, dt, BEFORE);
+ expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
+
+ fireEvent.dragEnd(alice);
+ expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
+ });
});
diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx
index 1a3ccef6..a8d4d15c 100644
--- a/components/email/__tests__/recipient-paste.test.tsx
+++ b/components/email/__tests__/recipient-paste.test.tsx
@@ -147,7 +147,10 @@ vi.mock('@/lib/email-sanitization', () => ({
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
-vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
+vi.mock('@/lib/reply-identity', () => ({
+ resolveReplyFrom: () => null,
+ findComposeIdentityId: () => null,
+}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
diff --git a/components/email/__tests__/selectable-avatar.test.tsx b/components/email/__tests__/selectable-avatar.test.tsx
new file mode 100644
index 00000000..51ac9269
--- /dev/null
+++ b/components/email/__tests__/selectable-avatar.test.tsx
@@ -0,0 +1,39 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SelectableAvatar } from '../selectable-avatar';
+
+// Isolate from the real Avatar (image fetching, libravatar hashing) — we only
+// care about the selection wrapper behaviour here.
+vi.mock('@/components/ui/avatar', () => ({
+ Avatar: (props: { name?: string }) =>
{props.name} ,
+}));
+
+describe('SelectableAvatar', () => {
+ it('renders the wrapped avatar', () => {
+ render(
{}} selectLabel="Select" />);
+ expect(screen.getByTestId('avatar')).toHaveTextContent('Marta');
+ });
+
+ it('fires onToggle and stops propagation when the avatar is clicked', () => {
+ const onToggle = vi.fn();
+ const onRowClick = vi.fn();
+ render(
+
+
+
,
+ );
+ fireEvent.click(screen.getByRole('checkbox'));
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ // Clicking the avatar must not bubble up to open/select the row.
+ expect(onRowClick).not.toHaveBeenCalled();
+ });
+
+ it('reflects the checked state via aria-checked', () => {
+ const { rerender } = render(
+ {}} selectLabel="Select" />,
+ );
+ expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
+ rerender( {}} selectLabel="Select" />);
+ expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
+ });
+});
diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx
index bd21fc54..9b8b0fe4 100644
--- a/components/email/calendar-invitation-banner.tsx
+++ b/components/email/calendar-invitation-banner.tsx
@@ -20,6 +20,7 @@ import {
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
+import { isDocumentRTL } from '@/i18n/direction';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
@@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [actionError, setActionError] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
- const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
+ const [pickerPosition, setPickerPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
const pickerTriggerRef = useRef(null);
const [selectedCalendarId, setSelectedCalendarId] = useState('');
const [rawIcsMethod, setRawIcsMethod] = useState('unknown');
@@ -582,7 +583,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
} else {
await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants,
- replyTo: replyToForRsvp ?? undefined,
+ // Stalwart routes the iTIP REPLY via the stored ORGANIZER
+ // (organizerCalendarAddress; the RFC 8984 replyTo is retired).
+ // Only repair a missing organizer - attendees may not modify it.
+ ...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
+ ? { organizerCalendarAddress: replyToForRsvp.imip }
+ : {}),
}, true);
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
@@ -1021,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
}
if (pickerTriggerRef.current) {
const rect = pickerTriggerRef.current.getBoundingClientRect();
- setPickerPosition({ top: rect.bottom + 4, left: rect.left });
+ setPickerPosition(
+ isDocumentRTL()
+ ? { top: rect.bottom + 4, right: window.innerWidth - rect.right }
+ : { top: rect.bottom + 4, left: rect.left }
+ );
}
setShowCalendarPicker(true);
}}
@@ -1036,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
{t('select_calendar')}
@@ -1048,7 +1058,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setShowCalendarPicker(false);
handleImport(cal.id);
}}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
>
+
)}
)}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 23b24ae8..df454863 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -5,19 +5,20 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
+import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
-import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
+import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
+import { isEditableEventTarget } from "@/lib/keyboard";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
-import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
+import type { AlmostSavedDraft, OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
@@ -26,16 +27,16 @@ import { useSettingsStore } from "@/stores/settings-store";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
-import { useContactStore } from "@/stores/contact-store";
+import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
-import { substitutePlaceholders } from "@/lib/template-utils";
+import { substitutePlaceholders, spliceTemplateAboveSignature } from "@/lib/template-utils";
import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
-import { resolveReplyFrom } from "@/lib/reply-identity";
+import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
@@ -44,12 +45,20 @@ import {
parseRecipient,
parseRecipientList,
formatRecipientList,
+ expandRecipients,
splitPastedRecipients,
+ waitForPendingUploads,
+ extractUserAuthoredText,
type Recipient,
+ enrichChipsWithColorsAndIcons,
+ ICON_MAP,
} from "@/lib/email-composer-utils";
+import { isValidEmail } from "@/lib/validation";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
+import { fileStorage } from "@/lib/plugin-storage";
+import { usePolicyStore } from "@/stores/policy-store";
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
@@ -88,6 +97,10 @@ function createChipDragPreview(label: string): HTMLElement {
return preview;
}
+// An autocomplete entry: a person, or a contact group (empty email) that
+// inserts as a single chip and expands into its members on send.
+type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } };
+
export interface ComposerDraftData {
to: string;
cc: string;
@@ -133,12 +146,28 @@ interface EmailComposerProps {
}) => void | Promise
;
onScheduledSendCreated?: () => void | Promise;
onClose?: () => void;
+ /**
+ * When provided, the composer assigns its close handler to `current`. The
+ * handler shows the unsaved-changes dialog when the draft is dirty, so a
+ * host (e.g. the Pro tab bar's close button) can route an external close
+ * request through the same guard instead of discarding silently.
+ */
+ requestCloseRef?: React.MutableRefObject<(() => void) | null>;
onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void;
className?: string;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
+ /**
+ * Email of the mailbox/account the user is viewing when they start a new
+ * message. When set (and `autoSelectReplyIdentity` is on), a fresh compose
+ * preselects the identity matching this address instead of the primary
+ * identity, so "New message" from info@ defaults its From to info@. Mirrors
+ * the reply-time identity match; ignored for reply/replyAll/forward (those
+ * resolve from the original recipients).
+ */
+ composeFromAccountEmail?: string;
replyTo?: {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
@@ -229,12 +258,14 @@ export function EmailComposer({
onSend,
onScheduledSendCreated,
onClose,
+ requestCloseRef,
onDiscardDraft,
onSaveState,
className,
initialDraftText,
initialData,
mode = 'compose',
+ composeFromAccountEmail,
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
@@ -263,6 +294,9 @@ export function EmailComposer({
: [];
const primaryIdentity = activeIdentities[0] ?? null;
+ const { isFeatureEnabled } = usePolicyStore();
+ const templatesEnabled = isFeatureEnabled('templatesEnabled');
+
// The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation
// below, but uses initialData (or primary) since selectedIdentityId state
@@ -667,6 +701,19 @@ export function EmailComposer({
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
+
+ // New message started from a specific mailbox/account: default the From to
+ // that mailbox's identity instead of the primary one, so composing while
+ // viewing info@ sends as info@. Reply/forward fall through to the
+ // recipient-based resolution below.
+ if (mode === 'compose') {
+ const composeIdentityId = findComposeIdentityId(identities, composeFromAccountEmail);
+ if (composeIdentityId) {
+ setSelectedIdentityId(composeIdentityId);
+ }
+ return;
+ }
+
if (mode !== 'reply' && mode !== 'replyAll') return;
const resolved = resolveReplyFrom(identities, {
@@ -700,6 +747,7 @@ export function EmailComposer({
}
}, [
autoSelectReplyIdentity,
+ composeFromAccountEmail,
fromOverrideEnabled,
identities,
initialData?.selectedIdentityId,
@@ -781,12 +829,37 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [composerClient, plainTextMode, mode]);
+ const processEnrichment = async (
+ recipients: Recipient[],
+ setRecipients: (items: Recipient[]) => void
+ ) => {
+ const hasUnenriched = recipients.some((r) => !r.extra?.enriched);
+ if (!hasUnenriched) return;
+
+ const newChips = await enrichChipsWithColorsAndIcons(recipients);
+ const fullyEnriched = newChips.map((chip) => ({
+ ...chip,
+ extra: { ...chip.extra, enriched: true },
+ }));
+ setRecipients(fullyEnriched);
+ };
+
+
+ useEffect(() => { processEnrichment(to, setTo); }, [to]);
+ useEffect(() => { processEnrichment(cc, setCc); }, [cc]);
+ useEffect(() => { processEnrichment(bcc, setBcc); }, [bcc]);
+
const composerSignatureHtml = signatureIdentity?.htmlSignature
- ? `${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}
`
+ ? `${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}
`
: signatureIdentity?.textSignature
? `${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, ' ')}
`
: '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
+ const getGroupMembers = useContactStore((s) => s.getGroupMembers);
+ const searchRecipients = useContactStore((s) => s.searchRecipients);
+ // Whether a Sent mailbox is known so the on-demand server search is worth
+ // offering (falls back to hiding the "search the server" row otherwise).
+ const canSearchServer = useContactStore((s) => s.sentMailboxId != null);
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
@@ -859,9 +932,13 @@ export function EmailComposer({
}
}, [mode]);
- const [autocompleteResults, setAutocompleteResults] = useState>([]);
+ const [autocompleteResults, setAutocompleteResults] = useState>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
+ // Current trimmed query behind the open dropdown, plus the in-flight flag for
+ // the on-demand Sent-folder lookup ("search the server" row).
+ const [autoQuery, setAutoQuery] = useState('');
+ const [isSearchingServer, setIsSearchingServer] = useState(false);
const autocompleteTimeoutRef = useRef(null);
const toInputRef = useRef(null);
const ccInputRef = useRef(null);
@@ -886,15 +963,26 @@ export function EmailComposer({
}
}, [plainTextMode]);
- const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
+ // Move a chip from one recipient field to another. `toIndex`, when given,
+ // inserts at that position in the destination (drag-and-drop reordering,
+ // #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button).
+ const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => {
if (fromField === toField) return;
const setters = { to: setTo, cc: setCc, bcc: setBcc };
- const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? '');
+ const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
+ const sameRecipient = (a: Recipient, b: Recipient) =>
+ a.email === b.email && (a.name ?? '') === (b.name ?? '') && groupKey(a) === groupKey(b);
setters[fromField](prev => {
const idx = prev.findIndex(r => sameRecipient(r, recipient));
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
});
- setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
+ setters[toField](prev => {
+ if (prev.some(r => sameRecipient(r, recipient))) return prev;
+ const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length));
+ const next = [...prev];
+ next.splice(at, 0, recipient);
+ return next;
+ });
if (toField === 'cc') setShowCc(true);
if (toField === 'bcc') setShowBcc(true);
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
@@ -909,25 +997,75 @@ export function EmailComposer({
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
+ setAutoQuery('');
return;
}
+ setAutoQuery(query);
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(query);
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
- const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
+ const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email, group: r.group }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
- setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
- setActiveAutoField(merged.length > 0 ? field : null);
+ setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email, group: s.group })));
+ // Keep the dropdown open even without local hits when a server search is
+ // available, so the "search the server" row stays reachable (OWA-style).
+ setActiveAutoField(merged.length > 0 || canSearchServer ? field : null);
setAutoSelectedIndex(-1);
}, 200);
- }, [getAutocomplete]);
+ }, [getAutocomplete, canSearchServer]);
- const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
+ // On-demand: search the Sent folder server-side for recipients matching the
+ // current query and merge fresh hits into the open dropdown (deduped by email).
+ const handleServerSearch = useCallback(async () => {
+ const query = autoQuery.trim();
+ if (!composerClient || !query || isSearchingServer) return;
+ setIsSearchingServer(true);
+ try {
+ const serverResults = await searchRecipients(composerClient, query);
+ setAutocompleteResults((prev) => {
+ const seen = new Set(prev.map((r) => r.email.toLowerCase()));
+ const merged = [...prev];
+ for (const r of serverResults) {
+ const key = r.email.toLowerCase();
+ if (!seen.has(key)) {
+ seen.add(key);
+ merged.push(r);
+ }
+ }
+ return merged;
+ });
+ } catch {
+ // Best-effort: a failed lookup just leaves the local suggestions in place.
+ } finally {
+ setIsSearchingServer(false);
+ }
+ }, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
+
+ const insertAutocomplete = (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => {
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput;
- setter(prev => [...prev, toRecipient(suggestion)]);
+ if (suggestion.group) {
+ // Insert the group as a single chip carrying a snapshot of its members
+ // (deduped, members without an address skipped). The chip is expanded
+ // into the members when the message is sent or saved as a draft.
+ const seen = new Set();
+ const members: Array<{ name?: string; email: string }> = [];
+ for (const m of getGroupMembers(suggestion.group.id)) {
+ const email = getContactPrimaryEmail(m).trim();
+ const key = email.toLowerCase();
+ if (!email || seen.has(key)) continue;
+ seen.add(key);
+ const name = getContactDisplayName(m);
+ members.push({ name: name && name !== email ? name : undefined, email });
+ }
+ if (members.length > 0) {
+ setter(prev => [...prev, { name: suggestion.name, email: '', group: { members } }]);
+ }
+ } else {
+ setter(prev => [...prev, toRecipient(suggestion)]);
+ }
inputSetter('');
setAutocompleteResults([]);
setActiveAutoField(null);
@@ -977,13 +1115,22 @@ export function EmailComposer({
: template.body;
// In plain text mode, use template body as-is; otherwise convert to HTML
- const bodyContent = plainTextMode
+ const bodyContent = plainTextMode || template.isHTML
? filledBody
: `${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, ' ')}
`;
if (mode === 'compose') {
setSubject(filledSubject);
- setBody(bodyContent);
+ // Compose bodies carry the embedded signature (see
+ // shouldEmbedSignatureInNewMail) and the send path assumes it stays
+ // there, so replace only the message content, not the signature block.
+ if (plainTextMode) {
+ setBody(shouldEmbedSignatureInNewMail
+ ? appendPlainTextSignature(bodyContent, signatureIdentity, { separator: signatureSeparatorEnabled })
+ : bodyContent);
+ } else {
+ setBody((prev) => spliceTemplateAboveSignature(prev, bodyContent));
+ }
if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.map(parseRecipient));
}
@@ -1004,14 +1151,14 @@ export function EmailComposer({
}
setShowTemplatePicker(false);
- }, [mode, plainTextMode]);
+ }, [mode, plainTextMode, shouldEmbedSignatureInNewMail, signatureIdentity, signatureSeparatorEnabled]);
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
- const target = e.target as HTMLElement;
- const tag = target?.tagName?.toLowerCase();
- if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
- if (target?.getAttribute('contenteditable') === 'true') return;
+ // composedPath-based check so editing inside the QuotedHtml shadow
+ // island doesn't trigger the picker (#654).
+ if (isEditableEventTarget(e)) return;
+ if (!templatesEnabled) return;
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
setShowTemplatePicker(true);
@@ -1019,7 +1166,7 @@ export function EmailComposer({
};
window.addEventListener('keydown', handleTemplateKey);
return () => window.removeEventListener('keydown', handleTemplateKey);
- }, []);
+ }, [templatesEnabled]);
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
@@ -1055,7 +1202,16 @@ export function EmailComposer({
const controller = newAttachments[i].abortController;
try {
if (controller?.signal.aborted) continue;
- const { blobId } = await client.uploadBlob(file);
+
+ const fileId = generateUUID();
+ await fileStorage.saveFile(fileId, file);
+
+ const newFileId = await emailHooks.onBeforeBlobUpload.transform(fileId);
+
+ const newFile = await fileStorage.getFile(newFileId) || file;
+ await fileStorage.deleteFile(newFileId);
+
+ const { blobId } = await client.uploadBlob(newFile);
if (controller?.signal.aborted) continue;
setAttachments(prev =>
@@ -1219,9 +1375,9 @@ export function EmailComposer({
const saveDraftOnce = async (): Promise => {
if (!client || !composerClient) return null;
- const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email));
- const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email));
- const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email));
+ const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email));
+ const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email));
+ const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email));
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
return null;
@@ -1263,21 +1419,36 @@ export function EmailComposer({
try {
const previousDraftId = draftIdRef.current;
+ let savedDraft : AlmostSavedDraft = {
+ to: toAddresses,
+ subject: subject || t('no_subject'),
+ body: plainTextMode ? body : htmlToPlainText(body),
+ cc: ccAddresses,
+ bcc: bccAddresses,
+ identityId: currentIdentityRawId,
+ fromEmail,
+ draftId: previousDraftId || undefined,
+ attachments: uploadedAttachments,
+ fromName,
+ htmlBody: plainTextMode ? undefined : body
+ }
+ savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft);
+
// Use the JMAP client and raw identity id for the *owning* account
// - falls back to active client for single-account / same-account
// identities. See `composerClient` derivation above.
const savedDraftId = await composerClient.createDraft(
- toAddresses,
- subject || t('no_subject'),
- plainTextMode ? body : htmlToPlainText(body),
- ccAddresses,
- bccAddresses,
- currentIdentityRawId,
- fromEmail,
- previousDraftId || undefined,
- uploadedAttachments,
- fromName,
- plainTextMode ? undefined : body
+ savedDraft.to,
+ savedDraft.subject,
+ savedDraft.body,
+ savedDraft.cc,
+ savedDraft.bcc,
+ savedDraft.identityId,
+ savedDraft.fromEmail,
+ savedDraft.draftId,
+ savedDraft.attachments,
+ savedDraft.fromName,
+ savedDraft.htmlBody
);
// Update the ref synchronously so a queued save sees the new id and
@@ -1375,12 +1546,15 @@ export function EmailComposer({
};
}, []);
- const toAddresses = withInput(to, toInput);
+ // Groups expand here so validation and every outgoing payload see the
+ // actual member addresses.
+ const toAddresses = expandRecipients(withInput(to, toInput));
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
const canSend = toAddresses.length > 0 && !!subject && hasContent;
const getSendTooltip = (): string | undefined => {
+ if (isWaitingForUploads) return t('validation.attachments_uploading');
if (canSend) return undefined;
if (toAddresses.length === 0) return t('validation.recipient_required');
if (!subject) return t('validation.subject_required');
@@ -1466,10 +1640,45 @@ export function EmailComposer({
const [isSending, setIsSending] = useState(false);
const isSendingRef = useRef(false);
+ // Attachments still uploading when Send is clicked used to be silently
+ // dropped from the outgoing message (the filters below exclude anything
+ // with uploading:true). attachmentsRef gives handleSend a way to read the
+ // freshest attachment state after waiting on in-flight uploads, since the
+ // `attachments` closure captured at click time won't reflect uploads that
+ // finish during that wait.
+ const attachmentsRef = useRef(attachments);
+ useEffect(() => {
+ attachmentsRef.current = attachments;
+ }, [attachments]);
+ const [isWaitingForUploads, setIsWaitingForUploads] = useState(false);
+ const sendCancelledRef = useRef(false);
+
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
if (isSendingRef.current) return;
- const ccAddresses = withInput(cc, ccInput);
- const bccAddresses = withInput(bcc, bccInput);
+
+ if (attachmentsRef.current.some(att => att.uploading)) {
+ isSendingRef.current = true;
+ setIsSending(true);
+ setIsWaitingForUploads(true);
+ const uploadResult = await waitForPendingUploads(
+ () => attachmentsRef.current,
+ () => sendCancelledRef.current
+ );
+ setIsWaitingForUploads(false);
+ isSendingRef.current = false;
+ setIsSending(false);
+ if (uploadResult === 'cancelled') return;
+ if (uploadResult === 'failed') {
+ // An upload broke while we were waiting - the user may not be
+ // looking at the composer, so auto-sending would silently drop
+ // the failed attachment. Abort and let them decide.
+ toast.error(t('validation.attachment_upload_failed'));
+ return;
+ }
+ }
+
+ const ccAddresses = expandRecipients(withInput(cc, ccInput));
+ const bccAddresses = expandRecipients(withInput(bcc, bccInput));
if (!canSend) {
const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
@@ -1488,9 +1697,15 @@ export function EmailComposer({
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
- const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
+ const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
- const bodyText = htmlToPlainText(body);
+ // Scan only the user-authored text: the quoted original of a
+ // reply/forward often mentions an attachment itself, which used to fire
+ // the reminder even when the user typed no keyword and added nothing (#570).
+ const bodyText = extractUserAuthoredText(body, {
+ plainTextMode,
+ forwardedSeparator: tQuote('forwarded_separator'),
+ });
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
@@ -1604,7 +1819,7 @@ export function EmailComposer({
textBody: finalBody,
identityId: currentIdentity?.id || '',
fromEmail,
- attachments: attachments
+ attachments: attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
@@ -1631,7 +1846,7 @@ export function EmailComposer({
references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
attachments: [
- ...attachments
+ ...attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })),
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
@@ -1648,7 +1863,7 @@ export function EmailComposer({
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
- const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
+ const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
@@ -1775,6 +1990,7 @@ export function EmailComposer({
}, []);
const cleanClose = () => {
+ sendCancelledRef.current = true;
explicitCloseRef.current = true;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -1784,6 +2000,7 @@ export function EmailComposer({
};
const handleSaveDraftAndClose = async () => {
+ sendCancelledRef.current = true;
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
@@ -1795,6 +2012,7 @@ export function EmailComposer({
};
const handleDiscardAndClose = () => {
+ sendCancelledRef.current = true;
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
@@ -1815,6 +2033,17 @@ export function EmailComposer({
}
};
+ // Expose the dirty-aware close handler so external hosts (e.g. the Pro tab
+ // bar) can trigger the same "Save or discard draft?" guard. Re-assigned on
+ // every render to capture the latest closure over the live refs/state.
+ useEffect(() => {
+ if (!requestCloseRef) return;
+ requestCloseRef.current = handleClose;
+ return () => {
+ requestCloseRef.current = null;
+ };
+ });
+
const handleComposerKeyDown = (e: React.KeyboardEvent) => {
if (e.defaultPrevented) return;
@@ -1857,10 +2086,10 @@ export function EmailComposer({
};
return (
-
+
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
-
+
{t('new_message')}
{saveStatus === 'saving' && (
@@ -1915,9 +2144,10 @@ export function EmailComposer({
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
+ data-testid="composer-send"
className="md:hidden h-9 px-4"
>
-
+
{t('send')}
@@ -1949,6 +2179,7 @@ export function EmailComposer({
) : identities.length > 1 ? (
setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
@@ -1961,7 +2192,7 @@ export function EmailComposer({
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
-
+
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
);
@@ -1973,24 +2204,24 @@ export function EmailComposer({
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
-
+
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
);
})}
) : (
-
+
{subAddressTag ? (
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
) : (
- <>
+
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
- >
+
)}
)}
@@ -2043,7 +2274,7 @@ export function EmailComposer({
{/* To field */}
-
+
{t('to')}:
@@ -2166,6 +2405,10 @@ export function EmailComposer({
autoSelectedIndex={autoSelectedIndex}
dropdownRef={bccDropdownRef}
onInsertAutocomplete={insertAutocomplete}
+ canSearchServer={canSearchServer}
+ onServerSearch={handleServerSearch}
+ isSearchingServer={isSearchingServer}
+ serverSearchQuery={autoQuery}
onMoveChip={handleMoveChip}
/>
@@ -2176,6 +2419,7 @@ export function EmailComposer({
{t('subject_label')}
removeAttachment(index)}
- className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
+ className="ms-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
title={att.uploading ? t('upload_cancel') : undefined}
>
@@ -2345,24 +2589,26 @@ export function EmailComposer({
>
-
setShowTemplatePicker(true)}
- title={t('use_template')}
- className="h-9 w-9"
- >
-
-
-
setShowSaveAsTemplate(true)}
- title={t('save_as_template')}
- className="h-9 w-9"
- >
-
-
+ {templatesEnabled && <>
+
setShowTemplatePicker(true)}
+ title={t('use_template')}
+ className="h-9 w-9"
+ >
+
+
+
setShowSaveAsTemplate(true)}
+ title={t('save_as_template')}
+ className="h-9 w-9"
+ >
+
+
+ >}
{/* Sign/encrypt controls are contributed by crypto plugins via the
composer-toolbar slot (rendered below). */}
@@ -2398,9 +2644,10 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
- className="rounded-r-none border-r border-primary-foreground/20"
+ data-testid="composer-send"
+ className="rounded-e-none border-e border-primary-foreground/20"
>
-
+
{t('send')}
setShowSendMenu((open) => !open)}
disabled={!canSend || isSending}
title={t('schedule_send')}
- className="rounded-l-none px-2"
+ className="rounded-s-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
>
@@ -2417,13 +2664,13 @@ export function EmailComposer({
{showSendMenu && (
{t('schedule_send')}
@@ -2436,9 +2683,10 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
+ data-testid="composer-send"
className="hidden md:inline-flex"
>
-
+
{t('send')}
)}
@@ -2557,8 +2805,8 @@ export function EmailComposer({
{t('discard')}
-
- {t('save_draft')}
+
+ {tCommon('save')}
@@ -2576,7 +2824,7 @@ export function EmailComposer({
);
@@ -2584,10 +2832,14 @@ export function EmailComposer({
const AutocompleteDropdown = React.forwardRef;
+ results: Array;
selectedIndex: number;
- onSelect: (suggestion: { name: string; email: string }) => void;
-}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
+ onSelect: (suggestion: SuggestionItem) => void;
+ onSearchServer?: () => void;
+ isSearchingServer?: boolean;
+}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) {
+ const t = useTranslations('email_composer');
+ const tContacts = useTranslations('contacts');
return (
{results.map((r, i) => (
@@ -2598,7 +2850,7 @@ const AutocompleteDropdown = React.forwardRef
{
@@ -2606,13 +2858,44 @@ const AutocompleteDropdown = React.forwardRef
-
+ {r.group ? (
+
+
+
+ ) : (
+
+ )}
{r.name || r.email}
- {r.name && (
+ {r.group ? (
+
+ {tContacts('groups.member_count', { count: r.group.memberCount })}
+
+ ) : r.name && (
<{r.email}>
)}
))}
+ {onSearchServer && (
+ 0 && "border-t border-border"
+ )}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ if (!isSearchingServer) onSearchServer();
+ }}
+ >
+ {isSearchingServer
+ ?
+ : }
+
+ {isSearchingServer ? t('autocomplete_searching') : t('autocomplete_search_server')}
+
+
+ )}
);
});
@@ -2633,6 +2916,10 @@ function RecipientChipInput({
autoSelectedIndex,
dropdownRef,
onInsertAutocomplete,
+ canSearchServer,
+ onServerSearch,
+ isSearchingServer,
+ serverSearchQuery,
validationError,
validationMessage,
onTab,
@@ -2649,14 +2936,18 @@ function RecipientChipInput({
onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void;
onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void;
activeAutoField: 'to' | 'cc' | 'bcc' | null;
- autocompleteResults: Array<{ name: string; email: string }>;
+ autocompleteResults: Array;
autoSelectedIndex: number;
dropdownRef: React.RefObject;
- onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
+ onInsertAutocomplete: (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => void;
+ canSearchServer: boolean;
+ onServerSearch: () => void;
+ isSearchingServer: boolean;
+ serverSearchQuery: string;
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
- onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
+ onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void;
}) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
@@ -2665,6 +2956,9 @@ function RecipientChipInput({
const [editValue, setEditValue] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [draggingIndex, setDraggingIndex] = useState(null);
+ // Gap (0..chips.length) a dragged chip would drop into; drives the insertion
+ // caret and positional drop for reordering (#593). null when not dragging.
+ const [dropIndex, setDropIndex] = useState(null);
const editInputRef = useRef(null);
// Focus edit input when editing starts
@@ -2681,7 +2975,9 @@ function RecipientChipInput({
// Format a recipient for display in a chip / context menu
const formatChipDisplay = (r: Recipient): string =>
- r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
+ r.group
+ ? `${r.name || 'Group'} (${r.group.members.length})`
+ : r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
// Handle saving an edited chip
const handleSaveEdit = (newValue: string) => {
@@ -2701,10 +2997,10 @@ function RecipientChipInput({
setEditingChip(null);
return;
}
- newChip = { name: chip.name, email: trimmedNew };
+ newChip = { ...chip, email: trimmedNew };
} else {
// Update name, keep email. Empty name clears the display name.
- newChip = { name: trimmedNew || undefined, email: chip.email };
+ newChip = { ...chip, name: trimmedNew || undefined };
}
const newChips = [...chips];
@@ -2749,7 +3045,15 @@ function RecipientChipInput({
}
}
- if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
+ // Enter / Tab commit whatever is typed. Space only commits when the input
+ // is already a complete email address; otherwise Space is a normal
+ // character so a name search like "John Doe" can continue past the space
+ // instead of committing "John" as a bogus recipient (#571).
+ const trimmedInput = inputText.trim();
+ const commitOnKey =
+ ((e.key === 'Enter' || e.key === 'Tab') && trimmedInput) ||
+ (e.key === ' ' && isValidEmail(trimmedInput));
+ if (commitOnKey) {
if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput();
if (e.key === 'Tab' && onTab) {
@@ -2811,27 +3115,86 @@ function RecipientChipInput({
onAutoBlur(e, field);
};
+ const isChipDrag = (e: React.DragEvent) =>
+ e.dataTransfer.types.includes('application/x-recipient-chip');
+
+ // Dragging over empty container space (past the last chip / over the input)
+ // targets the end of the list.
const handleContainerDragOver = (e: React.DragEvent) => {
- if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
+ if (!isChipDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
+ setDropIndex(chips.length);
};
const handleContainerDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
+ setDropIndex(null);
+ }
+ };
+
+ // Dragging over a chip picks the gap before or after it based on which half
+ // the pointer is in (mirrored for RTL). stopPropagation keeps the container
+ // handler from overriding this finer target.
+ const handleChipDragOver = (e: React.DragEvent, index: number) => {
+ if (!isChipDrag(e)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = 'move';
+ const rect = e.currentTarget.getBoundingClientRect();
+ const rtl = typeof window !== 'undefined' &&
+ getComputedStyle(e.currentTarget as Element).direction === 'rtl';
+ const past = rtl
+ ? e.clientX < rect.left + rect.width / 2
+ : e.clientX > rect.left + rect.width / 2;
+ setIsDragOver(true);
+ setDropIndex(past ? index + 1 : index);
+ };
+
+ // Insert the dragged chip at `target`. Same-field is a local reorder;
+ // cross-field routes through onMoveChip with the destination index (#593).
+ const performDrop = (e: React.DragEvent, target: number) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ setDropIndex(null);
+ setDraggingIndex(null);
+ const raw = e.dataTransfer.getData('application/x-recipient-chip');
+ if (!raw) return;
+ let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number };
+ try {
+ payload = JSON.parse(raw);
+ } catch {
+ return;
+ }
+ const { recipient, fromField, fromIndex } = payload;
+ const to = Math.max(0, Math.min(target, chips.length));
+
+ if (fromField === field) {
+ const from = typeof fromIndex === 'number'
+ ? fromIndex
+ : chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? ''));
+ if (from < 0 || from >= chips.length) return;
+ // Removing the source before `to` shifts the target left by one.
+ const insertAt = to > from ? to - 1 : to;
+ if (insertAt === from) return; // dropped onto its own position
+ const next = [...chips];
+ const [moved] = next.splice(from, 1);
+ next.splice(insertAt, 0, moved);
+ onChipsChange(next);
+ } else {
+ onMoveChip(recipient, fromField, field, to);
}
};
const handleContainerDrop = (e: React.DragEvent) => {
- e.preventDefault();
- setIsDragOver(false);
- const raw = e.dataTransfer.getData('application/x-recipient-chip');
- if (!raw) return;
- const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
- if (fromField === field) return;
- onMoveChip(recipient, fromField, field);
+ performDrop(e, dropIndex ?? chips.length);
+ };
+ const colorStyles: Record<'success' | 'destructive' | 'warning', string> = {
+ success: "bg-success/15 text-secondary-foreground hover:bg-success/30 !border-success",
+ destructive: "bg-destructive/15 text-secondary-foreground hover:bg-destructive/30 !border-destructive",
+ warning: "bg-warning/15 text-secondary-foreground hover:bg-warning/30 !border-warning",
};
return (
@@ -2850,30 +3213,60 @@ function RecipientChipInput({
{chips.map((chip, i) => {
const isEditing = editingChip?.index === i;
const chipDisplay = formatChipDisplay(chip);
+ let IconComponent = null;
+ if(chip.extra?.icon){
+ IconComponent = ICON_MAP[chip.extra?.icon];
+ }
+ const customColor = chip.extra?.color;
+
return (
+
+ {dropIndex === i && (
+
+ )}
{
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
- e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
+ e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i }));
// Show the address while dragging, matching the email-list drag preview.
- const dragPreview = createChipDragPreview(chip.email);
+ const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
requestAnimationFrame(() => dragPreview.remove());
setDraggingIndex(i);
}}
- onDragEnd={() => setDraggingIndex(null)}
+ onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }}
+ onDragOver={(e) => handleChipDragOver(e, i)}
+ onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
isEditing
? "bg-background ring-1 ring-ring"
- : "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing",
+ : ( customColor && colorStyles[customColor]
+ ? `${colorStyles[customColor]} cursor-grab active:cursor-grabbing`
+ : "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing"),
!isEditing && draggingIndex === i && "opacity-50"
)}
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
>
+ {IconComponent ? (
+
+ ) : null}
+
{isEditing ? (
) : (
- {chipDisplay}
+ m.email).join(', ') : undefined}
+ >
+ {chip.group && }
+ {chipDisplay}
+
)}
+
);
})}
+ {dropIndex === chips.length && chips.length > 0 && (
+
+ )}
{!editingChip && (
{validationMessage}
)}
- {activeAutoField === field && autocompleteResults.length > 0 && (
+ {activeAutoField === field &&
+ (autocompleteResults.length > 0 || (canSearchServer && serverSearchQuery.length > 0)) && (
onInsertAutocomplete(suggestion, field)}
+ onSearchServer={canSearchServer && serverSearchQuery.length > 0 ? onServerSearch : undefined}
+ isSearchingServer={isSearchingServer}
/>
)}
-
+ {!contextMenu.data.recipient.group && (
+
+ )}
{
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx
index 83a4b4de..7cdfb1f7 100644
--- a/components/email/email-context-menu.tsx
+++ b/components/email/email-context-menu.tsx
@@ -17,6 +17,8 @@ import {
Mail,
MailOpen,
Star,
+ Pin,
+ PinOff,
Trash2,
Archive,
FolderInput,
@@ -59,6 +61,7 @@ interface EmailContextMenuProps {
onForward?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
+ onTogglePinned?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
@@ -126,6 +129,7 @@ export function EmailContextMenu({
onForward,
onMarkAsRead,
onToggleStar,
+ onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
@@ -149,10 +153,14 @@ export function EmailContextMenu({
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
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 showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
+ // Marking your own outgoing mail as spam makes no sense - hide the action
+ // in Sent, Drafts and Scheduled.
+ const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
@@ -287,6 +295,7 @@ export function EmailContextMenu({
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
@@ -298,7 +307,7 @@ export function EmailContextMenu({
{/* Move to submenu */}
{moveTree.length > 0 && (
-
+
{(() => {
const renderNodes = (nodes: MailboxNode[]) => {
return nodes.map((node) => {
@@ -311,6 +320,7 @@ export function EmailContextMenu({
handleAction(() =>
showBatchActions
@@ -326,7 +336,7 @@ export function EmailContextMenu({
)}
{node.children.length > 0 && (
-
+
{renderNodes(node.children)}
)}
@@ -349,6 +359,15 @@ export function EmailContextMenu({
/>
)}
+ {/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
+ {!showBatchActions && onTogglePinned && (
+
handleAction(onTogglePinned)}
+ />
+ )}
+
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
@@ -360,7 +379,7 @@ export function EmailContextMenu({
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
- "w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
+ "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"
)}
>
@@ -385,22 +404,27 @@ export function EmailContextMenu({
)}
-
+ {/* Spam - contextual based on folder; pointless on own outgoing mail */}
+ {spamApplicable && (
+ <>
+
- {/* Spam - contextual based on folder */}
-
- handleAction(
- showBatchActions
- ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
- : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
- )
- }
- disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
- destructive={!isInJunkFolder}
- />
+
+ handleAction(
+ showBatchActions
+ ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
+ : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
+ )
+ }
+ disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
+ destructive={!isInJunkFolder}
+ />
+ >
+ )}
@@ -408,6 +432,7 @@ export function EmailContextMenu({
handleAction(() =>
showBatchActions
diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx
index 4e465fe7..a2691da7 100644
--- a/components/email/email-hover-actions.tsx
+++ b/components/email/email-hover-actions.tsx
@@ -21,6 +21,8 @@ interface EmailHoverActionsProps {
// the spam quick-action flips to "not spam".
isInJunk?: boolean;
onUndoSpam?: () => void;
+ // Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
+ spamApplicable?: boolean;
}
const ACTION_CONFIG: Record state.hoverActions);
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
@@ -121,6 +124,7 @@ export function EmailHoverActions({
const actionButtons = hoverActions.map((actionId) => {
const config = ACTION_CONFIG[actionId];
if (!config) return null;
+ if (actionId === "spam" && !spamApplicable) return null;
const Icon = config.icon;
// In a junk context the spam action becomes "not spam".
@@ -177,16 +181,17 @@ export function EmailHoverActions({
return (
-
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx
index b6549346..44c7e915 100644
--- a/components/email/email-list-item.tsx
+++ b/components/email/email-list-item.tsx
@@ -5,8 +5,8 @@ import { useCallback } from "react";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
-import { Avatar } from "@/components/ui/avatar";
-import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
+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";
@@ -34,16 +34,19 @@ interface EmailListItemProps {
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;
@@ -66,7 +69,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
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 = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
+ const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
@@ -87,7 +90,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
- toggleEmailSelection(email.id);
+ 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) => {
@@ -166,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{/* Unread indicator */}
{isUnread && (
-
+
)}
{/* Avatar */}
{density !== 'extra-compact' && (
-
toggleEmailSelection(email.id)}
+ selectLabel={tBatch('select')}
/>
)}
@@ -206,6 +219,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
+ {isPinned &&
}
{isStarred &&
}
{isImportant &&
}
{isAnswered && !isForwarded &&
}
@@ -242,6 +256,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{sender?.name || sender?.email || "Unknown"}
+ {isPinned && (
+
+ )}
{isStarred && (
)}
@@ -327,6 +344,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
+ spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
);
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 9c3ec0c7..8f1db908 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
-import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
+import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -35,6 +35,7 @@ interface EmailListProps {
onForward?: (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;
@@ -64,6 +65,7 @@ export function EmailList({
onForward,
onMarkAsRead,
onToggleStar,
+ onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
@@ -189,6 +191,22 @@ export function EmailList({
}
};
+ const handleBatchUndoSpam = async () => {
+ if (!client || isProcessing) return;
+ setIsProcessing(true);
+ try {
+ const emailIds = Array.from(selectedEmailIds);
+ await batchUndoSpam(client, emailIds);
+ const { toast } = await import('sonner');
+ toast.success(t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length }));
+ } catch {
+ const { toast } = await import('sonner');
+ toast.error(t('../email_viewer.spam.error_not_spam'));
+ } finally {
+ setTimeout(() => setIsProcessing(false), 500);
+ }
+ };
+
const handleBatchDelete = async () => {
if (!client || isProcessing) return;
@@ -355,6 +373,22 @@ export function EmailList({
)}
+ {effectiveMailboxRole === 'junk' && (
+
+ {isProcessing ? (
+
+ ) : (
+
+ )}
+
+ )}
{isProcessing ? (
-
+
) : (
-
+
)}
{t('empty_folder.button')}
@@ -551,6 +585,7 @@ export function EmailList({
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)}
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 03386e94..e45dab33 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -5,7 +5,7 @@ import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
-import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
+import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
@@ -333,7 +333,7 @@ function renderClickableRecipients(
return (
- {index > 0 && , }
+ {index > 0 && , }
+
{/* Header */}
{t('contact_sidebar.title')}
@@ -560,6 +560,8 @@ export function ContactSidebarPanel({
interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment;
client: IJMAPClient | null;
+ /** Owner accountId for the blob when it lives in a delegated/shared account. */
+ accountId?: string;
enabled: boolean;
downloadName?: string;
children: (dragProps: {
@@ -570,14 +572,14 @@ interface DraggableAttachmentChipProps {
}) => React.ReactNode;
}
-function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
+function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo
(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
- return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type);
+ return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId);
} catch {
return null;
}
@@ -595,7 +597,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, downloadName, ch
}
return null;
},
- }), [attachment, client, downloadName]);
+ }), [attachment, client, accountId, downloadName]);
const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}>;
}
@@ -607,7 +609,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component
{title}
-
{children}
+
{children}
);
}
@@ -652,6 +654,7 @@ export function EmailViewer({
const tDemoWelcome = useTranslations('demo_welcome');
const tWelcome = useTranslations('welcome');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
+ const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
@@ -693,6 +696,9 @@ export function EmailViewer({
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
+ // Marking your own outgoing mail as spam makes no sense - hide the action
+ // in Sent, Drafts and Scheduled.
+ const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
// Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true;
@@ -711,6 +717,40 @@ export function EmailViewer({
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
+ // Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle
+ // downloads) are account-scoped. In the unified / All-Mail view the open
+ // message may belong to another login (route to its client) or a delegated
+ // shared account (same client, owner accountId in the URL). Resolve both from
+ // the message's source so cross-account blob fetches don't 404 against the
+ // active account.
+ const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
+ const blobClient = useMemo(() => {
+ const scid = isUnifiedView ? email?.sourceClientAccountId : undefined;
+ return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client;
+ }, [isUnifiedView, email?.sourceClientAccountId, client]);
+ const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
+
+ // List-Unsubscribe mailto: send the message ourselves - this is a webmail
+ // client, handing a mailto: URL to the OS mail handler goes nowhere for
+ // most users. Route to the email's own account in unified views and prefer
+ // the identity that received the newsletter, so the list can match the
+ // subscriber; sendEmail resolves the identity (with its own fallback to
+ // the account default) from the address we pass.
+ const handleSendMailtoUnsubscribe = async (fields: { to: string[]; subject?: string; body?: string }) => {
+ const sendClient = (email?.sourceClientAccountId
+ ? useAuthStore.getState().getClientForAccount(email.sourceClientAccountId)
+ : undefined) ?? client;
+ if (!sendClient) throw new Error('Not connected');
+
+ const recipientAddresses = [...(email?.to ?? []), ...(email?.cc ?? [])].map(r => r.email?.toLowerCase());
+ // In unified views the owning account's identities are not loaded here -
+ // pass nothing and let its client fall back to its default identity.
+ const fromIdentity = email?.sourceClientAccountId
+ ? undefined
+ : identities.find(i => i.email && recipientAddresses.includes(i.email.toLowerCase()));
+
+ await sendClient.sendEmail(fields.to, fields.subject ?? '', fields.body ?? '', undefined, undefined, fromIdentity?.id, fromIdentity?.email, undefined, fromIdentity?.name);
+ };
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt'));
if (!value) return null;
@@ -752,6 +792,19 @@ export function EmailViewer({
const [quickReplyText, setQuickReplyText] = useState("");
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
+ const handleSendQuickReply = async () => {
+ if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
+ setIsSendingQuickReply(true);
+ try {
+ await onQuickReply(quickReplyText);
+ setQuickReplyText("");
+ setIsQuickReplyFocused(false);
+ } catch (error) {
+ console.error("Failed to send quick reply:", error);
+ } finally {
+ setIsSendingQuickReply(false);
+ }
+ };
const [showSourceModal, setShowSourceModal] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [moreMenuSub, setMoreMenuSub] = useState<'move' | 'tag' | null>(null);
@@ -771,6 +824,15 @@ export function EmailViewer({
const [pluginRenderedHtml, setPluginRenderedHtml] = useState(null);
const [pluginRenderedText, setPluginRenderedText] = useState(null);
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState([]);
+ // Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin
+ // after the user unlocks a key from the banner) to force the onRenderEmailBody
+ // hook to run again for the open message so the body re-decrypts.
+ const [pluginRenderNonce, setPluginRenderNonce] = useState(0);
+ useEffect(() => {
+ const bump = () => setPluginRenderNonce((n) => n + 1);
+ window.addEventListener('plugin:rerender-email', bump);
+ return () => window.removeEventListener('plugin:rerender-email', bump);
+ }, []);
// TNEF (winmail.dat) support
const [tnefHtml, setTnefHtml] = useState(null);
@@ -1128,6 +1190,7 @@ export function EmailViewer({
id: email.id,
contentType,
bodyStructure: email.bodyStructure,
+ bodyValues: email.bodyValues,
attachments: email.attachments,
blobId: email.blobId,
from: email.from,
@@ -1167,7 +1230,7 @@ export function EmailViewer({
})();
return () => { cancelled = true; };
- }, [email]);
+ }, [email, pluginRenderNonce]);
// TNEF (winmail.dat) detection and processing
useEffect(() => {
@@ -1203,7 +1266,7 @@ export function EmailViewer({
async function processTnef() {
try {
debug.time('TNEF fetch blob', 'email');
- const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
+ const blobBytes = await blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId);
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
@@ -1256,7 +1319,7 @@ export function EmailViewer({
processTnef();
return () => { cancelled = true; };
- }, [email, client]);
+ }, [email, client, blobClient, blobAccountId]);
// Embedded message/rfc822 unwrapping
// When Outlook forwards an email as an attachment, the outer email body is
@@ -1293,7 +1356,7 @@ export function EmailViewer({
async function unwrapEmbedded() {
try {
- const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
+ const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
@@ -1333,7 +1396,7 @@ export function EmailViewer({
unwrapEmbedded();
return () => { cancelled = true; };
- }, [email, client]);
+ }, [email, client, blobClient, blobAccountId]);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
@@ -1379,7 +1442,7 @@ export function EmailViewer({
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = att.cid!.replace(/^<|>$/g, '');
try {
- const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
+ const objectUrl = await blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId);
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
@@ -1401,7 +1464,7 @@ export function EmailViewer({
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
- }, [client, email?.id, pluginRenderedAttachments, email?.attachments]);
+ }, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
const effectiveAttachments = useMemo(() => {
if (pluginRenderedAttachments.length > 0) {
@@ -1600,10 +1663,8 @@ export function EmailViewer({
}
}
- if (node.tagName === 'A') {
- node.setAttribute('target', '_blank');
- node.setAttribute('rel', 'noopener noreferrer');
- }
+ // http(s) links open in a new tab; other schemes keep their default.
+ applyNewTabToAnchor(node);
// No dark mode color transforms - emails render true-to-life in iframe
});
@@ -1879,8 +1940,8 @@ export function EmailViewer({
for (const attachment of effectiveAttachments) {
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
try {
- if (attachment.blobId && client) {
- const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
+ if (attachment.blobId && blobClient) {
+ const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
zip.file(entryName, blob);
added++;
} else if (attachment.tnefData) {
@@ -1912,7 +1973,7 @@ export function EmailViewer({
} finally {
setIsDownloadingAll(false);
}
- }, [isDownloadingAll, effectiveAttachments, client, email]);
+ }, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
const downloadAllButton = effectiveAttachments.length > 1 ? (
@@ -1954,8 +2015,8 @@ export function EmailViewer({
await Promise.all(imageAttachments.map(async (att) => {
let url: string | undefined;
try {
- if (att.blobId && client) {
- url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type);
+ if (att.blobId && blobClient) {
+ url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId);
} else if (att.decryptedAttachment) {
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
if (!bytes || bytes.byteLength === 0) return;
@@ -1986,7 +2047,7 @@ export function EmailViewer({
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
- }, [effectiveAttachments, client, attachmentImagePreviewsEnabled]);
+ }, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]);
// Iframe for rendering HTML emails true-to-life
const iframeRef = useRef(null);
@@ -2056,9 +2117,21 @@ export function EmailViewer({
// Word/Outlook HTML emails ship a ${effectiveEmailContent.html}`;
- }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode]);
+ }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
@@ -2137,8 +2216,20 @@ export function EmailViewer({
// Gates the quick reply on the iframe having loaded the current srcDoc, so
// it doesn't flash in below a still-resizing iframe.
const [iframeReady, setIframeReady] = useState(false);
+ // Tracks which parsed document we've already wired up, so setup runs exactly
+ // once per srcDoc even though both the readiness poll below and the iframe
+ // 'load' event can trigger it.
+ const initializedDocRef = useRef(null);
+ // The document present at the instant srcDoc changed - i.e. the one about to
+ // be torn down. contentDocument keeps pointing at it until the browser swaps
+ // the new srcDoc in, so the poll skips it to avoid wiring up stale content.
+ const staleDocRef = useRef(null);
useLayoutEffect(() => {
setIframeReady(false);
+ initializedDocRef.current = null;
+ // Runs during commit, before the browser processes the new srcDoc, so
+ // contentDocument here is still the outgoing document.
+ staleDocRef.current = iframeRef.current?.contentDocument ?? null;
}, [emailIframeSrcDoc]);
const handleIframeLoad = useCallback(() => {
@@ -2146,25 +2237,64 @@ export function EmailViewer({
if (!iframe) return;
try {
const doc = iframe.contentDocument;
- if (doc?.body) {
+ // Ignore the outgoing document, a transient about:blank (a fresh srcDoc
+ // document reports URL 'about:srcdoc'), and anything that hasn't finished
+ // parsing yet; run the setup below at most once per document.
+ if (!doc?.body || doc === staleDocRef.current || doc.URL !== 'about:srcdoc' || doc.readyState === 'loading') return;
+ if (initializedDocRef.current === doc) return;
+ initializedDocRef.current = doc;
+ {
// Auto-resize iframe to fit content
- const resizeObserver = new ResizeObserver(() => {
- const height = doc.documentElement.scrollHeight;
+ // Measure max(documentElement, body): a height:100% wrapper can leave
+ // documentElement.scrollHeight short while the real content lives in body.
+ const applyHeight = () => {
+ if (iframe.contentDocument !== doc) return; // navigated away; stale
+ const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
iframe.style.height = height + 'px';
lastBodyHeightRef.current = height;
- });
+ };
+ const resizeObserver = new ResizeObserver(applyHeight);
resizeObserver.observe(doc.body);
- const initialHeight = doc.documentElement.scrollHeight;
- iframe.style.height = initialHeight + 'px';
- lastBodyHeightRef.current = initialHeight;
+ applyHeight();
+ // The ResizeObserver only fires on body's border box; a content overflow
+ // that grows scrollHeight without resizing that box (e.g. a height:100%
+ // wrapper, or images that reflow the layout after onload) is otherwise
+ // missed and the iframe stays short. Re-measure on a fixed cadence over a
+ // short settle window, then stop — a self-clearing catch-all that does
+ // not depend on image load/error events firing (blocked images may fire
+ // neither). Cheap: ~12 scrollHeight reads, no early-stop heuristic to
+ // mis-trigger on a brief-stable-then-grow reflow.
+ const poll = window.setInterval(() => {
+ if (iframe.contentDocument !== doc) { window.clearInterval(poll); return; }
+ applyHeight();
+ }, 200);
+ window.setTimeout(() => window.clearInterval(poll), 2400);
setIframeReady(true);
- // Make links open in new tab
- doc.querySelectorAll('a').forEach(a => {
- a.setAttribute('target', '_blank');
- a.setAttribute('rel', 'noopener noreferrer');
+ // Hide images that fail to load (dead/mixed-content/unreachable external
+ // URLs) rather than leaving the browser's broken-image placeholder and
+ // alt text, which read as stray label text in an otherwise image-only
+ // email (e.g. a blocked "logo" alt). Blocked images already carry a 1x1
+ // transparent pixel (naturalWidth 1) and display:none, so they're skipped.
+ const hideIfBroken = (img: HTMLImageElement) => {
+ if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) {
+ img.style.display = 'none';
+ }
+ };
+ doc.querySelectorAll('img').forEach((el) => {
+ const img = el as HTMLImageElement;
+ if (img.complete) {
+ hideIfBroken(img);
+ } else {
+ img.addEventListener('error', () => { img.style.display = 'none'; }, { once: true });
+ img.addEventListener('load', () => hideIfBroken(img), { once: true });
+ }
});
+ // Second pass over the rendered iframe DOM (the hook above only sees
+ // DOMPurify's output); http(s) → new tab, other schemes left in place.
+ doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
+
// Plugin intercept: let plugins cancel or rewrite external links inside
// the email body before navigation happens. Bound on the iframe doc so
// it survives DOM mutations from dark-mode pass below.
@@ -2287,6 +2417,26 @@ export function EmailViewer({
}
}, [isDark, emailHasNativeDarkMode, email?.id]);
+ // Wire up the iframe as soon as its sandboxed document has parsed, rather than
+ // waiting for the iframe 'load' event. 'load' also waits on every subresource,
+ // so a single unreachable remote image (server accepts the TCP connection but
+ // never responds) stalls it for the browser's ~60s timeout - freezing the body
+ // at its placeholder height that entire time. The parsed DOM we need for
+ // height, links and dark-mode is ready long before images resolve. Poll the
+ // fresh document's readyState because a sandbox without allow-scripts can't
+ // postMessage a DOMContentLoaded signal out, and the onLoad handler is
+ // idempotent per document so it stays a harmless backstop.
+ useEffect(() => {
+ if (!iframeRef.current) return;
+ const readyPoll = window.setInterval(() => {
+ handleIframeLoad();
+ if (initializedDocRef.current) window.clearInterval(readyPoll);
+ }, 50);
+ // Safety stop: the 'load' backstop covers anything the poll somehow misses.
+ const stop = window.setTimeout(() => window.clearInterval(readyPoll), 15000);
+ return () => { window.clearInterval(readyPoll); window.clearTimeout(stop); };
+ }, [emailIframeSrcDoc, handleIframeLoad]);
+
// Export email as .eml file
const handleExportEmail = async () => {
if (!email?.blobId || !client) return;
@@ -2580,7 +2730,7 @@ export function EmailViewer({
{tDemoWelcome('title')}
{tDemoWelcome('description')}
-
+
{tDemoWelcome('feature_email')}
@@ -2622,7 +2772,7 @@ export function EmailViewer({
{t('no_conversation_description')}
{onCompose && (
-
+
{t('compose')}
)}
@@ -2647,7 +2797,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
onClick={onBack}
- className="h-9 w-9 flex-shrink-0 -ml-1"
+ className="h-9 w-9 flex-shrink-0 -ms-1"
aria-label={t('back_to_list')}
>
@@ -2693,6 +2843,7 @@ export function EmailViewer({
variant="default"
size="sm"
onClick={() => onEditDraft()}
+ data-testid="edit-draft"
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')}
>
@@ -2782,7 +2933,7 @@ export function EmailViewer({
{showToolbarLabels &&
{t('move')} }
{moveMenuOpen && (
-
+
{(() => {
const renderNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
@@ -2793,7 +2944,7 @@ export function EmailViewer({
{isTarget ? (
{ onMoveToMailbox(node.id); setMoveMenuOpen(false); }}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
@@ -2853,7 +3004,7 @@ export function EmailViewer({
)}
{tagMenuOpen && (
-
+
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
@@ -2861,13 +3012,13 @@ export function EmailViewer({
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-left hover:bg-muted flex items-center gap-2",
+ "w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
{option.name}
- {isActive &&
}
+ {isActive &&
}
);
})}
@@ -2876,7 +3027,7 @@ export function EmailViewer({
{ if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
{t('remove_color')}
@@ -2889,7 +3040,7 @@ export function EmailViewer({
{/* Spam */}
- {(onMarkAsSpam || onUndoSpam) && (
+ {spamApplicable && (onMarkAsSpam || onUndoSpam) && (
{t('more_actions')}
{moreMenuOpen && !isMobile && (
-
+
{/* Star toggle */}
{ onToggleStar?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
@@ -2992,7 +3143,7 @@ export function EmailViewer({
{/* Overflow: reply */}
{ onReply?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(1) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(1) ? "" : "sm:hidden")}
>
{t('reply')}
@@ -3000,7 +3151,7 @@ export function EmailViewer({
{/* Overflow: reply all */}
{ onReplyAll?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(2) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(2) ? "" : "sm:hidden")}
>
{t('reply_all')}
@@ -3008,7 +3159,7 @@ export function EmailViewer({
{/* Overflow: forward */}
{ onForward?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(3) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(3) ? "" : "sm:hidden")}
>
{t('forward')}
@@ -3016,7 +3167,7 @@ export function EmailViewer({
{/* Overflow: archive */}
{ onArchive?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
>
{t('archive')}
@@ -3029,14 +3180,14 @@ export function EmailViewer({
>
setMoreMenuSub(moreMenuSub === 'move' ? null : 'move')}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
{t('move_to')}
{moreMenuSub === 'move' && (
-
+
{(() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
@@ -3047,7 +3198,7 @@ export function EmailViewer({
{isTarget ? (
{ onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
@@ -3081,14 +3232,14 @@ export function EmailViewer({
>
setMoreMenuSub(moreMenuSub === 'tag' ? null : 'tag')}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
{t('tag')}
{moreMenuSub === 'tag' && (
-
+
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
@@ -3096,13 +3247,13 @@ export function EmailViewer({
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-left hover:bg-muted flex items-center gap-2",
+ "w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
{option.name}
- {isActive &&
}
+ {isActive &&
}
);
})}
@@ -3111,7 +3262,7 @@ export function EmailViewer({
{ if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
{t('remove_color')}
@@ -3123,10 +3274,10 @@ export function EmailViewer({
)}
{/* Overflow: spam */}
- {(onMarkAsSpam || onUndoSpam) && (
+ {spamApplicable && (onMarkAsSpam || onUndoSpam) && (
{ (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
>
{isInJunkFolder ? (
@@ -3139,7 +3290,7 @@ export function EmailViewer({
{/* Overflow: toggle read */}
{ onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(8) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(8) ? "" : "sm:hidden")}
>
{isUnread ? : }
{isUnread ? t('mark_read') : t('mark_unread')}
@@ -3147,7 +3298,7 @@ export function EmailViewer({
{/* Overflow: print */}
{ handlePrint(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(9) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(9) ? "" : "sm:hidden")}
>
{t('print')}
@@ -3155,7 +3306,7 @@ export function EmailViewer({
{/* Overflow: view source */}
{ setShowSourceModal(true); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(10) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(10) ? "" : "sm:hidden")}
>
{t('view_source')}
@@ -3164,7 +3315,7 @@ export function EmailViewer({
{effectiveEmailContent.isHtml && (
{ setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(11) ? "" : "sm:hidden")}
+ className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(11) ? "" : "sm:hidden")}
>
{isDark ? : }
{isDark ? 'View in light mode' : 'View in dark mode'}
@@ -3174,7 +3325,7 @@ export function EmailViewer({
{/* Export email */}
{ handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
{t('export_email')}
@@ -3182,7 +3333,7 @@ export function EmailViewer({
{/* Import email */}
{ handleImportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
{t('import_email')}
@@ -3190,7 +3341,7 @@ export function EmailViewer({
{onShowShortcuts && (
{ onShowShortcuts(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
+ className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
{t('keyboard_shortcuts')}
@@ -3218,7 +3369,7 @@ export function EmailViewer({
)}
{!isScheduled && isMobile && (
setMoreMenuSub(null)}
- className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
+ className="flex items-center gap-1 -ms-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
>
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
@@ -3245,7 +3396,7 @@ export function EmailViewer({
{/* Star toggle */}
{ onToggleStar?.(); setMoreMenuOpen(false); }}
- className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
@@ -3254,12 +3405,12 @@ export function EmailViewer({
{colorOptions.length > 0 && (
setMoreMenuSub('tag')}
- className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{t('tag')}
{currentColors.length > 0 && (
-
+
{currentColors.slice(0, 3).map((c) => {
const opt = colorOptions.find((o) => o.value === c);
return opt ?
: null;
@@ -3271,14 +3422,14 @@ export function EmailViewer({
)}
{ handlePrint(); setMoreMenuOpen(false); }}
- className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{t('print')}
{ setShowSourceModal(true); setMoreMenuOpen(false); }}
- className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{t('view_source')}
@@ -3286,7 +3437,7 @@ export function EmailViewer({
{effectiveEmailContent.isHtml && (
{ setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
- className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{isDark ? : }
{isDark ? 'View in light mode' : 'View in dark mode'}
@@ -3295,14 +3446,14 @@ export function EmailViewer({
{ handleExportEmail(); setMoreMenuOpen(false); }}
- className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{t('export_email')}
{ handleImportEmail(); setMoreMenuOpen(false); }}
- className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{t('import_email')}
@@ -3310,7 +3461,7 @@ export function EmailViewer({
{onShowShortcuts && (
{ onShowShortcuts(); setMoreMenuOpen(false); }}
- className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
+ className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{t('keyboard_shortcuts')}
@@ -3328,7 +3479,7 @@ export function EmailViewer({
{isTarget ? (
{ onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
+ className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
@@ -3359,20 +3510,20 @@ export function EmailViewer({
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-left hover:bg-muted flex items-center gap-3",
+ "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"
)}
>
{option.name}
- {isActive && }
+ {isActive && }
);
})}
{currentColors.length > 0 && (
{ if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
- className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
+ 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"
>
{t('remove_color')}
@@ -3412,7 +3563,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
onClick={onBack}
- className="h-11 w-11 lg:h-10 lg:w-10 flex-shrink-0 -ml-2"
+ className="h-11 w-11 lg:h-10 lg:w-10 flex-shrink-0 -ms-2"
aria-label={t('back_to_list')}
>
@@ -3456,7 +3607,7 @@ export function EmailViewer({
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
-
+
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
@@ -3512,7 +3663,7 @@ export function EmailViewer({
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
- className="font-semibold text-left"
+ className="font-semibold text-start"
/>
) : (
{t('unknown_sender')}
@@ -3522,6 +3673,7 @@ export function EmailViewer({
{
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
@@ -3576,7 +3728,7 @@ export function EmailViewer({
)}
setShowFullHeaders(!showFullHeaders)}
- className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
+ className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1"
>
{showFullHeaders ? (
<>
@@ -3603,7 +3755,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
-
+
{(dragProps) => (
handleEffectiveAttachmentOpen(attachment)}
+ data-testid="attachment"
+ data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -3641,7 +3795,7 @@ export function EmailViewer({
2 && (
<>
setShowAllBesideAttachments(false)} />
-
+
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
-
+
{(dragProps) => (
{getAttachmentDisplayName(attachment.name, attachment.type)}
-
+
{formatFileSize(attachment.size)}
-
+
) : (
{t('unknown_sender')}
@@ -3767,6 +3921,7 @@ export function EmailViewer({
{
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
@@ -3800,7 +3955,7 @@ export function EmailViewer({
)}
setShowFullHeaders(!showFullHeaders)}
- className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
+ className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1"
>
{showFullHeaders ? (
<>
@@ -3817,7 +3972,7 @@ export function EmailViewer({
{/* Date/time + size on the right (mobile) */}
-
+
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
@@ -3949,7 +4104,7 @@ export function EmailViewer({
email={sender?.email || ''}
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
onViewContact={handleViewContactSidebar}
- className="text-sm text-left"
+ className="text-sm text-start"
/>
@@ -4376,7 +4531,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
-
+
{(dragProps) => (
handleEffectiveAttachmentOpen(attachment)}
+ data-testid="attachment"
+ data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -4419,7 +4576,7 @@ export function EmailViewer({
visibleBelowHeaderCount && (
<>
setShowAllBelowHeaderAttachments(false)} />
-
+
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
-
+
{(dragProps) => (
{getAttachmentDisplayName(attachment.name, attachment.type)}
-
+
{formatFileSize(attachment.size)}
-
+
+
{(dragProps) => (
handleEffectiveAttachmentOpen(attachment)}
+ data-testid="attachment"
+ data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -4558,7 +4717,7 @@ export function EmailViewer({
2 && (
<>
setShowAllMobileAttachments(false)} />
-
+
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
-
+
{(dragProps) => (
{getAttachmentDisplayName(attachment.name, attachment.type)}
-
+
{formatFileSize(attachment.size)}
-
+
setQuickReplyText(e.target.value)}
onFocus={() => setIsQuickReplyFocused(true)}
+ onKeyDown={(e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
+ e.preventDefault();
+ void handleSendQuickReply();
+ }
+ }}
placeholder={t('quick_reply_placeholder')}
className={cn(
"w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg",
@@ -4752,35 +4917,22 @@ export function EmailViewer({
disabled={isSendingQuickReply}
className="text-muted-foreground"
>
-
+
{t('more_options')}
{
- if (!quickReplyText.trim() || !onQuickReply) return;
-
- setIsSendingQuickReply(true);
- try {
- await onQuickReply(quickReplyText);
- setQuickReplyText("");
- setIsQuickReplyFocused(false);
- } catch (error) {
- console.error("Failed to send quick reply:", error);
- } finally {
- setIsSendingQuickReply(false);
- }
- }}
+ onClick={handleSendQuickReply}
disabled={!quickReplyText.trim() || isSendingQuickReply}
>
{isSendingQuickReply ? (
<>
-
+
{t('sending')}
>
) : (
<>
-
+
{t('send')}
>
)}
@@ -4916,7 +5068,7 @@ export function EmailViewer({
<>
{/* Collapse toggle when sidebar is collapsed */}
{detailSidebarCollapsed && (
-
+
setDetailSidebarWidth(280)}
/>
diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts
index edd5b44b..0350e679 100644
--- a/components/email/quoted-html.ts
+++ b/components/email/quoted-html.ts
@@ -10,6 +10,10 @@ import { buildSignatureBlock } from "@/components/email/signature-block";
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
+// Reusable style for the quote bar when quoting email text (like in a reply).
+const QUOTE_BAR_STYLE =
+ "border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
+
/**
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
* quoted/forwarded original email. The HTML is stored in the `html` attribute
@@ -68,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
const dom = document.createElement("div");
dom.setAttribute(QUOTED_HTML_MARKER, "");
dom.className = "quoted-html-island";
- dom.style.cssText =
- "border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
+ dom.style.cssText = QUOTE_BAR_STYLE;
// CRITICAL: render the quoted email inside a Shadow Root. The app's
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
@@ -192,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
* must be what serializeEditorContent emits too (round-trip consistency).
*/
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
- return `
${sanitizedInnerHtml}
`;
+ return `
${sanitizedInnerHtml}
`;
}
diff --git a/components/email/read-receipt-banner.tsx b/components/email/read-receipt-banner.tsx
index 933befc9..dfaff891 100644
--- a/components/email/read-receipt-banner.tsx
+++ b/components/email/read-receipt-banner.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
-import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
+import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface ReadReceiptBannerProps {
@@ -17,43 +17,61 @@ export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceipt
const t = useTranslations('email_viewer.read_receipt');
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
+ // Matches the host's "External Content" banner row: a round tinted icon chip,
+ // an uppercase eyebrow, a foreground message, and neutral bordered actions.
if (state === 'sent') {
return (
-
-
-
{t('sent')}
+
);
}
return (
-
-
-
{t('prompt')}
-
{requestedBy}
-
-
{
- setState('sending');
- try {
- await onSend();
- setState('sent');
- } catch {
- setState('idle');
- }
- }}
- disabled={state === 'sending'}
- className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
- >
- {state === 'sending' && }
- {t('send')}
-
-
- {t('ignore')}
-
+
+
+
+
+
+
+
+ Read receipt
+
+
+ {t('prompt')}
+
+
+ Requested by {requestedBy}
+
+
+
+ {
+ setState('sending');
+ try {
+ await onSend();
+ setState('sent');
+ } catch {
+ setState('idle');
+ }
+ }}
+ disabled={state === 'sending'}
+ className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {state === 'sending' ? : }
+ {t('send')}
+
+
+
+ {t('ignore')}
+
+
);
diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx
index 007e8b12..32b39b39 100644
--- a/components/email/recipient-popover.tsx
+++ b/components/email/recipient-popover.tsx
@@ -135,7 +135,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
className
)}
>
- {displayLabel || name || email}
+
{displayLabel || name || email}
{isOpen &&
@@ -221,7 +221,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
{onViewContact && (
{contact ? : }
diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx
index 936e90f5..c0e54519 100644
--- a/components/email/rich-text-editor.tsx
+++ b/components/email/rich-text-editor.tsx
@@ -8,6 +8,7 @@ import Heading from "@tiptap/extension-heading";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align";
+import { TextDirection } from "@/components/email/text-direction";
import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import { ResizableImage } from "@/components/email/resizable-image";
@@ -19,6 +20,7 @@ import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { SignatureBlock } from "@/components/email/signature-block";
import { cn } from "@/lib/utils";
+import { useSettingsStore } from "@/stores/settings-store";
import {
Bold,
Italic,
@@ -29,6 +31,7 @@ import {
AlignLeft,
AlignCenter,
AlignRight,
+ ArrowLeftRight,
Link as LinkIcon,
Undo,
Redo,
@@ -38,6 +41,7 @@ import {
Heading1,
Heading2,
Table as TableIcon,
+ Baseline,
Trash2,
Rows3,
Columns3,
@@ -140,6 +144,14 @@ function ToolbarSeparator() {
const TABLE_PICKER_ROWS = 6;
const TABLE_PICKER_COLS = 8;
+// Preset text colours (2 x 8). Inline `style="color: …"` survives email
+// round-trips; the TextStyle/Color extensions are already registered to
+// preserve pasted colours - this palette just adds a UI to set them.
+const TEXT_COLORS = [
+ "#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
+ "#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
+];
+
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
return (
@@ -183,6 +195,7 @@ export function RichTextEditor({
hasError,
onEditorReady,
}: RichTextEditorProps) {
+ const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
const onImageUploadRef = React.useRef(onImageUpload);
onImageUploadRef.current = onImageUpload;
const onEditorReadyRef = React.useRef(onEditorReady);
@@ -240,6 +253,7 @@ export function RichTextEditor({
// rich/branded signatures keep their inline styling in the editor and
// in the sent mail (see signature-block.ts).
SignatureBlock,
+ TextDirection,
],
content,
editorProps: {
@@ -331,6 +345,19 @@ export function RichTextEditor({
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef(null);
+ const [colorMenuOpen, setColorMenuOpen] = useState(false);
+ const colorWrapperRef = useRef(null);
+
+ useEffect(() => {
+ if (!colorMenuOpen) return;
+ const handler = (e: MouseEvent) => {
+ if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
+ setColorMenuOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [colorMenuOpen]);
useEffect(() => {
if (!tableMenuOpen) return;
@@ -381,6 +408,49 @@ export function RichTextEditor({
>
+
+
setColorMenuOpen((v) => !v)}
+ title="Text color"
+ >
+ {/* The icon itself previews the active colour - no layout shift. */}
+
+
+ {colorMenuOpen && (
+
+
+ {TEXT_COLORS.map((color) => (
+ {
+ editor.chain().focus().setColor(color).run();
+ setColorMenuOpen(false);
+ }}
+ className={cn(
+ "w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
+ editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
+ )}
+ style={{ backgroundColor: color }}
+ />
+ ))}
+
+
+
{
+ editor.chain().focus().unsetColor().run();
+ setColorMenuOpen(false);
+ }}
+ >
+ Remove color
+
+
+ )}
+
@@ -454,6 +524,22 @@ export function RichTextEditor({
+ {rtlEditingSupport && (
+ {
+ const cur =
+ editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
+ editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
+ }}
+ title="Text direction (RTL/LTR)"
+ >
+
+
+ )}
+
{tableMenuOpen && (
-
+
{editor.isActive("table") ? (
{ editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
>
Add row above
{ editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
>
Add row below
{ editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
>
Add column before
{ editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
>
Add column after
@@ -507,21 +593,21 @@ export function RichTextEditor({
{ editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
>
Delete row
{ editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
>
Delete column
{ editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
>
Toggle header row
@@ -529,7 +615,7 @@ export function RichTextEditor({
{ editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
>
Delete table
diff --git a/components/email/selectable-avatar.tsx b/components/email/selectable-avatar.tsx
new file mode 100644
index 00000000..02076a96
--- /dev/null
+++ b/components/email/selectable-avatar.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import type { ComponentProps } from "react";
+import { Check } from "lucide-react";
+import { Avatar } from "@/components/ui/avatar";
+import { cn } from "@/lib/utils";
+
+type SelectableAvatarProps = ComponentProps & {
+ /** Whether the underlying message/thread is currently selected. */
+ checked: boolean;
+ /** Toggle selection. The wrapper stops propagation so the row is not opened. */
+ onToggle: () => void;
+ /** Accessible label for the selection control. */
+ selectLabel?: string;
+};
+
+/**
+ * Avatar that doubles as a selection control, Thunderbird-style: clicking the
+ * avatar toggles the message/thread into the current selection instead of
+ * opening it. A check overlay appears on hover (hinting it is clickable) and
+ * stays visible while the row is selected.
+ */
+export function SelectableAvatar({
+ checked,
+ onToggle,
+ selectLabel,
+ className,
+ ...avatarProps
+}: SelectableAvatarProps) {
+ return (
+ {
+ e.stopPropagation();
+ onToggle();
+ }}
+ className={cn(
+ "group/select relative shrink-0 rounded-full outline-none",
+ "focus-visible:ring-2 focus-visible:ring-primary/60",
+ className,
+ )}
+ >
+
+
+
+
+
+ );
+}
diff --git a/components/email/signature-block.ts b/components/email/signature-block.ts
index 231b9f6a..9b0ee3ef 100644
--- a/components/email/signature-block.ts
+++ b/components/email/signature-block.ts
@@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
// so parseHTML can recognise it on the way back in (initial content, drafts).
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
+/**
+ * Force every link in the rendered signature to open in a new tab.
+ *
+ * Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is
+ * what serializeEditorContent emits into the sent message, and the recipient's
+ * copy should stay exactly as the user wrote it. Without this the composer's
+ * signature is a set of live, target-less anchors in the main document (the
+ * message body gets a sandboxed iframe; this does not), so one stray click
+ * navigates the whole app away and takes the unsent draft with it.
+ */
+function forceLinksToNewTab(root: HTMLElement): void {
+ root.querySelectorAll("a[href]").forEach((a) => {
+ a.setAttribute("target", "_blank");
+ a.setAttribute("rel", "noopener noreferrer");
+ });
+}
+
/**
* SignatureBlock — an atomic, NON-editable block node that carries the
* *verbatim* HTML of the user's identity signature in its `html` attribute.
@@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
dom.className = "signature-block-island";
+
// CRITICAL: render the signature inside a Shadow Root. The app's global
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
// would otherwise cascade INTO the signature and destroy its layout -
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
const inner = document.createElement("div");
// Read-only: a signature is inserted/removed as a unit, not edited inline.
inner.contentEditable = "false";
- inner.innerHTML = node.attrs.html || "";
+ // Track what we were given, not what's in the DOM: forceLinksToNewTab
+ // rewrites the markup, so inner.innerHTML no longer round-trips against
+ // attrs.html and comparing the two would rewrite on every transaction.
+ let appliedHtml = node.attrs.html || "";
+ inner.innerHTML = appliedHtml;
+ forceLinksToNewTab(inner);
shadow.appendChild(inner);
return {
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
stopEvent: () => false,
update: (updatedNode) => {
if (updatedNode.type.name !== "signatureBlock") return false;
- if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
- inner.innerHTML = updatedNode.attrs.html || "";
+ const nextHtml = updatedNode.attrs.html || "";
+ if (nextHtml !== appliedHtml) {
+ appliedHtml = nextHtml;
+ inner.innerHTML = nextHtml;
+ forceLinksToNewTab(inner);
}
return true;
},
diff --git a/components/email/text-direction.ts b/components/email/text-direction.ts
new file mode 100644
index 00000000..6665c373
--- /dev/null
+++ b/components/email/text-direction.ts
@@ -0,0 +1,63 @@
+import { Extension } from "@tiptap/core";
+
+export type TextDir = "ltr" | "rtl";
+
+declare module "@tiptap/core" {
+ interface Commands {
+ textDirection: {
+ setTextDirection: (dir: TextDir) => ReturnType;
+ unsetTextDirection: () => ReturnType;
+ };
+ }
+}
+
+/**
+ * Adds a `dir` attribute to block nodes so the composer can mark individual
+ * paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing).
+ *
+ * The default is `"auto"`: each block detects its own direction from its first
+ * strong character, so a paragraph typed in English renders LTR and one typed
+ * in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins
+ * an explicit `ltr`/`rtl` when you want to override the auto-detection, and the
+ * attribute round-trips to HTML so the direction is preserved in the sent mail.
+ */
+export const TextDirection = Extension.create({
+ name: "textDirection",
+
+ addOptions() {
+ return { types: ["paragraph", "heading", "blockquote", "listItem"] };
+ },
+
+ addGlobalAttributes() {
+ return [
+ {
+ types: this.options.types,
+ attributes: {
+ dir: {
+ default: "auto",
+ parseHTML: (element) => element.getAttribute("dir") || "auto",
+ renderHTML: (attributes) =>
+ attributes.dir ? { dir: attributes.dir } : { dir: "auto" },
+ },
+ },
+ },
+ ];
+ },
+
+ addCommands() {
+ return {
+ setTextDirection:
+ (dir) =>
+ ({ commands }) =>
+ this.options.types.every((type: string) =>
+ commands.updateAttributes(type, { dir }),
+ ),
+ unsetTextDirection:
+ () =>
+ ({ commands }) =>
+ this.options.types.every((type: string) =>
+ commands.resetAttributes(type, "dir"),
+ ),
+ };
+ },
+});
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 526599c2..37552e93 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -150,7 +150,7 @@ export function ThreadConversationView({
@@ -488,13 +488,13 @@ function EmailCard({
{/* Card Header - Always visible */}
-
+
{t("email_viewer.reply")}
)}
@@ -671,7 +671,7 @@ function EmailCard({
}}
className="flex-1"
>
-
+
{t("email_viewer.reply_all")}
)}
@@ -685,7 +685,7 @@ function EmailCard({
}}
className="flex-1"
>
-
+
{t("email_viewer.forward")}
)}
diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx
index 11cfd637..bd35f4c5 100644
--- a/components/email/thread-email-item.tsx
+++ b/components/email/thread-email-item.tsx
@@ -86,8 +86,8 @@ export function ThreadEmailItem({
{...longPressHandlers}
className={cn(
"relative cursor-pointer select-none transition-all duration-150",
- "pl-12 pr-4",
- "border-l-2 border-l-transparent",
+ "ps-12 pe-4",
+ "border-s-2 border-l-transparent",
selected
? "bg-selection border-l-primary"
: "hover:bg-muted/50",
@@ -130,7 +130,7 @@ export function ThreadEmailItem({
{/* Unread indicator */}
{isUnread && (
-
+
)}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index ebd8ceb0..51503972 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -2,10 +2,10 @@
import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
-import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
+import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
-import { Avatar } from "@/components/ui/avatar";
-import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
+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 { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -75,8 +75,10 @@ interface SingleEmailItemProps {
const SingleEmailItem = React.forwardRef
(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
const t = useTranslations('email_viewer');
+ const tBatch = useTranslations('email_list.batch_actions');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
+ const isPinned = email.keywords?.['$pinned'] === true;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
@@ -88,13 +90,14 @@ const SingleEmailItem = React.forwardRef(
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 tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
// Show the originating folder in the aggregate "All …" views.
- const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
+ const showSourceFolder = isUnifiedView && !!email.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
@@ -111,7 +114,7 @@ const SingleEmailItem = React.forwardRef(
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 = (() => {
+ const resolvedColorTag = !tintListRowsByTag ? null : (() => {
if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
})();
@@ -134,7 +137,11 @@ const SingleEmailItem = React.forwardRef(
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
- toggleEmailSelection(email.id);
+ if (e.shiftKey) {
+ selectRangeEmails(email.id);
+ } else {
+ toggleEmailSelection(email.id);
+ }
};
const handleContextMenu = (e: React.MouseEvent) => {
@@ -159,6 +166,10 @@ const SingleEmailItem = React.forwardRef(
ref={ref}
{...dragHandlers}
{...longPressHandlers}
+ data-testid="email-list-item"
+ data-email-id={email.id}
+ data-subject={email.subject || ''}
+ data-unread={isUnread ? 'true' : 'false'}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
resolvedColorTag ? resolvedColorTag : (
@@ -211,18 +222,21 @@ const SingleEmailItem = React.forwardRef(
)}
{isUnread && (
-
+
)}
{density !== 'extra-compact' && (
-
toggleEmailSelection(email.id)}
+ selectLabel={tBatch('select')}
/>
)}
@@ -256,6 +270,7 @@ const SingleEmailItem = React.forwardRef(
+ {isPinned &&
}
{isStarred &&
}
{isAnswered && !isForwarded &&
}
{isForwarded && !isAnswered &&
}
@@ -308,6 +323,9 @@ const SingleEmailItem = React.forwardRef
(
{sender?.name || sender?.email || "Unknown"}
+ {isPinned && (
+
+ )}
{isStarred && (
)}
@@ -397,6 +415,7 @@ const SingleEmailItem = React.forwardRef
(
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
+ spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
)}
@@ -427,13 +446,14 @@ export const ThreadListItem = React.forwardRef state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
- const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
+ const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
// 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 trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
@@ -443,7 +463,7 @@ export const ThreadListItem = React.forwardRef state.getAccountById);
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
// In Sent/Drafts folders, show recipient instead of sender (which is always
@@ -479,8 +499,9 @@ export const ThreadListItem = React.forwardRef state.emailKeywords);
+ const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
- const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
+ const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
@@ -511,9 +532,8 @@ export const ThreadListItem = React.forwardRef {
- e.stopPropagation();
- // Toggle selection for all emails in this thread
+ // Toggle selection for all emails in this thread.
+ const toggleThreadSelection = () => {
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
const newSelection = new Set(selectedEmailIds);
thread.emails.forEach(em => {
@@ -526,6 +546,15 @@ export const ThreadListItem = React.forwardRef {
+ e.stopPropagation();
+ if (e.shiftKey) {
+ selectRangeEmails(latestEmail.id);
+ return;
+ }
+ toggleThreadSelection();
+ };
+
const handleHeaderClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
@@ -618,21 +647,24 @@ export const ThreadListItem = React.forwardRef
+
)}
{density !== 'extra-compact' && (
-
- {!isMobile && !isFocusedMailLayout && (
+ {!isMobile && (
{
@@ -702,6 +734,7 @@ export const ThreadListItem = React.forwardRef
+ {hasPinned &&
}
{hasStarred &&
}
{hasAnswered && !hasForwarded &&
}
{hasForwarded && !hasAnswered &&
}
@@ -766,6 +799,9 @@ export const ThreadListItem = React.forwardRef
+ {hasPinned && (
+
+ )}
{hasStarred && (
)}
@@ -855,15 +891,16 @@ export const ThreadListItem = React.forwardRef
onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
+ spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
)}
- {isExpanded && !isMobile && !isFocusedMailLayout && (
+ {isExpanded && !isMobile && (
{isLoading ? (
-
+
{t('loading')}
) : (
diff --git a/components/email/unsubscribe-banner.tsx b/components/email/unsubscribe-banner.tsx
index 1f63a0e4..491f3c44 100644
--- a/components/email/unsubscribe-banner.tsx
+++ b/components/email/unsubscribe-banner.tsx
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
-import { isValidUnsubscribeUrl } from '@/lib/validation';
+import { isValidUnsubscribeUrl, parseMailtoUrl } from '@/lib/validation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useIsDesktop } from '@/hooks/use-media-query';
@@ -14,12 +14,17 @@ interface UnsubscribeBannerProps {
preferred?: 'http' | 'mailto';
};
senderEmail: string;
+ // Sends the unsubscribe message through the app's own account. This is a
+ // webmail client - handing a mailto: URL to the OS mail handler goes
+ // nowhere for most users.
+ onSendMailtoUnsubscribe: (fields: { to: string[]; subject?: string; body?: string }) => Promise
;
onDismiss: () => void;
}
export function UnsubscribeBanner({
listUnsubscribe,
senderEmail: _senderEmail,
+ onSendMailtoUnsubscribe,
onDismiss
}: UnsubscribeBannerProps) {
const t = useTranslations();
@@ -74,12 +79,18 @@ export function UnsubscribeBanner({
setShowConfirm(false);
setTimeout(onDismiss, 3000);
} else {
- const link = document.createElement('a');
- link.href = unsubUrl;
- link.style.display = 'none';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
+ // Send the unsubscribe message ourselves and only report success
+ // once the server accepted it. The previous hidden-link click handed
+ // the mailto: to the OS mail handler and claimed success even though
+ // nothing was ever sent.
+ const fields = parseMailtoUrl(unsubUrl);
+ if (!fields) {
+ setError(true);
+ setProcessing(false);
+ setShowConfirm(false);
+ return;
+ }
+ await onSendMailtoUnsubscribe(fields);
setSuccess(true);
setProcessing(false);
@@ -96,7 +107,7 @@ export function UnsubscribeBanner({
if (success) {
return (
-
+
{t(unsubMethod === 'http'
@@ -110,7 +121,7 @@ export function UnsubscribeBanner({
if (error) {
return (
-
+
{t('email_viewer.unsubscribe_banner.confirm_title')}
@@ -171,8 +182,8 @@ export function UnsubscribeBanner({
}}
title={t('email_viewer.unsubscribe_banner.confirm_title')}
message={t(unsubMethod === 'http'
- ? 'email_viewer.unsubscribe_banner.success_http'
- : 'email_viewer.unsubscribe_banner.success_mailto'
+ ? 'email_viewer.unsubscribe_banner.confirm_message_http'
+ : 'email_viewer.unsubscribe_banner.confirm_message_mailto'
)}
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
cancelText={t('email_viewer.unsubscribe_banner.cancel')}
diff --git a/components/error/error-fallbacks.tsx b/components/error/error-fallbacks.tsx
index 1c1dc5ba..53bc07b7 100644
--- a/components/error/error-fallbacks.tsx
+++ b/components/error/error-fallbacks.tsx
@@ -21,7 +21,7 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp
{t("page_error_description")}
-
+
{t("try_again")}
@@ -34,13 +34,13 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp
*/
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
return (
-
+
{t("sidebar_error")}
-
+
{t("reload")}
@@ -58,7 +58,7 @@ export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
{t("email_list_error")}
-
+
{t("reload_emails")}
@@ -81,7 +81,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
{t("viewer_error_description")}
-
+
{t("try_again")}
@@ -119,7 +119,7 @@ export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
{t("settings_error_description")}
-
+
{t("reload_settings")}
diff --git a/components/favicon-badge.tsx b/components/favicon-badge.tsx
new file mode 100644
index 00000000..0e3a4295
--- /dev/null
+++ b/components/favicon-badge.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { useEmailStore } from "@/stores/email-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import { useFaviconBadge } from "@/hooks/use-favicon-badge";
+
+/**
+ * Badges the browser-tab favicon with the inbox unread count, so new mail is
+ * visible without focusing the tab. See issue #560.
+ *
+ * Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
+ * default.
+ *
+ * Mounted in the root layout rather than on the mail route: the badge belongs
+ * to the tab, not to a page. Mounting it on the mail page unmounted it — and so
+ * cleared the badge, and flickered the icon — on every hop to /settings,
+ * /calendar or /contacts.
+ *
+ * Renders nothing.
+ */
+export function FaviconBadge() {
+ // The store's canonical inbox selector. `role === 'inbox'` alone is not
+ // enough: shared and group inboxes ship in the same `mailboxes` array, so on
+ // a delegated setup the first match can be somebody else's inbox.
+ const inboxUnread = useEmailStore(
+ (s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
+ );
+ const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
+
+ useFaviconBadge(inboxUnread, enabled);
+
+ return null;
+}
diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx
index aff7eb8e..3ed5e75d 100644
--- a/components/files/eml-preview.tsx
+++ b/components/files/eml-preview.tsx
@@ -68,10 +68,10 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
{message.subject || ""}
{message.from && (
-
{t("from")}: {formatAddress(message.from)}
+
{t("from")}: {formatAddress(message.from)}
)}
{message.to && message.to.length > 0 && (
-
{t("to")}: {message.to.map(formatAddress).join(", ")}
+
{t("to")}: {message.to.map(formatAddress).join(", ")}
)}
{message.date && (
{t("date")}: {new Date(message.date).toLocaleString()}
diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx
index b712c158..10fbd330 100644
--- a/components/files/file-browser.tsx
+++ b/components/files/file-browser.tsx
@@ -821,8 +821,8 @@ export function FileBrowser({
const SortIndicator = ({ column }: { column: SortKey }) => {
if (sortKey !== column) return null;
return sortDir === "asc"
- ?
- :
;
+ ?
+ :
;
};
// Keyboard shortcuts
@@ -942,7 +942,7 @@ export function FileBrowser({
setNarrowSidebarOpen((v) => !v)}
aria-label={t("open_folder_tree")}
>
@@ -982,7 +982,7 @@ export function FileBrowser({
className="h-8"
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
>
-
+
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
onBatchDelete([...selectedResources])}
>
-
+
{t("delete")} ({selectedResources.size})
>
@@ -1003,7 +1003,7 @@ export function FileBrowser({
className="h-8"
onClick={onPaste}
>
-
+
{t("paste")} ({clipboard.names.length})
)}
@@ -1157,7 +1157,7 @@ export function FileBrowser({
className="h-7 text-destructive hover:text-destructive shrink-0"
onClick={onRefresh}
>
-
+
{t("retry")}
@@ -1181,12 +1181,12 @@ export function FileBrowser({
{t("uploading")} {uploadProgress.name}
{uploadProgress.totalFiles > 1 && (
-
+
({uploadProgress.current}/{uploadProgress.totalFiles})
)}
-
+
{uploadProgress.total > 0
? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%`
: "…"}
@@ -1267,7 +1267,7 @@ export function FileBrowser({
)}
{/* Favorites & Recent sidebar (when layout is inline) */}
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
-
+
{favorites.length > 0 && (
@@ -1280,7 +1280,7 @@ export function FileBrowser({
key={fav}
onClick={() => onNavigate(fav)}
className={cn(
- "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left",
+ "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start",
currentPath === fav && "bg-muted font-medium"
)}
>
@@ -1301,7 +1301,7 @@ export function FileBrowser({
{recentFiles.slice(0, 10).map((recent) => (
@@ -1322,14 +1322,14 @@ export function FileBrowser({
-
+
- {t("size")}
- {t("modified")}
+ {t("size")}
+ {t("modified")}
@@ -1354,7 +1354,7 @@ export function FileBrowser({
key={`__account__:${acc.accountId}`}
onClick={() => onSelectAccount(acc.accountId)}
title={acc.email}
- className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left min-w-0"
+ className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-start min-w-0"
>
-
+
-
+
handleSortClick("size")} className="hover:text-foreground transition-colors">
{t("size")}
-
+
handleSortClick("modified")} className="hover:text-foreground transition-colors">
{t("modified")}
@@ -1681,7 +1681,7 @@ export function FileBrowser({
>
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && (
{
if (isImageFile(contextMenu.name)) {
onPreviewImage(contextMenu.name);
@@ -1697,7 +1697,7 @@ export function FileBrowser({
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
{
onDownload(contextMenu.name);
setContextMenu(null);
@@ -1708,7 +1708,7 @@ export function FileBrowser({
)}
{
onCut([contextMenu.name]);
setContextMenu(null);
@@ -1718,7 +1718,7 @@ export function FileBrowser({
{t("cut")}
{
onCopy([contextMenu.name]);
setContextMenu(null);
@@ -1729,7 +1729,7 @@ export function FileBrowser({
{clipboard && (
{
onPaste();
setContextMenu(null);
@@ -1741,7 +1741,7 @@ export function FileBrowser({
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
{
onDuplicate(contextMenu.name);
setContextMenu(null);
@@ -1753,7 +1753,7 @@ export function FileBrowser({
)}
{canShare(resources.find(r => r.name === contextMenu.name)) && (
{
const r = resources.find(res => res.name === contextMenu.name);
if (r) setShareTargetId(r.id);
@@ -1766,7 +1766,7 @@ export function FileBrowser({
)}
{
onShowDetails(contextMenu.name);
setContextMenu(null);
@@ -1776,7 +1776,7 @@ export function FileBrowser({
{t("details")}
{
setRenameTarget(contextMenu.name);
setContextMenu(null);
@@ -1786,7 +1786,7 @@ export function FileBrowser({
{t("rename")}
{
onDelete(contextMenu.name);
setContextMenu(null);
@@ -1808,7 +1808,7 @@ export function FileBrowser({
onClick={(e) => e.stopPropagation()}
>
{
setShowNewFolder(true);
setEmptyContextMenu(null);
@@ -1818,7 +1818,7 @@ export function FileBrowser({
{t("new_folder")}
{
setShowNewTextFile(true);
setEmptyContextMenu(null);
@@ -1828,7 +1828,7 @@ export function FileBrowser({
{t("new_text_file")}
{
fileInputRef.current?.click();
setEmptyContextMenu(null);
@@ -1838,7 +1838,7 @@ export function FileBrowser({
{t("upload")}
{
folderInputRef.current?.click();
setEmptyContextMenu(null);
@@ -1851,7 +1851,7 @@ export function FileBrowser({
<>
{
onPaste();
setEmptyContextMenu(null);
@@ -1864,7 +1864,7 @@ export function FileBrowser({
)}
{
onRefresh();
setEmptyContextMenu(null);
@@ -1895,7 +1895,7 @@ export function FileBrowser({
return (
{
onNavigate(folderPath, folder.id);
setBreadcrumbDropdown(null);
@@ -1926,7 +1926,7 @@ export function FileBrowser({
{/* Details sidebar */}
{showDetails && detailResource && (
-
+
{t("details")}
diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx
index 8422186b..666c8429 100644
--- a/components/files/file-preview-modal.tsx
+++ b/components/files/file-preview-modal.tsx
@@ -68,11 +68,11 @@ function SimpleMarkdown({ content }: { content: string }) {
} else if (line.startsWith("---") || line.startsWith("***")) {
elements.push( );
} else if (line.startsWith("- ") || line.startsWith("* ")) {
- elements.push({processInline(line.slice(2))} );
+ elements.push({processInline(line.slice(2))} );
} else if (/^\d+\. /.test(line)) {
- elements.push({processInline(line.replace(/^\d+\. /, ""))} );
+ elements.push({processInline(line.replace(/^\d+\. /, ""))} );
} else if (line.startsWith("> ")) {
- elements.push({processInline(line.slice(2))} );
+ elements.push({processInline(line.slice(2))} );
} else if (line.startsWith("```")) {
// Code block - collect until closing ```
const codeLines: string[] = [];
diff --git a/components/files/file-upload-area.tsx b/components/files/file-upload-area.tsx
index 5f678481..5a06763b 100644
--- a/components/files/file-upload-area.tsx
+++ b/components/files/file-upload-area.tsx
@@ -68,7 +68,7 @@ export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCre
size="sm"
onClick={onCreateFolder}
>
-
+
{t("new_folder")}
{onCreateTextFile && (
@@ -77,7 +77,7 @@ export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCre
size="sm"
onClick={onCreateTextFile}
>
-
+
{t("new_text_file")}
)}
diff --git a/components/files/folder-tree-sidebar.tsx b/components/files/folder-tree-sidebar.tsx
index 1d7b0c94..52b1aa38 100644
--- a/components/files/folder-tree-sidebar.tsx
+++ b/components/files/folder-tree-sidebar.tsx
@@ -135,7 +135,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
return (
handleFolderClick("/", null)}
- className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
+ className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
>
-
+
{t("breadcrumb_root")}
@@ -207,11 +207,11 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
>
handleFolderClick(path, r.id)}
- className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
+ className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start min-w-0"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
>
-
+
{r.name}
@@ -281,7 +281,7 @@ function FolderTreeItem({
onToggleExpand(node.id, node.path);
}}
className={cn(
- "p-0.5 rounded mr-1 transition-all duration-200",
+ "p-0.5 rounded me-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
style={{ marginLeft: `${indentPx}px` }}
@@ -297,14 +297,14 @@ function FolderTreeItem({
{/* Folder name */}
onFolderClick(node.path, node.id)}
- className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
+ className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start"
style={{
paddingBlock: "var(--density-sidebar-py)",
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
}}
>
0 && "text-muted-foreground"
)} />
diff --git a/components/files/pdf-mobile-viewer.tsx b/components/files/pdf-mobile-viewer.tsx
index b0dd59f7..fc83baee 100644
--- a/components/files/pdf-mobile-viewer.tsx
+++ b/components/files/pdf-mobile-viewer.tsx
@@ -212,7 +212,7 @@ export function PdfMobileViewer({ url }: { url: string }) {
size="sm"
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
>
-
+
{t("open_in_new_tab")}
diff --git a/components/filters/sieve-editor-modal.tsx b/components/filters/sieve-editor-modal.tsx
index 12652844..064b74de 100644
--- a/components/filters/sieve-editor-modal.tsx
+++ b/components/filters/sieve-editor-modal.tsx
@@ -100,7 +100,7 @@ export function SieveEditorModal({
{Array.from({ length: lineCount }, (_, i) => (
@@ -172,7 +172,7 @@ export function SieveEditorModal({
>
{isValidating ? (
<>
-
+
{t("validating")}
>
) : (
diff --git a/components/identity/identity-form.tsx b/components/identity/identity-form.tsx
index b1d67240..0155b7b7 100644
--- a/components/identity/identity-form.tsx
+++ b/components/identity/identity-form.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
-import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
+import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
@@ -305,7 +305,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
{tDisplay('preview')}
@@ -352,7 +352,7 @@ function SignatureByteCounter({ id, value }: { id: string; value: string }) {
aria-live="polite"
>
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
- {atLimit &&
{t('signature_byte_limit_reached')} }
+ {atLimit &&
{t('signature_byte_limit_reached')} }
);
}
diff --git a/components/identity/identity-manager-modal.tsx b/components/identity/identity-manager-modal.tsx
index 3c4a4280..5f42a563 100644
--- a/components/identity/identity-manager-modal.tsx
+++ b/components/identity/identity-manager-modal.tsx
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
+import { useAccountStore } from '@/stores/account-store';
import { useSettingsStore } from '@/stores/settings-store';
function useSyncIdentities() {
@@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const handleSetPrimary = useCallback((identity: Identity) => {
setPreferredPrimary(identity.id);
- // Persist to the synced settings (keyed by username, matching how
- // loadIdentities reads it back) so the choice survives a new browser /
- // cleared site data and reaches other devices (#507).
- const username = useAuthStore.getState().username || '';
- if (username) {
+ // Persist the choice per account in the synced settings store so it
+ // survives clearing site data, follows the user across devices, and shows
+ // up in exported settings (issue #507). JMAP identity ids are account-
+ // scoped, so the default is keyed by the active account.
+ const activeAccountId = useAccountStore.getState().activeAccountId;
+ if (activeAccountId) {
const current = useSettingsStore.getState().preferredIdentityIds;
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
...current,
- [username]: identity.id,
+ [activeAccountId]: identity.id,
});
}
// Re-sort: move the preferred identity to the front
@@ -275,7 +277,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
onClick={() => setIsCreating(true)}
className="mb-6 w-full sm:w-auto"
>
-
+
{t('create_new')}
)}
diff --git a/components/identity/sub-address-helper.tsx b/components/identity/sub-address-helper.tsx
index 83c86069..50836dc6 100644
--- a/components/identity/sub-address-helper.tsx
+++ b/components/identity/sub-address-helper.tsx
@@ -128,7 +128,7 @@ export function SubAddressHelper({
title={t('button_tooltip')}
className="h-8 px-2"
>
-
+
@@ -137,7 +137,7 @@ export function SubAddressHelper({
{description}
-
+
{keys.map((key, index) => (
{index > 0 && or }
diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx
index 24df5725..5c55fbc5 100644
--- a/components/layout/account-switcher.tsx
+++ b/components/layout/account-switcher.tsx
@@ -1,12 +1,13 @@
"use client";
-import { useState, useRef, useEffect, useCallback } from "react";
+import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
-import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
+import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
-import { getMaxAccounts } from "@/lib/account-utils";
+import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
+import { isDocumentRTL } from "@/i18n/direction";
import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
@@ -40,6 +41,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
+ const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
// Read activeAccountId from authStore so the selector matches the actually-loaded
// session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate
// persisted copy that can drift out of sync across hydration / partial persist writes.
@@ -47,23 +49,41 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const activeAccount = accounts.find((a) => a.id === activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
+ const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
+ const rtl = isDocumentRTL();
if (variant === "rail") {
- setPopoverStyle({
- position: "fixed",
- left: rect.right + 8,
- bottom: Math.max(8, window.innerHeight - rect.bottom),
- });
+ setPopoverStyle(
+ rtl
+ ? {
+ position: "fixed",
+ right: window.innerWidth - rect.left + 8,
+ bottom: Math.max(8, window.innerHeight - rect.bottom),
+ }
+ : {
+ position: "fixed",
+ left: rect.right + 8,
+ bottom: Math.max(8, window.innerHeight - rect.bottom),
+ }
+ );
} else {
- setPopoverStyle({
- position: "fixed",
- left: rect.left,
- top: rect.bottom + 4,
- });
+ setPopoverStyle(
+ rtl
+ ? {
+ position: "fixed",
+ right: window.innerWidth - rect.right,
+ top: rect.bottom + 4,
+ }
+ : {
+ position: "fixed",
+ left: rect.left,
+ top: rect.bottom + 4,
+ }
+ );
}
}, [variant]);
@@ -99,6 +119,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
router.push(`/login?mode=add-account` as never);
};
+ const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
+ e.stopPropagation();
+ const label = account.email || account.username;
+ if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
+ removeAccount(account.id);
+ };
+
const handleLogout = () => {
setOpen(false);
logout();
@@ -113,6 +140,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setDefaultAccount(accountId);
};
+ // Display order: default account pinned to the top, the rest reorderable.
+ const displayAccounts = useMemo(() => sortDefaultFirst(accounts), [accounts]);
+
+ // Drag-to-rearrange (non-default accounts only; the default stays pinned).
+ const [dragId, setDragId] = useState(null);
+ const [dragOverId, setDragOverId] = useState(null);
+ const resetDrag = () => { setDragId(null); setDragOverId(null); };
+
+ const handleDragStart = (e: React.DragEvent, id: string) => {
+ setDragId(id);
+ e.dataTransfer.effectAllowed = "move";
+ };
+ const handleDragOver = (e: React.DragEvent, overId: string) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ if (overId !== dragOverId) setDragOverId(overId);
+ };
+ const handleDrop = (e: React.DragEvent, overId: string) => {
+ e.preventDefault();
+ if (dragId) {
+ const next = reorderNonDefaultIds(accounts, dragId, overId);
+ if (next) reorderAccounts(next);
+ }
+ resetDrag();
+ };
+
// Show the account's own identity, not the preferred sending identity -
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
// the actually logged-in account (info@linusrath.de).
@@ -124,11 +177,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setOpen(!open)}
+ data-testid="account-switcher"
+ data-active-account-id={activeAccountId ?? undefined}
className={cn(
"flex items-center gap-2 rounded-md transition-colors",
variant === "rail"
? "justify-center w-10 h-10 hover:bg-muted"
- : "w-full px-2 py-1.5 hover:bg-muted text-left min-w-0",
+ : "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
className
)}
title={variant === "rail" ? (displayName || displayEmail) : undefined}
@@ -167,15 +222,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
>
{/* Account List */}
- {accounts.map((account) => {
+ {displayAccounts.map((account) => {
const isActive = account.id === activeAccountId;
+ const isDraggable = !account.isDefault && accounts.length > 2;
return (
- handleSwitch(account.id)}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? (e) => handleDragStart(e, account.id) : undefined}
+ onDragOver={isDraggable ? (e) => handleDragOver(e, account.id) : undefined}
+ onDrop={isDraggable ? (e) => handleDrop(e, account.id) : undefined}
+ onDragEnd={isDraggable ? resetDrag : undefined}
className={cn(
- "w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
- isActive ? "bg-accent/50" : "hover:bg-muted"
+ "group/acct relative",
+ dragId === account.id && "opacity-50",
+ dragOverId === account.id && dragId !== account.id && "border-t-2 border-primary"
+ )}
+ >
+ handleSwitch(account.id)}
+ data-testid="account-option"
+ data-account-id={account.id}
+ data-account-email={account.email || account.username}
+ className={cn(
+ "w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
+ isActive ? "bg-accent/50" : "hover:bg-muted",
+ (!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
)}
role="menuitem"
disabled={isActive}
@@ -215,6 +287,26 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
+ {isDraggable && (
+
+
+
+ )}
+ {!isActive && !account.isDefault && (
+
handleRemove(e, account)}
+ aria-label={t("remove_account")}
+ title={t("remove_account")}
+ className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
+ >
+
+
+ )}
+
);
})}
@@ -224,6 +316,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
diff --git a/components/layout/icon-picker.tsx b/components/layout/icon-picker.tsx
index 859fcc0e..70bc0fc8 100644
--- a/components/layout/icon-picker.tsx
+++ b/components/layout/icon-picker.tsx
@@ -95,7 +95,7 @@ export function IconPicker({ value, onChange, className }: IconPickerProps) {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('search_icons')}
- className="pl-8 h-8 text-xs"
+ className="ps-8 h-8 text-xs"
/>
{search && (
{
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
- setPopoverStyle({
- position: "fixed",
- left: rect.right + 8,
- bottom: window.innerHeight - rect.bottom,
- });
+ setPopoverStyle(
+ isDocumentRTL()
+ ? {
+ position: "fixed",
+ right: window.innerWidth - rect.left + 8,
+ bottom: window.innerHeight - rect.bottom,
+ }
+ : {
+ position: "fixed",
+ left: rect.right + 8,
+ bottom: window.innerHeight - rect.bottom,
+ }
+ );
}, []);
useEffect(() => {
@@ -91,7 +100,8 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
return () => document.removeEventListener("mousedown", handleClick);
}, [open, updatePosition]);
- const free = quota.total - quota.used;
+ // Usage can legitimately exceed the quota (e.g. limit lowered after the fact)
+ const free = Math.max(0, quota.total - quota.used);
const strokeColor = usagePercent > 90
? "stroke-destructive"
: usagePercent > 70
@@ -217,11 +227,19 @@ export function NavigationRail({
const updateLogoutPosition = useCallback(() => {
if (!logoutBtnRef.current) return;
const rect = logoutBtnRef.current.getBoundingClientRect();
- setLogoutPopoverStyle({
- position: "fixed",
- left: rect.right + 8,
- bottom: Math.max(8, window.innerHeight - rect.bottom),
- });
+ setLogoutPopoverStyle(
+ isDocumentRTL()
+ ? {
+ position: "fixed",
+ right: window.innerWidth - rect.left + 8,
+ bottom: Math.max(8, window.innerHeight - rect.bottom),
+ }
+ : {
+ position: "fixed",
+ left: rect.right + 8,
+ bottom: Math.max(8, window.innerHeight - rect.bottom),
+ }
+ );
}, []);
useEffect(() => {
diff --git a/components/layout/sidebar-apps-modal.tsx b/components/layout/sidebar-apps-modal.tsx
index fc4ec9f0..3301997a 100644
--- a/components/layout/sidebar-apps-modal.tsx
+++ b/components/layout/sidebar-apps-modal.tsx
@@ -154,7 +154,7 @@ function SidebarAppForm({
{t('icon_label')} *
{SelectedIcon && (
-
+
- {formData.icon}
)}
@@ -275,7 +275,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
onClick={() => setIsCreating(true)}
className="mb-6 w-full sm:w-auto"
>
-
+
{t('add_new')}
)}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 02268d8e..bc61bcc6 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -38,6 +38,7 @@ import {
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
+import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
@@ -79,9 +80,10 @@ interface SidebarProps {
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
- /** Gated "All Mail" virtual folder that merges all of the account's folders. */
- showAllMailMailbox?: boolean;
- /** Gated cross-account views in the "All accounts" section. */
+ /** True when the unified view spans multiple login accounts (cross-account).
+ * Drives the section header: "All accounts" when true, else "Unified Mailbox". */
+ crossAccountActive?: boolean;
+ /** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */
showCrossUnread?: boolean;
showCrossStarred?: boolean;
showCrossAll?: boolean;
@@ -220,7 +222,13 @@ function SidebarRowCounts({
) : null;
return (
- 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
+ 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
+ data-testid="folder-counts"
+ data-unread={unreadCount}
+ data-total={totalCount}
+ >
{unreadNode}
{unreadCount > 0 && totalCount > 0 && (
/
@@ -248,6 +256,11 @@ interface SidebarRowProps {
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
+ /** Stable identifiers for integration tests (not user-visible). */
+ testRole?: string | null;
+ testName?: string;
+ testMailboxId?: string;
+ testShared?: boolean;
}
function SidebarRow({
@@ -268,6 +281,10 @@ function SidebarRow({
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
+ testRole,
+ testName,
+ testMailboxId,
+ testShared,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -276,15 +293,20 @@ function SidebarRow({
;
@@ -379,6 +403,9 @@ function SidebarSectionHeader({
return (
)}
- {icon && {icon} }
-
+ {icon && {icon} }
+
{label}
{onSettings && (
@@ -409,7 +436,7 @@ function SidebarSectionHeader({
onSettings();
}
}}
- className="ml-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
+ className="ms-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={settingsTitle}
>
@@ -476,6 +503,10 @@ function MailboxTreeItem({
}
label={label}
+ testRole={node.role}
+ testName={node.name}
+ testMailboxId={node.id}
+ testShared={node.isShared}
depth={node.depth}
isSelected={isSelected}
isVirtual={isVirtualNode}
@@ -665,7 +696,7 @@ function VacationBanner() {
>
{t("vacation_active")}
-
+
);
}
@@ -691,7 +722,7 @@ export function Sidebar({
onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
- showAllMailMailbox = false,
+ crossAccountActive = false,
showCrossUnread = false,
showCrossStarred = false,
showCrossAll = false,
@@ -853,6 +884,12 @@ export function Sidebar({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
+ // Don't hijack Arrow keys while the user is typing. This is a global
+ // window listener, so without this guard typing in a new email (the
+ // contentEditable composer, the subject field, search, etc.) toggled the
+ // selected mailbox's subfolders open/closed on ArrowLeft/ArrowRight.
+ // composedPath-based so it also sees the QuotedHtml shadow island (#654).
+ if (isEditableEventTarget(e)) return;
if (!selectedMailbox || isCollapsed) return;
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
@@ -956,7 +993,7 @@ export function Sidebar({
return (
- {showAllMailMailbox && (
-
}
- label={t('mailboxes.all_mail')}
- depth={0}
- isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
- onClick={() => onMailboxSelect?.('__all_mail__')}
- isCollapsed={isCollapsed}
- />
- )}
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
}
label={t(`unified_${count.role}`)}
+ testRole={count.role}
+ testName={`unified-${count.role}`}
+ testMailboxId={unifiedId}
depth={0}
isSelected={isSelected}
unread={count.unreadEmails}
@@ -1053,6 +1083,8 @@ export function Sidebar({
key={id}
icon={
}
label={label}
+ testName={id}
+ testMailboxId={id}
depth={0}
isSelected={isSelected}
unread={unread}
@@ -1182,6 +1214,7 @@ export function Sidebar({
expanded={sharedExpanded}
onToggle={toggleShared}
isCollapsed={isCollapsed}
+ testId="section-shared"
/>
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
<>
@@ -1196,6 +1229,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
sub
icon={
}
+ testId="section-shared-account"
/>
{accountExpanded && !isCollapsed && account.children.map((child) => (
null);
+ const isPrompt = current?.kind === 'prompt';
+ const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]);
+
+ // Field values for a prompt, re-initialised whenever a new dialog surfaces.
+ const [values, setValues] = useState>({});
+ useEffect(() => {
+ if (!current) return;
+ const init: Record = {};
+ for (const f of current.fields ?? []) init[f.name] = '';
+ setValues(init);
+ }, [current]);
+
+ const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0);
+
+ const cancel = () => resolveHead(current?.kind === 'prompt' ? null : false);
+ const submitPrompt = () => { if (canSubmit) resolveHead(values); };
+
useEffect(() => {
if (!current) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
- resolveHead(false);
- } else if (e.key === 'Enter') {
+ cancel();
+ } else if (e.key === 'Enter' && !isPrompt) {
+ // For prompts, Enter is handled by the form (so it respects required
+ // validation and works from within an input); non-prompt dialogs accept.
e.preventDefault();
resolveHead(true);
}
}
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
- }, [current]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [current, isPrompt, canSubmit, values]);
if (!current) return null;
- const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : 'Confirm');
+ const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : current.kind === 'prompt' ? 'Submit' : 'Confirm');
const cancelLabel = current.cancelLabel ?? 'Cancel';
+ const btnBase: React.CSSProperties = { padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, cursor: 'pointer' };
+ const secondaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid var(--color-border, #e2e8f0)', background: 'transparent', color: 'inherit' };
+ const primaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid transparent', background: 'var(--color-primary, #3b82f6)', color: 'var(--color-primary-foreground, #fff)' };
+
return (
{
- if (e.target === e.currentTarget) resolveHead(false);
+ if (e.target === e.currentTarget) cancel();
}}
>
{current.title}
-
- {renderMessage(current.message)}
-
-
- {current.kind === 'confirm' && (
+ {current.message && (
+
+ {renderMessage(current.message)}
+
+ )}
+ {isPrompt ? (
+
+ ) : (
+
+ {current.kind === 'confirm' && (
+
+ {cancelLabel}
+
+ )}
resolveHead(false)}
+ autoFocus={current.kind === 'alert' || !current.danger}
+ onClick={() => resolveHead(true)}
style={{
- padding: '8px 16px',
- borderRadius: 8,
- fontSize: 13,
- fontWeight: 500,
- cursor: 'pointer',
- border: '1px solid var(--color-border, #e2e8f0)',
- background: 'transparent',
- color: 'inherit',
+ ...primaryBtn,
+ background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
+ color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
}}
>
- {cancelLabel}
+ {confirmLabel}
- )}
- resolveHead(true)}
- style={{
- padding: '8px 16px',
- borderRadius: 8,
- fontSize: 13,
- fontWeight: 500,
- cursor: 'pointer',
- border: '1px solid transparent',
- background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
- color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
- }}
- >
- {confirmLabel}
-
-
+
+ )}
From plugin: {current.pluginId}
diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx
index a175e157..039331bf 100644
--- a/components/pro/pro-compose-tab-body.tsx
+++ b/components/pro/pro-compose-tab-body.tsx
@@ -7,7 +7,7 @@ import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
-import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
+import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store";
import { debug } from "@/lib/debug";
interface ProComposeTabBodyProps {
@@ -38,6 +38,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const tabIdRef = useRef(tabId);
tabIdRef.current = tabId;
+ // Set by the composer to its dirty-aware close handler. Lets the Pro tab
+ // bar's "X" route through the same "Save or discard draft?" guard.
+ const requestCloseRef = useRef<(() => void) | null>(null);
+
const handleScheduledSendCreated = useCallback(async () => {
if (client) {
await refreshScheduledMetadata(client);
@@ -120,6 +124,20 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
closeTab(tabIdRef.current);
}, [closeTab]);
+ // Register a close interceptor so closing the tab from the tab bar (the
+ // "X" button or middle-click) goes through the composer's unsaved-changes
+ // guard, mirroring the non-Pro inline composer.
+ useEffect(() => {
+ const id = tabIdRef.current;
+ return registerProTabCloseInterceptor(id, () => {
+ if (requestCloseRef.current) {
+ requestCloseRef.current();
+ } else {
+ closeTab(id);
+ }
+ });
+ }, [closeTab]);
+
const handleDiscardDraft = useCallback(async (draftId: string) => {
if (!client) return;
try {
@@ -160,6 +178,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
onSend={handleSend}
onScheduledSendCreated={handleScheduledSendCreated}
onClose={handleClose}
+ requestCloseRef={requestCloseRef}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
className="flex-1"
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index e80e0865..bd7dfdfc 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -6,11 +6,13 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
+import { useIdentityStore } from "@/stores/identity-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
import type { Email } from "@/lib/jmap/types";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
+import { getQuoteBodies } from "@/lib/email-composer-utils";
interface ProEmailTabBodyProps {
tabId: string;
@@ -18,8 +20,6 @@ interface ProEmailTabBodyProps {
}
function buildReplyContext(email: Email): ProReplyContext {
- const textPartId = email.textBody?.[0]?.partId ?? '';
- const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
return {
from: email.from,
replyToAddresses: email.replyTo,
@@ -27,8 +27,7 @@ function buildReplyContext(email: Email): ProReplyContext {
cc: email.cc,
bcc: email.bcc,
subject: email.subject,
- body: email.bodyValues?.[textPartId]?.value || email.preview || '',
- htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
+ ...getQuoteBodies(email),
receivedAt: email.receivedAt,
accountId: email.accountId,
attachments: email.attachments,
@@ -56,6 +55,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
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 closeTab = useProTabStore((s) => s.closeTab);
const openComposeTab = useProTabStore((s) => s.openComposeTab);
@@ -227,6 +227,49 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
handleReply(body);
}, [handleReply]);
+ const handleEditDraft = useCallback(() => {
+ if (!email) return;
+
+ const bodyText = email.bodyValues
+ ? Object.values(email.bodyValues).map((v) => v.value).join('\n')
+ : '';
+ // A plain-text-only draft lists its text/plain part under htmlBody
+ // (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
+ const draftHtmlPart = email.htmlBody?.[0];
+ const htmlBody = draftHtmlPart?.partId
+ && (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
+ && email.bodyValues?.[draftHtmlPart.partId]
+ ? email.bodyValues[draftHtmlPart.partId].value
+ : undefined;
+
+ // Preserve the identity that matches the draft's From address.
+ const draftFromEmail = email.from?.[0]?.email;
+ const matchedIdentity = draftFromEmail
+ ? identities.find((id) => id.email === draftFromEmail)
+ : null;
+
+ composerSessionIdRef.current += 1;
+ openComposeTab({
+ sessionId: composerSessionIdRef.current,
+ mode: 'compose',
+ title: email.subject || t('email_composer.new_message'),
+ initialData: {
+ to: email.to?.map((a) => a.email).filter(Boolean).join(', ') || '',
+ cc: email.cc?.map((a) => a.email).filter(Boolean).join(', ') || '',
+ bcc: email.bcc?.map((a) => a.email).filter(Boolean).join(', ') || '',
+ subject: email.subject || '',
+ body: htmlBody || bodyText,
+ showCc: (email.cc?.length || 0) > 0,
+ showBcc: (email.bcc?.length || 0) > 0,
+ selectedIdentityId: matchedIdentity?.id ?? null,
+ subAddressTag: '',
+ mode: 'compose',
+ draftId: email.id,
+ },
+ });
+ closeTab(tabId);
+ }, [email, identities, openComposeTab, closeTab, tabId, t]);
+
return (
@@ -243,6 +286,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
onSetColorTag={handleSetColorTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
+ onEditDraft={handleEditDraft}
onMoveToMailbox={handleMoveToMailbox}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split('@')[0]}
diff --git a/components/pro/pro-tab-bar.tsx b/components/pro/pro-tab-bar.tsx
index 5b367fa6..64b00ebe 100644
--- a/components/pro/pro-tab-bar.tsx
+++ b/components/pro/pro-tab-bar.tsx
@@ -171,7 +171,7 @@ export function ProTabBar({
className={cn(
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
- "border-r border-border first:border-l",
+ "border-e border-border first:border-s",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
@@ -193,7 +193,7 @@ export function ProTabBar({
onClose(tab.id);
}}
className={cn(
- "ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
+ "ms-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
)}
diff --git a/components/protocol/protocol-account-picker.tsx b/components/protocol/protocol-account-picker.tsx
index 4ddfb5ab..6fa7401e 100644
--- a/components/protocol/protocol-account-picker.tsx
+++ b/components/protocol/protocol-account-picker.tsx
@@ -106,7 +106,7 @@ export function ProtocolAccountPicker({
disabled={isSwitching}
onClick={() => onSelect(account.id)}
className={cn(
- "flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
+ "flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isSwitching && "cursor-wait opacity-70"
)}
diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx
index 2d862a45..cae44248 100644
--- a/components/providers/intl-provider.tsx
+++ b/components/providers/intl-provider.tsx
@@ -1,13 +1,18 @@
"use client";
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
+import arMessages from '@/locales/ar/common.json';
import csMessages from '@/locales/cs/common.json';
import daMessages from '@/locales/da/common.json';
import deMessages from '@/locales/de/common.json';
+import { getLocaleDirection } from '@/i18n/direction';
+import { mergeMessages } from '@/i18n/merge-messages';
+import { detectBrowserLocale } from '@/i18n/detect-locale';
import enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json';
+import heMessages from '@/locales/he/common.json';
import faMessages from '@/locales/fa/common.json';
import frMessages from '@/locales/fr/common.json';
import huMessages from '@/locales/hu/common.json';
@@ -20,17 +25,20 @@ import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json';
import roMessages from '@/locales/ro/common.json';
import ruMessages from '@/locales/ru/common.json';
+import skMessages from '@/locales/sk/common.json';
import trMessages from '@/locales/tr/common.json';
import ukMessages from '@/locales/uk/common.json';
import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
+ ar: arMessages,
cs: csMessages,
da: daMessages,
de: deMessages,
en: enMessages,
es: esMessages,
+ he: heMessages,
fa: faMessages,
fr: frMessages,
hu: huMessages,
@@ -43,6 +51,7 @@ const ALL_MESSAGES = {
pt: ptMessages,
ro: roMessages,
ru: ruMessages,
+ sk: skMessages,
tr: trMessages,
uk: ukMessages,
zh: zhMessages,
@@ -56,7 +65,6 @@ interface IntlProviderProps {
export function IntlProvider({ locale: initialLocale, children }: IntlProviderProps) {
const currentLocale = useLocaleStore((state) => state.locale);
- const setLocale = useLocaleStore((state) => state.setLocale);
const [activeLocale, setActiveLocale] = useState(initialLocale);
const [timeZone, setTimeZone] = useState('UTC');
@@ -72,27 +80,38 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr
}
}, []);
- // First mount: seed the store from the server-resolved locale if nothing is persisted.
+ // Resolve the active locale from the user's stored choice. Empty or 'auto'
+ // means "follow the browser" (English default); a specific code forces it and
+ // is never overridden by detection.
useEffect(() => {
- if (!currentLocale) {
- setLocale(initialLocale);
- } else {
- setActiveLocale(currentLocale);
- }
+ setActiveLocale(
+ !currentLocale || currentLocale === 'auto'
+ ? detectBrowserLocale(initialLocale)
+ : currentLocale
+ );
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // Switch locale immediately when store changes
- useEffect(() => {
- if (currentLocale) {
- setActiveLocale(currentLocale);
- }
}, [currentLocale]);
+ // Keep lang/dir in sync with the active locale (RTL for he/fa).
+ useEffect(() => {
+ document.documentElement.lang = activeLocale;
+ document.documentElement.dir = getLocaleDirection(activeLocale);
+ }, [activeLocale]);
+
+ // Fall back to English for any key the active locale has not translated, so
+ // untranslated strings show English text instead of a raw message key.
+ const messages = useMemo(
+ () => mergeMessages(
+ ALL_MESSAGES.en as Record,
+ (ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES] ?? {}) as Record
+ ),
+ [activeLocale]
+ );
+
return (
{children}
diff --git a/components/search/advanced-search-panel.tsx b/components/search/advanced-search-panel.tsx
index 61c225a1..5bac76d5 100644
--- a/components/search/advanced-search-panel.tsx
+++ b/components/search/advanced-search-panel.tsx
@@ -84,7 +84,7 @@ export function AdvancedSearchPanel({
{t("title")}
-
+
{t("clear")}
diff --git a/components/search/search-chips.tsx b/components/search/search-chips.tsx
index 8d1a3615..93a9dee9 100644
--- a/components/search/search-chips.tsx
+++ b/components/search/search-chips.tsx
@@ -81,7 +81,7 @@ export function SearchChips({
onRemoveFilter(chip.key)}
- className="ml-0.5 p-0.5 rounded-full hover:bg-primary/20 transition-colors"
+ className="ms-0.5 p-0.5 rounded-full hover:bg-primary/20 transition-colors"
>
diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx
index 26be560c..0c0556b9 100644
--- a/components/settings/about-data-settings.tsx
+++ b/components/settings/about-data-settings.tsx
@@ -11,6 +11,7 @@ import { useUpdateStore } from '@/stores/update-store';
import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getPathPrefix } from '@/lib/browser-navigation';
+import { clearCachedData } from '@/lib/clear-cached-data';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
@@ -36,7 +37,7 @@ function VersionUpdateTag() {
return (
(null);
const { isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
@@ -105,6 +107,15 @@ export function AboutDataSettings() {
reader.readAsText(file);
};
+ const handleRefreshCache = () => {
+ if (showRefreshConfirm) {
+ clearCachedData(); // reloads the page
+ } else {
+ setShowRefreshConfirm(true);
+ setTimeout(() => setShowRefreshConfirm(false), 5000);
+ }
+ };
+
const handleReset = () => {
if (showResetConfirm) {
resetToDefaults();
@@ -121,7 +132,7 @@ export function AboutDataSettings() {
{showGame && setShowGame(false)} />}
-
+
)}
+
+
+ {showRefreshConfirm ? tCommon('yes') : t('refresh_cache.button')}
+
+
+
setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
- className="pr-10"
+ className="pe-10"
/>
- {isSaving ? : null}
+ {isSaving ? : null}
{t('password.submit')}
@@ -294,7 +294,7 @@ function TotpSection() {
{setupUrl && (
-
+
{t('totp.setup_instructions')}
{qrDataUrl && (
@@ -315,7 +315,7 @@ function TotpSection() {
{setupError &&
{setupError}
}
- {isSaving ? : null}
+ {isSaving ? : null}
{t('totp.confirm')}
{t('app_passwords.cancel')}
@@ -324,7 +324,7 @@ function TotpSection() {
)}
{disableOpen && (
-
+
{t('totp.disable_confirm_prompt')}
{setupError}}
- {isSaving ? : null}
+ {isSaving ? : null}
{t('totp.disable')}
{ setDisableOpen(false); setPassword(''); setSetupError(null); }}>
@@ -470,7 +470,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
{tk('title')}
setShowAdd(!showAdd)}>
-
+
{t('app_passwords.add')}
@@ -521,7 +521,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
- {isSaving ? : null}
+ {isSaving ? : null}
{t('app_passwords.create')}
setShowAdd(false)}>
@@ -768,9 +768,9 @@ function LinkDeviceSection() {
void generate()} disabled={loading}>
{loading ? (
-
+
) : (
-
+
)}
{hasGenerated ? t('link_device.regenerate') : t('link_device.generate')}
diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx
index 0ac31894..ae0ccbf8 100644
--- a/components/settings/account-settings.tsx
+++ b/components/settings/account-settings.tsx
@@ -52,7 +52,7 @@ export function AccountSettings() {
const [dragOverIndex, setDragOverIndex] = useState(null);
const draggedIndexRef = useRef(null);
- const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
+ const quotaPercentage = quota && quota.total > 0 ? Math.min(Math.round((quota.used / quota.total) * 100), 100) : 0;
const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined);
const email = primaryIdentity?.email || account?.email || username;
const max = getMaxAccounts();
@@ -220,7 +220,7 @@ export function AccountSettings() {
onClick={handleAddAccount}
className="w-full"
>
-
+
{t('accounts.add')}
)}
@@ -242,7 +242,7 @@ export function AccountSettings() {
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
- 'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
+ 'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-start transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
@@ -356,7 +356,7 @@ function AccountRow({
onClick={onSwitch}
disabled={isActive}
className={cn(
- 'min-w-0 flex-1 text-left',
+ 'min-w-0 flex-1 text-start',
!isActive && 'cursor-pointer'
)}
title={isActive ? labels.active : labels.switchTo}
diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx
index 998c791a..3a589dfe 100644
--- a/components/settings/appearance-settings.tsx
+++ b/components/settings/appearance-settings.tsx
@@ -141,7 +141,7 @@ export function AppearanceSettings() {
onClick={() => { resetTourCompletion(); startTour(); }}
className="text-xs h-7"
>
-
+
{tTour('restart_button')}
diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx
index 31b09af2..82eaed98 100644
--- a/components/settings/calendar-management-settings.tsx
+++ b/components/settings/calendar-management-settings.tsx
@@ -475,7 +475,7 @@ export function CalendarManagementSettings() {
{colorPickerId === cal.id && (
+
+ updateSetting('rtlEditingSupport', checked)}
+ />
+
+
{sendDelaySeconds > 0 && !delayedSendSupported && (
-
{t('send_delay.unsupported')}
+
{t('send_delay.unsupported')}
)}
diff --git a/components/settings/contacts-settings.tsx b/components/settings/contacts-settings.tsx
index a79d4779..8661979f 100644
--- a/components/settings/contacts-settings.tsx
+++ b/components/settings/contacts-settings.tsx
@@ -73,7 +73,7 @@ export function ContactsSettings() {
description={tSettings("import_description")}
>
setShowImport(true)}>
-
+
{t("import.title")}
@@ -88,7 +88,7 @@ export function ContactsSettings() {
onClick={handleExport}
disabled={individuals.length === 0}
>
-
+
{t("export.title")}
diff --git a/components/settings/content-senders-settings.tsx b/components/settings/content-senders-settings.tsx
index bd72b083..e453723a 100644
--- a/components/settings/content-senders-settings.tsx
+++ b/components/settings/content-senders-settings.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
@@ -8,6 +8,7 @@ import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
+import { useAuthStore } from '@/stores/auth-store';
export function ContentSendersSettings() {
const t = useTranslations('settings.email_behavior');
@@ -21,7 +22,15 @@ export function ContentSendersSettings() {
trustedSendersAddressBook,
updateSetting,
} = useSettingsStore();
- const { trustedSenderEmails } = useContactStore();
+ const { trustedSenderEmails, trustedSendersLoaded, loadTrustedSendersBook } = useContactStore();
+ const client = useAuthStore((state) => state.client);
+
+ // Load the address book so the count reflects the synced senders, not 0.
+ useEffect(() => {
+ if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
+ loadTrustedSendersBook(client);
+ }
+ }, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]);
const getTrustedSendersCount = () => {
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
@@ -67,7 +76,7 @@ export function ContentSendersSettings() {
updateSetting('trustedSendersAddressBook', checked)}
/>
diff --git a/components/settings/debug-settings.tsx b/components/settings/debug-settings.tsx
index 8418f74a..e1a481c0 100644
--- a/components/settings/debug-settings.tsx
+++ b/components/settings/debug-settings.tsx
@@ -25,7 +25,7 @@ export function DebugSettings() {
{debugMode && (
-
+
{t('debug_categories.description')}
{ALL_DEBUG_CATEGORIES.map((cat) => (
Name
- Size
- Modified
+ Size
+ Modified
{sortedFiles.map((file) => (
{file.name}
-
+
{formatSize(file.size)}
-
+
{file.modified.slice(5)}
@@ -142,7 +142,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
);
const sidebar = settings.folderLayout === "sidebar" && (
-
+
Files
diff --git a/components/settings/filter-settings.tsx b/components/settings/filter-settings.tsx
index 259f7705..88d31f9f 100644
--- a/components/settings/filter-settings.tsx
+++ b/components/settings/filter-settings.tsx
@@ -487,7 +487,7 @@ export function FilterSettings() {
try { localStorage.setItem('settings-active-tab', 'vacation'); } catch { /* ignore */ }
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: 'vacation' }));
}}
- className="flex items-center gap-3 w-full p-3 rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-left"
+ className="flex items-center gap-3 w-full p-3 rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-start"
>
@@ -661,7 +661,7 @@ export function FilterSettings() {
setShowRuleModal(true);
}}
>
-
+
{t("add_rule")}
)}
@@ -670,7 +670,7 @@ export function FilterSettings() {
size="sm"
onClick={() => setShowSieveEditor(true)}
>
-
+
{t("raw_editor")}
diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx
index c4f09aec..98f531ad 100644
--- a/components/settings/folder-settings.tsx
+++ b/components/settings/folder-settings.tsx
@@ -17,7 +17,16 @@ import {
type LucideIcon,
} from 'lucide-react';
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
-import { ChevronRight, ChevronDown } from 'lucide-react';
+import { ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
+import {
+ DndContext, closestCenter, PointerSensor, KeyboardSensor,
+ useSensor, useSensors, type DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ SortableContext, verticalListSortingStrategy, useSortable,
+ arrayMove, sortableKeyboardCoordinates,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
@@ -81,7 +90,7 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
return (
{ICON_CHOICES.map(({ name, icon: Icon }) => (
+
+
+
+ {children}
+
+ );
+}
+
export function FolderSettings() {
const t = useTranslations('settings.folders');
const { client } = useAuthStore();
- const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
+ const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes } = useEmailStore();
+
+ const sensors = useSensors(
+ // Small activation distance so clicking the row's buttons still works.
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
const { folderIcons, setFolderIcon } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
@@ -129,6 +175,31 @@ export function FolderSettings() {
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
const folderTree = buildMailboxTree(ownMailboxes);
+ // Reorder folders within a sibling group (same parent). Drops onto a folder
+ // in a different group are ignored — this reorders, it doesn't reparent.
+ const handleFolderDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id || !client) return;
+
+ const groups: MailboxNode[][] = [];
+ const collectGroups = (nodes: MailboxNode[]) => {
+ groups.push(nodes);
+ nodes.forEach(n => { if (n.children.length > 0) collectGroups(n.children); });
+ };
+ collectGroups(folderTree);
+
+ const group = groups.find(g => g.some(n => n.id === active.id));
+ if (!group) return;
+ const oldIndex = group.findIndex(n => n.id === active.id);
+ const newIndex = group.findIndex(n => n.id === over.id);
+ if (newIndex < 0) return; // dropped outside the active folder's sibling group
+
+ const orderedIds = arrayMove(group, oldIndex, newIndex).map(n => n.id);
+ reorderMailboxes(client, orderedIds).catch(() => {
+ toast.error(t('reorder_error'));
+ });
+ };
+
const getRoleMailboxId = (role: string): string => {
const mb = ownMailboxes.find(m => m.role === role);
return mb?.id ?? '';
@@ -385,6 +456,7 @@ export function FolderSettings() {
return (
+
+
{/* Inline subfolder creation */}
{renderCreateInline(mb.id, depth + 1)}
{/* Render children if expanded */}
{hasChildren && isExpanded && (
- {node.children.map(child => renderFolderNode(child))}
+ c.id)} strategy={verticalListSortingStrategy}>
+ {node.children.map(child => renderFolderNode(child))}
+
)}
@@ -505,7 +580,11 @@ export function FolderSettings() {
{t('no_folders')}
) : (
- folderTree.map(node => renderFolderNode(node))
+
+ n.id)} strategy={verticalListSortingStrategy}>
+ {folderTree.map(node => renderFolderNode(node))}
+
+
)}
diff --git a/components/settings/language-settings.tsx b/components/settings/language-settings.tsx
index 1a5cdf9d..fc46e523 100644
--- a/components/settings/language-settings.tsx
+++ b/components/settings/language-settings.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
-import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
+import type { DateFormat, DateLocale, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
import { formatDate } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
@@ -13,7 +13,7 @@ export function LanguageSettings() {
const t = useTranslations('settings.language_region');
const tDays = useTranslations('calendar.days');
- const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
+ const { dateFormat, dateLocale, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
// Subscribe to locale changes so the preview re-renders on language switch
// (formatDate reads it via getState() and would otherwise stay stale).
@@ -23,7 +23,7 @@ export function LanguageSettings() {
// Build sample timestamps for each bucket so users see what their pick
// will look like in practice. Use offsets relative to "now" so the
// bucketing is stable even though the wall-clock keeps moving.
- void locale; void dateFormat; void timeFormat;
+ void locale; void dateFormat; void dateLocale; void timeFormat;
const now = new Date();
const today = new Date(now);
today.setHours(15, 31, 0, 0);
@@ -38,7 +38,7 @@ export function LanguageSettings() {
thisWeek: formatDate(thisWeek),
older: formatDate(older),
};
- }, [locale, dateFormat, timeFormat]);
+ }, [locale, dateFormat, dateLocale, timeFormat]);
return (
@@ -57,7 +57,7 @@ export function LanguageSettings() {
{ value: 'full', label: t('date_format.full') },
]}
/>
-
+
{t('date_format.preview_today')}
{preview.today}
@@ -74,6 +74,19 @@ export function LanguageSettings() {
+
+ updateSetting('dateLocale', value as DateLocale)}
+ options={[
+ { value: 'auto', label: t('date_locale.auto') },
+ { value: 'iso', label: t('date_locale.iso') },
+ { value: 'en-GB', label: t('date_locale.dmy') },
+ { value: 'en-US', label: t('date_locale.mdy') },
+ ]}
+ />
+
+
updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
options={[
{ value: '1', label: tDays('monday') },
+ { value: '6', label: tDays('saturday') },
{ value: '0', label: tDays('sunday') },
]}
/>
diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx
index 14e39d60..c6112c4e 100644
--- a/components/settings/layout-settings.tsx
+++ b/components/settings/layout-settings.tsx
@@ -38,11 +38,11 @@ function MailLayoutPreview({
-
+
{value === 'split' && (
<>
-
+
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
- const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
- // Cross-account "All accounts" views, each gated independently by the admin.
+ const connectedAccountCount = useMemo(() => accounts.filter(a => a.isConnected).length, [accounts]);
+ const unifiedCrossAccountAllowed = isFeatureEnabled('unifiedCrossAccountEnabled');
+ // Unified Mailbox entries (All mail / Unread / Starred), each gated independently
+ // by the admin. Scope (single account vs. cross-account) is governed by
+ // `unifiedCrossAccount`; the folder picker below narrows which own folders feed them.
const crossViews = [
{ setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' },
{ setting: 'enableCrossStarredView', value: enableCrossStarredView, allowed: isFeatureEnabled('crossStarredViewEnabled'), labelKey: 'cross_starred.label', descKey: 'cross_starred.description' },
{ setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' },
] as const;
+ // The folder picker narrows the own folders included in the entries above; show
+ // it once the user has enabled at least one of them.
+ const anyCrossEnabled = enableCrossUnreadView || enableCrossStarredView || enableCrossAllView;
+ const anyCrossAllowed = crossViews.some(c => c.allowed);
// Own (non-shared) folders and the active account's All Mail selection. The
// selection is per account: a missing entry = never configured, which
@@ -217,6 +224,13 @@ export function LayoutSettings() {
/>
+
+ updateSetting('tintListRowsByTag', checked)}
+ />
+
+
+
+ updateSetting('faviconUnreadBadge', checked)}
+ />
+
+
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
)}
- {enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
+ {enableUnifiedMailbox && connectedAccountCount > 1 && unifiedCrossAccountAllowed && !isSettingHidden('unifiedCrossAccount') && (
+
+ updateSetting('unifiedCrossAccount', v)}
+ />
+
+
+ )}
+
+ {enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
+
)}
- {enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
-
+ {enableUnifiedMailbox && anyCrossAllowed && (
+
+
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
allowed && !isSettingHidden(setting) && (
)}
- {allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
-
- updateSetting('enableAllMailView', v)}
- />
-
- )}
+ {enableUnifiedMailbox && anyCrossAllowed && anyCrossEnabled && (
+
- {allMailViewAllowed && enableAllMailView && (
-
{t('all_mail.folders_label')}
{t('all_mail.folders_description')}
@@ -305,7 +330,7 @@ export function LayoutSettings() {
key={mb.id}
type="button"
onClick={() => toggleAllMailFolder(mb.id)}
- className="w-full flex items-center gap-2.5 py-1.5 px-2 rounded-md hover:bg-muted/50 text-left"
+ className="w-full flex items-center gap-2.5 py-1.5 px-2 rounded-md hover:bg-muted/50 text-start"
role="checkbox"
aria-checked={checked}
>
diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx
index 1a54f8f1..8ce27ab5 100644
--- a/components/settings/reading-settings.tsx
+++ b/components/settings/reading-settings.tsx
@@ -35,6 +35,7 @@ export function ReadingSettings() {
hoverActionsCorner,
hideInlineImageAttachments,
attachmentImagePreviewsEnabled,
+ messageSpacing,
updateSetting,
} = useSettingsStore();
@@ -115,6 +116,20 @@ export function ReadingSettings() {
)}
+ {!isSettingHidden('messageSpacing') && (
+
+ updateSetting('messageSpacing', value as typeof messageSpacing)}
+ options={[
+ { value: 'auto', label: t('message_spacing.auto') },
+ { value: 'always', label: t('message_spacing.always') },
+ { value: 'edge', label: t('message_spacing.edge') },
+ ]}
+ />
+
+ )}
+
{!isSettingHidden('deleteAction') && (
diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx
index a40ccd4e..64d79a0b 100644
--- a/components/settings/settings-section.tsx
+++ b/components/settings/settings-section.tsx
@@ -35,7 +35,7 @@ export function SettingItem({ label, description, children, locked }: SettingIte
data-search-label={label}
className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}
>
-
+
{label}
{locked &&
}
@@ -72,7 +72,7 @@ export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps)
diff --git a/components/settings/share-collection-dialog.tsx b/components/settings/share-collection-dialog.tsx
index db64cd04..c11e1c25 100644
--- a/components/settings/share-collection-dialog.tsx
+++ b/components/settings/share-collection-dialog.tsx
@@ -284,7 +284,7 @@ export function ShareCollectionDialog({
value={preset}
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
disabled={savingId === principalId}
- className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
+ className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{presetOptions.map((p) => (
{t(`preset.${p}`)}
@@ -318,7 +318,7 @@ export function ShareCollectionDialog({
onClick={() => setShowAdd(true)}
className="w-full"
>
-
+
{t("add_person")}
)}
@@ -336,7 +336,7 @@ export function ShareCollectionDialog({
{loadingPrincipals && (
-
+
{t("loading_principals")}
)}
@@ -350,7 +350,7 @@ export function ShareCollectionDialog({
key={p.id}
onClick={() => handleAdd(p)}
disabled={savingId === p.id}
- className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
+ className="w-full text-start px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
>
setShowAddForm(true)}
className="w-full"
>
-