wulf-pulse/app/admin/sync/mimecast/page.tsx
lorentz 8e28062d85 feat: add Delivered Mail tab with message-finder search, spam scoring, analysis dialog
- 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
2026-04-01 08:17:18 -04:00

1503 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
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, Info, TrendingUp, ExternalLink, Eye,
} from 'lucide-react';
import SyncScheduler from '@/components/admin/SyncScheduler';
function fmtDate(d: string | null | undefined) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
function fmtNum(n: number | null | undefined) {
if (n == null) return '—';
return n.toLocaleString();
}
function StatCard({ label, value, sub, icon: Icon, cls }: {
label: string; value: string | number; sub?: string; icon?: React.ElementType; cls?: string;
}) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'delivered' ? 'bg-green-500/15 text-green-700' :
status === 'rejected' ? 'bg-red-500/15 text-red-600' :
status === 'held' ? 'bg-yellow-500/15 text-yellow-700' :
status === 'bounced' ? 'bg-orange-500/15 text-orange-700' :
status === 'spam' ? 'bg-purple-500/15 text-purple-700' :
'bg-muted text-muted-foreground';
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{status || '—'}
</span>
);
}
function ThreatBadge({ level }: { level: string }) {
const cls =
level === 'high' ? 'bg-red-500/15 text-red-600' :
level === 'medium' ? 'bg-orange-500/15 text-orange-700' :
level === 'low' ? 'bg-yellow-500/15 text-yellow-700' :
'bg-muted text-muted-foreground';
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{level || 'info'}
</span>
);
}
// ── Status Tab ────────────────────────────────────────────────────────────────
function StatusTab({ data, onSync, syncing }: { data: any; onSync: (t: string) => void; syncing: boolean }) {
if (!data) return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
);
const stats = data.stats ?? {};
return (
<div className="space-y-6">
{/* Connection banner */}
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
{data.connected
? <CheckCircle2 className="w-4 h-4 text-green-500" />
: <XCircle className="w-4 h-4 text-red-500" />}
<p className="text-sm font-medium">
{data.connected ? `Connected — ${data.accountName ?? 'Mimecast'}` : 'Not connected'}
</p>
{data.packageName && (
<span className="text-xs text-muted-foreground">({data.packageName})</span>
)}
</div>
<p className="text-xs text-muted-foreground">
Last sync: {fmtDate(stats.lastSync)} · Oldest message: {fmtDate(stats.oldestMessage)}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => onSync('incremental')} disabled={syncing || !data.connected}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <RefreshCw className="w-4 h-4 mr-1" />}
Incremental
</Button>
<Button size="sm" onClick={() => onSync('full')} disabled={syncing || !data.connected}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <RefreshCw className="w-4 h-4 mr-1" />}
Full Sync (120d)
</Button>
</div>
</div>
{data.error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/20 p-3 text-sm text-red-600">
{data.error}
</div>
)}
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<StatCard label="Total Messages" value={fmtNum(stats.messages)} icon={Mail} />
<StatCard label="Inbound" value={fmtNum(stats.inbound)} icon={Inbox} cls="border-blue-500/20 bg-blue-500/5" />
<StatCard label="Outbound" value={fmtNum(stats.outbound)} icon={Send} cls="border-green-500/20 bg-green-500/5" />
<StatCard label="Threat Events" value={fmtNum(stats.threats)} icon={Shield}
cls={(stats.threats ?? 0) > 0 ? 'border-red-500/20 bg-red-500/5' : ''} />
<StatCard label="Bodies Stored" value={fmtNum(stats.bodies)} icon={Activity} />
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Data Retention</p>
<p className="text-sm text-muted-foreground">
120-day rolling window. Messages older than 120 days are automatically purged on each sync.
Message bodies are fetched for delivered inbound messages (up to 500 per sync run).
</p>
</div>
</div>
);
}
// ── Messages Tab ──────────────────────────────────────────────────────────────
function MessagesTab() {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [direction, setDirection] = useState('');
const [status, setStatus] = useState('');
const [days, setDays] = useState('7');
const load = () => {
setLoading(true);
const params = new URLSearchParams({ days, limit: '200' });
if (search) params.set('search', search);
if (direction) params.set('direction', direction);
if (status) params.set('status', status);
fetch(`/api/mimecast/messages?${params}`)
.then(r => r.json())
.then(d => setRows(d.messages ?? []))
.catch(() => setRows([]))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [days, direction, status]);
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap gap-2">
<input
type="text"
placeholder="Search sender, recipient, subject…"
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && load()}
className="flex-1 min-w-48 border rounded-md px-3 py-1.5 text-sm bg-background"
/>
<select value={direction} onChange={e => setDirection(e.target.value)}
className="border rounded-md px-3 py-1.5 text-sm bg-background">
<option value="">All directions</option>
<option value="inbound">Inbound</option>
<option value="outbound">Outbound</option>
</select>
<select value={status} onChange={e => setStatus(e.target.value)}
className="border rounded-md px-3 py-1.5 text-sm bg-background">
<option value="">All statuses</option>
<option value="delivered">Delivered</option>
<option value="rejected">Rejected</option>
<option value="held">Held</option>
<option value="bounced">Bounced</option>
<option value="spam">Spam</option>
</select>
<select value={days} onChange={e => setDays(e.target.value)}
className="border rounded-md px-3 py-1.5 text-sm bg-background">
<option value="1">Last 24h</option>
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="120">Last 120 days</option>
</select>
<Button size="sm" variant="outline" onClick={load}>
<RefreshCw className="w-4 h-4 mr-1" />Search
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : !rows.length ? (
<div className="text-center py-12 text-sm text-muted-foreground">
No messages found run a sync first or adjust filters
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">From</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">To</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Subject</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Direction</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 text-xs truncate max-w-[180px]" title={r.sender_address}>{r.sender_address ?? '—'}</td>
<td className="px-4 py-2 text-xs truncate max-w-[180px]" title={r.recipient_address}>{r.recipient_address ?? '—'}</td>
<td className="px-4 py-2 text-xs truncate max-w-[200px]" title={r.subject}>{r.subject ?? '—'}</td>
<td className="px-4 py-2 text-xs capitalize text-muted-foreground">{r.direction ?? '—'}</td>
<td className="px-4 py-2"><StatusBadge status={r.status} /></td>
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">{fmtDate(r.sent_datetime)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── Threats Tab ───────────────────────────────────────────────────────────────
function ThreatsTab() {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/mimecast/threats?limit=200')
.then(r => r.json())
.then(d => setRows(d.threats ?? []))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!rows.length) return <div className="text-center py-12 text-sm text-muted-foreground">No threat events run a sync first</div>;
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Level</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Actor</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Verdict</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">URL / File</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">When</th>
</tr>
</thead>
<tbody>
{rows.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 text-xs capitalize">{r.event_type ?? '—'}</td>
<td className="px-4 py-2"><ThreatBadge level={r.threat_level} /></td>
<td className="px-4 py-2 text-xs text-muted-foreground">{r.actor_email ?? '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground capitalize">{r.verdict ?? '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground truncate max-w-[200px]" title={r.url ?? r.file_name ?? ''}>
{r.url ?? r.file_name ?? '—'}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">{fmtDate(r.event_datetime)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── Cloud Users Tab ───────────────────────────────────────────────────────────
function CloudUserTab() {
const [email, setEmail] = useState('');
const [domain, setDomain] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [showRaw, setShowRaw] = useState(false);
const handleEmailChange = (v: string) => {
setEmail(v);
const atIdx = v.indexOf('@');
if (atIdx >= 0) setDomain(v.slice(atIdx + 1));
};
const lookup = async () => {
if (!email || !domain) return;
setLoading(true);
setResult(null);
setShowRaw(false);
try {
const params = new URLSearchParams({ emailAddress: email, domain });
const res = await fetch(`/api/mimecast/cloud-user?${params}`);
setResult(await res.json());
} catch (err: any) {
setResult({ error: err.message });
} finally {
setLoading(false);
}
};
const user = result?.user;
const lockedOut = user?.lockedOut ?? false;
return (
<div className="space-y-5">
<div className="flex flex-wrap gap-2 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Email address</label>
<input
type="email"
placeholder="user@domain.com"
value={email}
onChange={e => handleEmailChange(e.target.value)}
onKeyDown={e => e.key === 'Enter' && lookup()}
className="border rounded-md px-3 py-1.5 text-sm bg-background w-72"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Domain</label>
<input
type="text"
placeholder="domain.com"
value={domain}
onChange={e => setDomain(e.target.value)}
onKeyDown={e => e.key === 'Enter' && lookup()}
className="border rounded-md px-3 py-1.5 text-sm bg-background w-48"
/>
</div>
<Button size="sm" onClick={lookup} disabled={loading || !email || !domain}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Search className="w-4 h-4 mr-1" />}
Look Up
</Button>
</div>
{result?.error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/20 p-3 text-sm text-red-600">
{result.error}
</div>
)}
{result && !result.error && !result.found && (
<div className="rounded-lg border p-4 text-sm text-muted-foreground">
User not found in Mimecast Cloud Gateway.
</div>
)}
{user && (
<div className="space-y-3">
<div className={`rounded-lg border p-4 flex items-center gap-3 ${lockedOut ? 'border-red-400 bg-red-50 dark:bg-red-950/20' : 'border-green-400 bg-green-50 dark:bg-green-950/20'}`}>
{lockedOut
? <LockKeyhole className="w-5 h-5 text-red-600 shrink-0" />
: <UnlockKeyhole className="w-5 h-5 text-green-600 shrink-0" />}
<div>
<p className={`font-semibold text-sm ${lockedOut ? 'text-red-700' : 'text-green-700'}`}>
{lockedOut ? 'Account Locked Out' : 'Account Active'}
</p>
<p className="text-xs text-muted-foreground">
{user.name && <span>{user.name} · </span>}
{user.emailAddress}
{user.status && <span> · Status: {user.status}</span>}
</p>
</div>
</div>
<button
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
onClick={() => setShowRaw(v => !v)}
>
{showRaw ? 'Hide' : 'Show'} raw response
</button>
{showRaw && (
<pre className="rounded-lg border bg-muted/30 p-3 text-xs overflow-auto max-h-72">
{JSON.stringify(user._raw ?? user, null, 2)}
</pre>
)}
</div>
)}
</div>
);
}
// ── History Tab ───────────────────────────────────────────────────────────────
function HistoryTab() {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/sync/history?entityType=mimecast&limit=30')
.then(r => r.json())
.then(d => setRows(d.history ?? []))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!rows.length) return <div className="text-center py-12 text-sm text-muted-foreground">No sync history yet</div>;
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Messages</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Threats</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{rows.map((r: any, i: number) => {
const dur = r.completed_at && r.started_at
? new Date(r.completed_at).getTime() - new Date(r.started_at).getTime()
: null;
const durStr = dur == null ? '—' : dur < 60000 ? `${Math.round(dur / 1000)}s` : `${Math.floor(dur / 60000)}m ${Math.round((dur % 60000) / 1000)}s`;
const statusCls = r.status === 'completed' ? 'bg-green-500/15 text-green-700' : r.status === 'failed' ? 'bg-red-500/15 text-red-600' : 'bg-muted text-muted-foreground';
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata ?? {});
return (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 capitalize text-xs">{r.sync_type ?? '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusCls}`}>{r.status}</span>
</td>
<td className="px-4 py-2 tabular-nums text-xs">{fmtNum(meta.messagesUpserted ?? r.records_added)}</td>
<td className="px-4 py-2 tabular-nums text-xs">{fmtNum(meta.threatsUpserted)}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(r.started_at)}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{durStr}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ── Message Analysis ─────────────────────────────────────────────────────────
type AnalysisAction = { label: string; description: string; type: 'release' | 'info'; warning?: boolean };
type Analysis = {
headline: string;
explanation: string;
severity: 'high' | 'medium' | 'low';
priorSteps: string[];
actions: AnalysisAction[];
};
function analyzeMessage(m: any): Analysis {
const code: string = (m.reasonCode ?? '').toLowerCase();
const policy: string = (m.policyInfo ?? '').toLowerCase();
const reason: string = (m.reason ?? '').toLowerCase();
const from: string = m.from ?? '';
const fromDisplay: string = m.fromDisplay ?? '';
const fromDomain = from.includes('@') ? from.split('@')[1] : from;
const subject: string = (m.subject ?? '').toLowerCase();
// What Mimecast has already evaluated (always true — it went through the full pipeline)
const priorSteps = [
'Passed through Mimecast inbound gateway',
'Evaluated against permitted sender policies — no matching bypass found',
'Evaluated against recipient-based allow rules — no match',
];
// DMARC
if (code.includes('dmarc') || policy.includes('dmarc') || reason.includes('dmarc')) {
return {
headline: 'DMARC Authentication Failure',
severity: 'high',
explanation: `The sending domain failed DMARC validation. The "From" address (${fromDisplay || from}) does not align with the domain that actually sent the message (SPF/DKIM mismatch). This can indicate spoofing — but also fires for legitimate senders using shared email infrastructure (e.g. Mailchimp, Zendesk, HubSpot) who haven't set up DKIM alignment.`,
priorSteps: [...priorSteps, 'SPF and DKIM alignment checks failed'],
actions: [
{ label: 'Release this message', description: 'Deliver it now if you recognise the sender. The recipient will receive it normally.', type: 'release' },
{ label: 'Add a permitted sender policy', description: `In Mimecast: Administration > Gateway > Policies > Permitted Senders. Add sender domain "${fromDomain}" to bypass DMARC holds for this domain going forward.`, type: 'info' },
{ label: 'Ask the sender to fix their authentication', description: 'The sender should configure DKIM signing on their email platform and ensure the d= domain in DKIM matches their From domain.', type: 'info' },
],
};
}
// Impersonation
if (code.includes('impersonation') || policy.includes('impersonation') || reason.includes('impersonation')) {
return {
headline: 'Impersonation Protection Hold',
severity: 'high',
explanation: `Mimecast's impersonation protection flagged "${fromDisplay || from}" as potentially impersonating an internal user or trusted contact. The display name may match an executive or employee while the sending address is external. This is the primary vector for BEC (Business Email Compromise) fraud.`,
priorSteps: [...priorSteps, 'Display name matched internal user list — external sender flagged'],
actions: [
{ label: 'Release this message', description: 'Only release after verifying identity through another channel (phone/Teams). Do not confirm via reply to the held email.', type: 'release', warning: true },
{ label: 'Add to permitted senders', description: `If this is a legitimate contact, add "${from}" as a permitted sender in Mimecast to bypass impersonation checks for this specific address.`, type: 'info' },
],
};
}
// Spam
if (code.includes('spam') || policy.includes('spam') || reason.includes('spam')) {
const isAuthCode = subject.includes('authentication code') || subject.includes('verification code') ||
subject.includes('your code') || subject.includes('otp') || subject.includes('one-time') ||
subject.includes('access code') || subject.includes('login code');
const isMarketing = subject.includes('unsubscribe') || subject.includes('offer') ||
subject.includes('deal') || subject.includes('sale') || subject.includes('newsletter') ||
(m.hasAttachments === false && m.size > 30000);
if (isAuthCode) {
return {
headline: 'Authentication Code — Held by Spam Filter',
severity: 'low',
explanation: `This is almost certainly a legitimate authentication or verification code email from "${fromDisplay || from}". It was caught by spam detection due to the sending infrastructure's reputation score — not because the content is malicious. The recipient is likely waiting for this code.`,
priorSteps: [...priorSteps, `Spam score exceeded threshold for policy "${m.policyInfo}"`, 'No permitted sender rule found for this address'],
actions: [
{ label: 'Release this message', description: 'Deliver it now. The verification code is time-sensitive.', type: 'release' },
{ label: 'Add permitted sender rule', description: `Add "${from}" to Mimecast > Administration > Gateway > Policies > Permitted Senders so future codes from this address are delivered without holds.`, type: 'info' },
],
};
}
if (isMarketing) {
return {
headline: 'Marketing / Promotional Email',
severity: 'low',
explanation: `This appears to be a marketing or promotional email from "${fromDisplay || from}" that triggered the spam policy "${m.policyInfo}". These are frequently held when sent from bulk mail platforms (Mailchimp, Constant Contact, etc.) with mixed sender reputation.`,
priorSteps: [...priorSteps, `Spam score exceeded threshold for policy "${m.policyInfo}"`],
actions: [
{ label: 'Release this message', description: 'Deliver if the recipient has opted in or is expecting communications from this sender.', type: 'release' },
{ label: 'Add permitted sender rule', description: `Add sender domain "${fromDomain}" to permitted senders if this is a trusted marketing partner.`, type: 'info' },
{ label: 'Block this sender', description: `Add "${fromDomain}" to blocked senders in Mimecast > Gateway > Policies > Blocked Senders if this is unwanted mail.`, type: 'info' },
],
};
}
return {
headline: 'Spam Signature Match',
severity: 'medium',
explanation: `The email from "${fromDisplay || from}" matched a spam signature under policy "${m.policyInfo}". This can be a false positive for legitimate transactional or notification emails sent through shared infrastructure with a low sender reputation.`,
priorSteps: [...priorSteps, `Spam score exceeded threshold for policy "${m.policyInfo}"`],
actions: [
{ label: 'Release this message', description: 'Deliver if you recognise the sender and the recipient is expecting this email.', type: 'release' },
{ label: 'Add permitted sender rule', description: `Add "${from}" to Mimecast > Administration > Gateway > Policies > Permitted Senders to prevent future holds.`, type: 'info' },
{ label: 'Block this sender', description: `Add "${fromDomain}" to blocked senders if this is definitively spam.`, type: 'info' },
],
};
}
// Malware / threat
if (code.includes('malware') || code.includes('virus') || code.includes('threat') ||
policy.includes('malware') || reason.includes('malware')) {
return {
headline: 'Malware or Threat Detected',
severity: 'high',
explanation: `Mimecast detected a potentially malicious attachment or URL in this email. Do not release without thorough review by a security administrator.`,
priorSteps: [...priorSteps, 'Attachment/URL scanned — threat signature matched'],
actions: [
{ label: 'Do not release without security review', description: 'Contact the sender through a separate channel to verify this email is legitimate before considering release.', type: 'info', warning: true },
{ label: 'View full threat details', description: 'Open Mimecast Administration Console > Gateway > Held Queue to view the full attachment analysis and URL scan results.', type: 'info' },
],
};
}
return {
headline: 'Message Hold Applied',
severity: 'medium',
explanation: `This email was held under policy "${m.policyInfo || m.reason}". Review the sender and content before releasing.`,
priorSteps,
actions: [
{ label: 'Release this message', description: 'Deliver it to the recipient if you determine it is safe.', type: 'release' },
{ label: 'Add permitted sender rule', description: `Add "${from}" to Mimecast > Administration > Gateway > Policies > Permitted Senders to bypass this hold for future messages.`, type: 'info' },
],
};
}
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 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: 'Likely safe' };
const severityIcon = {
high: <XCircle className="w-4 h-4" />,
medium: <AlertTriangle className="w-4 h-4" />,
low: <CheckCircle2 className="w-4 h-4" />,
};
return (
<Dialog open={!!message} onOpenChange={open => !open && onClose()}>
<DialogContent className="max-w-xl w-full max-h-[85vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2 flex-wrap">
<DialogTitle className="text-base">{analysis.headline}</DialogTitle>
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${severityBadge[analysis.severity]}`}>
{severityIcon[analysis.severity]}
{severityLabel[analysis.severity]}
</span>
</div>
</DialogHeader>
{/* Message details */}
<div className="rounded-md border bg-muted/20 divide-y text-sm">
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Subject</span>
<span className="font-medium break-words">{message.subject || '(no subject)'}</span>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">From</span>
<span className="break-all">{message.fromDisplay ? `${message.fromDisplay} ` : ''}<span className="text-muted-foreground">{message.fromDisplay ? `<${message.from}>` : message.from}</span></span>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">To</span>
<span>{message.toDisplay || message.to}</span>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Received</span>
<span>{new Date(message.dateReceived).toLocaleString()}</span>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Policy</span>
<span className="font-medium">{message.policyInfo || '—'}</span>
</div>
{message.reason && (
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Reason</span>
<span className="text-muted-foreground">{message.reason}</span>
</div>
)}
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Size</span>
<span className="text-muted-foreground">
{message.size ? `${(message.size / 1024).toFixed(1)} KB` : '—'}
{message.hasAttachments ? <span className="ml-2 inline-flex items-center gap-0.5 text-xs"><Mail className="w-3 h-3" /> has attachments</span> : ''}
</span>
</div>
</div>
{/* Body not available note */}
<div className="rounded-md border border-dashed px-3 py-2.5 text-xs text-muted-foreground flex items-start gap-2">
<Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>Message body is not accessible via the Mimecast held mail API. To preview the full content, open the Mimecast Administration Console.
<a href="https://admin.services.mimecast.com" target="_blank" rel="noopener noreferrer" className="ml-1 underline hover:text-foreground">Open Mimecast Console </a>
</span>
</div>
{/* Explanation */}
<div className={`rounded-md border-l-4 border border-border pl-3 pr-3 py-2.5 text-sm text-foreground/90 ${severityBar[analysis.severity]}`}>
{analysis.explanation}
</div>
{/* What Mimecast already checked */}
<div className="space-y-1.5">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">What was already evaluated</p>
<div className="rounded-md border bg-muted/10 divide-y">
{analysis.priorSteps.map((step, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-2 text-xs text-muted-foreground">
<CheckCircle2 className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-muted-foreground/50" />
{step}
</div>
))}
</div>
</div>
{/* Resolution options */}
<div className="space-y-1.5">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Resolution options</p>
<div className="space-y-2">
{analysis.actions.map((action, i) => (
<div key={i} className={`rounded-md border p-3 flex items-start justify-between gap-3 ${
action.warning ? 'border-amber-300 bg-amber-50/50 dark:bg-amber-950/10' : 'bg-background'
}`}>
<div className="space-y-0.5 flex-1 min-w-0">
<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 text-green-700 border-green-300 hover:bg-green-50 dark:hover:bg-green-950/20"
disabled={releasing}
onClick={() => { onRelease(message); onClose(); }}
>
{releasing && <Loader2 className="w-3 h-3 animate-spin mr-1" />}
Release
</Button>
)}
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ── Held Mail Tab ─────────────────────────────────────────────────────────────
const TENANT_OPTIONS = [
{ id: '1', name: 'Wulf Consulting' },
{ id: '2', name: 'Seubert & Associates' },
];
function HeldMailTab() {
const [data, setData] = useState<any>(null);
const [messages, setMessages] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [recipient, setRecipient] = useState('');
const [tenantId, setTenantId] = useState('1');
const [policyFilter, setPolicyFilter] = useState('');
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);
setLoadError(null);
const params = new URLSearchParams();
const r = recipientVal ?? recipient;
if (r) params.set('recipient', r);
if (tenantId) params.set('tenantId', tenantId);
try {
const res = await fetch(`/api/mimecast/held?${params}`);
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const d = await res.json();
setData(d);
setMessages(d.messages ?? []);
setLoaded(true);
} catch (e: any) {
setLoadError(e.message ?? 'Unknown error');
} finally {
setLoading(false);
}
};
const release = async (m: any) => {
setReleasing(r => ({ ...r, [m.id]: true }));
setReleaseErrors(e => { const n = { ...e }; delete n[m.id]; return n; });
try {
const res = await fetch('/api/mimecast/held/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: m.id, tenantId: m.tenantId }),
});
const d = await res.json();
if (!res.ok || !d.released) {
throw new Error(d.error ?? 'Release failed');
}
// Optimistically remove from list
setMessages(prev => prev.filter(x => x.id !== m.id));
} catch (e: any) {
setReleaseErrors(prev => ({ ...prev, [m.id]: e.message }));
} finally {
setReleasing(r => { const n = { ...r }; delete n[m.id]; return n; });
}
};
const recipientLower = recipient.trim().toLowerCase();
const filtered = messages.filter(m => {
if (recipientLower && !m.to?.toLowerCase().includes(recipientLower) && !m.toDisplay?.toLowerCase().includes(recipientLower)) return false;
if (policyFilter && !m.policyInfo?.toLowerCase().includes(policyFilter.toLowerCase())) return false;
return true;
});
const policies = [...new Set(messages.map((m: any) => m.policyInfo).filter(Boolean))].sort();
const tenantInfo: any[] = data?.tenants ?? [];
const currentTenant = tenantInfo[0];
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex flex-wrap gap-2 items-end">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Tenant</label>
<select
value={tenantId}
onChange={e => { setTenantId(e.target.value); setLoaded(false); setData(null); setMessages([]); }}
className="border rounded-md px-3 py-1.5 text-sm bg-background"
>
{TENANT_OPTIONS.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div className="flex-1 min-w-56">
<label className="text-xs text-muted-foreground mb-1 block">
Recipient email
{loaded && recipient.trim() && (
<span className="ml-1 text-muted-foreground/60">({filtered.length} match{filtered.length !== 1 ? 'es' : ''})</span>
)}
</label>
<input
type="text"
placeholder="filter by recipient…"
value={recipient}
onChange={e => setRecipient(e.target.value)}
className="w-full border rounded-md px-3 py-1.5 text-sm bg-background"
/>
</div>
{loaded && policies.length > 0 && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">Policy</label>
<select value={policyFilter} onChange={e => setPolicyFilter(e.target.value)}
className="border rounded-md px-3 py-1.5 text-sm bg-background min-w-48">
<option value="">All policies</option>
{policies.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
)}
<Button onClick={() => load()} disabled={loading} className="gap-2">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
{loaded ? 'Refresh' : 'Load Held Mail'}
</Button>
</div>
{/* Tenant summary badge */}
{loaded && currentTenant && (
<div className="flex flex-wrap gap-2">
<div className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs border ${
currentTenant.error ? 'border-red-300 bg-red-50 dark:bg-red-950/20 text-red-600' :
currentTenant.count > 0 ? 'border-yellow-400/40 bg-yellow-500/5 text-yellow-700' :
'border-border bg-muted/30 text-muted-foreground'
}`}>
<Building2 className="w-3 h-3" />
<span className="font-medium">{currentTenant.accountName}</span>
{currentTenant.error
? <span title={currentTenant.error}> permission denied</span>
: <span> showing {messages.length.toLocaleString()}{currentTenant.totalCount > messages.length ? ` of ${currentTenant.totalCount.toLocaleString()}` : ''} held</span>
}
</div>
</div>
)}
{loadError && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/20 p-3 text-sm text-red-600">
{loadError}
</div>
)}
{!loaded && !loading && !loadError && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground text-sm">
Select a tenant and click &ldquo;Load Held Mail&rdquo;
</div>
)}
{loading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Fetching held messages</span>
</div>
)}
{loaded && !loading && filtered.length === 0 && (
<div className="rounded-lg border p-12 text-center text-muted-foreground text-sm">
No held messages found.
</div>
)}
{loaded && !loading && filtered.length > 0 && (
<div className="rounded-lg border overflow-hidden">
<div className="px-4 py-2 bg-muted/40 border-b">
<span className="text-sm font-medium">
{filtered.length.toLocaleString()} held message{filtered.length !== 1 ? 's' : ''}
{filtered.length < messages.length
? ` — filtered from ${messages.length.toLocaleString()}${currentTenant?.totalCount > messages.length ? ` of ${currentTenant.totalCount.toLocaleString()} total` : ''}`
: currentTenant?.totalCount > messages.length
? ` (showing ${messages.length.toLocaleString()} of ${currentTenant.totalCount.toLocaleString()} total)`
: ''}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm table-fixed">
<thead className="bg-muted/30">
<tr>
<th style={{width:'120px'}} className="text-left px-3 py-2 font-medium text-xs">Date</th>
<th style={{width:'160px'}} className="text-left px-3 py-2 font-medium text-xs">To</th>
<th style={{width:'180px'}} className="text-left px-3 py-2 font-medium text-xs">From</th>
<th className="text-left px-3 py-2 font-medium text-xs">Subject</th>
<th style={{width:'160px'}} className="text-left px-3 py-2 font-medium text-xs">Policy</th>
<th style={{width:'160px'}} className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((m: any) => (
<tr key={m.id} className="hover:bg-muted/20">
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs">
{new Date(m.dateReceived).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.to}</div>
</td>
<td className="px-3 py-2" style={{overflow:'hidden'}}>
<div className="font-medium text-xs truncate">{m.fromDisplay || m.from}</div>
{m.fromDisplay && <div className="text-xs text-muted-foreground truncate">{m.from}</div>}
</td>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.subject || '(no subject)'}</div>
</td>
<td className="px-3 py-2" style={{overflow:'hidden'}}>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
m.policyInfo?.includes('DMARC') || m.policyInfo?.includes('Impersonation')
? 'bg-red-500/10 text-red-600'
: 'bg-muted text-muted-foreground'
}`}>
{m.policyInfo || m.reason || '—'}
</span>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1 justify-end">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs px-2 whitespace-nowrap"
onClick={() => setAnalysisMessage(m)}
>
<Info className="w-3 h-3 mr-1" />
Analyze
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2 whitespace-nowrap text-green-700 border-green-300 hover:bg-green-50 dark:hover:bg-green-950/20"
disabled={releasing[m.id]}
onClick={() => release(m)}
>
{releasing[m.id] ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
Release
</Button>
</div>
{releaseErrors[m.id] && (
<div className="text-xs text-red-500 text-right mt-0.5">{releaseErrors[m.id]}</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<MessageAnalysisDialog
message={analysisMessage}
onClose={() => setAnalysisMessage(null)}
onRelease={release}
releasing={analysisMessage ? !!releasing[analysisMessage.id] : false}
/>
</div>
);
}
// ── 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: <XCircle className="w-4 h-4" />,
medium: <AlertTriangle className="w-4 h-4" />,
low: <CheckCircle2 className="w-4 h-4" />,
};
return (
<Dialog open={!!message} onOpenChange={open => !open && onClose()}>
<DialogContent className="max-w-xl w-full max-h-[85vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2 flex-wrap">
<DialogTitle className="text-base">{analysis.headline}</DialogTitle>
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${severityBadge[analysis.severity]}`}>
{severityIcon[analysis.severity]}
{severityLabel[analysis.severity]}
</span>
</div>
</DialogHeader>
{/* Message details */}
<div className="rounded-md border bg-muted/20 divide-y text-sm">
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Subject</span>
<span className="font-medium break-words">{message.subject || '(no subject)'}</span>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">From</span>
<div>
<div className="break-all">{message.from}</div>
{message.fromEnv && message.fromEnv !== message.from && (
<div className="text-xs text-muted-foreground mt-0.5">Envelope: {message.fromEnv}</div>
)}
</div>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">To</span>
<span>{message.toDisplay ? `${message.toDisplay} <${message.to}>` : message.to}</span>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Received</span>
<span>{new Date(message.received).toLocaleString()}</span>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Status</span>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium w-fit ${
message.status === 'accepted' ? 'bg-green-500/10 text-green-700'
: message.status === 'held' ? 'bg-amber-500/10 text-amber-700'
: message.status === 'rejected' || message.status === 'bounced' ? 'bg-red-500/10 text-red-600'
: 'bg-muted text-muted-foreground'
}`}>{message.status}</span>
</div>
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Spam score</span>
<div className="flex items-center gap-2">
<span className={`font-medium ${message.spamScore >= 10 ? 'text-red-600' : message.spamScore >= 5 ? 'text-amber-600' : 'text-green-700'}`}>
{message.spamScore}
</span>
{message.detectionLevel && (
<span className="text-xs text-muted-foreground">({message.detectionLevel})</span>
)}
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden max-w-24">
<div className={`h-full rounded-full ${message.spamScore >= 10 ? 'bg-red-500' : message.spamScore >= 5 ? 'bg-amber-500' : 'bg-green-500'}`}
style={{ width: `${Math.min(100, (message.spamScore / 20) * 100)}%` }} />
</div>
</div>
</div>
{message.senderIP && (
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Sender IP</span>
<span className="font-mono text-xs">{message.senderIP}</span>
</div>
)}
<div className="grid grid-cols-[72px_1fr] gap-2 px-3 py-2">
<span className="text-muted-foreground text-xs pt-0.5">Attachments</span>
<span className="text-muted-foreground">{message.attachments ? 'Yes' : 'No'}</span>
</div>
</div>
{/* Explanation */}
<div className={`rounded-md border-l-4 border border-border pl-3 pr-3 py-2.5 text-sm text-foreground/90 ${severityBar[analysis.severity]}`}>
{analysis.explanation}
</div>
{/* Actions */}
<div className="space-y-1.5">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Recommended actions</p>
<div className="space-y-2">
{analysis.actions.map((action, i) => (
<div key={i} className="rounded-md border p-3 space-y-0.5">
<p className="text-sm font-medium">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</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 &gt; Message Center &gt; Message Finder.
<a href="https://admin.services.mimecast.com" target="_blank" rel="noopener noreferrer" className="ml-1 underline hover:text-foreground">Open Console </a>
</span>
</div>
</DialogContent>
</Dialog>
);
}
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<any[]>([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [statusFilter, setStatusFilter] = useState('');
const [sortBy, setSortBy] = useState<'received' | 'spamScore'>('received');
const [analysisMessage, setAnalysisMessage] = useState<any>(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 (
<div className="space-y-4">
{/* Search controls */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Search Delivered Mail</span>
<span className="text-xs text-muted-foreground ml-1"> find messages that passed through Mimecast</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Tenant</label>
<select value={tenantId} onChange={e => { setTenantId(e.target.value); setLoaded(false); setMessages([]); }}
className="w-full border rounded-md px-3 py-1.5 text-sm bg-background">
{TENANT_OPTIONS.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Recipient (to)</label>
<input type="text" placeholder="user@domain.com" value={to}
onChange={e => 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" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Sender (from)</label>
<input type="text" placeholder="sender@domain.com" value={from}
onChange={e => 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" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Subject contains</label>
<input type="text" placeholder="keyword…" value={subject}
onChange={e => 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" />
</div>
</div>
<div className="flex items-end gap-3 flex-wrap">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Time range</label>
<select value={startHours} onChange={e => setStartHours(Number(e.target.value))}
className="border rounded-md px-3 py-1.5 text-sm bg-background">
<option value={6}>Last 6 hours</option>
<option value={24}>Last 24 hours</option>
<option value={48}>Last 48 hours</option>
<option value={72}>Last 72 hours</option>
<option value={168}>Last 7 days</option>
</select>
</div>
<Button onClick={search} disabled={loading || (!to && !from && !subject)} className="gap-2">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
Search
</Button>
{!to && !from && !subject && (
<span className="text-xs text-muted-foreground">Enter at least one search field</span>
)}
</div>
</div>
{loadError && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/20 p-3 text-sm text-red-600">{loadError}</div>
)}
{loading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Searching message logs</span>
</div>
)}
{/* Summary stats */}
{loaded && !loading && messages.length > 0 && (
<div className="flex flex-wrap gap-3">
<div className="rounded-lg border px-4 py-2 text-sm">
<span className="text-muted-foreground">Total </span>
<span className="font-semibold">{messages.length}</span>
</div>
{highRisk > 0 && (
<div className="rounded-lg border border-red-300 bg-red-50/50 dark:bg-red-950/10 px-4 py-2 text-sm text-red-700 dark:text-red-400">
<XCircle className="w-3.5 h-3.5 inline mr-1" />
<span className="font-semibold">{highRisk}</span> high spam score (10)
</div>
)}
{medRisk > 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50/50 dark:bg-amber-950/10 px-4 py-2 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-3.5 h-3.5 inline mr-1" />
<span className="font-semibold">{medRisk}</span> moderate spam score (59)
</div>
)}
<div className="rounded-lg border border-green-300 bg-green-50/50 dark:bg-green-950/10 px-4 py-2 text-sm text-green-700 dark:text-green-400">
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
<span className="font-semibold">{messages.length - highRisk - medRisk}</span> clean
</div>
</div>
)}
{/* Filters + sort */}
{loaded && !loading && messages.length > 0 && (
<div className="flex flex-wrap gap-2 items-center">
{statuses.length > 1 && (
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="border rounded-md px-3 py-1.5 text-sm bg-background">
<option value="">All statuses</option>
{statuses.map(s => <option key={s} value={s}>{s}</option>)}
</select>
)}
<select value={sortBy} onChange={e => setSortBy(e.target.value as any)}
className="border rounded-md px-3 py-1.5 text-sm bg-background">
<option value="received">Sort by date</option>
<option value="spamScore">Sort by spam score</option>
</select>
{filtered.length !== messages.length && (
<span className="text-xs text-muted-foreground">{filtered.length} of {messages.length} shown</span>
)}
</div>
)}
{loaded && !loading && messages.length === 0 && (
<div className="rounded-lg border p-12 text-center text-muted-foreground text-sm">
No messages found for this search. Try a broader time range or different search terms.
</div>
)}
{loaded && !loading && filtered.length > 0 && (
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm table-fixed">
<thead className="bg-muted/30">
<tr>
<th style={{width:'110px'}} className="text-left px-3 py-2 font-medium text-xs">Date</th>
<th style={{width:'150px'}} className="text-left px-3 py-2 font-medium text-xs">To</th>
<th style={{width:'170px'}} className="text-left px-3 py-2 font-medium text-xs">From</th>
<th className="text-left px-3 py-2 font-medium text-xs">Subject</th>
<th style={{width:'90px'}} className="text-left px-3 py-2 font-medium text-xs">Status</th>
<th style={{width:'80px'}} className="text-left px-3 py-2 font-medium text-xs">Spam</th>
<th style={{width:'70px'}} className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((m: any) => (
<tr key={m.id} className={`hover:bg-muted/20 ${m.spamScore >= 10 ? 'bg-red-500/5' : m.spamScore >= 5 ? 'bg-amber-500/5' : ''}`}>
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs">
{new Date(m.received).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.to}</div>
</td>
<td className="px-3 py-2" style={{overflow:'hidden'}}>
<div className="text-xs truncate font-medium">{m.from}</div>
{m.fromEnv && m.fromEnv !== m.from && (
<div className="text-xs text-muted-foreground truncate">{m.fromEnv}</div>
)}
</td>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.subject || '(no subject)'}</div>
</td>
<td className="px-3 py-2">
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${
m.status === 'accepted' ? 'bg-green-500/10 text-green-700'
: m.status === 'held' ? 'bg-amber-500/10 text-amber-700'
: m.status === 'rejected' || m.status === 'bounced' ? 'bg-red-500/10 text-red-600'
: 'bg-muted text-muted-foreground'
}`}>{m.status}</span>
</td>
<td className="px-3 py-2">
<span className={`text-xs font-semibold ${m.spamScore >= 10 ? 'text-red-600' : m.spamScore >= 5 ? 'text-amber-600' : 'text-muted-foreground'}`}>
{m.spamScore}
</span>
</td>
<td className="px-3 py-2">
<Button size="sm" variant="ghost" className="h-7 text-xs px-2 whitespace-nowrap"
onClick={() => setAnalysisMessage(m)}>
<Eye className="w-3 h-3 mr-1" />
View
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<DeliveredAnalysisDialog message={analysisMessage} onClose={() => setAnalysisMessage(null)} />
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function MimecastSyncPage() {
const [statusData, setStatusData] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const [lastResult, setLastResult] = useState<any>(null);
const fetchStatus = () => {
fetch('/api/mimecast/status')
.then(r => r.json())
.then(d => setStatusData(d))
.catch(() => setStatusData({ configured: false, connected: false }));
};
useEffect(() => { fetchStatus(); }, []);
const handleSync = async (syncType: string) => {
setSyncing(true);
setLastResult(null);
try {
const res = await fetch('/api/sync/mimecast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncType }),
});
const data = await res.json();
setLastResult(data);
fetchStatus();
} catch (err: any) {
setLastResult({ error: err.message });
} finally {
setSyncing(false);
}
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-blue-500/30 bg-blue-500/5">
<Mail className="w-5 h-5 text-blue-500" />
</div>
<div>
<h1 className="text-2xl font-bold">Email Security Mimecast</h1>
<p className="text-sm text-muted-foreground">Message logs, threat events, 120-day retention</p>
</div>
</div>
</div>
{/* Last sync result banner */}
{lastResult && (
<div className={`rounded-lg border p-3 text-sm ${lastResult.error ? 'border-red-300 bg-red-50 dark:bg-red-950/20 text-red-600' : 'border-green-300 bg-green-50 dark:bg-green-950/20 text-green-700'}`}>
{lastResult.error
? `Sync failed: ${lastResult.error}`
: `Sync complete — ${fmtNum(lastResult.messagesUpserted)} messages, ${fmtNum(lastResult.threatsUpserted)} threats, ${fmtNum(lastResult.bodiesFetched)} bodies fetched in ${Math.round((lastResult.durationMs ?? 0) / 1000)}s`
}
{lastResult.errors?.length > 0 && (
<div className="mt-1 text-xs opacity-80">{lastResult.errors.slice(0, 3).join(' · ')}</div>
)}
</div>
)}
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-5xl grid-cols-8">
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</TabsTrigger>
<TabsTrigger value="held" className="gap-1.5"><PauseCircle className="h-4 w-4" />Held Mail</TabsTrigger>
<TabsTrigger value="delivered" className="gap-1.5"><TrendingUp className="h-4 w-4" />Delivered</TabsTrigger>
<TabsTrigger value="messages" className="gap-1.5"><Mail className="h-4 w-4" />Messages</TabsTrigger>
<TabsTrigger value="threats" className="gap-1.5"><Shield className="h-4 w-4" />Threats</TabsTrigger>
<TabsTrigger value="cloudusers" className="gap-1.5"><Users className="h-4 w-4" />Cloud Users</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5"><Clock className="h-4 w-4" />History</TabsTrigger>
<TabsTrigger value="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<StatusTab data={statusData} onSync={handleSync} syncing={syncing} />
</TabsContent>
<TabsContent value="held" className="mt-6"><HeldMailTab /></TabsContent>
<TabsContent value="delivered" className="mt-6"><DeliveredMailTab /></TabsContent>
<TabsContent value="messages" className="mt-6"><MessagesTab /></TabsContent>
<TabsContent value="threats" className="mt-6"><ThreatsTab /></TabsContent>
<TabsContent value="cloudusers" className="mt-6"><CloudUserTab /></TabsContent>
<TabsContent value="history" className="mt-6"><HistoryTab /></TabsContent>
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
</Tabs>
</div>
);
}