diff --git a/app/admin/sync/mimecast/page.tsx b/app/admin/sync/mimecast/page.tsx index f509dc5..51e6a89 100644 --- a/app/admin/sync/mimecast/page.tsx +++ b/app/admin/sync/mimecast/page.tsx @@ -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, TrendingUp, ExternalLink, Eye, + PauseCircle, Building2, Check, Info, TrendingUp, ExternalLink, Eye, Trash2, } from 'lucide-react'; import SyncScheduler from '@/components/admin/SyncScheduler'; @@ -1113,9 +1113,71 @@ function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages onFindSimilar?: (type: 'sender' | 'ip' | 'subject', value: string) => void; allMessages?: any[]; }) { + const [remedStep, setRemedStep] = useState<'idle' | 'searching' | 'confirm' | 'removing' | 'done'>('idle'); + const [remedMatches, setRemedMatches] = useState([]); + const [remedSelected, setRemedSelected] = useState>(new Set()); + const [remedResults, setRemedResults] = useState<{ succeeded: number; failed: number } | null>(null); + const [remedError, setRemedError] = useState(null); + const [permError, setPermError] = useState(null); + + // Reset remediation state when message changes + const prevMessageId = message?.id; + useEffect(() => { + setRemedStep('idle'); + setRemedMatches([]); + setRemedSelected(new Set()); + setRemedResults(null); + setRemedError(null); + setPermError(null); + }, [prevMessageId]); + if (!message) return null; const analysis = analyzeDelivered(message); + const searchMailbox = async () => { + setRemedStep('searching'); + setRemedError(null); + setPermError(null); + try { + const res = await fetch('/api/mimecast/mailbox-remediate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'search', userEmail: message.to, fromAddress: message.from }), + }); + const d = await res.json(); + if (!res.ok) { + if (d.permissionRequired) { setPermError(d.detail); setRemedStep('idle'); return; } + throw new Error(d.error ?? `HTTP ${res.status}`); + } + const matches = d.messages ?? []; + setRemedMatches(matches); + setRemedSelected(new Set(matches.map((m: any) => m.id))); + setRemedStep('confirm'); + } catch (e: any) { + setRemedError(e.message); + setRemedStep('idle'); + } + }; + + const removeSelected = async () => { + setRemedStep('removing'); + setRemedError(null); + try { + const res = await fetch('/api/mimecast/mailbox-remediate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'move', userEmail: message.to, messageIds: [...remedSelected] }), + }); + const d = await res.json(); + if (!res.ok) throw new Error(d.error ?? `HTTP ${res.status}`); + setRemedResults({ succeeded: d.succeeded, failed: d.failed }); + setRemedStep('done'); + } catch (e: any) { + setRemedError(e.message); + setRemedStep('confirm'); + } + }; + 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', @@ -1271,6 +1333,106 @@ function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages ); })()} + {/* Mailbox Remediation */} +
+
+
+ + Remove from mailbox + — search {message.to}'s mailbox and delete +
+ {remedStep === 'idle' && ( + + )} +
+ + {permError && ( +
+

Permission required

+

{permError}

+

Mail.ReadWrite (Application)

+
+ )} + + {remedError && ( +
{remedError}
+ )} + + {remedStep === 'searching' && ( +
+ Searching {message.to}'s mailbox… +
+ )} + + {remedStep === 'confirm' && ( +
+ {remedMatches.length === 0 ? ( +
No matching messages found in mailbox.
+ ) : ( + <> +
+ Found {remedMatches.length} message{remedMatches.length !== 1 ? 's' : ''} in mailbox — select to move to Deleted Items: +
+
+ {remedMatches.map(m => ( + + ))} +
+
+ {remedSelected.size} selected — will move to Deleted Items (recoverable) +
+ + +
+
+ + )} +
+ )} + + {remedStep === 'removing' && ( +
+ Removing {remedSelected.size} message{remedSelected.size !== 1 ? 's' : ''}… +
+ )} + + {remedStep === 'done' && remedResults && ( +
+ {remedResults.failed === 0 + ? + : } + + {remedResults.succeeded} message{remedResults.succeeded !== 1 ? 's' : ''} moved to Deleted Items + {remedResults.failed > 0 && `, ${remedResults.failed} failed`} + +
+ )} +
+
View full message tracking in the Mimecast Administration Console under Gateway > Message Center > Message Finder. diff --git a/app/api/mimecast/mailbox-remediate/route.ts b/app/api/mimecast/mailbox-remediate/route.ts new file mode 100644 index 0000000..36355d5 --- /dev/null +++ b/app/api/mimecast/mailbox-remediate/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getMsgraphClient, isMsgraphConfigured } from '@/lib/services/msgraph-factory'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/mimecast/mailbox-remediate + * Body: { action: 'search' | 'delete' | 'move', userEmail, fromAddress?, subject?, messageIds? } + * + * Requires Mail.ReadWrite application permission on the Graph app registration. + */ +export async function POST(req: NextRequest) { + try { + if (!isMsgraphConfigured()) { + return NextResponse.json({ error: 'Microsoft Graph not configured' }, { status: 503 }); + } + + const { action, userEmail, fromAddress, subject, messageIds, hardDelete } = await req.json(); + + if (!userEmail) { + return NextResponse.json({ error: 'userEmail is required' }, { status: 400 }); + } + + const client = getMsgraphClient(); + + if (action === 'search') { + if (!fromAddress && !subject) { + return NextResponse.json({ error: 'fromAddress or subject required for search' }, { status: 400 }); + } + const messages = await client.searchMailboxMessages({ + userEmail, + fromAddress: fromAddress || undefined, + subject: subject || undefined, + maxResults: 100, + }); + return NextResponse.json({ messages, total: messages.length }); + } + + if (action === 'delete' || action === 'move') { + if (!Array.isArray(messageIds) || messageIds.length === 0) { + return NextResponse.json({ error: 'messageIds array required' }, { status: 400 }); + } + + const results: { id: string; ok: boolean; error?: string }[] = []; + + for (const id of messageIds) { + try { + if (action === 'delete' && hardDelete) { + await client.deleteMailboxMessage(userEmail, id); + } else { + await client.moveToDeletedItems(userEmail, id); + } + results.push({ id, ok: true }); + } catch (err: any) { + results.push({ id, ok: false, error: err.message }); + } + } + + const succeeded = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok).length; + return NextResponse.json({ results, succeeded, failed }); + } + + return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 }); + } catch (error: any) { + const isPermission = error.message?.includes('403') || error.message?.includes('AccessDenied') || error.message?.includes('Access is denied'); + if (isPermission) { + return NextResponse.json({ + error: 'Mail.ReadWrite permission not granted', + detail: 'Grant Mail.ReadWrite application permission to the Graph app in Azure Portal → App registrations → API permissions, then click Grant admin consent.', + permissionRequired: true, + }, { status: 403 }); + } + console.error('[mailbox-remediate] error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/lib/services/msgraph-client.ts b/lib/services/msgraph-client.ts index d83cbf1..01cb2a0 100644 --- a/lib/services/msgraph-client.ts +++ b/lib/services/msgraph-client.ts @@ -330,6 +330,82 @@ export class MsGraphClient { return messages; } + /** + * POST /users/{user}/messages — search by sender and/or subject in a date range. + * Requires Mail.ReadWrite application permission. + */ + async searchMailboxMessages(options: { + userEmail: string; + fromAddress?: string; + subject?: string; + receivedAfter?: string; + receivedBefore?: string; + maxResults?: number; + }): Promise<{ id: string; subject: string; from: string; receivedDateTime: string; isRead: boolean }[]> { + const filters: string[] = []; + if (options.fromAddress) { + filters.push(`from/emailAddress/address eq '${options.fromAddress.replace(/'/g, "''")}'`); + } + if (options.subject) { + filters.push(`contains(subject,'${options.subject.replace(/'/g, "''")}') `); + } + if (options.receivedAfter) { + filters.push(`receivedDateTime ge ${options.receivedAfter}`); + } + if (options.receivedBefore) { + filters.push(`receivedDateTime le ${options.receivedBefore}`); + } + if (!filters.length) throw new Error('At least one search filter required'); + + const top = Math.min(options.maxResults ?? 50, 100); + const qs = `$filter=${encodeURIComponent(filters.join(' and '))}&$select=id,subject,from,receivedDateTime,isRead&$top=${top}&$orderby=receivedDateTime desc`; + const url = `/users/${encodeURIComponent(options.userEmail)}/messages?${qs}`; + + const data = await this.fetchJson<{ value: any[] }>(url); + return (data.value ?? []).map(m => ({ + id: m.id, + subject: m.subject ?? '', + from: m.from?.emailAddress?.address ?? '', + receivedDateTime: m.receivedDateTime ?? '', + isRead: m.isRead ?? false, + })); + } + + /** + * DELETE /users/{user}/messages/{messageId} + * Permanently deletes a message. Requires Mail.ReadWrite application permission. + */ + async deleteMailboxMessage(userEmail: string, messageId: string): Promise { + const token = await this.getToken(); + const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/messages/${encodeURIComponent(messageId)}`; + const res = await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 204) return; + if (res.status === 404) return; // already gone + const text = await res.text(); + throw new Error(`Graph delete failed ${res.status}: ${text}`); + } + + /** + * Move a message to Deleted Items (soft delete — recoverable). + * Requires Mail.ReadWrite application permission. + */ + async moveToDeletedItems(userEmail: string, messageId: string): Promise { + const token = await this.getToken(); + const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/messages/${encodeURIComponent(messageId)}/move`; + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ destinationId: 'deleteditems' }), + }); + if (res.ok) return; + if (res.status === 404) return; + const text = await res.text(); + throw new Error(`Graph move failed ${res.status}: ${text}`); + } + /** * Get calendar events for a user in a date range (paginated). * Returns empty array and logs if the mailbox is not Exchange Online (graceful degradation).