diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 14ab996..8329a08 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -51,6 +51,21 @@ export const CleanupApi = {
execute: (req) => api.post('/cleanup/execute', req).then((r) => r.data)
};
+// Bulk actions over an explicit set of email IDs (selection-driven, always confirmed —
+// the user already opted in by selecting rows and clicking the action).
+// Numeric values must match CleanupActionType in Domain/Enums/Enums.cs (no string
+// enum converter is configured on the API, so plain numbers are required here).
+const CLEANUP_ACTION = { Archive: 0, Trash: 1, MarkRead: 5, MarkUnread: 6, Star: 7, Unstar: 8 };
+
+export const BulkApi = {
+ markRead: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.MarkRead, emailIds: ids, confirmed: true }),
+ markUnread: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.MarkUnread, emailIds: ids, confirmed: true }),
+ star: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Star, emailIds: ids, confirmed: true }),
+ unstar: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Unstar, emailIds: ids, confirmed: true }),
+ archive: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Archive, emailIds: ids, confirmed: true }),
+ trash: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Trash, emailIds: ids, confirmed: true }),
+};
+
export const UnsubscribeApi = {
detect: () => api.post('/unsubscribe/detect'),
safeList: () => api.get('/unsubscribe/safe-list').then((r) => r.data),
diff --git a/frontend/src/components/BulkToolbar.jsx b/frontend/src/components/BulkToolbar.jsx
new file mode 100644
index 0000000..ef2bcfe
--- /dev/null
+++ b/frontend/src/components/BulkToolbar.jsx
@@ -0,0 +1,30 @@
+import { BulkApi } from '../api/client.js';
+
+///
+/// Toolbar shown above an email list when one or more rows are selected.
+/// `onDone` is called with the action key after a successful bulk call so the
+/// caller can update local state (e.g. remove trashed/archived rows).
+///
+export default function BulkToolbar({ selectedIds, onDone, onClear }) {
+ const count = selectedIds.length;
+ if (count === 0) return null;
+
+ const run = (fn, action) => async () => {
+ await fn(selectedIds);
+ onDone(action, selectedIds);
+ onClear();
+ };
+
+ return (
+
+
{count} selected
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/EmailRow.jsx b/frontend/src/components/EmailRow.jsx
index 8fc63fb..d7819b5 100644
--- a/frontend/src/components/EmailRow.jsx
+++ b/frontend/src/components/EmailRow.jsx
@@ -16,7 +16,7 @@ const fmtSize = (b) => {
return `${(b / 1048576).toFixed(1)} MB`;
};
-export default function EmailRow({ email: initial, onRemove }) {
+export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect }) {
const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false);
@@ -72,6 +72,11 @@ export default function EmailRow({ email: initial, onRemove }) {
onClick={openInGmail}
title="Open in Gmail"
>
+ {onToggleSelect && (
+ e.stopPropagation()}>
+ onToggleSelect(email.id)} />
+ |
+ )}
{email.isUnread && } |
{email.senderDisplayName || email.senderAddress}
diff --git a/frontend/src/hooks/useSelection.js b/frontend/src/hooks/useSelection.js
new file mode 100644
index 0000000..9fd6c4b
--- /dev/null
+++ b/frontend/src/hooks/useSelection.js
@@ -0,0 +1,26 @@
+import { useCallback, useState } from 'react';
+
+/// Tracks a set of selected row IDs for bulk actions. Resets on `resetKey` change.
+export default function useSelection() {
+ const [selected, setSelected] = useState(() => new Set());
+
+ const toggle = useCallback((id) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const clear = useCallback(() => setSelected(new Set()), []);
+
+ const removeIds = useCallback((ids) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ ids.forEach((id) => next.delete(id));
+ return next;
+ });
+ }, []);
+
+ return { selected, toggle, clear, removeIds, selectedIds: [...selected] };
+}
diff --git a/frontend/src/pages/FolderView.jsx b/frontend/src/pages/FolderView.jsx
index 2d0107c..fb56f64 100644
--- a/frontend/src/pages/FolderView.jsx
+++ b/frontend/src/pages/FolderView.jsx
@@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx';
+import BulkToolbar from '../components/BulkToolbar.jsx';
+import useSelection from '../hooks/useSelection.js';
const FOLDER_META = {
inbox: { icon: '📥', label: 'Inbox' },
@@ -43,6 +45,7 @@ export default function FolderView() {
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
// Reset when the folder changes
useEffect(() => {
@@ -51,7 +54,8 @@ export default function FolderView() {
setPage(1);
setHasMore(true);
setError(null);
- }, [slug]);
+ clear();
+ }, [slug, clear]);
// Fetch a page and append results
const fetchPage = useCallback((p) => {
@@ -98,6 +102,17 @@ export default function FolderView() {
{error && {error} }
+ {
+ if (action === 'trash' || action === 'archive') {
+ setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
+ removeIds(ids);
+ }
+ }}
+ />
+
{!loading && !error && emails.length === 0 && (
No emails in this folder.
)}
@@ -109,6 +124,8 @@ export default function FolderView() {
setEmails((prev) => prev.filter((x) => x.id !== id))}
/>
))}
diff --git a/frontend/src/pages/SearchResults.jsx b/frontend/src/pages/SearchResults.jsx
index 56c01c9..fb501fe 100644
--- a/frontend/src/pages/SearchResults.jsx
+++ b/frontend/src/pages/SearchResults.jsx
@@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx';
+import BulkToolbar from '../components/BulkToolbar.jsx';
+import useSelection from '../hooks/useSelection.js';
const PAGE_SIZE = 50;
@@ -16,6 +18,7 @@ export default function SearchResults() {
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
useEffect(() => {
setEmails([]);
@@ -23,7 +26,8 @@ export default function SearchResults() {
setPage(1);
setHasMore(true);
setError(null);
- }, [q]);
+ clear();
+ }, [q, clear]);
const fetchPage = useCallback((p) => {
if (!q.trim()) return;
@@ -70,6 +74,17 @@ export default function SearchResults() {
No results for "{q}".
)}
+ {
+ if (action === 'trash' || action === 'archive') {
+ setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
+ removeIds(ids);
+ }
+ }}
+ />
+
{emails.length > 0 && (
@@ -77,6 +92,8 @@ export default function SearchResults() {
setEmails((prev) => prev.filter((x) => x.id !== id))}
/>
))}
diff --git a/frontend/src/pages/Senders.jsx b/frontend/src/pages/Senders.jsx
index 688b53d..e432dc3 100644
--- a/frontend/src/pages/Senders.jsx
+++ b/frontend/src/pages/Senders.jsx
@@ -1,5 +1,7 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js';
+import BulkToolbar from '../components/BulkToolbar.jsx';
+import useSelection from '../hooks/useSelection.js';
// ── helpers ──────────────────────────────────────────────────────────────────
@@ -123,7 +125,7 @@ function EmailDetail({ email: summary, onBack }) {
// ── EmailListRow (row inside sender email list) ───────────────────────────────
-function EmailListRow({ email: initial, onRemove, onOpen }) {
+function EmailListRow({ email: initial, onRemove, onOpen, selected, onToggleSelect }) {
const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false);
@@ -150,6 +152,9 @@ function EmailListRow({ email: initial, onRemove, onOpen }) {
title="View email"
style={{ cursor: 'pointer' }}
>
+ | e.stopPropagation()}>
+ onToggleSelect(email.id)} />
+ |
{email.isUnread && } |
{email.subject || '(no subject)'}
@@ -195,6 +200,7 @@ function SenderEmails({ sender }) {
const [loading, setLoading] = useState(false);
const [openEmail, setOpenEmail] = useState(null);
const sentinelRef = useRef(null);
+ const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
// Reset when sender changes
useEffect(() => {
@@ -203,7 +209,8 @@ function SenderEmails({ sender }) {
setPage(1);
setHasMore(true);
setOpenEmail(null);
- }, [sender.senderId]);
+ clear();
+ }, [sender.senderId, clear]);
const fetchPage = useCallback((p) => {
setLoading(true);
@@ -245,6 +252,17 @@ function SenderEmails({ sender }) {
)}
+ {
+ if (action === 'trash' || action === 'archive') {
+ setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
+ removeIds(ids);
+ }
+ }}
+ />
+
{emails.length > 0 && (
@@ -252,6 +270,8 @@ function SenderEmails({ sender }) {
setEmails((prev) => prev.filter((x) => x.id !== id))}
/>
| |