import { ImapFlow } from 'imapflow'; import nodemailer from 'nodemailer'; import { prisma } from '@/lib/prisma'; import { simpleParser } from 'mailparser'; // Types for the email service export interface EmailCredentials { email: string; password: string; host: string; port: number; } export interface EmailMessage { id: string; messageId?: string; subject: string; from: EmailAddress[]; to: EmailAddress[]; cc?: EmailAddress[]; bcc?: EmailAddress[]; date: Date; flags: { seen: boolean; flagged: boolean; answered: boolean; deleted: boolean; draft: boolean; }; preview?: string; content?: string; html?: string; text?: string; hasAttachments: boolean; attachments?: EmailAttachment[]; folder: string; size?: number; contentFetched: boolean; } export interface EmailAddress { name: string; address: string; } export interface EmailAttachment { contentId?: string; filename: string; contentType: string; size: number; path?: string; content?: string; } 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): Promise { // Get credentials from database const credentials = await getUserEmailCredentials(userId); if (!credentials) { throw new Error('No email credentials found'); } const connectionKey = `${userId}:${credentials.email}`; const existingConnection = connectionPool[connectionKey]; // Return existing connection if available and connected if (existingConnection) { try { if (existingConnection.client.usable) { existingConnection.lastUsed = 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 } } // 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(); // Store in connection pool connectionPool[connectionKey] = { client, lastUsed: Date.now() }; return client; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 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.findUnique({ where: { userId } }); if (!credentials) { return null; } return { email: credentials.email, password: credentials.password, host: credentials.host, port: credentials.port }; } /** * Save or update user's email credentials */ export async function saveUserEmailCredentials( userId: string, credentials: EmailCredentials ): Promise { await prisma.mailCredentials.upsert({ where: { userId }, update: { email: credentials.email, password: credentials.password, host: credentials.host, port: credentials.port }, create: { userId, email: credentials.email, password: credentials.password, host: credentials.host, port: credentials.port } }); } // 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 = '' ): Promise { const client = await getImapConnection(userId); try { // 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 mailboxes = await getMailboxes(client); return { emails: [], totalEmails: 0, page, perPage, totalPages: 0, folder, mailboxes }; } // 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 mailboxes = await getMailboxes(client); return { emails, totalEmails: totalMessages, page, perPage, totalPages: Math.ceil(totalMessages / perPage), folder, mailboxes }; } 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 { 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 || ''; return { 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 }; } 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']); } 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 const transporter = nodemailer.createTransport({ host: 'smtp.infomaniak.com', // Using Infomaniak SMTP server port: 587, secure: false, auth: { user: credentials.email, pass: credentials.password, }, tls: { rejectUnauthorized: false } }); try { // Verify connection await transporter.verify(); // Prepare email options const mailOptions = { from: credentials.email, to: emailData.to, cc: emailData.cc || undefined, bcc: emailData.bcc || undefined, subject: emailData.subject || '(No subject)', html: emailData.body, attachments: emailData.attachments?.map(file => ({ filename: file.name, content: file.content, contentType: file.type })) || [] }; // Send email const info = await transporter.sendMail(mailOptions); return { success: true, messageId: info.messageId }; } catch (error) { console.error('Error sending email:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Get list of mailboxes (folders) */ export async function getMailboxes(client: ImapFlow): Promise { try { const list = await client.list(); return list.map(mailbox => mailbox.path); } catch (error) { console.error('Error listing mailboxes:', error); return []; } } /** * Test email connection with given credentials */ export async function testEmailConnection(credentials: EmailCredentials): Promise { const client = new ImapFlow({ host: credentials.host, port: credentials.port, secure: true, auth: { user: credentials.email, pass: credentials.password, }, logger: false, tls: { rejectUnauthorized: false } }); try { await client.connect(); await client.mailboxOpen('INBOX'); return true; } catch (error) { console.error('Connection test failed:', error); return false; } finally { try { await client.logout(); } catch (e) { // Ignore logout errors } } } /** * Format email for reply/forward */ export function formatEmailForReplyOrForward( email: EmailMessage, type: 'reply' | 'reply-all' | 'forward' ): { to: string; cc?: string; subject: string; body: string; } { // Format the subject with Re: or Fwd: prefix const subject = formatSubject(email.subject, type); // Create the email quote with proper formatting const quoteHeader = createQuoteHeader(email); const quotedContent = email.html || email.text || ''; // Format recipients let to = ''; let cc = ''; if (type === 'reply') { // Reply to sender only to = email.from.map(addr => `${addr.name} <${addr.address}>`).join(', '); } else if (type === 'reply-all') { // Reply to sender and all recipients to = email.from.map(addr => `${addr.name} <${addr.address}>`).join(', '); // Add all original recipients to CC, except ourselves const allRecipients = [ ...(email.to || []), ...(email.cc || []) ]; cc = allRecipients .map(addr => `${addr.name} <${addr.address}>`) .join(', '); } else if (type === 'forward') { formattedContent = `

---------- Forwarded message ---------

From: ${decoded.from || ''}

Date: ${formatDate(decoded.date ? new Date(decoded.date) : null)}

Subject: ${decoded.subject || ''}

To: ${decoded.to || ''}


${decoded.html || `
${decoded.text || ''}
`}
`; } // Format the email body with quote const body = `


${quoteHeader}
${quotedContent}
`; return { to, cc: cc || undefined, subject, body }; } /** * Format subject with appropriate prefix (Re:, Fwd:) */ function formatSubject(subject: string, type: 'reply' | 'reply-all' | 'forward'): string { // Clean up any existing prefixes let cleanSubject = subject .replace(/^(Re|Fwd|FW|Forward):\s*/i, '') .trim(); // Add appropriate prefix if (type === 'reply' || type === 'reply-all') { if (!subject.match(/^Re:/i)) { return `Re: ${cleanSubject}`; } } else if (type === 'forward') { if (!subject.match(/^(Fwd|FW|Forward):/i)) { return `Fwd: ${cleanSubject}`; } } return subject; } /** * Create a quote header for reply/forward */ function createQuoteHeader(email: EmailMessage): string { // Format the date const date = new Date(email.date); const formattedDate = date.toLocaleString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); // Format the sender const sender = email.from[0]; const fromText = sender?.name ? `${sender.name} <${sender.address}>` : sender?.address || 'Unknown sender'; return `
On ${formattedDate}, ${fromText} wrote:
`; }