feat: held mail release button + per-tenant fetch
- Add releaseHeldMessage() to MimecastClient (POST /api/gateway/hold-release) - Add POST /api/mimecast/held/release route - HeldMailTab: tenant selector before load (defaults to Wulf), only fetches selected tenant - Release button per row with spinner + optimistic removal on success - Error shown inline under Release button if release fails
This commit is contained in:
parent
a18d5b66bf
commit
a15946daf8
3 changed files with 139 additions and 47 deletions
|
|
@ -8,7 +8,7 @@ import {
|
|||
ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2,
|
||||
CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send,
|
||||
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
|
||||
PauseCircle, Building2,
|
||||
PauseCircle, Building2, Check,
|
||||
} from 'lucide-react';
|
||||
import SyncScheduler from '@/components/admin/SyncScheduler';
|
||||
|
||||
|
|
@ -459,14 +459,22 @@ function HistoryTab() {
|
|||
}
|
||||
|
||||
// ── 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 [tenantFilter, setTenantFilter] = 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 load = async (recipientVal?: string) => {
|
||||
setLoading(true);
|
||||
|
|
@ -474,7 +482,7 @@ function HeldMailTab() {
|
|||
const params = new URLSearchParams();
|
||||
const r = recipientVal ?? recipient;
|
||||
if (r) params.set('recipient', r);
|
||||
if (tenantFilter) params.set('tenantId', tenantFilter);
|
||||
if (tenantId) params.set('tenantId', tenantId);
|
||||
try {
|
||||
const res = await fetch(`/api/mimecast/held?${params}`);
|
||||
if (!res.ok) {
|
||||
|
|
@ -483,50 +491,72 @@ function HeldMailTab() {
|
|||
}
|
||||
const d = await res.json();
|
||||
setData(d);
|
||||
setMessages(d.messages ?? []);
|
||||
setLoaded(true);
|
||||
} catch (e: any) {
|
||||
setLoadError(e.message ?? 'Unknown error');
|
||||
console.error('[HeldMail] fetch error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const messages: any[] = data?.messages ?? [];
|
||||
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 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 ?? [];
|
||||
const tenantInfo: any[] = data?.tenants ?? [];
|
||||
const currentTenant = tenantInfo[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search bar */}
|
||||
{/* 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</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="filter by recipient address…"
|
||||
placeholder="filter by recipient…"
|
||||
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>
|
||||
|
|
@ -543,23 +573,21 @@ function HeldMailTab() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tenant summary badges */}
|
||||
{loaded && tenants.length > 0 && (
|
||||
{/* Tenant summary badge */}
|
||||
{loaded && currentTenant && (
|
||||
<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 title={t.error}>— permission denied</span>
|
||||
: <span>— {t.count.toLocaleString()}{t.totalCount > t.count ? ` of ${t.totalCount.toLocaleString()}` : ''} held</span>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -571,14 +599,14 @@ function HeldMailTab() {
|
|||
|
||||
{!loaded && !loading && !loadError && (
|
||||
<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.
|
||||
Select a tenant and click “Load Held Mail”
|
||||
</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>
|
||||
<span className="ml-2 text-sm text-muted-foreground">Fetching held messages…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -590,10 +618,10 @@ function HeldMailTab() {
|
|||
|
||||
{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">
|
||||
<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' : ''}
|
||||
{data?.totalCount > filtered.length ? ` (showing ${filtered.length} of ${data.totalCount.toLocaleString()} total)` : ''}
|
||||
{currentTenant?.totalCount > messages.length ? ` (showing ${messages.length.toLocaleString()} of ${currentTenant.totalCount.toLocaleString()} total)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
|
|
@ -605,7 +633,7 @@ function HeldMailTab() {
|
|||
<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>}
|
||||
<th className="text-left px-4 py-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
|
|
@ -614,9 +642,7 @@ function HeldMailTab() {
|
|||
<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 text-xs">{m.to}</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>}
|
||||
|
|
@ -633,9 +659,25 @@ function HeldMailTab() {
|
|||
{m.policyInfo || m.reason || '—'}
|
||||
</span>
|
||||
</td>
|
||||
{tenants.length > 1 && (
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{m.accountName}</td>
|
||||
)}
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs gap-1 text-green-700 border-green-300 hover:bg-green-50"
|
||||
disabled={releasing[m.id]}
|
||||
onClick={() => release(m)}
|
||||
>
|
||||
{releasing[m.id]
|
||||
? <Loader2 className="w-3 h-3 animate-spin" />
|
||||
: <Check className="w-3 h-3" />}
|
||||
Release
|
||||
</Button>
|
||||
{releaseErrors[m.id] && (
|
||||
<span className="text-xs text-red-500">{releaseErrors[m.id]}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
34
app/api/mimecast/held/release/route.ts
Normal file
34
app/api/mimecast/held/release/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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 { id, tenantId } = await req.json();
|
||||
if (!id || !tenantId) {
|
||||
return NextResponse.json({ error: 'id and tenantId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const r = await postgresClient.query(
|
||||
`SELECT * FROM mimecast_tenants WHERE id = $1 AND 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.releaseHeldMessage(id);
|
||||
|
||||
if (!result.released) {
|
||||
return NextResponse.json({ error: result.error ?? 'Release failed — message may already be released or expired' }, { status: 422 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ released: true });
|
||||
} catch (error: any) {
|
||||
console.error('[HeldMail] release error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -526,6 +526,22 @@ export class MimecastClient {
|
|||
return { messages: all, totalCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/gateway/hold-release
|
||||
* Releases a held message by ID. Returns true if released successfully.
|
||||
*/
|
||||
async releaseHeldMessage(id: string): Promise<{ released: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await this.request<any>('POST', '/api/gateway/hold-release', {
|
||||
data: [{ id, action: 'release' }],
|
||||
});
|
||||
const row = result?.data?.[0];
|
||||
return { released: row?.release === true };
|
||||
} catch (err: any) {
|
||||
return { released: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue