'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'); } 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, 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 { try { // Extract the base folder name by removing the accountId suffix if present const baseFolder = accountId && folder.includes(`-${accountId}`) ? folder.split(`-${accountId}`)[0] : folder; console.log(`Fetching emails for folder: ${baseFolder} (original: ${folder})`); // Get IMAP connection for the account const imap = await getImapConnection(userId, accountId); if (!imap) { throw new Error('Failed to establish IMAP connection'); } // Open the mailbox await imap.mailboxOpen(baseFolder); // Calculate message range for pagination const totalMessages = await imap.status(baseFolder, { messages: true }); const total = totalMessages.messages || 0; const start = Math.max(1, total - (page * perPage) + 1); const end = Math.max(1, total - ((page - 1) * perPage)); // Fetch messages const messages = await imap.fetch(`${start}:${end}`, { envelope: true, flags: true, bodyStructure: true, uid: 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'), answered: message.flags.has('\\Answered'), flagged: message.flags.has('\\Flagged'), 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: folder, contentFetched: false, accountId: accountId || '', content: '', messageId: message.envelope?.messageId || undefined }; emails.push(email); } // Cache the result if (accountId) { await cacheEmailList(userId, accountId, folder, page, perPage, emails); } return emails; } catch (error) { console.error(`Error fetching emails for folder ${folder}:`, error); throw 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, 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); try { await client.mailboxOpen(folder); 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 })), html: rawHtml, text: parsedEmail.text || undefined, content: rawHtml || parsedEmail.text || '', folder, contentFetched: true, size: size || 0 }; // Cache the email content await cacheEmailContent(userId, 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(); // Map special folders to standard names const specialFolders = new Map([ ['Sent Messages', 'Sent'], ['Sent Items', 'Sent'], ['Drafts', 'Drafts'], ['Deleted Items', 'Trash'], ['Junk Email', 'Junk'], ['Spam', 'Junk'] ]); // Process mailboxes and map special folders const processedMailboxes = mailboxes.map(mailbox => { const path = mailbox.path; // Check if this is a special folder for (const [special, standard] of specialFolders) { if (path.includes(special)) { return standard; } } return path; }); // Ensure we have all standard folders const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']; // Combine standard folders with custom folders const uniqueFolders = new Set([...standardFolders, ...processedMailboxes]); // If accountId is provided, append it to each folder name return accountId ? Array.from(uniqueFolders).map(f => `${f}-${accountId}`) : Array.from(uniqueFolders); } catch (error) { console.error('Error fetching mailboxes:', error); // Return default folders on error const defaultFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']; return accountId ? defaultFolders.map(f => `${f}-${accountId}`) : defaultFolders; } } /** * 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'}` }; } }