From a15946daf8f59203bb1b45b52bcd01473ed9e653 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 1 Apr 2026 07:09:42 -0400 Subject: [PATCH] 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 --- app/admin/sync/mimecast/page.tsx | 136 ++++++++++++++++--------- app/api/mimecast/held/release/route.ts | 34 +++++++ lib/services/mimecast-client.ts | 16 +++ 3 files changed, 139 insertions(+), 47 deletions(-) create mode 100644 app/api/mimecast/held/release/route.ts diff --git a/app/admin/sync/mimecast/page.tsx b/app/admin/sync/mimecast/page.tsx index 2ab5198..0ba4a58 100644 --- a/app/admin/sync/mimecast/page.tsx +++ b/app/admin/sync/mimecast/page.tsx @@ -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(null); + const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(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>({}); + const [releaseErrors, setReleaseErrors] = useState>({}); 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 (
- {/* Search bar */} + {/* Controls */}
+
+ + +
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" />
- {tenants.length > 1 && ( -
- - -
- )} {loaded && policies.length > 0 && (
@@ -543,23 +573,21 @@ function HeldMailTab() {
- {/* Tenant summary badges */} - {loaded && tenants.length > 0 && ( + {/* Tenant summary badge */} + {loaded && currentTenant && (
- {tenants.map(t => ( -
0 ? 'border-yellow-400/40 bg-yellow-500/5 text-yellow-700' : - 'border-border bg-muted/30 text-muted-foreground' - }`}> - - {t.accountName} - {t.error - ? — permission denied - : — {t.count.toLocaleString()}{t.totalCount > t.count ? ` of ${t.totalCount.toLocaleString()}` : ''} held - } -
- ))} +
0 ? 'border-yellow-400/40 bg-yellow-500/5 text-yellow-700' : + 'border-border bg-muted/30 text-muted-foreground' + }`}> + + {currentTenant.accountName} + {currentTenant.error + ? — permission denied + : — showing {messages.length.toLocaleString()}{currentTenant.totalCount > messages.length ? ` of ${currentTenant.totalCount.toLocaleString()}` : ''} held + } +
)} @@ -571,14 +599,14 @@ function HeldMailTab() { {!loaded && !loading && !loadError && (
- Click “Load Held Mail” to fetch held messages across all configured Mimecast tenants. + Select a tenant and click “Load Held Mail”
)} {loading && (
- Fetching held messages from all tenants… + Fetching held messages…
)} @@ -590,10 +618,10 @@ function HeldMailTab() { {loaded && !loading && filtered.length > 0 && (
-
+
{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)` : ''}
@@ -605,7 +633,7 @@ function HeldMailTab() { From Subject Policy - {tenants.length > 1 && Tenant} + @@ -614,9 +642,7 @@ function HeldMailTab() { {new Date(m.dateReceived).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} - -
{m.to}
- + {m.to}
{m.fromDisplay || m.from}
{m.fromDisplay &&
{m.from}
} @@ -633,9 +659,25 @@ function HeldMailTab() { {m.policyInfo || m.reason || '—'} - {tenants.length > 1 && ( - {m.accountName} - )} + +
+ + {releaseErrors[m.id] && ( + {releaseErrors[m.id]} + )} +
+ ))} diff --git a/app/api/mimecast/held/release/route.ts b/app/api/mimecast/held/release/route.ts new file mode 100644 index 0000000..daadf65 --- /dev/null +++ b/app/api/mimecast/held/release/route.ts @@ -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 }); + } +} diff --git a/lib/services/mimecast-client.ts b/lib/services/mimecast-client.ts index 80d4cc7..aaad11e 100644 --- a/lib/services/mimecast-client.ts +++ b/lib/services/mimecast-client.ts @@ -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('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 ──────────────────────────────────────────────────────────────── /**