'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, accountId); 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'); } // Use accountId in connection key to ensure different accounts get different connections const connectionKey = `${userId}:${accountId || 'default'}`; 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, accountId?: string): Promise { const credentials = await prisma.mailCredentials.findFirst({ where: { AND: [ { userId }, accountId ? { id: accountId } : {} ] } }); if (!credentials) return null; const mailCredentials = credentials as unknown as { email: string; password: string; host: string; port: number; secure: boolean; smtp_host: string | null; smtp_port: number | null; smtp_secure: boolean | null; display_name: string | null; color: string | null; }; return { email: mailCredentials.email, password: mailCredentials.password, host: mailCredentials.host, port: mailCredentials.port, secure: mailCredentials.secure, smtp_host: mailCredentials.smtp_host || undefined, smtp_port: mailCredentials.smtp_port || undefined, smtp_secure: mailCredentials.smtp_secure || false, display_name: mailCredentials.display_name || undefined, color: mailCredentials.color || undefined }; } /** * 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); if (!credentials) { throw new Error('No credentials provided'); } // Extract only the fields that exist in the database schema const dbCredentials = { email: credentials.email, password: credentials.password ?? '', host: credentials.host, port: credentials.port, secure: credentials.secure ?? true, smtp_host: credentials.smtp_host || null, smtp_port: credentials.smtp_port || null, smtp_secure: credentials.smtp_secure ?? false, display_name: credentials.display_name || null, color: credentials.color || null }; try { // Save to database using the unique constraint on [userId, email] await prisma.mailCredentials.upsert({ where: { id: await prisma.mailCredentials.findFirst({ where: { AND: [ { userId }, { email: accountId } ] }, select: { id: true } }).then(result => result?.id ?? '') }, update: dbCredentials, create: { userId, ...dbCredentials } }); // Cache the full credentials object in Redis await cacheEmailCredentials(userId, accountId, credentials); console.log('Successfully saved and cached credentials for user:', userId); } catch (error) { console.error('Error saving credentials:', error); throw error; } } // 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, page: number = 1, perPage: number = 20, accountId?: string ): Promise { let client: ImapFlow | undefined; try { // Extract the actual folder name (remove account prefix if present) const actualFolder = folder.includes(':') ? folder.split(':')[1] : folder; console.log(`Fetching emails for folder: ${folder} (actual: ${actualFolder})`); // Get IMAP connection client = await getImapConnection(userId, accountId); if (!client) { throw new Error('Failed to establish IMAP connection'); } // Open mailbox with the actual folder name await client.mailboxOpen(actualFolder); const mailbox = client.mailbox; if (!mailbox || typeof mailbox === 'boolean') { throw new Error(`Failed to open mailbox: ${actualFolder}`); } // Get total messages const total = mailbox.exists || 0; console.log(`Total messages in ${actualFolder}: ${total}`); // If no messages, return empty result if (total === 0) { return { emails: [], totalEmails: 0, page, perPage, totalPages: 0, folder: actualFolder, mailboxes: [] }; } // Calculate message range for pagination const start = Math.max(1, total - (page * perPage) + 1); const end = Math.max(1, total - ((page - 1) * perPage)); console.log(`Fetching messages ${start}:${end} from ${actualFolder}`); // Fetch messages const messages = await client.fetch(`${start}:${end}`, { envelope: true, flags: true, bodyStructure: true }); const emails: EmailMessage[] = []; for await (const message of messages) { const email: EmailMessage = { id: message.uid.toString(), from: message.envelope.from?.map(addr => ({ name: addr.name || '', address: addr.address || '' })) || [], to: message.envelope.to?.map(addr => ({ name: addr.name || '', address: addr.address || '' })) || [], subject: message.envelope.subject || '', date: message.envelope.date || new Date(), flags: { seen: message.flags.has('\\Seen'), flagged: message.flags.has('\\Flagged'), answered: message.flags.has('\\Answered'), draft: message.flags.has('\\Draft'), deleted: message.flags.has('\\Deleted') }, size: message.size || 0, hasAttachments: message.bodyStructure?.childNodes?.some(node => node.disposition === 'attachment') || false, folder: actualFolder, contentFetched: false, accountId: accountId || 'default', content: { text: '', html: '' } }; emails.push(email); } // Cache the result if we have an accountId if (accountId) { await cacheEmailList(userId, accountId, actualFolder, page, perPage, { emails, totalEmails: total, page, perPage, totalPages: Math.ceil(total / perPage), folder: actualFolder, mailboxes: [] }); } return { emails, totalEmails: total, page, perPage, totalPages: Math.ceil(total / perPage), folder: actualFolder, mailboxes: [] }; } catch (error) { console.error('Error fetching emails:', error); throw error; } finally { if (client) { 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', accountId?: string ): Promise { // Try to get from cache first const cachedEmail = await getCachedEmailContent(userId, accountId || folder, 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, accountId); try { // Remove accountId prefix if present const actualFolder = folder.includes(':') ? folder.split(':')[1] : folder; await client.mailboxOpen(actualFolder); const message = await client.fetchOne(emailId, { source: true, envelope: true, flags: true, size: true }); if (!message) { throw new Error('Email not found'); } const { source, envelope, flags, size } = 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: EmailMessage = { 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 })), content: { text: parsedEmail.text || '', html: rawHtml }, folder: actualFolder, contentFetched: true, size: size || 0 }; // Cache the email content await cacheEmailContent(userId, accountId || folder, 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, folder, emailId); // Also invalidate folder cache because unread counts may have changed await invalidateFolderCache(userId, folder, 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' }; } } /** * Get list of mailboxes from an IMAP connection */ export async function getMailboxes(client: ImapFlow, accountId?: string): Promise { try { const mailboxes = await client.list(); // If we have an accountId, prefix the folder names to prevent namespace collisions if (accountId) { return mailboxes.map(mailbox => `${accountId}:${mailbox.path}`); } // For backward compatibility, return unprefixed names when no accountId return mailboxes.map(mailbox => mailbox.path); } catch (error) { console.error('Error fetching mailboxes:', error); // Return empty array on error to avoid showing incorrect folders return []; } } /** * Test IMAP and SMTP connections for an email account */ export async function testEmailConnection(credentials: EmailCredentials): Promise<{ imap: boolean; smtp?: boolean; error?: string; folders?: string[]; }> { console.log('Testing connection with:', { ...credentials, password: '***' }); // Test IMAP connection try { console.log(`Testing IMAP connection to ${credentials.host}:${credentials.port} for ${credentials.email}`); const client = new ImapFlow({ host: credentials.host, port: credentials.port, secure: credentials.secure ?? true, auth: { user: credentials.email, pass: credentials.password, }, logger: false, tls: { rejectUnauthorized: false } }); await client.connect(); const folders = await getMailboxes(client); await client.logout(); console.log(`IMAP connection successful for ${credentials.email}`); console.log(`Found ${folders.length} folders:`, folders); // Test SMTP connection if SMTP settings are provided let smtpSuccess = false; if (credentials.smtp_host && credentials.smtp_port) { try { console.log(`Testing SMTP connection to ${credentials.smtp_host}:${credentials.smtp_port}`); const transporter = nodemailer.createTransport({ host: credentials.smtp_host, port: credentials.smtp_port, secure: credentials.smtp_secure ?? false, auth: { user: credentials.email, pass: credentials.password, }, tls: { rejectUnauthorized: false } }); await transporter.verify(); console.log(`SMTP connection successful for ${credentials.email}`); smtpSuccess = true; } catch (smtpError) { console.error(`SMTP connection failed for ${credentials.email}:`, smtpError); return { imap: true, smtp: false, error: `SMTP connection failed: ${smtpError instanceof Error ? smtpError.message : 'Unknown error'}`, folders }; } } return { imap: true, smtp: smtpSuccess, folders }; } catch (error) { console.error(`IMAP connection failed for ${credentials.email}:`, error); return { imap: false, error: `IMAP connection failed: ${error instanceof Error ? error.message : 'Unknown error'}` }; } }