feat: pattern analysis panel — cluster by sender/IP/subject, Find Similar from dialog, counts recipients per cluster

This commit is contained in:
lorentz 2026-04-01 10:00:20 -04:00
parent 0020be1dbf
commit c2ebbe586b

View file

@ -1107,7 +1107,12 @@ function analyzeDelivered(m: any): { headline: string; explanation: string; seve
};
}
function DeliveredAnalysisDialog({ message, onClose }: { message: any; onClose: () => void }) {
function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages }: {
message: any;
onClose: () => void;
onFindSimilar?: (type: 'sender' | 'ip' | 'subject', value: string) => void;
allMessages?: any[];
}) {
if (!message) return null;
const analysis = analyzeDelivered(message);
@ -1214,6 +1219,58 @@ function DeliveredAnalysisDialog({ message, onClose }: { message: any; onClose:
</div>
</div>
{/* Cluster context — show how many other messages match sender/IP in current results */}
{onFindSimilar && allMessages && allMessages.length > 1 && (() => {
const sameFrom = allMessages.filter(x => x.from === message.from && x.id !== message.id);
const sameIP = message.senderIP ? allMessages.filter(x => x.senderIP === message.senderIP && x.id !== message.id) : [];
const subjectWords = (message.subject ?? '').split(/\s+/).slice(0, 5).join(' ');
const sameSubject = subjectWords.length > 8 ? allMessages.filter(x => x.id !== message.id && (x.subject ?? '').startsWith(subjectWords)) : [];
const hasClusters = sameFrom.length > 0 || sameIP.length > 0 || sameSubject.length > 0;
if (!hasClusters) return null;
return (
<div className="rounded-md border border-amber-300/60 bg-amber-50/40 dark:bg-amber-950/10 p-3 space-y-2">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 uppercase tracking-wide">Pattern matches in current results</p>
<div className="space-y-1.5">
{sameFrom.length > 0 && (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-foreground/80">
<span className="font-medium">{sameFrom.length + 1} messages</span> from <span className="font-mono">{message.from}</span>
{' '} {[...new Set([message.to, ...sameFrom.map((x: any) => x.to)])].join(', ')}
</span>
<Button size="sm" variant="outline" className="h-6 text-xs px-2 flex-shrink-0"
onClick={() => { onClose(); onFindSimilar('sender', message.from); }}>
Find all
</Button>
</div>
)}
{sameIP.length > 0 && sameIP.length !== sameFrom.length && (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-foreground/80">
<span className="font-medium">{sameIP.length + 1} messages</span> from IP <span className="font-mono">{message.senderIP}</span>
{' '}(multiple senders)
</span>
<Button size="sm" variant="outline" className="h-6 text-xs px-2 flex-shrink-0"
onClick={() => { onClose(); onFindSimilar('ip', message.senderIP); }}>
Find all
</Button>
</div>
)}
{sameSubject.length > 0 && (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-foreground/80">
<span className="font-medium">{sameSubject.length + 1} messages</span> with similar subject
</span>
<Button size="sm" variant="outline" className="h-6 text-xs px-2 flex-shrink-0"
onClick={() => { onClose(); onFindSimilar('subject', subjectWords); }}>
Find all
</Button>
</div>
)}
</div>
</div>
);
})()}
<div className="rounded-md border border-dashed px-3 py-2.5 text-xs text-muted-foreground flex items-start gap-2">
<ExternalLink className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>View full message tracking in the Mimecast Administration Console under Gateway &gt; Message Center &gt; Message Finder.
@ -1238,6 +1295,57 @@ function DeliveredMailTab() {
const [statusFilter, setStatusFilter] = useState('');
const [sortBy, setSortBy] = useState<'received' | 'spamScore'>('received');
const [analysisMessage, setAnalysisMessage] = useState<any>(null);
const [clusterExpanded, setClusterExpanded] = useState(true);
const handleFindSimilar = (type: 'sender' | 'ip' | 'subject', value: string) => {
if (type === 'sender') {
const domain = value.includes('@') ? value.split('@')[1] : value;
const isFreemail = ['gmail.com','yahoo.com','hotmail.com','outlook.com','live.com','aol.com','icloud.com'].includes(domain);
if (isFreemail) {
setFrom(value);
} else {
setFrom('@' + domain);
}
} else if (type === 'ip') {
setFrom('');
setSubject('');
} else if (type === 'subject') {
setSubject(value);
setFrom('');
}
setTimeout(() => searchWithOverrides(type, value), 50);
};
const searchWithOverrides = async (type: 'sender' | 'ip' | 'subject', value: string) => {
setLoading(true);
setLoadError(null);
setLoaded(false);
try {
let body: any = { tenantId, startHours };
if (type === 'sender') {
const domain = value.includes('@') ? value.split('@')[1] : value;
const isFreemail = ['gmail.com','yahoo.com','hotmail.com','outlook.com','live.com','aol.com','icloud.com'].includes(domain);
body.from = isFreemail ? value : '@' + domain;
} else if (type === 'subject') {
body.subject = value;
}
if (!body.from && !body.subject && !to) body.to = to || undefined;
if (!body.from && !body.to && !body.subject) { setLoading(false); return; }
const res = await fetch('/api/mimecast/delivered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) { const t = await res.text(); throw new Error(`HTTP ${res.status}: ${t.slice(0, 200)}`); }
const d = await res.json();
setMessages(d.messages ?? []);
setLoaded(true);
} catch (e: any) {
setLoadError(e.message ?? 'Unknown error');
} finally {
setLoading(false);
}
};
const search = async () => {
if (!to && !from && !subject) return;
@ -1370,6 +1478,139 @@ function DeliveredMailTab() {
</div>
)}
{/* Cluster / Pattern Analysis */}
{loaded && !loading && messages.length > 1 && (() => {
// Group by sender email
const bySender: Record<string, any[]> = {};
for (const m of messages) { if (m.from) { (bySender[m.from] ??= []).push(m); } }
const topSenders = Object.entries(bySender).filter(([, v]) => v.length > 1)
.sort((a, b) => b[1].length - a[1].length).slice(0, 5);
// Group by sender IP
const byIP: Record<string, any[]> = {};
for (const m of messages) { if (m.senderIP) { (byIP[m.senderIP] ??= []).push(m); } }
const topIPs = Object.entries(byIP).filter(([, v]) => v.length > 1)
.sort((a, b) => b[1].length - a[1].length).slice(0, 5);
// Group by subject prefix (first 5 words)
const bySubject: Record<string, any[]> = {};
for (const m of messages) {
const prefix = (m.subject ?? '').split(/\s+/).slice(0, 5).join(' ').toLowerCase();
if (prefix.length > 5) { (bySubject[prefix] ??= []).push(m); }
}
const topSubjects = Object.entries(bySubject).filter(([, v]) => v.length > 1)
.sort((a, b) => b[1].length - a[1].length).slice(0, 3);
if (!topSenders.length && !topIPs.length && !topSubjects.length) return null;
return (
<div className="rounded-lg border overflow-hidden">
<button
className="w-full flex items-center justify-between px-4 py-2.5 bg-muted/30 hover:bg-muted/50 text-left"
onClick={() => setClusterExpanded(v => !v)}
>
<div className="flex items-center gap-2">
<Search className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm font-medium">Pattern Analysis</span>
<span className="text-xs text-muted-foreground"> repeated senders, IPs, subjects in these results</span>
</div>
{clusterExpanded ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
</button>
{clusterExpanded && (
<div className="divide-y">
{topSenders.length > 0 && (
<div className="px-4 py-3 space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Repeated senders</p>
<div className="space-y-1.5">
{topSenders.map(([sender, msgs]) => {
const recipients = [...new Set(msgs.map((m: any) => m.to))];
const isThreat = msgs.some((m: any) => detectSubjectThreat(m.subject) !== null || (m.spamScore ?? 0) >= 5);
return (
<div key={sender} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0 flex items-start gap-2">
{isThreat && <XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0 mt-0.5" />}
<div className="min-w-0">
<span className="font-mono text-xs break-all">{sender}</span>
<div className="text-xs text-muted-foreground">
{msgs.length} messages {recipients.length} recipient{recipients.length !== 1 ? 's' : ''}: {recipients.slice(0, 3).join(', ')}{recipients.length > 3 ? ` +${recipients.length - 3} more` : ''}
</div>
</div>
</div>
<Button size="sm" variant={isThreat ? 'destructive' : 'outline'} className="h-6 text-xs px-2 flex-shrink-0 opacity-80"
onClick={() => handleFindSimilar('sender', sender)}>
Search
</Button>
</div>
);
})}
</div>
</div>
)}
{topIPs.length > 0 && (
<div className="px-4 py-3 space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Repeated sending IPs</p>
<div className="space-y-1.5">
{topIPs.map(([ip, msgs]) => {
const senders = [...new Set(msgs.map((m: any) => m.from))];
const recipients = [...new Set(msgs.map((m: any) => m.to))];
const isThreat = msgs.some((m: any) => detectSubjectThreat(m.subject) !== null || (m.spamScore ?? 0) >= 5);
return (
<div key={ip} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0 flex items-start gap-2">
{isThreat && <XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0 mt-0.5" />}
<div className="min-w-0">
<span className="font-mono text-xs">{ip}</span>
<div className="text-xs text-muted-foreground">
{msgs.length} messages · {senders.length} sender{senders.length !== 1 ? 's' : ''} · {recipients.length} recipient{recipients.length !== 1 ? 's' : ''}
{senders.length <= 2 ? `: ${senders.join(', ')}` : ''}
</div>
</div>
</div>
<span className="text-xs text-muted-foreground flex-shrink-0">IP pivot N/A</span>
</div>
);
})}
</div>
</div>
)}
{topSubjects.length > 0 && (
<div className="px-4 py-3 space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Repeated subject patterns</p>
<div className="space-y-1.5">
{topSubjects.map(([prefix, msgs]) => {
const senders = [...new Set(msgs.map((m: any) => m.from))];
const recipients = [...new Set(msgs.map((m: any) => m.to))];
const isThreat = msgs.some((m: any) => detectSubjectThreat(m.subject) !== null);
return (
<div key={prefix} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0 flex items-start gap-2">
{isThreat && <XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0 mt-0.5" />}
<div className="min-w-0">
<span className="text-xs truncate block">"{msgs[0].subject}"</span>
<div className="text-xs text-muted-foreground">
{msgs.length} messages · {senders.length} sender{senders.length !== 1 ? 's' : ''} · {recipients.length} recipient{recipients.length !== 1 ? 's' : ''}
</div>
</div>
</div>
<Button size="sm" variant={isThreat ? 'destructive' : 'outline'} className="h-6 text-xs px-2 flex-shrink-0 opacity-80"
onClick={() => handleFindSimilar('subject', prefix)}>
Search
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
})()}
{/* Filters + sort */}
{loaded && !loading && messages.length > 0 && (
<div className="flex flex-wrap gap-2 items-center">
@ -1464,7 +1705,12 @@ function DeliveredMailTab() {
</div>
)}
<DeliveredAnalysisDialog message={analysisMessage} onClose={() => setAnalysisMessage(null)} />
<DeliveredAnalysisDialog
message={analysisMessage}
onClose={() => setAnalysisMessage(null)}
onFindSimilar={handleFindSimilar}
allMessages={messages}
/>
</div>
);
}