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,
|
||||
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 > 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue