440 lines
14 KiB
TypeScript
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 });
|
|
}
|
|
}
|