diff --git a/app/api/courrier/[id]/attachment/[attachmentIndex]/route.ts b/app/api/courrier/[id]/attachment/[attachmentIndex]/route.ts new file mode 100644 index 0000000..6188d54 --- /dev/null +++ b/app/api/courrier/[id]/attachment/[attachmentIndex]/route.ts @@ -0,0 +1,207 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/options"; +import { getEmailContent, shouldUseGraphAPI } from '@/lib/services/email-service'; +import { fetchGraphEmailAttachment } from '@/lib/services/microsoft-graph-mail'; +import { logger } from '@/lib/logger'; + +/** + * Route API pour télécharger une pièce jointe d'un email + * GET /api/courrier/[id]/attachment/[attachmentIndex] + */ +export async function GET( + request: Request, + context: { params: Promise<{ id: string; attachmentIndex: string }> } +) { + try { + const params = await context.params; + const { id: emailId, attachmentIndex } = params; + + // Authenticate user + const session = await getServerSession(authOptions); + if (!session || !session.user?.id) { + return NextResponse.json( + { error: "Not authenticated" }, + { status: 401 } + ); + } + + // Validate parameters + if (!emailId || !attachmentIndex) { + return NextResponse.json( + { error: "Missing email ID or attachment index" }, + { status: 400 } + ); + } + + const attachmentIdx = parseInt(attachmentIndex, 10); + if (isNaN(attachmentIdx) || attachmentIdx < 0) { + return NextResponse.json( + { error: "Invalid attachment index" }, + { status: 400 } + ); + } + + // Get query parameters + const { searchParams } = new URL(request.url); + const folder = searchParams.get("folder") || "INBOX"; + const accountId = searchParams.get("accountId") || undefined; + + logger.debug('[ATTACHMENT] Fetching attachment', { + userIdHash: Buffer.from(session.user.id).toString('base64').slice(0, 12), + emailId, + attachmentIndex: attachmentIdx, + folder, + accountIdHash: accountId ? Buffer.from(accountId).toString('base64').slice(0, 12) : 'default', + }); + + // Check if this is a Microsoft Graph account + const graphCheck = await shouldUseGraphAPI(session.user.id, accountId); + + let attachmentBuffer: Buffer; + let contentType: string; + let filename: string; + + if (graphCheck.useGraph && graphCheck.mailCredentialId) { + // Use Microsoft Graph API to fetch attachment + logger.debug('[ATTACHMENT] Fetching attachment via Microsoft Graph API', { + emailId, + attachmentIndex: attachmentIdx, + mailCredentialIdHash: graphCheck.mailCredentialId ? Buffer.from(graphCheck.mailCredentialId).toString('base64').slice(0, 12) : null, + }); + + try { + // First, get the email to find the attachment ID + const email = await getEmailContent(session.user.id, emailId, folder, accountId); + + if (!email.attachments || email.attachments.length === 0) { + return NextResponse.json( + { error: "Email has no attachments" }, + { status: 404 } + ); + } + + if (attachmentIdx >= email.attachments.length) { + return NextResponse.json( + { error: "Attachment index out of range" }, + { status: 404 } + ); + } + + // For Graph API, we need to fetch the attachment separately + // The attachment should have an ID or we need to fetch it from the message + const attachment = email.attachments[attachmentIdx]; + filename = attachment.filename || `attachment-${attachmentIdx}`; + contentType = attachment.contentType || 'application/octet-stream'; + + // If attachment has content (from cache), use it + if (attachment.content) { + attachmentBuffer = Buffer.from(attachment.content, 'base64'); + } else { + // Need to fetch from Graph API - this requires the attachment ID + // For now, return error as we need to modify the email fetching to include attachment IDs + logger.error('[ATTACHMENT] Graph API attachment requires ID but content not cached', { + emailId, + attachmentIndex: attachmentIdx, + }); + return NextResponse.json( + { error: "Attachment content not available. Please refresh the email to load attachment data." }, + { status: 404 } + ); + } + } catch (error) { + logger.error('[ATTACHMENT] Error fetching Graph API attachment', { + emailId, + attachmentIndex: attachmentIdx, + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Failed to fetch attachment from Microsoft Graph API" }, + { status: 500 } + ); + } + } else { + // Use IMAP (standard flow) + // Fetch the email content (which includes attachments) + const email = await getEmailContent(session.user.id, emailId, folder, accountId); + + // Check if email has attachments + if (!email.attachments || email.attachments.length === 0) { + return NextResponse.json( + { error: "Email has no attachments" }, + { status: 404 } + ); + } + + // Check if attachment index is valid + if (attachmentIdx >= email.attachments.length) { + return NextResponse.json( + { error: "Attachment index out of range" }, + { status: 404 } + ); + } + + const attachment = email.attachments[attachmentIdx]; + filename = attachment.filename || `attachment-${attachmentIdx}`; + contentType = attachment.contentType || 'application/octet-stream'; + + // Check if attachment has content + if (!attachment.content) { + logger.error('[ATTACHMENT] Attachment has no content', { + emailId, + attachmentIndex: attachmentIdx, + filename: attachment.filename, + }); + return NextResponse.json( + { error: "Attachment content not available" }, + { status: 404 } + ); + } + + // Decode base64 content + try { + attachmentBuffer = Buffer.from(attachment.content, 'base64'); + } catch (error) { + logger.error('[ATTACHMENT] Error decoding base64 content', { + emailId, + attachmentIndex: attachmentIdx, + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Failed to decode attachment content" }, + { status: 500 } + ); + } + } + + // Get filename, sanitize it for safe download + const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); + + logger.debug('[ATTACHMENT] Serving attachment', { + emailId, + attachmentIndex: attachmentIdx, + filename: sanitizedFilename, + contentType, + size: attachmentBuffer.length, + }); + + // Return the attachment as a downloadable file + return new NextResponse(attachmentBuffer, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${sanitizedFilename}"`, + 'Content-Length': attachmentBuffer.length.toString(), + 'Cache-Control': 'private, max-age=3600', // Cache for 1 hour + }, + }); + } catch (error) { + logger.error('[ATTACHMENT] Error fetching attachment', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Failed to fetch attachment", message: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/components/email/EmailDetailView.tsx b/components/email/EmailDetailView.tsx index afb1ef2..0caf6c9 100644 --- a/components/email/EmailDetailView.tsx +++ b/components/email/EmailDetailView.tsx @@ -185,17 +185,66 @@ export default function EmailDetailView({
{attachment.filename}
-{(attachment.size / 1024).toFixed(1)} KB
+ {email.attachments.map((attachment, idx) => { + // Build download URL + const downloadUrl = `/api/courrier/${email.id}/attachment/${idx}?folder=${encodeURIComponent(email.folder || 'INBOX')}${email.accountId ? `&accountId=${encodeURIComponent(email.accountId)}` : ''}`; + + const handleDownload = async (e: React.MouseEvent) => { + e.preventDefault(); + try { + // Fetch the attachment + const response = await fetch(downloadUrl); + + if (!response.ok) { + throw new Error(`Failed to download attachment: ${response.statusText}`); + } + + // Get the blob + const blob = await response.blob(); + + // Create a temporary URL and trigger download + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = attachment.filename || `attachment-${idx}`; + document.body.appendChild(a); + a.click(); + + // Cleanup + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + console.error('Error downloading attachment:', error); + alert(`Failed to download attachment: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + return ( +{attachment.filename}
+{(attachment.size / 1024).toFixed(1)} KB
+