feat: held mail analysis dialog + fix release check

- Add analyzeMessage() — context-aware explanations for DMARC/impersonation/spam/auth-code/malware holds
- Add MessageAnalysisDialog with severity icon, message details, explanation, and resolution options
- Analyze button per row opens the dialog; Release button inside dialog triggers release + closes
- Fix releaseHeldMessage(): treat HTTP 200 + empty fail[] as success (not release===true check)
- Remove action:'release' from payload (API doesn't need it)
This commit is contained in:
lorentz 2026-04-01 07:18:52 -04:00
parent a15946daf8
commit 9952365df1
2 changed files with 203 additions and 16 deletions

View file

@ -4,11 +4,12 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2,
CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send,
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
PauseCircle, Building2, Check,
PauseCircle, Building2, Check, Info,
} from 'lucide-react';
import SyncScheduler from '@/components/admin/SyncScheduler';
@ -458,6 +459,169 @@ function HistoryTab() {
);
}
// ── Message Analysis ─────────────────────────────────────────────────────────
function analyzeMessage(m: any): { headline: string; explanation: string; severity: 'red' | 'yellow' | 'green'; actions: { label: string; description: string; type: 'release' | 'info' }[] } {
const policy: string = (m.policyInfo ?? m.reason ?? '').toLowerCase();
const reason: string = (m.reason ?? '').toLowerCase();
const from: string = m.from ?? '';
const fromDisplay: string = m.fromDisplay ?? '';
// DMARC failures
if (policy.includes('dmarc') || reason.includes('dmarc')) {
return {
headline: 'DMARC Authentication Failure',
severity: 'red',
explanation: `This email failed DMARC authentication. The sender domain's DMARC policy rejected it because the email's "From" header (${fromDisplay || from}) doesn't align with the authenticated sending domain. This is a common pattern in spoofing attempts — but can also occur with legitimate senders who haven't configured SPF/DKIM on their sending infrastructure.`,
actions: [
{ label: 'Release this email', description: 'Deliver it now if you recognize the sender and trust the content.', type: 'release' },
{ label: 'How to fix permanently', description: 'Ask the sender to configure SPF and DKIM records on their domain, or add an inbound DMARC bypass policy in Mimecast for this sender domain.', type: 'info' },
],
};
}
// Impersonation
if (policy.includes('impersonation') || reason.includes('impersonation')) {
return {
headline: 'Impersonation Attempt Detected',
severity: 'red',
explanation: `Mimecast flagged this email as a potential impersonation attack. The display name "${fromDisplay || from}" may resemble an internal user, executive, or trusted partner. These are frequently used in BEC (Business Email Compromise) attacks.`,
actions: [
{ label: 'Release this email', description: 'Only release if you have verified this is a legitimate sender out-of-band (phone/Teams). Do not rely on the email itself to confirm identity.', type: 'release' },
{ label: 'Add to permitted senders', description: 'In Mimecast, add a permitted sender policy for this specific address to prevent future holds.', type: 'info' },
],
};
}
// Spam / spam signature
if (policy.includes('spam') || reason.includes('spam')) {
const isAuth = (m.subject ?? '').toLowerCase().includes('authentication code') ||
(m.subject ?? '').toLowerCase().includes('verification code') ||
(m.subject ?? '').toLowerCase().includes('your code') ||
(m.subject ?? '').toLowerCase().includes('otp') ||
(m.subject ?? '').toLowerCase().includes('one-time');
if (isAuth) {
return {
headline: 'Authentication / Verification Code Held',
severity: 'yellow',
explanation: `This looks like a legitimate authentication or verification code email from "${fromDisplay || from}". It was held by spam detection, likely due to a spam-like sending pattern (bulk infrastructure, shared IP reputation), not because it's genuinely malicious.`,
actions: [
{ label: 'Release this email', description: 'Release to deliver the verification code to the recipient.', type: 'release' },
{ label: 'Allow this sender permanently', description: `Add "${from}" to the permitted senders list in Mimecast > Gateway > Policies > Permitted Senders to prevent future holds from this address.`, type: 'info' },
],
};
}
return {
headline: 'Spam Signature Detected',
severity: 'yellow',
explanation: `Mimecast's spam engine matched this email against known spam signatures. The sender "${fromDisplay || from}" triggered a spam policy rule. This may be a marketing email, newsletter, or a legitimate transactional email from a sender with low reputation.`,
actions: [
{ label: 'Release this email', description: 'Deliver it if you recognize the sender and the recipient is expecting it.', type: 'release' },
{ label: 'Allow this sender permanently', description: `Add "${from}" to permitted senders in Mimecast > Gateway > Policies > Permitted Senders.`, type: 'info' },
{ label: 'Block this sender', description: `Add "${from}" to blocked senders in Mimecast > Gateway > Policies > Blocked Senders if this is unwanted mail.`, type: 'info' },
],
};
}
// Malware / attachment
if (policy.includes('malware') || policy.includes('attachment') || reason.includes('malware')) {
return {
headline: 'Malware or Dangerous Attachment',
severity: 'red',
explanation: `This email was held because Mimecast detected a potentially malicious attachment or URL. Do not release without careful review.`,
actions: [
{ label: 'Do not release', description: 'This email should be reviewed by security before delivery. Contact the sender via another channel to verify legitimacy.', type: 'info' },
{ label: 'View in Mimecast console', description: 'Open the Mimecast Administration Console to view full threat details and attachment analysis.', type: 'info' },
],
};
}
// Generic hold
return {
headline: 'Message Hold Applied',
severity: 'yellow',
explanation: `This email was held by a Mimecast policy: "${m.policyInfo || m.reason}". Review the sender and subject before releasing.`,
actions: [
{ label: 'Release this email', description: 'Deliver it to the recipient if you determine it is safe.', type: 'release' },
],
};
}
function MessageAnalysisDialog({ message, onClose, onRelease, releasing }: {
message: any;
onClose: () => void;
onRelease: (m: any) => void;
releasing: boolean;
}) {
if (!message) return null;
const analysis = analyzeMessage(message);
const severityColors = {
red: 'border-red-300 bg-red-50 dark:bg-red-950/20 text-red-700',
yellow: 'border-yellow-300 bg-yellow-50 dark:bg-yellow-950/20 text-yellow-700',
green: 'border-green-300 bg-green-50 dark:bg-green-950/20 text-green-700',
};
const severityIcon = {
red: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
yellow: <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0" />,
green: <CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />,
};
return (
<Dialog open={!!message} onOpenChange={open => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{severityIcon[analysis.severity]}
{analysis.headline}
</DialogTitle>
</DialogHeader>
{/* Message details */}
<div className="rounded-lg border bg-muted/30 p-3 space-y-1 text-sm">
<div className="flex gap-2"><span className="text-muted-foreground w-16 flex-shrink-0">Subject</span><span className="font-medium">{message.subject || '(no subject)'}</span></div>
<div className="flex gap-2"><span className="text-muted-foreground w-16 flex-shrink-0">From</span><span>{message.fromDisplay || message.from}{message.fromDisplay ? <span className="text-muted-foreground ml-1">&lt;{message.from}&gt;</span> : ''}</span></div>
<div className="flex gap-2"><span className="text-muted-foreground w-16 flex-shrink-0">To</span><span>{message.toDisplay || message.to}</span></div>
<div className="flex gap-2"><span className="text-muted-foreground w-16 flex-shrink-0">Date</span><span>{new Date(message.dateReceived).toLocaleString()}</span></div>
<div className="flex gap-2"><span className="text-muted-foreground w-16 flex-shrink-0">Policy</span>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
analysis.severity === 'red' ? 'bg-red-500/10 text-red-600' : 'bg-yellow-500/10 text-yellow-700'
}`}>{message.policyInfo || message.reason}</span>
</div>
</div>
{/* Explanation */}
<div className={`rounded-lg border p-3 text-sm ${severityColors[analysis.severity]}`}>
{analysis.explanation}
</div>
{/* Resolution options */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Resolution Options</p>
{analysis.actions.map((action, i) => (
<div key={i} className="rounded-lg border p-3 flex items-start justify-between gap-3">
<div className="space-y-0.5 flex-1">
<p className="text-sm font-medium">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
{action.type === 'release' && (
<Button
size="sm"
variant="outline"
className="flex-shrink-0 gap-1 text-green-700 border-green-300 hover:bg-green-50"
disabled={releasing}
onClick={() => { onRelease(message); onClose(); }}
>
{releasing ? <Loader2 className="w-3 h-3 animate-spin" /> : <Check className="w-3 h-3" />}
Release
</Button>
)}
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
// ── Held Mail Tab ─────────────────────────────────────────────────────────────
const TENANT_OPTIONS = [
{ id: '1', name: 'Wulf Consulting' },
@ -475,6 +639,7 @@ function HeldMailTab() {
const [loaded, setLoaded] = useState(false);
const [releasing, setReleasing] = useState<Record<string, boolean>>({});
const [releaseErrors, setReleaseErrors] = useState<Record<string, string>>({});
const [analysisMessage, setAnalysisMessage] = useState<any>(null);
const load = async (recipientVal?: string) => {
setLoading(true);
@ -661,18 +826,29 @@ function HeldMailTab() {
</td>
<td className="px-4 py-2">
<div className="flex flex-col items-end gap-1">
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 text-green-700 border-green-300 hover:bg-green-50"
disabled={releasing[m.id]}
onClick={() => release(m)}
>
{releasing[m.id]
? <Loader2 className="w-3 h-3 animate-spin" />
: <Check className="w-3 h-3" />}
Release
</Button>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={() => setAnalysisMessage(m)}
>
<Info className="w-3 h-3" />
Analyze
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 text-green-700 border-green-300 hover:bg-green-50"
disabled={releasing[m.id]}
onClick={() => release(m)}
>
{releasing[m.id]
? <Loader2 className="w-3 h-3 animate-spin" />
: <Check className="w-3 h-3" />}
Release
</Button>
</div>
{releaseErrors[m.id] && (
<span className="text-xs text-red-500">{releaseErrors[m.id]}</span>
)}
@ -685,6 +861,13 @@ function HeldMailTab() {
</div>
</div>
)}
<MessageAnalysisDialog
message={analysisMessage}
onClose={() => setAnalysisMessage(null)}
onRelease={release}
releasing={analysisMessage ? !!releasing[analysisMessage.id] : false}
/>
</div>
);
}

View file

@ -533,10 +533,14 @@ export class MimecastClient {
async releaseHeldMessage(id: string): Promise<{ released: boolean; error?: string }> {
try {
const result = await this.request<any>('POST', '/api/gateway/hold-release', {
data: [{ id, action: 'release' }],
data: [{ id }],
});
const row = result?.data?.[0];
return { released: row?.release === true };
// API returns 200 with fail[] empty on success; release field may be false for already-released msgs
const hasFail = result?.fail?.length > 0;
if (hasFail) {
return { released: false, error: result.fail[0]?.message ?? 'Release rejected by Mimecast' };
}
return { released: true };
} catch (err: any) {
return { released: false, error: err.message };
}