diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 4f48e19..697024e 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -98,6 +98,15 @@ function folderToRequest(slug, page, pageSize) {
}
}
+export const EmailApi = {
+ markRead: (id) => api.post(`/email/${id}/read`),
+ markUnread: (id) => api.post(`/email/${id}/unread`),
+ star: (id) => api.post(`/email/${id}/star`),
+ unstar: (id) => api.post(`/email/${id}/unstar`),
+ trash: (id) => api.post(`/email/${id}/trash`),
+ untrash: (id) => api.post(`/email/${id}/untrash`),
+};
+
export const ExportApi = {
reportUrl: (format) => `/api/v1/export/report?format=${format}`
};
diff --git a/frontend/src/components/EmailRow.jsx b/frontend/src/components/EmailRow.jsx
new file mode 100644
index 0000000..ef2be84
--- /dev/null
+++ b/frontend/src/components/EmailRow.jsx
@@ -0,0 +1,95 @@
+import { useState } from 'react';
+import { EmailApi } from '../api/client.js';
+
+const fmtDate = (iso) => {
+ const d = new Date(iso);
+ const now = new Date();
+ const diffDays = (now - d) / 86400000;
+ if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
+};
+
+const fmtSize = (b) => {
+ if (b < 1024) return `${b} B`;
+ if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
+ return `${(b / 1048576).toFixed(1)} MB`;
+};
+
+export default function EmailRow({ email: initial, onRemove }) {
+ const [email, setEmail] = useState(initial);
+ const [acting, setActing] = useState(false);
+
+ const act = (fn, patch) => async (e) => {
+ e.stopPropagation();
+ if (acting) return;
+ setActing(true);
+ try {
+ await fn(email.id);
+ setEmail((prev) => ({ ...prev, ...patch }));
+ } finally {
+ setActing(false);
+ }
+ };
+
+ const handleTrash = async (e) => {
+ e.stopPropagation();
+ if (acting) return;
+ setActing(true);
+ try {
+ await EmailApi.trash(email.id);
+ onRemove?.(email.id);
+ } finally {
+ setActing(false);
+ }
+ };
+
+ const openInGmail = () => window.open(
+ `https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
+ '_blank',
+ 'noopener,noreferrer'
+ );
+
+ return (
+
+ | {email.isUnread && } |
+
+ {email.senderDisplayName || email.senderAddress}
+ |
+
+ {email.subject || '(no subject)'}
+ {email.snippet && — {email.snippet}}
+ |
+
+ {email.hasAttachments && 📎}
+ {email.sizeEstimateBytes > 1048576 && {fmtSize(email.sizeEstimateBytes)}}
+ |
+ {fmtDate(email.sentAtUtc)} |
+ e.stopPropagation()}>
+
+
+
+ |
+
+ );
+}
diff --git a/frontend/src/pages/FolderView.jsx b/frontend/src/pages/FolderView.jsx
index adfe8e3..2d0107c 100644
--- a/frontend/src/pages/FolderView.jsx
+++ b/frontend/src/pages/FolderView.jsx
@@ -1,6 +1,7 @@
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';
const FOLDER_META = {
inbox: { icon: '📥', label: 'Inbox' },
@@ -31,20 +32,6 @@ const FOLDER_META = {
const PAGE_SIZE = 50;
-const fmtDate = (iso) => {
- const d = new Date(iso);
- const now = new Date();
- const diffDays = (now - d) / 86400000;
- if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
- return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
-};
-
-const fmtSize = (b) => {
- if (b < 1024) return `${b} B`;
- if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
- return `${(b / 1048576).toFixed(1)} MB`;
-};
export default function FolderView() {
const { slug } = useParams();
@@ -119,30 +106,11 @@ export default function FolderView() {
{emails.map((e) => (
- window.open(
- `https://mail.google.com/mail/u/0/#all/${e.gmailMessageId}`,
- '_blank',
- 'noopener,noreferrer'
- )}
- title="Open in Gmail"
- >
- | {e.isUnread && } |
-
- {e.senderDisplayName || e.senderAddress}
- |
-
- {e.subject || '(no subject)'}
- {e.snippet && — {e.snippet}}
- |
-
- {e.hasAttachments && 📎}
- {e.sizeEstimateBytes > 1048576 && {fmtSize(e.sizeEstimateBytes)}}
- |
- {fmtDate(e.sentAtUtc)} |
-
+ email={e}
+ onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
+ />
))}
diff --git a/frontend/src/pages/SearchResults.jsx b/frontend/src/pages/SearchResults.jsx
index 7ba9e3b..56c01c9 100644
--- a/frontend/src/pages/SearchResults.jsx
+++ b/frontend/src/pages/SearchResults.jsx
@@ -1,23 +1,10 @@
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';
const PAGE_SIZE = 50;
-const fmtDate = (iso) => {
- const d = new Date(iso);
- const now = new Date();
- const diffDays = (now - d) / 86400000;
- if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
- return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
-};
-
-const fmtSize = (b) => {
- if (b < 1024) return `${b} B`;
- if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
- return `${(b / 1048576).toFixed(1)} MB`;
-};
export default function SearchResults() {
const [searchParams] = useSearchParams();
@@ -87,30 +74,11 @@ export default function SearchResults() {
{emails.map((e) => (
- window.open(
- `https://mail.google.com/mail/u/0/#all/${e.gmailMessageId}`,
- '_blank',
- 'noopener,noreferrer'
- )}
- title="Open in Gmail"
- >
- | {e.isUnread && } |
-
- {e.senderDisplayName || e.senderAddress}
- |
-
- {e.subject || '(no subject)'}
- {e.snippet && — {e.snippet}}
- |
-
- {e.hasAttachments && 📎}
- {e.sizeEstimateBytes > 1048576 && {fmtSize(e.sizeEstimateBytes)}}
- |
- {fmtDate(e.sentAtUtc)} |
-
+ email={e}
+ onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
+ />
))}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 38a04c3..111da29 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -239,6 +239,18 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.el-size { font-size: 11px; color: var(--muted); }
.el-date { width: 70px; padding: 10px 0 10px 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; }
+.el-actions { width: 80px; padding: 0 6px; text-align: right; white-space: nowrap; }
+.action-btn {
+ background: none; border: none; padding: 3px 4px; cursor: pointer;
+ font-size: 13px; opacity: 0; transition: opacity 0.1s, color 0.1s;
+ border-radius: 4px; color: var(--muted);
+}
+.action-btn:hover { background: var(--panel-2); opacity: 1 !important; }
+.action-btn--active { opacity: 1 !important; }
+.action-btn--danger:hover { color: var(--danger); }
+.email-row:hover .action-btn { opacity: 0.6; }
+.email-row--acting { opacity: 0.6; pointer-events: none; }
+
.fv-sentinel { height: 1px; }
.fv-loading-more { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
.fv-end { color: var(--muted); font-size: 12px; padding: 20px 0 8px; text-align: center; }
diff --git a/src/InboxIntel.Api/Controllers/EmailController.cs b/src/InboxIntel.Api/Controllers/EmailController.cs
new file mode 100644
index 0000000..57241d9
--- /dev/null
+++ b/src/InboxIntel.Api/Controllers/EmailController.cs
@@ -0,0 +1,44 @@
+using InboxIntel.Application.Abstractions;
+using InboxIntel.Application.DTOs;
+using InboxIntel.Domain.Enums;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InboxIntel.Api.Controllers;
+
+///
+/// Single-email quick actions. All writes are scoped to the authenticated user's
+/// UserId so one user cannot mutate another user's email.
+/// Read/star are non-destructive and execute without confirmation.
+/// Trash is reversible and also executes without a separate confirm step —
+/// the single-email context makes the intent unambiguous.
+///
+public class EmailController : ApiControllerBase
+{
+ private readonly ICleanupService _cleanup;
+ public EmailController(ICleanupService cleanup) => _cleanup = cleanup;
+
+ [HttpPost("{id:guid}/read")]
+ public Task MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
+
+ [HttpPost("{id:guid}/unread")]
+ public Task MarkUnread(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkUnread, ct);
+
+ [HttpPost("{id:guid}/star")]
+ public Task Star(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Star, ct);
+
+ [HttpPost("{id:guid}/unstar")]
+ public Task Unstar(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Unstar, ct);
+
+ [HttpPost("{id:guid}/trash")]
+ public Task Trash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Trash, ct);
+
+ [HttpPost("{id:guid}/untrash")]
+ public Task Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
+
+ private async Task Act(Guid id, CleanupActionType action, CancellationToken ct)
+ {
+ var req = new CleanupRequestDto(action, new[] { id }, null, null, Confirmed: true);
+ var result = await _cleanup.ExecuteAsync(UserId, req, ct);
+ return result.Succeeded ? Ok() : BadRequest(new { error = result.Error });
+ }
+}
diff --git a/src/InboxIntel.Domain/Enums/Enums.cs b/src/InboxIntel.Domain/Enums/Enums.cs
index 7bb5234..1b616d7 100644
--- a/src/InboxIntel.Domain/Enums/Enums.cs
+++ b/src/InboxIntel.Domain/Enums/Enums.cs
@@ -38,7 +38,9 @@ public enum CleanupActionType
AddLabel = 3,
RemoveLabel = 4,
MarkRead = 5,
- MarkUnread = 6
+ MarkUnread = 6,
+ Star = 7,
+ Unstar = 8
}
public enum UnsubscribeMethod
diff --git a/src/InboxIntel.Infrastructure/Cleanup/CleanupService.cs b/src/InboxIntel.Infrastructure/Cleanup/CleanupService.cs
index c1030b5..3a1d423 100644
--- a/src/InboxIntel.Infrastructure/Cleanup/CleanupService.cs
+++ b/src/InboxIntel.Infrastructure/Cleanup/CleanupService.cs
@@ -78,6 +78,14 @@ public class CleanupService : ICleanupService
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { "UNREAD" }, Array.Empty(), ct);
emails.ForEach(e => e.IsUnread = true);
break;
+ case CleanupActionType.Star:
+ await _gmail.BatchModifyAsync(userId, gmailIds, new[] { "STARRED" }, Array.Empty(), ct);
+ emails.ForEach(e => e.IsStarred = true);
+ break;
+ case CleanupActionType.Unstar:
+ await _gmail.BatchModifyAsync(userId, gmailIds, Array.Empty(), new[] { "STARRED" }, ct);
+ emails.ForEach(e => e.IsStarred = false);
+ break;
case CleanupActionType.AddLabel:
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { request.LabelId! }, Array.Empty(), ct);
break;