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:
+250
-15
@@ -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 & 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 & 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' }}>
|
||||
|
||||
Reference in New Issue
Block a user