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/email-list-item.tsx b/components/email/email-list-item.tsx
index 2ee2ca84..27fb5c64 100644
--- a/components/email/email-list-item.tsx
+++ b/components/email/email-list-item.tsx
@@ -5,7 +5,7 @@ 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 { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
@@ -34,6 +34,7 @@ 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);
@@ -180,12 +181,15 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{/* Avatar */}
{density !== 'extra-compact' && (
- toggleEmailSelection(email.id)}
+ selectLabel={tBatch('select')}
/>
)}
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 (
+
+ );
+}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index 2e9caac6..b6cf70bc 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -4,7 +4,7 @@ import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
-import { Avatar } from "@/components/ui/avatar";
+import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, 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";
@@ -75,6 +75,7 @@ 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 isAnswered = email.keywords?.$answered;
@@ -221,12 +222,15 @@ const SingleEmailItem = React.forwardRef(
)}
{density !== 'extra-compact' && (
- toggleEmailSelection(email.id)}
+ selectLabel={tBatch('select')}
/>
)}
@@ -431,6 +435,7 @@ export const ThreadListItem = React.forwardRef state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -515,13 +520,8 @@ export const ThreadListItem = React.forwardRef {
- e.stopPropagation();
- if (e.shiftKey) {
- selectRangeEmails(latestEmail.id);
- return;
- }
- // 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 => {
@@ -534,6 +534,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();
@@ -633,12 +642,15 @@ export const ThreadListItem = React.forwardRef
-
{!isMobile && !isFocusedMailLayout && (