Email search/query + windowed digest preview (v0.1.0:83)

Communications tab (search/query roadmap items 1 & 2):
- Fix the investor dropdown: the facet only listed grid investors, so it
  came back empty whenever email matched a classic contact or org domain
  (no grid id — the common case). It now mirrors the email list, resolving
  each link to a typed identity (fund:/org:/contact:/addr:) with precedence
  grid -> org -> contact -> address; investor_id accepts the typed key
  (bare id = fund: for back-compat) and an unknown prefix matches nothing.
- Add a date-range filter and a click-to-expand full-body view
  (GET /api/email/detail, admin, soft-delete-gated; body_text only, never
  raw remote HTML).
- Add a "Search content" mode: GET /api/email/search wraps the ingest
  hybrid_search over the Qdrant email index (doc_type=email), hydrated and
  soft-delete-filtered against SQLite (canonical), 503 if Spark/Qdrant down.

Daily digest:
- Settings -> Admin builds a digest over a chosen window (last 24h or since
  a date) as an in-app preview before sending (POST /api/admin/digest/preview),
  so the local-Spark summarizer can be verified on demand even on a quiet day.
  Manual send uses the same window; neither advances the daily cursor, so a
  preview never suppresses the scheduled digest.

Code-only, migrations no-op. 22/22 backend tests, render-smoke pass.
This commit is contained in:
Keysat
2026-06-16 20:46:15 -05:00
parent c29ac2f2ee
commit c7b74a2704
14 changed files with 989 additions and 138 deletions
+250 -15
View File
@@ -4273,8 +4273,17 @@
const [investorId, setInvestorId] = useState('');
const [accountId, setAccountId] = useState('');
const [direction, setDirection] = useState('');
const [since, setSince] = useState('');
const [until, setUntil] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [expandedId, setExpandedId] = useState(null);
const [detailCache, setDetailCache] = useState({}); // id -> {loading, data, error}
const [mode, setMode] = useState('filter'); // 'filter' | 'search' (semantic content)
const [contentQuery, setContentQuery] = useState('');
const [searchResults, setSearchResults] = useState(null); // {results, count} | null
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState('');
// Debounce the free-text box so each keystroke doesn't hit the server.
useEffect(() => {
@@ -4292,6 +4301,16 @@
if (investorId) params.set('investor_id', investorId);
if (accountId) params.set('account_id', accountId);
if (direction) params.set('direction', direction);
if (since) params.set('since', since + 'T00:00:00');
// `until` is exclusive in the query; send the day AFTER the picked
// date at midnight so the whole "to" day is included regardless of
// the stored timestamp's precision/zone suffix.
if (until) {
const u = new Date(until + 'T00:00:00');
u.setDate(u.getDate() + 1);
const nd = `${u.getFullYear()}-${String(u.getMonth() + 1).padStart(2, '0')}-${String(u.getDate()).padStart(2, '0')}`;
params.set('until', nd + 'T00:00:00');
}
if (debouncedSearch) params.set('q', debouncedSearch);
params.set('limit', '200');
const res = await api(`/api/email/activity?${params.toString()}`, {}, token);
@@ -4313,7 +4332,73 @@
}
})();
return () => { cancelled = true; };
}, [token, isAdmin, investorId, accountId, direction, debouncedSearch]);
}, [token, isAdmin, investorId, accountId, direction, since, until, debouncedSearch]);
// Lazily fetch and cache the full body when a row is expanded.
const toggleExpand = async (id) => {
if (expandedId === id) { setExpandedId(null); return; }
setExpandedId(id);
if (detailCache[id]) return;
setDetailCache((c) => ({ ...c, [id]: { loading: true } }));
try {
const res = await api(`/api/email/detail?id=${encodeURIComponent(id)}`, {}, token);
setDetailCache((c) => ({ ...c, [id]: { loading: false, data: res } }));
} catch (err) {
setDetailCache((c) => ({ ...c, [id]: { loading: false, error: getErrorMessage(err, 'Failed to load email') } }));
}
};
// Shared expanded-body panel (used by both the filter list and search results).
const renderExpandedBody = (id) => {
const det = detailCache[id];
return (
<div style={{ marginTop: '8px', border: '1px solid #263548', borderRadius: '6px', background: '#0d1622', padding: '10px' }}>
{det?.loading ? <SkeletonBlock lines={4} />
: det?.error ? <div className="toast error" style={{ position: 'static' }}>{det.error}</div>
: det?.data ? (() => {
const d = det.data;
const rcpt = (kind) => (d.recipients || []).filter((r) => r.kind === kind)
.map((r) => r.display_name ? `${r.display_name} <${r.address}>` : r.address).join(', ');
const to = rcpt('to'), cc = rcpt('cc');
return (
<>
{to && <div style={{ fontSize: '11px', color: '#8ea2b7' }}><b>To:</b> {to}</div>}
{cc && <div style={{ fontSize: '11px', color: '#8ea2b7' }}><b>Cc:</b> {cc}</div>}
{(d.attachments || []).length > 0 && (
<div style={{ fontSize: '11px', color: '#8ea2b7', marginTop: '2px' }}>
<b>Attachments:</b> {d.attachments.map((a) => a.filename).join(', ')}
</div>
)}
<pre style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '12px', lineHeight: 1.5, color: '#cdd9e5', fontFamily: 'inherit' }}>
{d.body_text || (d.has_html ? '(HTML-only email — open in Gmail to view formatting)' : '(no body captured)')}
</pre>
</>
);
})() : null}
</div>
);
};
// Semantic search over email *content* (bodies) — heavier (Spark + Qdrant),
// so it runs on submit, not per keystroke.
const runContentSearch = async () => {
const query = contentQuery.trim();
if (!query) { setSearchResults(null); setSearchError(''); return; }
setSearchLoading(true); setSearchError('');
try {
const res = await api(`/api/email/search?q=${encodeURIComponent(query)}`, {}, token);
setSearchResults(res);
} catch (err) {
if (err?.status === 503) {
setSearchError('Content search is unavailable right now (Spark/Qdrant not reachable).');
} else {
setSearchError(getErrorMessage(err, 'Search failed'));
}
setSearchResults(null);
} finally {
setSearchLoading(false);
}
};
if (!isAdmin) {
return (
@@ -4327,7 +4412,7 @@
const emails = data?.emails || [];
const accounts = data?.accounts || [];
const investors = data?.investors || [];
const hasFilter = !!(investorId || accountId || direction || debouncedSearch);
const hasFilter = !!(investorId || accountId || direction || since || until || debouncedSearch);
return (
<div className="page-container">
@@ -4336,6 +4421,20 @@
<span className="form-help">Captured email activity. Logging &amp; drafts live in the Fundraising Grid and Outreach.</span>
</div>
<div className="section">
<div style={{ display: 'flex', gap: '8px', marginBottom: '14px' }}>
<button type="button" onClick={() => setMode('filter')}
style={mode === 'filter' ? {} : { background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}>
Filter
</button>
<button type="button" onClick={() => setMode('search')}
style={mode === 'search' ? {} : { background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}>
Search content
</button>
<span className="form-help" style={{ alignSelf: 'center' }}>
{mode === 'filter' ? 'Structured filters over captured email metadata.' : 'Semantic search over email bodies (finds by meaning, not just keywords).'}
</span>
</div>
{mode === 'filter' && (<>
<div className="controls">
<input
type="text"
@@ -4357,6 +4456,18 @@
<option value="inbound">Received</option>
<option value="outbound">Sent</option>
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
from <input type="date" value={since} max={until || undefined} onChange={(e) => setSince(e.target.value)} />
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#8ea2b7' }}>
to <input type="date" value={until} min={since || undefined} onChange={(e) => setUntil(e.target.value)} />
</label>
{(since || until) && (
<button type="button" onClick={() => { setSince(''); setUntil(''); }}
style={{ background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none', padding: '6px 10px' }}>
Clear dates
</button>
)}
</div>
{loading ? (
<SkeletonBlock lines={8} />
@@ -4375,12 +4486,14 @@
{emails.map((em) => {
const sent = em.direction === 'outbound';
const who = em.from_name ? `${em.from_name} <${em.from_email}>` : (em.from_email || 'Unknown sender');
const tags = [...(em.investors || []).map((iv) => iv.name), ...(em.investor_labels || [])];
const tags = (em.investors || []).map((iv) => iv.name);
const open = expandedId === em.id;
return (
<div key={em.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">
<div className="timeline-header" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(em.id)}>
<span style={{ color: '#6b7c90', marginRight: '4px' }}>{open ? '▾' : '▸'}</span>
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
{' · '}{who}{em.has_attachments ? ' 📎' : ''}
</div>
@@ -4388,8 +4501,9 @@
{formatDate(em.sent_at)}
{(em.mailboxes || []).length > 0 && <span> · {em.mailboxes.join(', ')}</span>}
</div>
{em.subject && <div className="timeline-body" style={{ fontWeight: 600 }}>{em.subject}</div>}
{em.snippet && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{em.snippet}</div>}
{em.subject && <div className="timeline-body" style={{ fontWeight: 600, cursor: 'pointer' }} onClick={() => toggleExpand(em.id)}>{em.subject}</div>}
{!open && em.snippet && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{em.snippet}</div>}
{open && renderExpandedBody(em.id)}
{tags.length > 0 ? (
<div style={{ marginTop: '6px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{tags.map((t) => (
@@ -4408,6 +4522,66 @@
</div>
</>
)}
</>)}
{mode === 'search' && (
<>
<div className="controls">
<input
type="text"
className="search-input"
placeholder="Find emails by content, e.g. “the mining deal wire timeline”"
value={contentQuery}
onChange={(e) => setContentQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') runContentSearch(); }}
/>
<button type="button" onClick={runContentSearch} disabled={searchLoading || !contentQuery.trim()}>
{searchLoading ? <Spinner /> : 'Search'}
</button>
</div>
{searchLoading ? (
<SkeletonBlock lines={6} />
) : searchError ? (
<div className="toast error" style={{ position: 'static' }}>{searchError}</div>
) : searchResults === null ? (
<div className="empty-state">Search the text of captured (investor-matched) emails. Only matched email bodies are indexed.</div>
) : (searchResults.results || []).length === 0 ? (
<div className="empty-state">No emails matched “{contentQuery.trim()}”.</div>
) : (
<>
<div className="form-help" style={{ marginBottom: '10px' }}>
{searchResults.results.length} result{searchResults.results.length === 1 ? '' : 's'}, most relevant first.
</div>
<div className="timeline">
{searchResults.results.map((r) => {
const sent = r.direction === 'outbound';
const who = r.from_name ? `${r.from_name} <${r.from_email}>` : (r.from_email || 'Unknown sender');
const open = expandedId === r.email_id;
return (
<div key={r.email_id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(r.email_id)}>
<span style={{ color: '#6b7c90', marginRight: '4px' }}>{open ? '▾' : '▸'}</span>
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
{' · '}{who}{r.has_attachments ? ' 📎' : ''}
</div>
<div className="timeline-meta">
{formatDate(r.sent_at)}
{r.lp_name && <span> · {r.lp_name}</span>}
{typeof r.score === 'number' && <span> · score {r.score.toFixed(2)}</span>}
</div>
{r.subject && <div className="timeline-body" style={{ fontWeight: 600, cursor: 'pointer' }} onClick={() => toggleExpand(r.email_id)}>{r.subject}</div>}
{!open && r.excerpt && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2', fontStyle: 'italic' }}>…{r.excerpt}…</div>}
{open && renderExpandedBody(r.email_id)}
</div>
</div>
);
})}
</div>
</>
)}
</>
)}
</div>
</div>
);
@@ -7341,6 +7515,12 @@
const [digestPolicy, setDigestPolicy] = useState({ enabled: false, send_hour: 18 });
const [digestPolicyLoading, setDigestPolicyLoading] = useState(false);
const [digestPolicySaving, setDigestPolicySaving] = useState(false);
// Manual run: window selector + in-panel preview before pushing to email.
const [digestWindowMode, setDigestWindowMode] = useState('24h'); // '24h' | 'since'
const [digestSince, setDigestSince] = useState(() =>
new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10));
const [digestPreview, setDigestPreview] = useState(null);
const [digestPreviewLoading, setDigestPreviewLoading] = useState(false);
const [auditLogs, setAuditLogs] = useState([]);
const [auditLoading, setAuditLoading] = useState(false);
const [automationRules, setAutomationRules] = useState([]);
@@ -7839,19 +8019,39 @@
}
};
// The selected manual-run window: a specific start date, or the last 24h.
const digestWindowBody = () =>
digestWindowMode === 'since' && digestSince ? { since: digestSince } : { hours: 24 };
const digestSummary = (d) => d.has_activity
? `${d.user_count} member(s), ${d.email_count} email(s), ${d.investor_count} investor(s)`
: 'no activity in this window';
const handlePreviewDigest = async () => {
setDigestPreviewLoading(true);
try {
const result = await api('/api/admin/digest/preview', {
method: 'POST',
body: JSON.stringify(digestWindowBody())
}, token);
setDigestPreview(result?.data || null);
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to build preview — is Spark Control reachable?'), 'error');
} finally {
setDigestPreviewLoading(false);
}
};
const handleSendDigestNow = async () => {
setSendDigestLoading(true);
try {
const result = await api('/api/admin/digest/send-now', {
method: 'POST',
body: JSON.stringify({})
body: JSON.stringify(digestWindowBody())
}, token);
const d = result?.data || {};
const to = (d.recipients || []).join(', ');
const summary = d.has_activity
? `${d.user_count} member(s), ${d.email_count} email(s), ${d.investor_count} investor(s)`
: 'no activity in the last 24h';
onShowToast(`Digest sent (${summary})${to ? ` to ${to}` : ''}`, 'success');
onShowToast(`Digest sent (${digestSummary(d)})${to ? ` to ${to}` : ''}`, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to send digest — is a transport (Gmail/DWD or SMTP) configured?'), 'error');
} finally {
@@ -8142,7 +8342,7 @@
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>Daily Digest Email</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '10px' }}>
A daily email to all active admins: each team member's activity per investor, plus a by-investor view (inbound + outbound), summarized locally (Spark), never Claude. <b>Send Test</b> verifies the outbound pipe with a fixed message; <b>Send Now</b> sends the real last-24h digest immediately.
A daily email to all active admins: each team member's activity per investor, plus a by-investor view (inbound + outbound), summarized locally (Spark), never Claude. Use <b>Preview</b> below to build the digest over a window and read it before sending — a wide window is how you verify the summarizer on a quiet day. <b>Send</b> pushes that same window to the admin inboxes now. Neither touches the daily schedule.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap', marginBottom: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
@@ -8170,14 +8370,49 @@
<span style={{ fontSize: '11px', color: '#8ea2b7' }}>(server local time)</span>
</label>
</div>
<div style={{ fontWeight: 600, fontSize: '13px', margin: '4px 0 8px' }}>Manual run &amp; preview</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', marginBottom: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input type="radio" name="digestWindow" checked={digestWindowMode === '24h'}
onChange={() => setDigestWindowMode('24h')} />
Last 24 hours
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input type="radio" name="digestWindow" checked={digestWindowMode === 'since'}
onChange={() => setDigestWindowMode('since')} />
Since
<input type="date" value={digestSince} max={new Date().toISOString().slice(0, 10)}
onChange={(e) => { setDigestSince(e.target.value); setDigestWindowMode('since'); }}
disabled={digestWindowMode !== 'since'} />
</label>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" onClick={handleSendTestDigestEmail} disabled={testEmailLoading}>
{testEmailLoading ? <Spinner /> : 'Send Test Digest Email'}
<button type="button" onClick={handlePreviewDigest} disabled={digestPreviewLoading}>
{digestPreviewLoading ? <Spinner /> : 'Preview'}
</button>
<button type="button" onClick={handleSendDigestNow} disabled={sendDigestLoading}>
{sendDigestLoading ? <Spinner /> : 'Send Digest Now'}
{sendDigestLoading ? <Spinner /> : 'Send to admins now'}
</button>
<button type="button" onClick={handleSendTestDigestEmail} disabled={testEmailLoading}
style={{ background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }}
title="Send a fixed test message to verify the outbound transport (Gmail-DWD / SMTP)">
{testEmailLoading ? <Spinner /> : 'Send transport test'}
</button>
</div>
{digestPreview && (
<div style={{ marginTop: '12px', border: '1px solid #263548', borderRadius: '6px', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', padding: '8px 10px', background: '#16202e', flexWrap: 'wrap' }}>
<span style={{ fontSize: '12px', color: '#cdd9e5' }}>
<b>{digestPreview.subject}</b> — {digestSummary(digestPreview)}
{Array.isArray(digestPreview.window) && (
<span style={{ color: '#8ea2b7' }}> · {formatDate(digestPreview.window[0])} → {formatDate(digestPreview.window[1])}</span>
)}
</span>
<button type="button" style={{ fontSize: '11px', padding: '4px 8px', background: 'transparent', color: '#8ea2b7', border: '1px solid #2a3a4d', boxShadow: 'none' }} onClick={() => setDigestPreview(null)}>Clear</button>
</div>
<pre style={{ margin: 0, padding: '10px', maxHeight: '380px', overflow: 'auto', fontSize: '12px', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: '#cdd9e5' }}>{digestPreview.body}</pre>
</div>
)}
</div>
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>