feat: mailbox remediation via Graph Mail.ReadWrite — search + move to Deleted Items from analysis dialog

- Add searchMailboxMessages, deleteMailboxMessage, moveToDeletedItems to MsGraphClient
- POST /api/mimecast/mailbox-remediate: search, move, delete actions with permission error handling
- DeliveredAnalysisDialog: Remove from mailbox panel with search → confirm → delete flow
  - Shows matching messages in mailbox with checkboxes, received time, read/unread status
  - Moves selected to Deleted Items (recoverable) via Graph API
  - Surfaces clear permission guidance if Mail.ReadWrite not yet granted
This commit is contained in:
lorentz 2026-04-01 10:09:38 -04:00
parent c2ebbe586b
commit bc3904de4e
3 changed files with 316 additions and 1 deletions

View file

@ -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<any[]>([]);
const [remedSelected, setRemedSelected] = useState<Set<string>>(new Set());
const [remedResults, setRemedResults] = useState<{ succeeded: number; failed: number } | null>(null);
const [remedError, setRemedError] = useState<string | null>(null);
const [permError, setPermError] = useState<string | null>(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 */}
<div className="rounded-md border overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 bg-muted/20">
<div className="flex items-center gap-2">
<Trash2 className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm font-medium">Remove from mailbox</span>
<span className="text-xs text-muted-foreground"> search {message.to}'s mailbox and delete</span>
</div>
{remedStep === 'idle' && (
<Button size="sm" variant="outline" className="h-7 text-xs px-2 text-red-700 border-red-300 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={searchMailbox}>
<Search className="w-3 h-3 mr-1" />Search mailbox
</Button>
)}
</div>
{permError && (
<div className="px-3 py-2.5 space-y-1.5 border-t">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400">Permission required</p>
<p className="text-xs text-muted-foreground">{permError}</p>
<p className="text-xs font-mono bg-muted/40 rounded px-2 py-1">Mail.ReadWrite (Application)</p>
</div>
)}
{remedError && (
<div className="px-3 py-2 border-t text-xs text-red-600">{remedError}</div>
)}
{remedStep === 'searching' && (
<div className="px-3 py-3 border-t flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />Searching {message.to}'s mailbox
</div>
)}
{remedStep === 'confirm' && (
<div className="border-t divide-y">
{remedMatches.length === 0 ? (
<div className="px-3 py-2.5 text-xs text-muted-foreground">No matching messages found in mailbox.</div>
) : (
<>
<div className="px-3 py-2 text-xs text-muted-foreground bg-muted/10">
Found <span className="font-semibold text-foreground">{remedMatches.length}</span> message{remedMatches.length !== 1 ? 's' : ''} in mailbox select to move to Deleted Items:
</div>
<div className="max-h-40 overflow-y-auto divide-y">
{remedMatches.map(m => (
<label key={m.id} className="flex items-start gap-2 px-3 py-1.5 hover:bg-muted/20 cursor-pointer">
<input type="checkbox" className="mt-0.5 flex-shrink-0"
checked={remedSelected.has(m.id)}
onChange={e => {
const s = new Set(remedSelected);
e.target.checked ? s.add(m.id) : s.delete(m.id);
setRemedSelected(s);
}} />
<div className="min-w-0">
<div className="text-xs truncate">{m.subject || '(no subject)'}</div>
<div className="text-xs text-muted-foreground">
{new Date(m.receivedDateTime).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{!m.isRead && <span className="ml-1 text-blue-500 font-medium">unread</span>}
</div>
</div>
</label>
))}
</div>
<div className="px-3 py-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{remedSelected.size} selected will move to Deleted Items (recoverable)</span>
<div className="flex gap-2">
<Button size="sm" variant="ghost" className="h-7 text-xs px-2"
onClick={() => setRemedStep('idle')}>Cancel</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs px-2"
disabled={remedSelected.size === 0}
onClick={removeSelected}>
<Trash2 className="w-3 h-3 mr-1" />
Move {remedSelected.size} to Deleted
</Button>
</div>
</div>
</>
)}
</div>
)}
{remedStep === 'removing' && (
<div className="px-3 py-3 border-t flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />Removing {remedSelected.size} message{remedSelected.size !== 1 ? 's' : ''}
</div>
)}
{remedStep === 'done' && remedResults && (
<div className="px-3 py-2.5 border-t flex items-center gap-2">
{remedResults.failed === 0
? <CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0" />
: <AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />}
<span className="text-xs">
<span className="font-medium">{remedResults.succeeded}</span> message{remedResults.succeeded !== 1 ? 's' : ''} moved to Deleted Items
{remedResults.failed > 0 && `, ${remedResults.failed} failed`}
</span>
</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 &gt; Message Center &gt; Message Finder.

View file

@ -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 });
}
}

View file

@ -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<void> {
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<void> {
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).