From 8e28062d8503fa1f382ba55db553f4cff811bc38 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 1 Apr 2026 08:17:18 -0400 Subject: [PATCH] feat: add Delivered Mail tab with message-finder search, spam scoring, analysis dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New POST /api/mimecast/delivered route using message-finder/search API - DeliveredMailTab: search by recipient, sender, subject, time range (6h–7d) - Results table with status badge, spam score, row tinting for high/moderate risk - Summary stats bar: total / high spam (≥10) / moderate (5-9) / clean counts - Sort by date or spam score; filter by status (accepted/held/rejected/bounced) - DeliveredAnalysisDialog: explains why high-score mail got through, envelope mismatch detection, actionable remediation steps (block domain, adjust policy threshold, report) - MimecastDeliveredMessage interface + searchDeliveredMessages() method in client --- app/admin/sync/mimecast/page.tsx | 432 +++++++++++++++++++++++++++- app/api/mimecast/delivered/route.ts | 46 +++ lib/services/mimecast-client.ts | 76 +++++ 3 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 app/api/mimecast/delivered/route.ts diff --git a/app/admin/sync/mimecast/page.tsx b/app/admin/sync/mimecast/page.tsx index 351c9ed..4d93b29 100644 --- a/app/admin/sync/mimecast/page.tsx +++ b/app/admin/sync/mimecast/page.tsx @@ -9,7 +9,7 @@ import { ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2, CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send, Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole, - PauseCircle, Building2, Check, Info, + PauseCircle, Building2, Check, Info, TrendingUp, ExternalLink, Eye, } from 'lucide-react'; import SyncScheduler from '@/components/admin/SyncScheduler'; @@ -982,6 +982,432 @@ function HeldMailTab() { ); } +// ── Delivered Mail Tab ──────────────────────────────────────────────────────── +function analyzeDelivered(m: any): { headline: string; explanation: string; severity: 'high' | 'medium' | 'low'; actions: AnalysisAction[] } { + const score: number = m.spamScore ?? 0; + const level: string = (m.detectionLevel ?? '').toLowerCase(); + const status: string = (m.status ?? '').toLowerCase(); + const from: string = m.from ?? ''; + const fromDomain = from.includes('@') ? from.split('@')[1] : from; + const fromEnvDomain = m.fromEnv?.includes('@') ? m.fromEnv.split('@')[1] : ''; + const envelopeMismatch = fromEnvDomain && fromDomain && fromEnvDomain !== fromDomain; + + if (score >= 10 || level === 'high') { + return { + headline: 'High Spam Score — Delivered', + severity: 'high', + explanation: `This message scored ${score} on Mimecast's spam engine and was still delivered. A score of 10+ typically indicates bulk spam infrastructure or known spam signatures. The ${envelopeMismatch ? `envelope sender (${m.fromEnv}) differs from the header From (${from}), which is a common indicator of spoofing or mailing list abuse. ` : ''}message passed through without being held, likely because no policy threshold was set at this score level.`, + actions: [ + { label: 'Review spam policy thresholds', description: 'In Mimecast: Administration > Gateway > Policies > Spam Scanning. Consider lowering the "hold" threshold to catch messages with scores ≥10.', type: 'info' }, + { label: 'Block this sender domain', description: `Add "${fromDomain}" to Administration > Gateway > Policies > Blocked Senders to prevent future delivery from this domain.`, type: 'info' }, + { label: 'Report as spam', description: 'Forward the email as an attachment to abuse@mimecast.com to improve future detection.', type: 'info' }, + ], + }; + } + + if (score >= 5 || level === 'moderate') { + return { + headline: 'Moderate Spam Score — Delivered', + severity: 'medium', + explanation: `This message scored ${score} on spam detection (detection level: ${m.detectionLevel || 'moderate'}) but was delivered because it fell below the hold threshold. ${envelopeMismatch ? `The envelope sender (${m.fromEnv}) differs from the header From (${from}), suggesting use of a third-party sending platform. ` : ''}This may be legitimate marketing mail or a marginal false negative.`, + actions: [ + { label: 'Add to blocked senders', description: `If this is unwanted, add "${fromDomain}" to Mimecast > Administration > Gateway > Policies > Blocked Senders.`, type: 'info' }, + { label: 'Adjust spam hold threshold', description: 'Lower the spam hold threshold in Mimecast Spam Scanning policy to hold messages with scores ≥5 for admin review.', type: 'info' }, + ], + }; + } + + if (envelopeMismatch) { + return { + headline: 'Envelope / Header Mismatch', + severity: 'medium', + explanation: `The email's envelope sender (${m.fromEnv}) doesn't match the From header (${from}). This is common with third-party sending platforms (Mailchimp, SendGrid, HubSpot) but can also indicate spoofing. Spam score was ${score}. The message was delivered.`, + actions: [ + { label: 'Verify the sender', description: 'Check whether the sending platform is authorised to send on behalf of this domain (SPF/DKIM). Contact the sender via another channel if unsure.', type: 'info' }, + { label: 'Add DMARC bypass if legitimate', description: `If this is a known sender using a third-party platform, add "${fromDomain}" to a Mimecast permitted sender policy.`, type: 'info' }, + ], + }; + } + + if (status === 'rejected' || status === 'bounced') { + return { + headline: `Message ${status === 'rejected' ? 'Rejected' : 'Bounced'}`, + severity: 'low', + explanation: `This message was ${status} — it was not delivered to the recipient. ${status === 'rejected' ? 'Mimecast or the destination server rejected it during the SMTP session.' : 'It was accepted but subsequently bounced by the destination mailbox.'}`, + actions: [ + { label: 'Check recipient mailbox', description: 'Verify the recipient address is valid and the mailbox is not full or disabled.', type: 'info' }, + ], + }; + } + + return { + headline: 'Delivered — Clean', + severity: 'low', + explanation: `This message was delivered with a spam score of ${score} and no threat flags. Status: ${m.status}. No action is required.`, + actions: [ + { label: 'No action needed', description: 'This message appears clean. If you believe it is malicious, report it via the Mimecast console.', type: 'info' }, + ], + }; +} + +function DeliveredAnalysisDialog({ message, onClose }: { message: any; onClose: () => void }) { + if (!message) return null; + const analysis = analyzeDelivered(message); + + const severityBar = { high: 'border-l-red-500', medium: 'border-l-amber-500', low: 'border-l-blue-500' }; + const severityBadge = { + high: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400', + medium: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400', + low: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400', + }; + const severityLabel = { high: 'High risk', medium: 'Review needed', low: 'Clean' }; + const severityIcon = { + high: , + medium: , + low: , + }; + + return ( + !open && onClose()}> + + +
+ {analysis.headline} + + {severityIcon[analysis.severity]} + {severityLabel[analysis.severity]} + +
+
+ + {/* Message details */} +
+
+ Subject + {message.subject || '(no subject)'} +
+
+ From +
+
{message.from}
+ {message.fromEnv && message.fromEnv !== message.from && ( +
Envelope: {message.fromEnv}
+ )} +
+
+
+ To + {message.toDisplay ? `${message.toDisplay} <${message.to}>` : message.to} +
+
+ Received + {new Date(message.received).toLocaleString()} +
+
+ Status + {message.status} +
+
+ Spam score +
+ = 10 ? 'text-red-600' : message.spamScore >= 5 ? 'text-amber-600' : 'text-green-700'}`}> + {message.spamScore} + + {message.detectionLevel && ( + ({message.detectionLevel}) + )} +
+
= 10 ? 'bg-red-500' : message.spamScore >= 5 ? 'bg-amber-500' : 'bg-green-500'}`} + style={{ width: `${Math.min(100, (message.spamScore / 20) * 100)}%` }} /> +
+
+
+ {message.senderIP && ( +
+ Sender IP + {message.senderIP} +
+ )} +
+ Attachments + {message.attachments ? 'Yes' : 'No'} +
+
+ + {/* Explanation */} +
+ {analysis.explanation} +
+ + {/* Actions */} +
+

