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:
cesnimda
2026-06-30 21:50:59 +02:00
parent a24c48179a
commit 8c92263cc3
8 changed files with 293 additions and 18 deletions
+1
View File
@@ -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`),
+2
View File
@@ -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>
+74
View File
@@ -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>
);
}
+24 -2
View File
@@ -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"
+88 -16
View File
@@ -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>
+71
View File
@@ -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,