feat: sync status indicator, unsubscribe queue UI, email body preview
SyncStatus widget polls /sync/status and shows a live progress bar in
the topbar while syncing, or last-synced time / error otherwise.
Unsubscribe page reworked into a proper queue: status filter tabs
(All/Pending/Succeeded/Failed), select-all, sorted by volume, status
badges, and a result toast after processing.
GET /email/{id} returns full BodyText; Senders detail view fetches
and renders it instead of just the snippet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,7 @@ function folderToRequest(slug, page, pageSize) {
|
||||
}
|
||||
|
||||
export const EmailApi = {
|
||||
get: (id) => api.get(`/email/${id}`).then((r) => r.data),
|
||||
markRead: (id) => api.post(`/email/${id}/read`),
|
||||
markUnread: (id) => api.post(`/email/${id}/unread`),
|
||||
star: (id) => api.post(`/email/${id}/star`),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
|
||||
import Logo from './Logo.jsx';
|
||||
import DevBanner from './DevBanner.jsx';
|
||||
import SyncStatus from './SyncStatus.jsx';
|
||||
|
||||
// ── Folder definitions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -236,6 +237,7 @@ export default function Layout() {
|
||||
<button type="submit" className="search-btn" aria-label="Search">🔍</button>
|
||||
</form>
|
||||
<div className="spacer" />
|
||||
<SyncStatus />
|
||||
<button onClick={startSync}>Sync now</button>
|
||||
<span className="user">{user?.email}</span>
|
||||
<button className="ghost" onClick={logout}>Log out</button>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SyncApi } from '../api/client.js';
|
||||
|
||||
const POLL_INTERVAL_IDLE = 60_000; // 1 min when not syncing
|
||||
const POLL_INTERVAL_ACTIVE = 2_000; // 2 sec while running
|
||||
|
||||
function fmtAge(iso) {
|
||||
if (!iso) return null;
|
||||
const mins = Math.floor((Date.now() - new Date(iso)) / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function SyncStatus() {
|
||||
const [progress, setProgress] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
|
||||
const fetch = () => {
|
||||
SyncApi.status().then((p) => {
|
||||
setProgress(p);
|
||||
const next = p.isRunning ? POLL_INTERVAL_ACTIVE : POLL_INTERVAL_IDLE;
|
||||
timer = setTimeout(fetch, next);
|
||||
}).catch(() => {
|
||||
timer = setTimeout(fetch, POLL_INTERVAL_IDLE);
|
||||
});
|
||||
};
|
||||
|
||||
fetch();
|
||||
|
||||
// Also refresh when a sync is manually kicked off
|
||||
const handler = () => { clearTimeout(timer); fetch(); };
|
||||
window.addEventListener('inboxintel:sync-started', handler);
|
||||
|
||||
return () => { clearTimeout(timer); window.removeEventListener('inboxintel:sync-started', handler); };
|
||||
}, []);
|
||||
|
||||
if (!progress) return null;
|
||||
|
||||
if (progress.isRunning) {
|
||||
const pct = progress.total > 0
|
||||
? Math.round((progress.processed / progress.total) * 100)
|
||||
: null;
|
||||
return (
|
||||
<div className="sync-status sync-status--running" title="Sync in progress">
|
||||
<span className="sync-spinner" />
|
||||
<span className="sync-label">
|
||||
Syncing{pct !== null ? ` ${pct}%` : '…'}
|
||||
</span>
|
||||
{progress.total > 0 && (
|
||||
<span className="sync-bar-wrap">
|
||||
<span className="sync-bar" style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const age = fmtAge(progress.lastSuccessfulSyncUtc);
|
||||
return (
|
||||
<div
|
||||
className={`sync-status${progress.lastError ? ' sync-status--error' : ''}`}
|
||||
title={progress.lastError ?? (age ? `Last synced ${age}` : 'Never synced')}
|
||||
>
|
||||
{progress.lastError
|
||||
? <span className="sync-label sync-label--err">⚠ Sync error</span>
|
||||
: <span className="sync-label">{age ? `Synced ${age}` : 'Never synced'}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,21 @@ function SenderList({ senders, selectedId, onSelect, search, onSearch }) {
|
||||
|
||||
// ── EmailDetail (full single-email view) ──────────────────────────────────────
|
||||
|
||||
function EmailDetail({ email, onBack }) {
|
||||
function EmailDetail({ email: summary, onBack }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
EmailApi.get(summary.id)
|
||||
.then(setDetail)
|
||||
.catch(() => setDetail(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [summary.id]);
|
||||
|
||||
const email = detail ?? summary;
|
||||
|
||||
return (
|
||||
<div className="sd-detail">
|
||||
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
||||
@@ -83,9 +97,17 @@ function EmailDetail({ email, onBack }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{email.snippet && (
|
||||
|
||||
{loading && <div className="sd-body-loading muted">Loading message…</div>}
|
||||
|
||||
{!loading && detail?.bodyText && (
|
||||
<div className="sd-body">{detail.bodyText}</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail?.bodyText && email.snippet && (
|
||||
<div className="sd-snippet">{email.snippet}</div>
|
||||
)}
|
||||
|
||||
<div className="sd-detail-actions">
|
||||
<button
|
||||
className="btn-sm"
|
||||
|
||||
@@ -1,49 +1,121 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UnsubscribeApi } from '../api/client.js';
|
||||
|
||||
const METHOD = ['None', 'HTTP link', 'mailto', 'One-click'];
|
||||
const METHOD = ['—', 'HTTP link', 'mailto', 'One-click'];
|
||||
const STATUS = ['Detected', 'Queued', 'In progress', 'Succeeded', 'Failed', 'Skipped'];
|
||||
const STATUS_CLASS = ['detected', 'queued', 'progress', 'ok', 'fail', 'skip'];
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'pending', label: 'Pending', statuses: [0, 1, 2] },
|
||||
{ key: 'succeeded', label: 'Succeeded', statuses: [3] },
|
||||
{ key: 'failed', label: 'Failed', statuses: [4] },
|
||||
];
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filter, setFilter] = useState('pending');
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const load = async () => setItems(await UnsubscribeApi.safeList());
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const detect = async () => { setBusy(true); try { await UnsubscribeApi.detect(); await load(); } finally { setBusy(false); } };
|
||||
const visible = useMemo(() => {
|
||||
const f = FILTERS.find((f) => f.key === filter);
|
||||
const list = !f?.statuses ? items : items.filter((i) => f.statuses.includes(i.status));
|
||||
return [...list].sort((a, b) => b.emailCount - a.emailCount);
|
||||
}, [items, filter]);
|
||||
|
||||
const allVisibleSelected = visible.length > 0 && visible.every((it) => selected[it.id]);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
setSelected((prev) => {
|
||||
const next = { ...prev };
|
||||
visible.forEach((it) => { next[it.id] = !allVisibleSelected; });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const detect = async () => {
|
||||
setBusy(true);
|
||||
try { await UnsubscribeApi.detect(); await load(); setToast('Scan complete.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const process = async () => {
|
||||
const ids = Object.keys(selected).filter((k) => selected[k]);
|
||||
if (!ids.length) return;
|
||||
if (!window.confirm(`Unsubscribe from ${ids.length} sender(s)?`)) return;
|
||||
if (!window.confirm(`Unsubscribe from ${ids.length} sender(s)? This cannot be undone for one-click unsubscribes.`)) return;
|
||||
setBusy(true);
|
||||
try { await UnsubscribeApi.process({ itemIds: ids, confirmed: true }); await load(); setSelected({}); }
|
||||
finally { setBusy(false); }
|
||||
try {
|
||||
const result = await UnsubscribeApi.process({ itemIds: ids, confirmed: true });
|
||||
await load();
|
||||
setSelected({});
|
||||
setToast(`${result.succeededCount} succeeded, ${result.failedCount} failed.`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page unsub-page">
|
||||
<h2>Unsubscribe Manager</h2>
|
||||
<p className="muted">Review senders you can unsubscribe from, queue them up, and track results.</p>
|
||||
|
||||
<div className="form-row">
|
||||
<button onClick={detect} disabled={busy}>Re-scan for subscriptions</button>
|
||||
<button onClick={process} disabled={busy} className="danger">Unsubscribe selected</button>
|
||||
<button onClick={detect} disabled={busy}>{busy ? 'Scanning…' : 'Re-scan for subscriptions'}</button>
|
||||
<button onClick={process} disabled={busy || selectedCount === 0} className="danger">
|
||||
Unsubscribe selected{selectedCount > 0 ? ` (${selectedCount})` : ''}
|
||||
</button>
|
||||
{toast && <span className="unsub-toast">{toast}</span>}
|
||||
</div>
|
||||
<table className="grid">
|
||||
<thead><tr><th></th><th>Sender</th><th>Domain</th><th>Method</th><th>Emails</th><th>Status</th></tr></thead>
|
||||
|
||||
<div className="unsub-tabs">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`unsub-tab${filter === f.key ? ' unsub-tab--active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
<span className="unsub-tab-count">
|
||||
{f.statuses ? items.filter((i) => f.statuses.includes(i.status)).length : items.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table className="grid unsub-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAll} disabled={visible.length === 0} /></th>
|
||||
<th>Sender</th>
|
||||
<th>Domain</th>
|
||||
<th>Method</th>
|
||||
<th className="num">Emails</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
{visible.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td><input type="checkbox" checked={!!selected[it.id]} onChange={(e) => setSelected({ ...selected, [it.id]: e.target.checked })} /></td>
|
||||
<td>{it.senderAddress}</td>
|
||||
<td>{it.domain}</td>
|
||||
<td className="muted">{it.domain}</td>
|
||||
<td>{METHOD[it.method]}</td>
|
||||
<td className="num">{it.emailCount}</td>
|
||||
<td>{STATUS[it.status]}</td>
|
||||
<td className="num">{it.emailCount.toLocaleString()}</td>
|
||||
<td><span className={`unsub-badge unsub-badge--${STATUS_CLASS[it.status]}`}>{STATUS[it.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && <tr><td colSpan="6" className="muted">Nothing detected yet. Run a scan.</td></tr>}
|
||||
{!visible.length && (
|
||||
<tr><td colSpan="6" className="muted">
|
||||
{items.length === 0 ? 'Nothing detected yet. Run a scan.' : 'No items in this filter.'}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -339,3 +339,74 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
|
||||
.progress-fill[data-indeterminate="true"] { animation: indet 1.2s ease-in-out infinite; }
|
||||
@keyframes indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
|
||||
.progress-label { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* ── Sync status (topbar) ──────────────────────────────────────────────── */
|
||||
.sync-status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); margin-right: 4px; }
|
||||
.sync-status--error { color: var(--danger); }
|
||||
.sync-label--err { color: var(--danger); }
|
||||
.sync-spinner {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
border: 2px solid #36405c; border-top-color: var(--accent);
|
||||
animation: sync-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes sync-spin { to { transform: rotate(360deg); } }
|
||||
.sync-bar-wrap { width: 50px; height: 4px; background: var(--panel-2); border-radius: 3px; overflow: hidden; }
|
||||
.sync-bar { display: block; height: 100%; background: var(--accent); transition: width 0.3s ease; }
|
||||
|
||||
/* ── Email detail body (Senders page) ──────────────────────────────────── */
|
||||
.sd-body-loading { padding: 8px 0 18px; }
|
||||
.sd-body {
|
||||
background: var(--panel); border: 1px solid #2c3550; border-radius: 8px;
|
||||
padding: 16px 18px; font-size: 13px; line-height: 1.7; color: var(--text);
|
||||
white-space: pre-wrap; word-break: break-word; margin-bottom: 18px;
|
||||
max-height: 60vh; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Unsubscribe queue ──────────────────────────────────────────────────── */
|
||||
.unsub-page { max-width: 1000px; }
|
||||
.unsub-toast { color: var(--ok); font-size: 13px; margin-left: 6px; }
|
||||
.unsub-tabs { display: flex; gap: 6px; margin: 16px 0 10px; }
|
||||
.unsub-tab {
|
||||
background: var(--panel); border: 1px solid #2c3550; color: var(--muted);
|
||||
border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.unsub-tab:hover { color: var(--text); }
|
||||
.unsub-tab--active { color: var(--text); border-color: var(--accent); background: var(--panel-2); }
|
||||
.unsub-tab-count { font-size: 11px; color: var(--muted); background: var(--bg); border-radius: 8px; padding: 1px 6px; }
|
||||
.unsub-grid { width: 100%; }
|
||||
.unsub-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; border: 1px solid #36405c; }
|
||||
.unsub-badge--detected { color: var(--muted); }
|
||||
.unsub-badge--queued { color: #f2c94c; border-color: #f2c94c66; }
|
||||
.unsub-badge--progress { color: var(--accent); border-color: var(--accent); }
|
||||
.unsub-badge--ok { color: var(--ok); border-color: var(--ok); }
|
||||
.unsub-badge--fail { color: var(--danger); border-color: var(--danger); }
|
||||
.unsub-badge--skip { color: var(--muted); }
|
||||
|
||||
/* ── Bulk selection toolbar (folder/search/sender email lists) ──────────── */
|
||||
.bulk-toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--panel-2); border: 1px solid #2c3550; border-radius: 8px;
|
||||
padding: 8px 14px; margin-bottom: 10px; font-size: 13px;
|
||||
}
|
||||
.bulk-toolbar .muted { font-size: 12px; }
|
||||
.bulk-toolbar-spacer { flex: 1; }
|
||||
.bulk-btn {
|
||||
background: var(--panel); border: 1px solid #2c3550; color: var(--text);
|
||||
border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.bulk-btn:hover { border-color: var(--accent); }
|
||||
.bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||
.el-select { width: 28px; text-align: center; }
|
||||
|
||||
/* ── Keyboard shortcut help ──────────────────────────────────────────────── */
|
||||
.kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #2c3550; border-radius: 6px; padding: 4px 10px; opacity: 0.7; }
|
||||
.kbd-hint kbd { background: var(--panel-2); border: 1px solid #36405c; border-radius: 3px; padding: 0 4px; font-family: inherit; }
|
||||
|
||||
/* ── Saved searches ─────────────────────────────────────────────────────── */
|
||||
.saved-search-row { display: flex; align-items: center; gap: 8px; }
|
||||
.saved-search-save-btn {
|
||||
background: none; border: 1px solid #2c3550; color: var(--muted);
|
||||
border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
|
||||
@@ -27,6 +27,21 @@ public class EmailController : ApiControllerBase
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>Full email detail including body text, for the inline detail view.</summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.Emails
|
||||
.Where(e => e.Id == id && e.UserId == UserId)
|
||||
.Select(e => new EmailDetailDto(
|
||||
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.BodyText,
|
||||
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
|
||||
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes,
|
||||
e.Category, e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return e is null ? NotFound() : Ok(e);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/read")]
|
||||
public Task<IActionResult> MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
|
||||
|
||||
|
||||
@@ -18,6 +18,24 @@ public record EmailSummaryDto(
|
||||
bool HasListUnsubscribe,
|
||||
bool SupportsOneClick);
|
||||
|
||||
/// <summary>Full single-email view, including body text, for the detail pane.</summary>
|
||||
public record EmailDetailDto(
|
||||
Guid Id,
|
||||
string GmailMessageId,
|
||||
string? Subject,
|
||||
string? Snippet,
|
||||
string? BodyText,
|
||||
string SenderAddress,
|
||||
string? SenderDisplayName,
|
||||
DateTimeOffset SentAtUtc,
|
||||
bool IsUnread,
|
||||
bool IsStarred,
|
||||
bool HasAttachments,
|
||||
long SizeEstimateBytes,
|
||||
EmailCategory Category,
|
||||
bool HasListUnsubscribe,
|
||||
bool SupportsOneClick);
|
||||
|
||||
public record SenderStatDto(
|
||||
Guid SenderId,
|
||||
string Address,
|
||||
|
||||
Reference in New Issue
Block a user