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
This commit is contained in:
parent
0df5c344b5
commit
8e28062d85
3 changed files with 552 additions and 2 deletions
|
|
@ -9,7 +9,7 @@ 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,
|
||||
PauseCircle, Building2, Check, Info, TrendingUp, ExternalLink, Eye,
|
||||
} from 'lucide-react';
|
||||
import SyncScheduler from '@/components/admin/SyncScheduler';
|
||||
|
||||
|
|
@ -982,6 +982,432 @@ function HeldMailTab() {
|
|||
);
|
||||
}
|
||||
|
||||
// ── 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 > Message Center > 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 (5–9)
|
||||
</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);
|
||||
|
|
@ -1050,9 +1476,10 @@ export default function MimecastSyncPage() {
|
|||
)}
|
||||
|
||||
<Tabs defaultValue="status" className="w-full">
|
||||
<TabsList className="grid w-full max-w-4xl grid-cols-7">
|
||||
<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>
|
||||
|
|
@ -1064,6 +1491,7 @@ export default function MimecastSyncPage() {
|
|||
<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>
|
||||
|
|
|
|||
46
app/api/mimecast/delivered/route.ts
Normal file
46
app/api/mimecast/delivered/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { getMimecastClientForTenant } from '@/lib/services/mimecast-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { tenantId, to, from, subject, startHours } = await req.json();
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenantId is required' }, { status: 400 });
|
||||
}
|
||||
if (!to && !from && !subject) {
|
||||
return NextResponse.json({ error: 'At least one of to, from, or subject is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const r = await postgresClient.query(
|
||||
`SELECT mt.*, c.company_name FROM mimecast_tenants mt
|
||||
LEFT JOIN companies c ON c.id = mt.company_id
|
||||
WHERE mt.id = $1 AND mt.enabled = true`,
|
||||
[tenantId]
|
||||
);
|
||||
if (!r.rows.length) {
|
||||
return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const client = getMimecastClientForTenant(r.rows[0]);
|
||||
const result = await client.searchDeliveredMessages({
|
||||
to: to || undefined,
|
||||
from: from || undefined,
|
||||
subject: subject || undefined,
|
||||
startHours: startHours ?? 24,
|
||||
route: 'INBOUND',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return NextResponse.json({ error: result.error }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ messages: result.messages, total: result.messages.length });
|
||||
} catch (error: any) {
|
||||
console.error('[Delivered] search error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue