Fondation
This commit is contained in:
parent
c06691ce47
commit
c93a88daf5
207
app/api/courrier/[id]/attachment/[attachmentIndex]/route.ts
Normal file
207
app/api/courrier/[id]/attachment/[attachmentIndex]/route.ts
Normal file
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -185,17 +185,66 @@ export default function EmailDetailView({
|
|||||||
<div className="mt-6 border-t border-gray-100 pt-4">
|
<div className="mt-6 border-t border-gray-100 pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Attachments</h3>
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Attachments</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{email.attachments.map((attachment, idx) => (
|
{email.attachments.map((attachment, idx) => {
|
||||||
<div
|
// Build download URL
|
||||||
key={idx}
|
const downloadUrl = `/api/courrier/${email.id}/attachment/${idx}?folder=${encodeURIComponent(email.folder || 'INBOX')}${email.accountId ? `&accountId=${encodeURIComponent(email.accountId)}` : ''}`;
|
||||||
className="flex items-center gap-2 p-2 border border-gray-200 rounded-md"
|
|
||||||
>
|
const handleDownload = async (e: React.MouseEvent) => {
|
||||||
<div className="flex-1 min-w-0">
|
e.preventDefault();
|
||||||
<p className="text-sm font-medium text-gray-700 truncate">{attachment.filename}</p>
|
try {
|
||||||
<p className="text-xs text-gray-500">{(attachment.size / 1024).toFixed(1)} KB</p>
|
// 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 (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center gap-2 p-2 border border-gray-200 rounded-md hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
onClick={handleDownload}
|
||||||
|
title={`Click to download ${attachment.filename}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-700 truncate">{attachment.filename}</p>
|
||||||
|
<p className="text-xs text-gray-500">{(attachment.size / 1024).toFixed(1)} KB</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ml-2 p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDownload(e);
|
||||||
|
}}
|
||||||
|
title="Download attachment"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1314,6 +1314,30 @@ export async function getEmailContent(
|
|||||||
const { fetchGraphEmail } = await import('./microsoft-graph-mail');
|
const { fetchGraphEmail } = await import('./microsoft-graph-mail');
|
||||||
const graphMessage = await fetchGraphEmail(graphCheck.mailCredentialId, emailId);
|
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
|
// Convert Graph message to EmailMessage format
|
||||||
const email: EmailMessage = {
|
const email: EmailMessage = {
|
||||||
id: graphMessage.id,
|
id: graphMessage.id,
|
||||||
@ -1339,7 +1363,8 @@ export async function getEmailContent(
|
|||||||
deleted: false,
|
deleted: false,
|
||||||
},
|
},
|
||||||
size: 0,
|
size: 0,
|
||||||
hasAttachments: graphMessage.hasAttachments || false,
|
hasAttachments: graphMessage.hasAttachments || attachments.length > 0,
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
folder: normalizedFolder,
|
folder: normalizedFolder,
|
||||||
contentFetched: true,
|
contentFetched: true,
|
||||||
accountId: effectiveAccountId,
|
accountId: effectiveAccountId,
|
||||||
@ -1520,11 +1545,32 @@ export async function getEmailContent(
|
|||||||
draft: flagsArray.includes("\\Draft"),
|
draft: flagsArray.includes("\\Draft"),
|
||||||
},
|
},
|
||||||
hasAttachments: parsedEmail.attachments?.length > 0,
|
hasAttachments: parsedEmail.attachments?.length > 0,
|
||||||
attachments: parsedEmail.attachments?.map(att => ({
|
attachments: parsedEmail.attachments?.map(att => {
|
||||||
filename: att.filename || 'attachment',
|
// Convert attachment content to base64 for storage
|
||||||
contentType: att.contentType,
|
let contentBase64: string | undefined;
|
||||||
size: att.size || 0
|
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: {
|
content: {
|
||||||
text: parsedEmail.text || '',
|
text: parsedEmail.text || '',
|
||||||
html: rawHtml || '',
|
html: rawHtml || '',
|
||||||
|
|||||||
@ -83,6 +83,14 @@ export interface GraphMailMessage {
|
|||||||
flag?: {
|
flag?: {
|
||||||
flagStatus: 'notFlagged' | 'flagged' | 'complete';
|
flagStatus: 'notFlagged' | 'flagged' | 'complete';
|
||||||
};
|
};
|
||||||
|
attachments?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
contentBytes?: string; // Base64 encoded content
|
||||||
|
isInline?: boolean;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user