From 2f6d6abdd39518f4bab818b0eab861c4593f882f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 20:18:44 +0200 Subject: [PATCH] feat: quick actions on email rows (read/unread, star, trash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmailRow shared component with hover action buttons: mark read/unread, star/unstar, trash - Optimistic UI — local state patches immediately on click; row fades and becomes non-interactive during the API call - Trashed rows disappear from the current folder view via onRemove callback - Backend: EmailController single-email endpoints (POST /email/{id}/read|unread|star|unstar|trash|untrash) - Star/Unstar added to CleanupActionType enum and CleanupService (maps to STARRED Gmail label via BatchModifyAsync) - UserId scope enforced in ResolveTargetsAsync — a user can only act on their own emails - action buttons use stopPropagation so clicking them does not open Gmail Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.js | 9 ++ frontend/src/components/EmailRow.jsx | 95 +++++++++++++++++++ frontend/src/pages/FolderView.jsx | 42 +------- frontend/src/pages/SearchResults.jsx | 42 +------- frontend/src/styles.css | 12 +++ .../Controllers/EmailController.cs | 44 +++++++++ src/InboxIntel.Domain/Enums/Enums.cs | 4 +- .../Cleanup/CleanupService.cs | 8 ++ 8 files changed, 181 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/EmailRow.jsx create mode 100644 src/InboxIntel.Api/Controllers/EmailController.cs 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" - > - - - - - - + email={e} + onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} + /> ))}
{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)}
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" - > - - - - - - + email={e} + onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} + /> ))}
{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)}
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;