'use server'; import 'server-only'; import { ImapFlow } from 'imapflow'; import nodemailer from 'nodemailer'; import { prisma } from '@/lib/prisma'; import { simpleParser } from 'mailparser'; import { cacheEmailCredentials, getCachedEmailCredentials, cacheEmailList, getCachedEmailList, cacheEmailContent, getCachedEmailContent, cacheImapSession, getCachedImapSession, invalidateFolderCache, invalidateEmailContentCache } from '@/lib/redis'; import { EmailCredentials, EmailMessage, EmailAddress, EmailAttachment } from '@/lib/types'; // Types specific to this service export interface EmailListResult { emails: EmailMessage[]; totalEmails: number; page: number; perPage: number; totalPages: number; folder: string; mailboxes: string[]; } // Connection pool to reuse IMAP clients const connectionPool: Record = {}; const CONNECTION_TIMEOUT = 5 * 60 * 1000; // 5 minutes // Clean up idle connections periodically setInterval(() => { const now = Date.now(); Object.entries(connectionPool).forEach(([key, { client, lastUsed }]) => { if (now - lastUsed > CONNECTION_TIMEOUT) { console.log(`Closing idle IMAP connection for ${key}`); client.logout().catch(err => { console.error(`Error closing connection for ${key}:`, err); }); delete connectionPool[key]; } }); }, 60 * 1000); // Check every minute /** * Get IMAP connection for a user, reusing existing connections when possible */ export async function getImapConnection( userId: string, accountId?: string ): Promise { console.log(`Getting IMAP connection for user ${userId}${accountId ? ` account ${accountId}` : ''}`); // First try to get credentials from Redis cache let credentials = accountId ? await getCachedEmailCredentials(userId, accountId) : await getCachedEmailCredentials(userId, 'default'); // If not in cache, get from database and cache them if (!credentials) { console.log(`Credentials not found in cache for ${userId}${accountId ? ` account ${accountId}` : ''}, attempting database lookup`); credentials = await getUserEmailCredentials(userId); if (!credentials) { throw new Error('No email credentials found'); } // Cache credentials for future use await cacheEmailCredentials(userId, accountId || 'default', credentials); } // Validate credentials if (!credentials.password) { console.error(`Missing password in credentials for user ${userId}${accountId ? ` account ${accountId}` : ''}`); throw new Error('No password configured'); } if (!credentials.email || !credentials.host) { console.error(`Incomplete credentials for user ${userId}${accountId ? ` account ${accountId}` : ''}`); throw new Error('Invalid email credentials configuration'); } const connectionKey = `${userId}:${credentials.email}`; const existingConnection = connectionPool[connectionKey]; // Try to get session data from Redis const sessionData = await getCachedImapSession(userId); // Return existing connection if available and connected if (existingConnection) { try { if (existingConnection.client.usable) { existingConnection.lastUsed = Date.now(); console.log(`Reusing existing IMAP connection for ${connectionKey}`); // Update session data in Redis if (sessionData) { await cacheImapSession(userId, { ...sessionData, lastActive: Date.now() }); } return existingConnection.client; } } catch (error) { console.warn(`Existing connection for ${connectionKey} is not usable, creating new connection`); // Will create a new connection below } } console.log(`Creating new IMAP connection for ${connectionKey}`); // Create new connection const client = new ImapFlow({ host: credentials.host, port: credentials.port, secure: true, auth: { user: credentials.email, pass: credentials.password, }, logger: false, emitLogs: false, tls: { rejectUnauthorized: false } }); try { await client.connect(); console.log(`Successfully connected to IMAP server for ${connectionKey}`); // Store in connection pool connectionPool[connectionKey] = { client, lastUsed: Date.now() }; return client; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`IMAP connection error for ${connectionKey}:`, errorMessage); throw new Error(`Failed to connect to IMAP server: ${errorMessage}`); } } /** * Get user's email credentials from database */ export async function getUserEmailCredentials(userId: string): Promise { const credentials = await prisma.mailCredentials.findFirst({ where: { userId }, select: { email: true, password: true, host: true, port: true, secure: true, smtp_host: true, smtp_port: true, smtp_secure: true, display_name: true, color: true } }); if (!credentials) return null; return { email: credentials.email, password: credentials.password, host: credentials.host, port: credentials.port, secure: credentials.secure, smtp_host: credentials.smtp_host, smtp_port: credentials.smtp_port, smtp_secure: credentials.smtp_secure, display_name: credentials.display_name, color: credentials.color }; } /** * Save or update user's email credentials */ export async function saveUserEmailCredentials( userId: string, accountId: string, credentials: EmailCredentials ): Promise { console.log('Saving credentials for user:', userId, 'account:', accountId); // Extract only the fields that exist in the database schema const dbCredentials = { email: credentials.email, password: credentials.password ?? '', host: credentials.host, port: credentials.port }; // Save to database - only using fields that exist in the schema await prisma.mailCredentials.upsert({ where: { id: accountId, userId }, update: dbCredentials, create: { id: accountId, userId, ...dbCredentials } }); // Cache the full credentials object in Redis (with all fields) await cacheEmailCredentials(userId, accountId, credentials); } // Helper type for IMAP fetch options interface FetchOptions { envelope: boolean; flags: boolean; bodyStructure: boolean; internalDate: boolean; size: boolean; bodyParts: { part: string; query: any; limit?: number }[]; } /** * Get list of emails for a user */ export async function getEmails( userId: string, folder: string = 'INBOX', page: number = 1, perPage: number = 20, searchQuery: string = '', accountId?: string ): Promise { // Try to get from cache first if (!searchQuery) { const cacheKey = accountId ? `${userId}:${accountId}:${folder}` : `${userId}:${folder}`; const cachedResult = await getCachedEmailList(userId, folder, page, perPage); if (cachedResult) { console.log(`Using cached email list for ${cacheKey}:${page}:${perPage}`); return cachedResult; } } console.log(`Cache miss for emails ${userId}:${folder}:${page}:${perPage}${accountId ? ` for account ${accountId}` : ''}, fetching from IMAP`); // If accountId is provided, connect to that specific account let client: ImapFlow; if (accountId) { try { // Get account from database const account = await prisma.mailCredentials.findUnique({ where: { id: accountId, userId } }); if (!account) { throw new Error(`Account with ID ${accountId} not found`); } // Connect to IMAP server for this specific account client = new ImapFlow({ host: account.host, port: account.port, secure: true, // Default to secure connection auth: { user: account.email, pass: account.password, }, logger: false, tls: { rejectUnauthorized: false } }); await client.connect(); } catch (error) { console.error(`Error connecting to account ${accountId}:`, error); // Fallback to default connection client = await getImapConnection(userId); } } else { // Use the default connection logic client = await getImapConnection(userId); } let mailboxes: string[] = []; try { console.log(`[DEBUG] Fetching mailboxes for user ${userId}`); // Get list of mailboxes first try { mailboxes = await getMailboxes(client); console.log(`[DEBUG] Found ${mailboxes.length} mailboxes:`, mailboxes); // Save mailboxes in session data const cachedSession = await getCachedImapSession(userId); await cacheImapSession(userId, { ...(cachedSession || { lastActive: Date.now() }), mailboxes }); console.log(`[DEBUG] Updated cached session with mailboxes for user ${userId}`); } catch (mailboxError) { console.error(`[ERROR] Failed to fetch mailboxes:`, mailboxError); } // Open mailbox const mailboxData = await client.mailboxOpen(folder); const totalMessages = mailboxData.exists; // Calculate range based on total messages const endIdx = page * perPage; const startIdx = (page - 1) * perPage + 1; const from = Math.max(totalMessages - endIdx + 1, 1); const to = Math.max(totalMessages - startIdx + 1, 1); // Empty result if no messages if (totalMessages === 0 || from > to) { const result = { emails: [], totalEmails: 0, page, perPage, totalPages: 0, folder, mailboxes }; // Cache even empty results if (!searchQuery) { await cacheEmailList(userId, folder, page, perPage, result); } return result; } // Search if needed let messageIds: any[] = []; if (searchQuery) { messageIds = await client.search({ body: searchQuery }); messageIds = messageIds.filter(id => id >= from && id <= to); } else { messageIds = Array.from({ length: to - from + 1 }, (_, i) => from + i); } // Fetch messages const emails: EmailMessage[] = []; for (const id of messageIds) { try { // Define fetch options with proper typing const fetchOptions: any = { envelope: true, flags: true, bodyStructure: true, internalDate: true, size: true, bodyParts: [{ part: '1', query: { type: "text" }, limit: 5000 }] }; const message = await client.fetchOne(id, fetchOptions); if (!message) continue; const { envelope, flags, bodyStructure, internalDate, size, bodyParts } = message; // Extract preview content let preview = ''; if (bodyParts && typeof bodyParts === 'object') { // Convert to array if it's a Map const partsArray = Array.isArray(bodyParts) ? bodyParts : Array.from(bodyParts.values()); const textPart = partsArray.find((part: any) => part.type === 'text/plain'); const htmlPart = partsArray.find((part: any) => part.type === 'text/html'); 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) + '...'; } } // Process attachments const attachments: EmailAttachment[] = []; const processAttachments = (node: any, path: Array = []) => { 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); emails.push({ id: id.toString(), 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, preview, folder, contentFetched: false }); } catch (error) { console.error(`Error fetching message ${id}:`, error); } } // 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 }; // Always cache the result if it's not a search query, even for pagination if (!searchQuery) { console.log(`Caching email list for ${userId}:${folder}:${page}:${perPage}`); await cacheEmailList(userId, folder, page, perPage, result); } return result; } finally { // Don't logout, keep connection in pool if (folder !== 'INBOX') { try { await client.mailboxClose(); } catch (error) { console.error('Error closing mailbox:', error); } } } } /** * Get a single email with full content */ export async function getEmailContent( userId: string, emailId: string, folder: string = 'INBOX' ): Promise { // Try to get from cache first const cachedEmail = await getCachedEmailContent(userId, emailId); if (cachedEmail) { console.log(`Using cached email content for ${userId}:${emailId}`); return cachedEmail; } console.log(`Cache miss for email content ${userId}:${emailId}, fetching from IMAP`); const client = await getImapConnection(userId); try { await client.mailboxOpen(folder); const message = await client.fetchOne(emailId, { source: true, envelope: true, flags: true }); if (!message) { throw new Error('Email not found'); } const { source, envelope, flags } = message; // Parse the email content, ensuring all styles and structure are preserved const parsedEmail = await simpleParser(source.toString(), { skipHtmlToText: true, // Don't convert HTML to plain text keepCidLinks: true // Keep Content-ID references for inline images }); // Convert flags from Set to boolean checks const flagsArray = Array.from(flags as Set); // Preserve the raw HTML exactly as it was in the original email const rawHtml = parsedEmail.html || ''; const email = { id: emailId, 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: envelope.date || new Date(), flags: { seen: flagsArray.includes("\\Seen"), flagged: flagsArray.includes("\\Flagged"), answered: flagsArray.includes("\\Answered"), deleted: flagsArray.includes("\\Deleted"), 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 })), // Preserve the exact raw HTML to maintain all styling html: rawHtml, text: parsedEmail.text || undefined, // For content field, prioritize using the raw HTML to preserve all styling content: rawHtml || parsedEmail.text || '', folder, contentFetched: true }; // Cache the email content await cacheEmailContent(userId, emailId, email); return email; } finally { try { await client.mailboxClose(); } catch (error) { console.error('Error closing mailbox:', error); } } } /** * Mark an email as read or unread */ export async function markEmailReadStatus( userId: string, emailId: string, isRead: boolean, folder: string = 'INBOX' ): Promise { const client = await getImapConnection(userId); try { await client.mailboxOpen(folder); if (isRead) { await client.messageFlagsAdd(emailId, ['\\Seen']); } else { await client.messageFlagsRemove(emailId, ['\\Seen']); } // Invalidate content cache since the flags changed await invalidateEmailContentCache(userId, emailId); // Also invalidate folder cache because unread counts may have changed await invalidateFolderCache(userId, folder); return true; } catch (error) { console.error(`Error marking email ${emailId} as ${isRead ? 'read' : 'unread'}:`, error); return false; } finally { try { await client.mailboxClose(); } catch (error) { console.error('Error closing mailbox:', error); } } } /** * Send an email */ export async function sendEmail( userId: string, emailData: { to: string; cc?: string; bcc?: string; subject: string; body: string; attachments?: Array<{ name: string; content: string; type: string; }>; } ): Promise<{ success: boolean; messageId?: string; error?: string }> { const credentials = await getUserEmailCredentials(userId); if (!credentials) { return { success: false, error: 'No email credentials found' }; } // Create SMTP transporter with user's SMTP settings if available const transporter = nodemailer.createTransport({ host: credentials.smtp_host || 'smtp.infomaniak.com', port: credentials.smtp_port || 587, secure: credentials.smtp_secure || false, auth: { user: credentials.email, pass: credentials.password, }, tls: { rejectUnauthorized: false } }); try { const info = await transporter.sendMail({ from: credentials.email, to: emailData.to, cc: emailData.cc, bcc: emailData.bcc, subject: emailData.subject, text: emailData.body, html: emailData.body, attachments: emailData.attachments?.map(att => ({ filename: att.name, content: att.content, contentType: att.type })), }); return { success: true, messageId: info.messageId }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } }