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:
parent
a15946daf8
commit
9952365df1
2 changed files with 203 additions and 16 deletions
|
|
@ -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"><{message.from}></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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue