feat: AI categorisation fallback, unsubscribe confidence scoring, one-line summaries
Heuristic classifier falls back to AI only when it can't determine a category; unsubscribe confidence blends method reliability with an AI safety opinion and surfaces it in the Unsubscribe Manager; emails get an on-demand AI one-line summary in the detail pane. All AI calls degrade gracefully when AI is disabled or the provider errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -135,6 +135,7 @@ function folderToRequest(slug, page, pageSize) {
|
||||
|
||||
export const EmailApi = {
|
||||
get: (id) => api.get(`/email/${id}`).then((r) => r.data),
|
||||
summary: (id) => api.get(`/email/${id}/summary`).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`),
|
||||
|
||||
@@ -71,10 +71,13 @@ function SenderList({ senders, selectedId, onSelect, search, onSearch }) {
|
||||
function EmailDetail({ email: summary, onBack }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [aiSummary, setAiSummary] = useState(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
setAiSummary(null);
|
||||
EmailApi.get(summary.id)
|
||||
.then(setDetail)
|
||||
.catch(() => setDetail(null))
|
||||
@@ -83,6 +86,14 @@ function EmailDetail({ email: summary, onBack }) {
|
||||
|
||||
const email = detail ?? summary;
|
||||
|
||||
const fetchAiSummary = () => {
|
||||
setAiLoading(true);
|
||||
EmailApi.summary(summary.id)
|
||||
.then((r) => setAiSummary(r.summary))
|
||||
.catch(() => setAiSummary(null))
|
||||
.finally(() => setAiLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sd-detail">
|
||||
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
||||
@@ -101,6 +112,18 @@ function EmailDetail({ email: summary, onBack }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && (
|
||||
<div className="sd-ai-summary">
|
||||
{aiSummary != null ? (
|
||||
<div className="sd-ai-summary-text">✨ {aiSummary}</div>
|
||||
) : (
|
||||
<button className="btn-sm" onClick={fetchAiSummary} disabled={aiLoading}>
|
||||
{aiLoading ? 'Summarising…' : '✨ AI summary'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="sd-body-loading muted">Loading message…</div>}
|
||||
|
||||
{!loading && detail?.bodyText && (
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Unsubscribe() {
|
||||
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);
|
||||
return [...list].sort((a, b) => b.confidence - a.confidence || b.emailCount - a.emailCount);
|
||||
}, [items, filter]);
|
||||
|
||||
const allVisibleSelected = visible.length > 0 && visible.every((it) => selected[it.id]);
|
||||
@@ -97,6 +97,7 @@ export default function Unsubscribe() {
|
||||
<th>Domain</th>
|
||||
<th>Method</th>
|
||||
<th className="num">Emails</th>
|
||||
<th>Confidence</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -108,11 +109,16 @@ export default function Unsubscribe() {
|
||||
<td className="muted">{it.domain}</td>
|
||||
<td>{METHOD[it.method]}</td>
|
||||
<td className="num">{it.emailCount.toLocaleString()}</td>
|
||||
<td>
|
||||
<span className={`unsub-confidence unsub-confidence--${it.confidence >= 0.7 ? 'high' : it.confidence >= 0.4 ? 'mid' : 'low'}`}>
|
||||
{Math.round(it.confidence * 100)}%
|
||||
</span>
|
||||
</td>
|
||||
<td><span className={`unsub-badge unsub-badge--${STATUS_CLASS[it.status]}`}>{STATUS[it.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!visible.length && (
|
||||
<tr><td colSpan="6" className="muted">
|
||||
<tr><td colSpan="7" className="muted">
|
||||
{items.length === 0 ? 'Nothing detected yet. Run a scan.' : 'No items in this filter.'}
|
||||
</td></tr>
|
||||
)}
|
||||
|
||||
@@ -233,6 +233,8 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
|
||||
.sd-detail-meta { font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
.sd-detail-sep { opacity: 0.4; }
|
||||
.sd-snippet { background: var(--panel); border: 1px solid #2c3550; border-radius: 8px; padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; margin-bottom: 18px; }
|
||||
.sd-ai-summary { margin-bottom: 14px; }
|
||||
.sd-ai-summary-text { background: var(--panel-2); border: 1px solid #2c3550; border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--text); }
|
||||
.sd-detail-actions { display: flex; gap: 10px; }
|
||||
.btn-sm {
|
||||
background: var(--accent); color: #fff; border: none; border-radius: 6px;
|
||||
@@ -382,6 +384,10 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
|
||||
.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); }
|
||||
.unsub-confidence { font-size: 11px; font-weight: 600; }
|
||||
.unsub-confidence--high { color: var(--ok); }
|
||||
.unsub-confidence--mid { color: #f2c94c; }
|
||||
.unsub-confidence--low { color: var(--danger); }
|
||||
|
||||
/* ── Bulk selection toolbar (folder/search/sender email lists) ──────────── */
|
||||
.bulk-toolbar {
|
||||
|
||||
Reference in New Issue
Block a user