diff --git a/app/admin/sync/mimecast/page.tsx b/app/admin/sync/mimecast/page.tsx index 5fb4c8d..f509dc5 100644 --- a/app/admin/sync/mimecast/page.tsx +++ b/app/admin/sync/mimecast/page.tsx @@ -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: + {/* 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 ( +
+

Pattern matches in current results

+
+ {sameFrom.length > 0 && ( +
+ + {sameFrom.length + 1} messages from {message.from} + {' '}→ {[...new Set([message.to, ...sameFrom.map((x: any) => x.to)])].join(', ')} + + +
+ )} + {sameIP.length > 0 && sameIP.length !== sameFrom.length && ( +
+ + {sameIP.length + 1} messages from IP {message.senderIP} + {' '}(multiple senders) + + +
+ )} + {sameSubject.length > 0 && ( +
+ + {sameSubject.length + 1} messages with similar subject + + +
+ )} +
+
+ ); + })()} +
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(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() {
)} + {/* Cluster / Pattern Analysis */} + {loaded && !loading && messages.length > 1 && (() => { + // Group by sender email + const bySender: Record = {}; + 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 = {}; + 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 = {}; + 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 ( +
+ + + {clusterExpanded && ( +
+ {topSenders.length > 0 && ( +
+

Repeated senders

+
+ {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 ( +
+
+ {isThreat && } +
+ {sender} +
+ {msgs.length} messages → {recipients.length} recipient{recipients.length !== 1 ? 's' : ''}: {recipients.slice(0, 3).join(', ')}{recipients.length > 3 ? ` +${recipients.length - 3} more` : ''} +
+
+
+ +
+ ); + })} +
+
+ )} + + {topIPs.length > 0 && ( +
+

Repeated sending IPs

+
+ {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 ( +
+
+ {isThreat && } +
+ {ip} +
+ {msgs.length} messages · {senders.length} sender{senders.length !== 1 ? 's' : ''} · {recipients.length} recipient{recipients.length !== 1 ? 's' : ''} + {senders.length <= 2 ? `: ${senders.join(', ')}` : ''} +
+
+
+ IP pivot N/A +
+ ); + })} +
+
+ )} + + {topSubjects.length > 0 && ( +
+

Repeated subject patterns

+
+ {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 ( +
+
+ {isThreat && } +
+ "{msgs[0].subject}" +
+ {msgs.length} messages · {senders.length} sender{senders.length !== 1 ? 's' : ''} · {recipients.length} recipient{recipients.length !== 1 ? 's' : ''} +
+
+
+ +
+ ); + })} +
+
+ )} +
+ )} +
+ ); + })()} + {/* Filters + sort */} {loaded && !loading && messages.length > 0 && (
@@ -1464,7 +1705,12 @@ function DeliveredMailTab() {
)} - setAnalysisMessage(null)} /> + setAnalysisMessage(null)} + onFindSimilar={handleFindSimilar} + allMessages={messages} + /> ); }