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:
parent
c2ebbe586b
commit
bc3904de4e
3 changed files with 316 additions and 1 deletions
|
|
@ -9,7 +9,7 @@ import {
|
||||||
ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2,
|
ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2,
|
||||||
CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send,
|
CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send,
|
||||||
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
|
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';
|
} from 'lucide-react';
|
||||||
import SyncScheduler from '@/components/admin/SyncScheduler';
|
import SyncScheduler from '@/components/admin/SyncScheduler';
|
||||||
|
|
||||||
|
|
@ -1113,9 +1113,71 @@ function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages
|
||||||
onFindSimilar?: (type: 'sender' | 'ip' | 'subject', value: string) => void;
|
onFindSimilar?: (type: 'sender' | 'ip' | 'subject', value: string) => void;
|
||||||
allMessages?: any[];
|
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;
|
if (!message) return null;
|
||||||
const analysis = analyzeDelivered(message);
|
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 severityBar = { high: 'border-l-red-500', medium: 'border-l-amber-500', low: 'border-l-blue-500' };
|
||||||
const severityBadge = {
|
const severityBadge = {
|
||||||
high: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400',
|
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">
|
<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" />
|
<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.
|
<span>View full message tracking in the Mimecast Administration Console under Gateway > Message Center > Message Finder.
|
||||||
|
|
|
||||||
77
app/api/mimecast/mailbox-remediate/route.ts
Normal file
77
app/api/mimecast/mailbox-remediate/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -330,6 +330,82 @@ export class MsGraphClient {
|
||||||
return messages;
|
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).
|
* 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).
|
* Returns empty array and logs if the mailbox is not Exchange Online (graceful degradation).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue