feat: pattern analysis panel — cluster by sender/IP/subject, Find Similar from dialog, counts recipients per cluster
This commit is contained in:
parent
0020be1dbf
commit
c2ebbe586b
1 changed files with 248 additions and 2 deletions
|
|
@ -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 > Message Center > 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue