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({

Attachments

- {email.attachments.map((attachment, idx) => ( -
-
-

{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

+
+
-
- ))} + ); + })}
)} diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index c7afc85..006f0fa 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -1314,6 +1314,30 @@ export async function getEmailContent( const { fetchGraphEmail } = await import('./microsoft-graph-mail'); const graphMessage = await fetchGraphEmail(graphCheck.mailCredentialId, emailId); + // Convert Graph attachments to EmailAttachment format + const attachments: EmailAttachment[] = []; + if (graphMessage.attachments && Array.isArray(graphMessage.attachments)) { + for (const graphAtt of graphMessage.attachments) { + // Graph API attachments have contentBytes in base64 + if (graphAtt.contentBytes) { + attachments.push({ + filename: graphAtt.name || 'attachment', + contentType: graphAtt.contentType || 'application/octet-stream', + size: graphAtt.size || 0, + content: graphAtt.contentBytes, // Already base64 from Graph API + }); + } else { + // If no contentBytes, it might be a reference attachment - store metadata only + attachments.push({ + filename: graphAtt.name || 'attachment', + contentType: graphAtt.contentType || 'application/octet-stream', + size: graphAtt.size || 0, + // No content - will need to fetch separately via attachment ID + }); + } + } + } + // Convert Graph message to EmailMessage format const email: EmailMessage = { id: graphMessage.id, @@ -1339,7 +1363,8 @@ export async function getEmailContent( deleted: false, }, size: 0, - hasAttachments: graphMessage.hasAttachments || false, + hasAttachments: graphMessage.hasAttachments || attachments.length > 0, + attachments: attachments.length > 0 ? attachments : undefined, folder: normalizedFolder, contentFetched: true, accountId: effectiveAccountId, @@ -1520,11 +1545,32 @@ export async function getEmailContent( draft: flagsArray.includes("\\Draft"), }, hasAttachments: parsedEmail.attachments?.length > 0, - attachments: parsedEmail.attachments?.map(att => ({ - filename: att.filename || 'attachment', - contentType: att.contentType, - size: att.size || 0 - })), + attachments: parsedEmail.attachments?.map(att => { + // Convert attachment content to base64 for storage + let contentBase64: string | undefined; + if (att.content) { + if (Buffer.isBuffer(att.content)) { + contentBase64 = att.content.toString('base64'); + } else if (typeof att.content === 'string') { + // If it's already a string, check if it's base64 or needs encoding + try { + // Try to decode as base64 to check if it's already encoded + Buffer.from(att.content, 'base64'); + contentBase64 = att.content; + } catch { + // Not base64, encode it + contentBase64 = Buffer.from(att.content).toString('base64'); + } + } + } + + return { + filename: att.filename || 'attachment', + contentType: att.contentType, + size: att.size || 0, + content: contentBase64 // Store as base64 for API serving + }; + }), content: { text: parsedEmail.text || '', html: rawHtml || '', diff --git a/lib/services/microsoft-graph-mail.ts b/lib/services/microsoft-graph-mail.ts index c1976b3..8c2c0d1 100644 --- a/lib/services/microsoft-graph-mail.ts +++ b/lib/services/microsoft-graph-mail.ts @@ -83,6 +83,14 @@ export interface GraphMailMessage { flag?: { flagStatus: 'notFlagged' | 'flagged' | 'complete'; }; + attachments?: Array<{ + id: string; + name: string; + contentType: string; + size: number; + contentBytes?: string; // Base64 encoded content + isInline?: boolean; + }>; } /**