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:
cesnimda
2026-06-30 22:21:10 +02:00
parent 0163165beb
commit 64f835f719
14 changed files with 949 additions and 10 deletions
+1
View File
@@ -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`),
+23
View File
@@ -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 && (
+8 -2
View File
@@ -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>
)}
+6
View File
@@ -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 {