Recommended actions

+
+ {analysis.actions.map((action, i) => ( +
+

{action.label}

+

{action.description}

+
+ ))} +
+
+ +
+ + View full message tracking in the Mimecast Administration Console under Gateway > Message Center > Message Finder. + Open Console → + +
+ +
+ ); +} + +function DeliveredMailTab() { + const [tenantId, setTenantId] = useState('1'); + const [to, setTo] = useState(''); + const [from, setFrom] = useState(''); + const [subject, setSubject] = useState(''); + const [startHours, setStartHours] = useState(24); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [loaded, setLoaded] = useState(false); + const [statusFilter, setStatusFilter] = useState(''); + const [sortBy, setSortBy] = useState<'received' | 'spamScore'>('received'); + const [analysisMessage, setAnalysisMessage] = useState(null); + + const search = async () => { + if (!to && !from && !subject) return; + setLoading(true); + setLoadError(null); + try { + const res = await fetch('/api/mimecast/delivered', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenantId, to: to || undefined, from: from || undefined, subject: subject || undefined, startHours }), + }); + 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 filtered = messages + .filter(m => !statusFilter || m.status === statusFilter) + .sort((a, b) => sortBy === 'spamScore' + ? (b.spamScore ?? 0) - (a.spamScore ?? 0) + : new Date(b.received).getTime() - new Date(a.received).getTime() + ); + + const statuses = [...new Set(messages.map(m => m.status).filter(Boolean))].sort(); + const highRisk = messages.filter(m => (m.spamScore ?? 0) >= 10).length; + const medRisk = messages.filter(m => (m.spamScore ?? 0) >= 5 && (m.spamScore ?? 0) < 10).length; + + return ( +
+ {/* Search controls */} +
+
+ + Search Delivered Mail + — find messages that passed through Mimecast +
+
+
+ + +
+
+ + setTo(e.target.value)} + onKeyDown={e => e.key === 'Enter' && search()} + className="w-full border rounded-md px-3 py-1.5 text-sm bg-background" /> +
+
+ + setFrom(e.target.value)} + onKeyDown={e => e.key === 'Enter' && search()} + className="w-full border rounded-md px-3 py-1.5 text-sm bg-background" /> +
+
+ + setSubject(e.target.value)} + onKeyDown={e => e.key === 'Enter' && search()} + className="w-full border rounded-md px-3 py-1.5 text-sm bg-background" /> +
+
+
+
+ + +
+ + {!to && !from && !subject && ( + Enter at least one search field + )} +
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && ( +
+ + Searching message logs… +
+ )} + + {/* Summary stats */} + {loaded && !loading && messages.length > 0 && ( +
+
+ Total + {messages.length} +
+ {highRisk > 0 && ( +
+ + {highRisk} high spam score (≥10) +
+ )} + {medRisk > 0 && ( +
+ + {medRisk} moderate spam score (5–9) +
+ )} +
+ + {messages.length - highRisk - medRisk} clean +
+
+ )} + + {/* Filters + sort */} + {loaded && !loading && messages.length > 0 && ( +
+ {statuses.length > 1 && ( + + )} + + {filtered.length !== messages.length && ( + {filtered.length} of {messages.length} shown + )} +
+ )} + + {loaded && !loading && messages.length === 0 && ( +
+ No messages found for this search. Try a broader time range or different search terms. +
+ )} + + {loaded && !loading && filtered.length > 0 && ( +
+
+ + + + + + + + + + + + + + {filtered.map((m: any) => ( + = 10 ? 'bg-red-500/5' : m.spamScore >= 5 ? 'bg-amber-500/5' : ''}`}> + + + + + + + + + ))} + +
DateToFromSubjectStatusSpam
+ {new Date(m.received).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + +
{m.to}
+
+
{m.from}
+ {m.fromEnv && m.fromEnv !== m.from && ( +
{m.fromEnv}
+ )} +
+
{m.subject || '(no subject)'}
+
+ {m.status} + + = 10 ? 'text-red-600' : m.spamScore >= 5 ? 'text-amber-600' : 'text-muted-foreground'}`}> + {m.spamScore} + + + +
+
+
+ )} + + setAnalysisMessage(null)} /> +
+ ); +} + // ── Page ────────────────────────────────────────────────────────────────────── export default function MimecastSyncPage() { const [statusData, setStatusData] = useState(null); @@ -1050,9 +1476,10 @@ export default function MimecastSyncPage() { )} - + Status Held Mail + Delivered Messages Threats Cloud Users @@ -1064,6 +1491,7 @@ export default function MimecastSyncPage() { + diff --git a/app/api/mimecast/delivered/route.ts b/app/api/mimecast/delivered/route.ts new file mode 100644 index 0000000..50730e1 --- /dev/null +++ b/app/api/mimecast/delivered/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; +import { getMimecastClientForTenant } from '@/lib/services/mimecast-client'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + try { + const { tenantId, to, from, subject, startHours } = await req.json(); + + if (!tenantId) { + return NextResponse.json({ error: 'tenantId is required' }, { status: 400 }); + } + if (!to && !from && !subject) { + return NextResponse.json({ error: 'At least one of to, from, or subject is required' }, { status: 400 }); + } + + const r = await postgresClient.query( + `SELECT mt.*, c.company_name FROM mimecast_tenants mt + LEFT JOIN companies c ON c.id = mt.company_id + WHERE mt.id = $1 AND mt.enabled = true`, + [tenantId] + ); + if (!r.rows.length) { + return NextResponse.json({ error: 'Tenant not found' }, { status: 404 }); + } + + const client = getMimecastClientForTenant(r.rows[0]); + const result = await client.searchDeliveredMessages({ + to: to || undefined, + from: from || undefined, + subject: subject || undefined, + startHours: startHours ?? 24, + route: 'INBOUND', + }); + + if (result.error) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json({ messages: result.messages, total: result.messages.length }); + } catch (error: any) { + console.error('[Delivered] search error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/lib/services/mimecast-client.ts b/lib/services/mimecast-client.ts index f38cb79..aa5b03a 100644 --- a/lib/services/mimecast-client.ts +++ b/lib/services/mimecast-client.ts @@ -89,6 +89,23 @@ export interface MimecastHeldMessage { size: number; } +export interface MimecastDeliveredMessage { + id: string; + status: string; + subject: string; + from: string; + fromEnv: string; + to: string; + toDisplay: string; + received: string; + senderIP: string; + spamScore: number; + detectionLevel: string; + attachments: boolean; + route: string; + info: string; +} + export interface MimecastCloudUser { emailAddress: string; domain: string; @@ -526,6 +543,65 @@ export class MimecastClient { return { messages: all, totalCount }; } + /** + * POST /api/message-finder/search + * Search delivered/accepted inbound messages. Requires at least one of: to, from, subject, senderIP, url. + */ + async searchDeliveredMessages(options: { + to?: string; + from?: string; + subject?: string; + startHours?: number; + start?: string; + end?: string; + route?: string; + }): Promise<{ messages: MimecastDeliveredMessage[]; error?: string }> { + const now = new Date(); + const startDate = options.start + ? options.start + : new Date(now.getTime() - (options.startHours ?? 24) * 60 * 60 * 1000) + .toISOString() + .replace(/\.\d{3}Z$/, '+0000'); + const endDate = options.end ?? now.toISOString().replace(/\.\d{3}Z$/, '+0000'); + + const opts: Record = {}; + if (options.to) opts.to = options.to; + if (options.from) opts.from = options.from; + if (options.subject) opts.subject = options.subject; + if (options.route) opts.route = options.route; + + try { + const result = await this.request('POST', '/api/message-finder/search', { + data: [{ + start: startDate, + end: endDate, + advancedTrackAndTraceOptions: opts, + }], + }); + const emails: any[] = result?.data?.[0]?.trackedEmails ?? []; + return { + messages: emails.map(e => ({ + id: e.id, + status: e.status, + subject: e.subject ?? '', + from: e.fromHdr?.emailAddress ?? e.fromEnv?.emailAddress ?? '', + fromEnv: e.fromEnv?.emailAddress ?? '', + to: e.to?.[0]?.emailAddress ?? '', + toDisplay: e.to?.[0]?.displayableName ?? '', + received: e.received, + senderIP: e.senderIP ?? '', + spamScore: e.spamScore ?? 0, + detectionLevel: e.detectionLevel ?? '', + attachments: e.attachments ?? false, + route: e.route ?? '', + info: e.info ?? '', + })), + }; + } catch (err: any) { + return { messages: [], error: err.message }; + } + } + /** * POST /api/gateway/hold-release * Releases a held message by ID. Returns true if released successfully.