Neah/app/api/courrier/route.ts
2025-04-25 21:06:57 +02:00

440 lines
14 KiB
TypeScript

import { NextResponse } from 'next/server';
import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
import { simpleParser } from 'mailparser';
// Type definitions
interface EmailCacheEntry {
data: any;
timestamp: number;
}
interface CredentialsCacheEntry {
client: any; // Use any for ImapFlow to avoid type issues
timestamp: number;
}
// Email cache structure
const emailListCache: Record<string, EmailCacheEntry> = {};
const credentialsCache: Record<string, CredentialsCacheEntry> = {};
// Cache TTL in milliseconds
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Helper function to get credentials with caching
async function getCredentialsWithCache(userId: string) {
// Check if we have fresh cached credentials
const cachedCreds = credentialsCache[userId];
const now = Date.now();
if (cachedCreds && now - cachedCreds.timestamp < CACHE_TTL) {
return cachedCreds.client;
}
// Otherwise fetch from database
const credentials = await prisma.mailCredentials.findUnique({
where: { userId }
});
// Cache the result
if (credentials) {
credentialsCache[userId] = {
client: credentialsCache[userId]?.client || new ImapFlow({
host: credentials.host,
port: credentials.port,
secure: true,
auth: {
user: credentials.email,
pass: credentials.password,
},
logger: false,
}),
timestamp: now
};
}
return credentialsCache[userId]?.client || null;
}
// Retry logic for IMAP operations
async function retryOperation<T>(operation: () => Promise<T>, maxAttempts = 3, delay = 1000): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
console.warn(`Operation failed (attempt ${attempt}/${maxAttempts}):`, error);
if (attempt < maxAttempts) {
// Exponential backoff
const backoffDelay = delay * Math.pow(2, attempt - 1);
console.log(`Retrying in ${backoffDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, backoffDelay));
}
}
}
throw lastError!;
}
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
// Get mail credentials
const credentials = await prisma.mailCredentials.findUnique({
where: {
userId: session.user.id,
},
});
if (!credentials) {
return NextResponse.json(
{ error: "No mail credentials found" },
{ status: 404 }
);
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const perPage = parseInt(searchParams.get("perPage") || "20");
const folder = searchParams.get("folder") || "INBOX";
const searchQuery = searchParams.get("search") || "";
// Check for entry in emailCache
const cacheKey = `${session.user.id}:${folder}:${page}:${perPage}:${searchQuery}`;
const cachedEmails = emailListCache[cacheKey];
if (cachedEmails) {
console.log(`Using cached emails for ${cacheKey}`);
return NextResponse.json(cachedEmails.data);
}
console.log(`Cache miss for ${cacheKey}, fetching from IMAP`);
// Fetch from IMAP
const cacheCredKey = `credentials:${session.user.id}`;
let imapClient: any = credentialsCache[cacheCredKey]?.client || null;
if (!imapClient) {
// Create IMAP client
const connectWithRetry = async (retries = 3, delay = 1000): Promise<any> => {
try {
console.log(`Attempting to connect to IMAP server (${credentials.host}:${credentials.port})...`);
const client = new ImapFlow({
host: credentials.host,
port: credentials.port,
secure: true,
auth: {
user: credentials.email,
pass: credentials.password,
},
logger: false,
});
await client.connect();
console.log("Successfully connected to IMAP server");
return client;
} catch (error) {
if (retries > 0) {
console.log(`Connection failed, retrying... (${retries} attempts left)`);
await new Promise((resolve) => setTimeout(resolve, delay));
return connectWithRetry(retries - 1, delay * 1.5);
}
throw error;
}
};
try {
imapClient = await connectWithRetry();
// Cache for future use
credentialsCache[cacheCredKey] = {
client: imapClient,
timestamp: Date.now()
};
} catch (error: any) {
console.error("Failed to connect to IMAP server after retries:", error);
return NextResponse.json(
{ error: "Failed to connect to IMAP server", message: error.message },
{ status: 500 }
);
}
} else {
console.log("Using cached IMAP client connection");
}
// Function to get mailboxes
const getMailboxes = async () => {
try {
console.log("Getting list of mailboxes...");
const mailboxes = [];
const list = await imapClient.list();
console.log(`Found ${list.length} mailboxes from IMAP server`);
for (const mailbox of list) {
mailboxes.push(mailbox.path);
}
console.log("Available mailboxes:", mailboxes);
return mailboxes;
} catch (error) {
console.error("Error listing mailboxes:", error);
return [];
}
};
// Setup paging
const startIdx = (page - 1) * perPage + 1;
const endIdx = page * perPage;
let emails: any[] = [];
let mailboxData = null;
try {
// Select and lock mailbox
mailboxData = await imapClient.mailboxOpen(folder);
console.log(`Opened mailbox ${folder}, ${mailboxData.exists} messages total`);
// Calculate range based on total messages
const totalMessages = mailboxData.exists;
const from = Math.max(totalMessages - endIdx + 1, 1);
const to = Math.max(totalMessages - startIdx + 1, 1);
// Skip if no messages or invalid range
if (totalMessages === 0 || from > to) {
console.log("No messages in range, returning empty array");
const result = {
emails: [],
totalEmails: 0,
page,
perPage,
totalPages: 0,
folder,
mailboxes: await getMailboxes(),
};
emailListCache[cacheKey] = {
data: result,
timestamp: Date.now()
};
return NextResponse.json(result);
}
console.log(`Fetching messages ${from}:${to} (page ${page}, ${perPage} per page)`);
// Search if needed
let messageIds: any[] = [];
if (searchQuery) {
console.log(`Searching for: "${searchQuery}"`);
messageIds = await imapClient.search({
body: searchQuery
});
// Filter to our page range
messageIds = messageIds.filter(id => id >= from && id <= to);
console.log(`Found ${messageIds.length} messages matching search`);
} else {
messageIds = Array.from(
{ length: to - from + 1 },
(_, i) => from + i
);
}
// Fetch messages with their content
for (const id of messageIds) {
try {
const message = await imapClient.fetchOne(id, {
envelope: true,
flags: true,
bodyStructure: true,
internalDate: true,
size: true,
// Only fetch a preview of the body initially for faster loading
bodyParts: [
{
query: {
type: "text",
},
limit: 5000, // Limit to first 5KB of text
}
]
});
if (!message) continue;
const { envelope, flags, bodyStructure, internalDate, size, bodyParts } = message;
// Extract content from the body parts for a preview
let preview = '';
if (bodyParts && bodyParts.length > 0) {
const textPart = bodyParts.find((part: any) => part.type === 'text/plain');
const htmlPart = bodyParts.find((part: any) => part.type === 'text/html');
// Prefer text for preview as it's smaller and faster to process
const content = textPart?.content || htmlPart?.content || '';
if (typeof content === 'string') {
preview = content.substring(0, 150) + '...';
} else if (Buffer.isBuffer(content)) {
preview = content.toString('utf-8', 0, 150) + '...';
}
}
// Convert attachments to our format
const attachments: Array<{
contentId?: string;
filename: string;
contentType: string;
size: number;
path: string;
}> = [];
const processAttachments = (node: any, path: Array<string | number> = []) => {
if (!node) return;
if (node.type === 'attachment') {
attachments.push({
contentId: node.contentId,
filename: node.filename || 'attachment',
contentType: node.contentType,
size: node.size,
path: [...path, node.part].join('.')
});
}
if (node.childNodes) {
node.childNodes.forEach((child: any, index: number) => {
processAttachments(child, [...path, node.part || index + 1]);
});
}
};
if (bodyStructure) {
processAttachments(bodyStructure);
}
// Convert flags from Set to boolean checks
const flagsArray = Array.from(flags as Set<string>);
emails.push({
id,
messageId: envelope.messageId,
subject: envelope.subject || "(No Subject)",
from: envelope.from.map((f: any) => ({
name: f.name || f.address,
address: f.address,
})),
to: envelope.to.map((t: any) => ({
name: t.name || t.address,
address: t.address,
})),
cc: (envelope.cc || []).map((c: any) => ({
name: c.name || c.address,
address: c.address,
})),
bcc: (envelope.bcc || []).map((b: any) => ({
name: b.name || b.address,
address: b.address,
})),
date: internalDate || new Date(),
flags: {
seen: flagsArray.includes("\\Seen"),
flagged: flagsArray.includes("\\Flagged"),
answered: flagsArray.includes("\\Answered"),
deleted: flagsArray.includes("\\Deleted"),
draft: flagsArray.includes("\\Draft"),
},
hasAttachments: attachments.length > 0,
attachments,
size,
// Just include the preview instead of the full content initially
preview,
// Store the fetched state to know we only have preview
contentFetched: false
});
} catch (messageError) {
console.error(`Error fetching message ${id}:`, messageError);
}
}
// Sort by date, newest first
emails.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const result = {
emails,
totalEmails: totalMessages,
page,
perPage,
totalPages: Math.ceil(totalMessages / perPage),
folder,
mailboxes: await getMailboxes(),
};
// Cache the result
emailListCache[cacheKey] = {
data: result,
timestamp: Date.now()
};
return NextResponse.json(result);
} finally {
// If we opened a mailbox, close it
if (mailboxData) {
await imapClient.mailboxClose();
}
}
} catch (error: any) {
console.error("Error in GET:", error);
return NextResponse.json(
{ error: "Internal server error", message: error.message },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { emailId, folderName, action } = await request.json();
if (!emailId) {
return NextResponse.json({ error: 'Missing emailId parameter' }, { status: 400 });
}
// Invalidate cache entries for this folder
const userId = session.user.id;
// If folder is specified, only invalidate that folder's cache
if (folderName) {
Object.keys(emailListCache).forEach(key => {
if (key.includes(`${userId}:${folderName}`)) {
delete emailListCache[key];
}
});
} else {
// Otherwise invalidate all cache entries for this user
Object.keys(emailListCache).forEach(key => {
if (key.startsWith(`${userId}:`)) {
delete emailListCache[key];
}
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error in POST handler:', error);
return NextResponse.json({ error: 'An unexpected error occurred' }, { status: 500 });
}
}