feat: quick actions on email rows (read/unread, star, trash)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = {
|
export const ExportApi = {
|
||||||
reportUrl: (format) => `/api/v1/export/report?format=${format}`
|
reportUrl: (format) => `/api/v1/export/report?format=${format}`
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<tr
|
||||||
|
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}`}
|
||||||
|
onClick={openInGmail}
|
||||||
|
title="Open in Gmail"
|
||||||
|
>
|
||||||
|
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
|
||||||
|
<td className="el-sender" title={email.senderAddress}>
|
||||||
|
{email.senderDisplayName || email.senderAddress}
|
||||||
|
</td>
|
||||||
|
<td className="el-subject">
|
||||||
|
<span className="el-subj-text">{email.subject || '(no subject)'}</span>
|
||||||
|
{email.snippet && <span className="el-snippet"> — {email.snippet}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="el-meta">
|
||||||
|
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
||||||
|
{email.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(email.sizeEstimateBytes)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="el-date">{fmtDate(email.sentAtUtc)}</td>
|
||||||
|
<td className="el-actions" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
title={email.isUnread ? 'Mark as read' : 'Mark as unread'}
|
||||||
|
onClick={email.isUnread
|
||||||
|
? act(EmailApi.markRead, { isUnread: false })
|
||||||
|
: act(EmailApi.markUnread, { isUnread: true })}
|
||||||
|
>{email.isUnread ? '✓' : '●'}</button>
|
||||||
|
<button
|
||||||
|
className={`action-btn${email.isStarred ? ' action-btn--active' : ''}`}
|
||||||
|
title={email.isStarred ? 'Unstar' : 'Star'}
|
||||||
|
onClick={email.isStarred
|
||||||
|
? act(EmailApi.unstar, { isStarred: false })
|
||||||
|
: act(EmailApi.star, { isStarred: true })}
|
||||||
|
>⭐</button>
|
||||||
|
<button
|
||||||
|
className="action-btn action-btn--danger"
|
||||||
|
title="Move to trash"
|
||||||
|
onClick={handleTrash}
|
||||||
|
>🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { SearchApi } from '../api/client.js';
|
import { SearchApi } from '../api/client.js';
|
||||||
|
import EmailRow from '../components/EmailRow.jsx';
|
||||||
|
|
||||||
const FOLDER_META = {
|
const FOLDER_META = {
|
||||||
inbox: { icon: '📥', label: 'Inbox' },
|
inbox: { icon: '📥', label: 'Inbox' },
|
||||||
@@ -31,20 +32,6 @@ const FOLDER_META = {
|
|||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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() {
|
export default function FolderView() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
@@ -119,30 +106,11 @@ export default function FolderView() {
|
|||||||
<table className="email-list">
|
<table className="email-list">
|
||||||
<tbody>
|
<tbody>
|
||||||
{emails.map((e) => (
|
{emails.map((e) => (
|
||||||
<tr
|
<EmailRow
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className={`email-row${e.isUnread ? ' email-row--unread' : ''}`}
|
email={e}
|
||||||
onClick={() => window.open(
|
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||||
`https://mail.google.com/mail/u/0/#all/${e.gmailMessageId}`,
|
/>
|
||||||
'_blank',
|
|
||||||
'noopener,noreferrer'
|
|
||||||
)}
|
|
||||||
title="Open in Gmail"
|
|
||||||
>
|
|
||||||
<td className="el-unread">{e.isUnread && <span className="unread-dot" />}</td>
|
|
||||||
<td className="el-sender" title={e.senderAddress}>
|
|
||||||
{e.senderDisplayName || e.senderAddress}
|
|
||||||
</td>
|
|
||||||
<td className="el-subject">
|
|
||||||
<span className="el-subj-text">{e.subject || '(no subject)'}</span>
|
|
||||||
{e.snippet && <span className="el-snippet"> — {e.snippet}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="el-meta">
|
|
||||||
{e.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
|
||||||
{e.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(e.sizeEstimateBytes)}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="el-date">{fmtDate(e.sentAtUtc)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,23 +1,10 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { SearchApi } from '../api/client.js';
|
import { SearchApi } from '../api/client.js';
|
||||||
|
import EmailRow from '../components/EmailRow.jsx';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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() {
|
export default function SearchResults() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -87,30 +74,11 @@ export default function SearchResults() {
|
|||||||
<table className="email-list">
|
<table className="email-list">
|
||||||
<tbody>
|
<tbody>
|
||||||
{emails.map((e) => (
|
{emails.map((e) => (
|
||||||
<tr
|
<EmailRow
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className={`email-row${e.isUnread ? ' email-row--unread' : ''}`}
|
email={e}
|
||||||
onClick={() => window.open(
|
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||||
`https://mail.google.com/mail/u/0/#all/${e.gmailMessageId}`,
|
/>
|
||||||
'_blank',
|
|
||||||
'noopener,noreferrer'
|
|
||||||
)}
|
|
||||||
title="Open in Gmail"
|
|
||||||
>
|
|
||||||
<td className="el-unread">{e.isUnread && <span className="unread-dot" />}</td>
|
|
||||||
<td className="el-sender" title={e.senderAddress}>
|
|
||||||
{e.senderDisplayName || e.senderAddress}
|
|
||||||
</td>
|
|
||||||
<td className="el-subject">
|
|
||||||
<span className="el-subj-text">{e.subject || '(no subject)'}</span>
|
|
||||||
{e.snippet && <span className="el-snippet"> — {e.snippet}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="el-meta">
|
|
||||||
{e.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
|
||||||
{e.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(e.sizeEstimateBytes)}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="el-date">{fmtDate(e.sentAtUtc)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -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-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-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-sentinel { height: 1px; }
|
||||||
.fv-loading-more { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
.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; }
|
.fv-end { color: var(--muted); font-size: 12px; padding: 20px 0 8px; text-align: center; }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using InboxIntel.Application.Abstractions;
|
||||||
|
using InboxIntel.Application.DTOs;
|
||||||
|
using InboxIntel.Domain.Enums;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace InboxIntel.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class EmailController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ICleanupService _cleanup;
|
||||||
|
public EmailController(ICleanupService cleanup) => _cleanup = cleanup;
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/read")]
|
||||||
|
public Task<IActionResult> MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/unread")]
|
||||||
|
public Task<IActionResult> MarkUnread(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkUnread, ct);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/star")]
|
||||||
|
public Task<IActionResult> Star(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Star, ct);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/unstar")]
|
||||||
|
public Task<IActionResult> Unstar(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Unstar, ct);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/trash")]
|
||||||
|
public Task<IActionResult> Trash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Trash, ct);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/untrash")]
|
||||||
|
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
|
||||||
|
|
||||||
|
private async Task<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,9 @@ public enum CleanupActionType
|
|||||||
AddLabel = 3,
|
AddLabel = 3,
|
||||||
RemoveLabel = 4,
|
RemoveLabel = 4,
|
||||||
MarkRead = 5,
|
MarkRead = 5,
|
||||||
MarkUnread = 6
|
MarkUnread = 6,
|
||||||
|
Star = 7,
|
||||||
|
Unstar = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum UnsubscribeMethod
|
public enum UnsubscribeMethod
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ public class CleanupService : ICleanupService
|
|||||||
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { "UNREAD" }, Array.Empty<string>(), ct);
|
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { "UNREAD" }, Array.Empty<string>(), ct);
|
||||||
emails.ForEach(e => e.IsUnread = true);
|
emails.ForEach(e => e.IsUnread = true);
|
||||||
break;
|
break;
|
||||||
|
case CleanupActionType.Star:
|
||||||
|
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { "STARRED" }, Array.Empty<string>(), ct);
|
||||||
|
emails.ForEach(e => e.IsStarred = true);
|
||||||
|
break;
|
||||||
|
case CleanupActionType.Unstar:
|
||||||
|
await _gmail.BatchModifyAsync(userId, gmailIds, Array.Empty<string>(), new[] { "STARRED" }, ct);
|
||||||
|
emails.ForEach(e => e.IsStarred = false);
|
||||||
|
break;
|
||||||
case CleanupActionType.AddLabel:
|
case CleanupActionType.AddLabel:
|
||||||
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { request.LabelId! }, Array.Empty<string>(), ct);
|
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { request.LabelId! }, Array.Empty<string>(), ct);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user