feat: Mimecast email integration — message logs, threat events, 120d retention, admin UI

This commit is contained in:
lorentz 2026-03-17 16:23:47 -04:00
parent 7792e91587
commit 25bb70cfa6
11 changed files with 112285 additions and 1 deletions

View file

@ -0,0 +1,435 @@
'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,
} 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>
);
}
// ── 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>
);
}
// ── 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-2xl grid-cols-5">
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</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="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="messages" className="mt-6"><MessagesTab /></TabsContent>
<TabsContent value="threats" className="mt-6"><ThreatsTab /></TabsContent>
<TabsContent value="history" className="mt-6"><HistoryTab /></TabsContent>
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
</Tabs>
</div>
);
}

View file

@ -23,6 +23,7 @@ const INTEGRATIONS: IntegrationCard[] = [
{ id: 'auvik', category: 'NMS', product: 'Auvik', description: 'Network devices, topology, tenant mappings', href: '/admin/sync/auvik', logo: '/logos/auvik.ico', color: 'purple' },
{ id: 'addigy', category: 'Apple RMM', product: 'Addigy', description: 'macOS/iOS device management, policies, compliance', href: '/admin/sync/addigy', logo: '/logos/addigy.ico', color: 'gray' },
{ id: 'sentinelone', category: 'EDR/AV', product: 'SentinelOne', description: 'Endpoint agents, threat detections, site coverage, AV health', href: '/admin/sync/sentinelone', logo: '/logos/sentinelone.ico', color: 'purple' },
{ id: 'mimecast', category: 'Email Security', product: 'Mimecast', description: 'Message tracking logs, threat events, SIEM data, 120-day retention', href: '/admin/sync/mimecast', logo: '/logos/mimecast.ico', color: 'blue' },
];
const COLOR_MAP: Record<string, { bg: string; border: string }> = {
@ -54,13 +55,16 @@ export default function SyncOverviewPage() {
const [itglueSyncData, setItglueSyncData] = useState<any>(null);
const [s1SyncData, setS1SyncData] = useState<any>(null);
const [mimecastData, setMimecastData] = useState<any>(null);
const fetchAll = async () => {
try {
const [intRes, atRes, itgRes, s1Res] = await Promise.all([
const [intRes, atRes, itgRes, s1Res, mcRes] = await Promise.all([
fetch('/api/integrations/status'),
fetch('/api/sync/last-sync'),
fetch('/api/itglue/sync'),
fetch('/api/sentinelone/sync'),
fetch('/api/mimecast/status'),
]);
if (intRes.ok) setStatus(await intRes.json());
if (atRes.ok) {
@ -69,6 +73,7 @@ export default function SyncOverviewPage() {
}
if (itgRes.ok) setItglueSyncData(await itgRes.json());
if (s1Res.ok) setS1SyncData(await s1Res.json());
if (mcRes.ok) setMimecastData(await mcRes.json());
} catch (e) {
console.error(e);
} finally {
@ -147,6 +152,16 @@ export default function SyncOverviewPage() {
}
if (id === 'auvik') return { lastSync: status.auvik?.lastSync, configured: status.auvik?.configured };
if (id === 'addigy') return { lastSync: status.addigy?.lastSync, configured: status.addigy?.configured };
if (id === 'mimecast') {
if (!mimecastData) return null;
const s = mimecastData.stats ?? {};
return {
lastSync: s.lastSync ?? null,
connected: mimecastData.connected ?? false,
messages: s.messages ?? 0,
threats: s.threats ?? 0,
};
}
return null;
};
@ -176,6 +191,11 @@ export default function SyncOverviewPage() {
if (summary.infected > 0) return <AlertTriangle className="w-4 h-4 text-red-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
if (id === 'mimecast') {
if (!summary.connected) return <Clock className="w-4 h-4 text-muted-foreground" />;
if ((summary as any).threats > 0) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
};
@ -322,6 +342,24 @@ export default function SyncOverviewPage() {
)}
</>
)}
{intg.id === 'mimecast' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate((summary as any).lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Messages</span>
<span className="font-medium text-foreground">{((summary as any).messages ?? 0).toLocaleString()}</span>
</div>
{(summary as any).threats > 0 && (
<div className="flex justify-between text-yellow-700">
<span>Threat events</span>
<span className="font-medium">{(summary as any).threats}</span>
</div>
)}
</>
)}
{(intg.id === 'auvik' || intg.id === 'addigy') && (
<div className="flex justify-between">
<span>Status</span>

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient as pg } from '@/lib/services/postgres-client';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const days = parseInt(searchParams.get('days') ?? '7');
const limit = Math.min(parseInt(searchParams.get('limit') ?? '200'), 1000);
const search = searchParams.get('search') ?? '';
const direction = searchParams.get('direction') ?? '';
const status = searchParams.get('status') ?? '';
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const conditions: string[] = ['sent_datetime >= $1'];
const params: any[] = [cutoff.toISOString()];
let paramIdx = 2;
if (direction) {
conditions.push(`direction = $${paramIdx++}`);
params.push(direction);
}
if (status) {
conditions.push(`status = $${paramIdx++}`);
params.push(status);
}
if (search) {
conditions.push(`(
sender_address ILIKE $${paramIdx} OR
recipient_address ILIKE $${paramIdx} OR
subject ILIKE $${paramIdx}
)`);
params.push(`%${search}%`);
paramIdx++;
}
const where = conditions.join(' AND ');
params.push(limit);
const result = await pg.query(`
SELECT id, sender_address, sender_domain, recipient_address, subject,
direction, status, action, spam_score, size_bytes, attachment_count,
sent_datetime, received_datetime, route, reject_reason, held_reason, source_ip
FROM mimecast_messages
WHERE ${where}
ORDER BY sent_datetime DESC
LIMIT $${paramIdx}
`, params);
return NextResponse.json({ messages: result.rows });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { getMimecastClient } from '@/lib/services/mimecast-client';
import { getMimecastStats } from '@/lib/services/mimecast-sync-service';
export async function GET() {
try {
const client = getMimecastClient();
const [connection, stats] = await Promise.all([
client.testConnection(),
getMimecastStats().catch(() => null),
]);
return NextResponse.json({
configured: true,
connected: connection.ok,
accountName: connection.accountName,
packageName: connection.packageName,
error: connection.error,
stats,
});
} catch (err: any) {
return NextResponse.json({
configured: false,
connected: false,
error: err.message,
stats: null,
});
}
}

View file

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient as pg } from '@/lib/services/postgres-client';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get('limit') ?? '200'), 1000);
const level = searchParams.get('level') ?? '';
const type = searchParams.get('type') ?? '';
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (level) {
conditions.push(`threat_level = $${paramIdx++}`);
params.push(level);
}
if (type) {
conditions.push(`event_type = $${paramIdx++}`);
params.push(type);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(limit);
const result = await pg.query(`
SELECT id, message_id, event_type, threat_level, url, file_name,
verdict, actor_email, event_datetime
FROM mimecast_threat_events
${where}
ORDER BY event_datetime DESC NULLS LAST
LIMIT $${paramIdx}
`, params);
return NextResponse.json({ threats: result.rows });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { runMimecastFullSync, runMimecastIncrementalSync, getMimecastStats } from '@/lib/services/mimecast-sync-service';
let _syncing = false;
export async function POST(req: NextRequest) {
if (_syncing) {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
}
try {
const body = await req.json().catch(() => ({}));
const syncType = body.syncType ?? 'incremental';
_syncing = true;
const result = syncType === 'full'
? await runMimecastFullSync()
: await runMimecastIncrementalSync();
return NextResponse.json({
success: true,
syncType,
...result,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
} finally {
_syncing = false;
}
}
export async function GET() {
try {
const stats = await getMimecastStats();
return NextResponse.json({ isSyncing: _syncing, ...stats });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}