- migration 062: mimecast_tenants table (company_id, client_id/secret, account_code) - Seed Wulf (CUSA13A95) + Seubert (CUSA96A181) tenants - MimecastClient.getHeldMessages(): full pagination via meta.pagination.next cursor (API always returns 10/page regardless of pageSize param, totalCount in meta) - getMimecastClientForTenant() factory for per-tenant instantiation - GET /api/mimecast/held?tenantId=&recipient= — fetches all tenants in parallel, merges + sorts by date, returns per-tenant counts + combined messages[] - Held Mail tab on /admin/sync/mimecast (on-demand load, recipient filter, tenant badges, policy filter dropdown, DMARC/impersonation highlighted red)
722 lines
32 KiB
TypeScript
722 lines
32 KiB
TypeScript
'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 {
|
|
ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2,
|
|
CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send,
|
|
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
|
|
PauseCircle, Building2,
|
|
} 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>
|
|
);
|
|
}
|
|
|
|
// ── Held Mail Tab ─────────────────────────────────────────────────────────────
|
|
function HeldMailTab() {
|
|
const [data, setData] = useState<any>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [recipient, setRecipient] = useState('');
|
|
const [tenantFilter, setTenantFilter] = useState('');
|
|
const [policyFilter, setPolicyFilter] = useState('');
|
|
const [loaded, setLoaded] = useState(false);
|
|
|
|
const load = (recipientVal?: string) => {
|
|
setLoading(true);
|
|
const params = new URLSearchParams();
|
|
const r = recipientVal ?? recipient;
|
|
if (r) params.set('recipient', r);
|
|
if (tenantFilter) params.set('tenantId', tenantFilter);
|
|
fetch(`/api/mimecast/held?${params}`)
|
|
.then(res => res.json())
|
|
.then(d => { setData(d); setLoaded(true); })
|
|
.catch(() => setData(null))
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
const messages: any[] = data?.messages ?? [];
|
|
const filtered = policyFilter
|
|
? messages.filter(m => m.policyInfo?.toLowerCase().includes(policyFilter.toLowerCase()))
|
|
: messages;
|
|
|
|
const policies = [...new Set(messages.map((m: any) => m.policyInfo).filter(Boolean))].sort();
|
|
const tenants: any[] = data?.tenants ?? [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Search bar */}
|
|
<div className="flex flex-wrap gap-2 items-end">
|
|
<div className="flex-1 min-w-56">
|
|
<label className="text-xs text-muted-foreground mb-1 block">Recipient email</label>
|
|
<input
|
|
type="text"
|
|
placeholder="filter by recipient address…"
|
|
value={recipient}
|
|
onChange={e => setRecipient(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && load()}
|
|
className="w-full border rounded-md px-3 py-1.5 text-sm bg-background"
|
|
/>
|
|
</div>
|
|
{tenants.length > 1 && (
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Tenant</label>
|
|
<select value={tenantFilter} onChange={e => setTenantFilter(e.target.value)}
|
|
className="border rounded-md px-3 py-1.5 text-sm bg-background">
|
|
<option value="">All tenants</option>
|
|
{tenants.map(t => (
|
|
<option key={t.tenantId} value={String(t.tenantId)}>{t.accountName}</option>
|
|
))}
|
|
</select>
|
|
</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 badges */}
|
|
{loaded && tenants.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{tenants.map(t => (
|
|
<div key={t.tenantId} className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs border ${
|
|
t.error ? 'border-red-300 bg-red-50 dark:bg-red-950/20 text-red-600' :
|
|
t.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">{t.accountName}</span>
|
|
{t.error
|
|
? <span>error</span>
|
|
: <span>— {t.count.toLocaleString()}{t.totalCount > t.count ? ` of ${t.totalCount.toLocaleString()}` : ''} held</span>
|
|
}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!loaded && !loading && (
|
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground text-sm">
|
|
Click “Load Held Mail” to fetch held messages across all configured Mimecast tenants.
|
|
</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 from all tenants…</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 flex items-center justify-between">
|
|
<span className="text-sm font-medium">
|
|
{filtered.length.toLocaleString()} held message{filtered.length !== 1 ? 's' : ''}
|
|
{data?.totalCount > filtered.length ? ` (showing ${filtered.length} of ${data.totalCount.toLocaleString()} total)` : ''}
|
|
</span>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/30">
|
|
<tr>
|
|
<th className="text-left px-4 py-2 font-medium">Date</th>
|
|
<th className="text-left px-4 py-2 font-medium">To</th>
|
|
<th className="text-left px-4 py-2 font-medium">From</th>
|
|
<th className="text-left px-4 py-2 font-medium">Subject</th>
|
|
<th className="text-left px-4 py-2 font-medium">Policy</th>
|
|
{tenants.length > 1 && <th className="text-left px-4 py-2 font-medium">Tenant</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-4 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-4 py-2">
|
|
<div className="text-xs">{m.to}</div>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<div className="font-medium text-xs">{m.fromDisplay || m.from}</div>
|
|
{m.fromDisplay && <div className="text-xs text-muted-foreground">{m.from}</div>}
|
|
</td>
|
|
<td className="px-4 py-2 max-w-xs">
|
|
<div className="truncate">{m.subject || '(no subject)'}</div>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<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-yellow-500/10 text-yellow-700'
|
|
}`}>
|
|
{m.policyInfo || m.reason || '—'}
|
|
</span>
|
|
</td>
|
|
{tenants.length > 1 && (
|
|
<td className="px-4 py-2 text-xs text-muted-foreground">{m.accountName}</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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-4xl grid-cols-7">
|
|
<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="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="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>
|
|
);
|
|
}
|