Files
Inboxintel/frontend/src/pages/Senders.jsx
T
cesnimda 64f835f719 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>
2026-06-30 22:21:10 +02:00

357 lines
13 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useCallback, useRef, useState } from 'react';
import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js';
import BulkToolbar from '../components/BulkToolbar.jsx';
import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
// ── helpers ──────────────────────────────────────────────────────────────────
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) return '';
if (b < 1024) return `${b} B`;
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
return `${(b / 1048576).toFixed(1)} MB`;
};
const PAGE_SIZE = 50;
// ── SenderList (left panel) ───────────────────────────────────────────────────
function SenderList({ senders, selectedId, onSelect, search, onSearch }) {
const filtered = senders.filter((s) => {
const q = search.toLowerCase();
return !q || (s.address + ' ' + (s.displayName ?? '')).toLowerCase().includes(q);
});
return (
<div className="sl-panel">
<div className="sl-search-wrap">
<input
className="sl-search"
type="search"
placeholder="Filter senders…"
value={search}
onChange={(e) => onSearch(e.target.value)}
aria-label="Filter senders"
/>
</div>
<div className="sl-count">{filtered.length.toLocaleString()} sender{filtered.length !== 1 ? 's' : ''}</div>
<ul className="sl-list">
{filtered.map((s) => (
<li key={s.senderId}>
<button
className={`sl-item${selectedId === s.senderId ? ' sl-item--active' : ''}`}
onClick={() => onSelect(s)}
>
<div className="sl-name">{s.displayName || s.address}</div>
<div className="sl-addr">{s.displayName ? s.address : s.domain}</div>
<div className="sl-meta">
<span className="sl-email-count">{s.emailCount.toLocaleString()}</span>
{s.unreadCount > 0 && <span className="sl-unread">{s.unreadCount} unread</span>}
</div>
</button>
</li>
))}
</ul>
</div>
);
}
// ── EmailDetail (full single-email view) ──────────────────────────────────────
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))
.finally(() => setLoading(false));
}, [summary.id]);
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>
<div className="sd-detail-header">
<div className="sd-detail-subject">{email.subject || '(no subject)'}</div>
<div className="sd-detail-meta">
<span>{email.senderDisplayName || email.senderAddress}</span>
<span className="sd-detail-sep">·</span>
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
{email.sizeEstimateBytes > 0 && (
<>
<span className="sd-detail-sep">·</span>
<span>{fmtSize(email.sizeEstimateBytes)}</span>
</>
)}
</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 && (
<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"
onClick={() => window.open(
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
'_blank', 'noopener,noreferrer'
)}
>Open in Gmail </button>
</div>
</div>
);
}
// ── EmailListRow (row inside sender email list) ───────────────────────────────
function EmailListRow({ email: initial, onRemove, onOpen, selected, onToggleSelect, focused }) {
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((p) => ({ ...p, ...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); }
};
return (
<tr
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
onClick={() => onOpen(email)}
title="View email"
style={{ cursor: 'pointer' }}
>
<td className="el-select" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
</td>
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</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>
);
}
// ── SenderEmails (right panel) ────────────────────────────────────────────────
function SenderEmails({ sender }) {
const [emails, setEmails] = useState([]);
const [totalCount, setTotalCount] = useState(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [openEmail, setOpenEmail] = useState(null);
const sentinelRef = useRef(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
// Reset when sender changes
useEffect(() => {
setEmails([]);
setTotalCount(null);
setPage(1);
setHasMore(true);
setOpenEmail(null);
clear();
}, [sender.senderId, clear]);
const fetchPage = useCallback((p) => {
setLoading(true);
SearchApi.query(`from:${sender.address}`, p, PAGE_SIZE)
.then((r) => {
setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]);
setTotalCount(r.totalCount);
setHasMore(p < r.totalPages);
setPage(p);
})
.finally(() => setLoading(false));
}, [sender]);
useEffect(() => { fetchPage(1); }, [fetchPage]);
// Infinite scroll sentinel
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting && hasMore && !loading) fetchPage(page + 1); },
{ rootMargin: '200px' }
);
obs.observe(el);
return () => obs.disconnect();
}, [hasMore, loading, page, fetchPage]);
if (openEmail) {
return <EmailDetail email={openEmail} onBack={() => setOpenEmail(null)} />;
}
return (
<div className="sd-panel">
<div className="sd-panel-header">
<div className="sd-panel-name">{sender.displayName || sender.address}</div>
<div className="sd-panel-addr">{sender.displayName ? sender.address : ''}</div>
{totalCount !== null && (
<div className="sd-panel-count">{totalCount.toLocaleString()} email{totalCount !== 1 ? 's' : ''}</div>
)}
</div>
<BulkToolbar
selectedIds={selectedIds}
onClear={clear}
onDone={(action, ids) => {
if (action === 'trash' || action === 'archive') {
setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
removeIds(ids);
}
}}
/>
{emails.length > 0 && (
<table className="email-list">
<tbody>
{emails.map((e) => (
<EmailListRow
key={e.id}
email={e}
selected={selected.has(e.id)}
onToggleSelect={toggle}
focused={focusedId === e.id}
onOpen={setOpenEmail}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/>
))}
</tbody>
</table>
)}
<div ref={sentinelRef} />
{loading && <div className="fv-loading-more">Loading</div>}
{!loading && !hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} emails </div>
)}
{!loading && emails.length === 0 && (
<div className="fv-empty">No emails found from this sender.</div>
)}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function Senders() {
const [senders, setSenders] = useState(null);
const [selected, setSelected] = useState(null);
const [search, setSearch] = useState('');
useEffect(() => {
AnalyticsApi.allSenders().then((list) => {
setSenders(list);
if (list.length > 0) setSelected(list[0]);
}).catch(() => setSenders([]));
}, []);
if (!senders) return <div className="page"><h2>Senders</h2><p className="muted">Loading</p></div>;
if (senders.length === 0) return (
<div className="page">
<h2>Senders</h2>
<p className="muted">No senders yet run a sync first.</p>
</div>
);
return (
<div className="senders-master-detail">
<SenderList
senders={senders}
selectedId={selected?.senderId}
onSelect={setSelected}
search={search}
onSearch={setSearch}
/>
{selected && <SenderEmails key={selected.senderId} sender={selected} />}
</div>
);
}