Neah/lib/services/email-service.ts
2025-04-27 16:52:21 +02:00

773 lines
22 KiB
TypeScript

'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<string, { client: ImapFlow; lastUsed: number }> = {};
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<ImapFlow> {
console.log(`Getting IMAP connection for user ${userId}`);
// First try to get credentials from Redis cache
let credentials = await getCachedEmailCredentials(userId);
// If not in cache, get from database and cache them
if (!credentials) {
console.log(`Credentials not found in cache for ${userId}, attempting database lookup`);
credentials = await getUserEmailCredentials(userId);
if (!credentials) {
throw new Error('No email credentials found');
}
// Cache credentials for future use
await cacheEmailCredentials(userId, credentials);
}
// Validate credentials
if (!credentials.password) {
console.error(`Missing password in credentials for user ${userId}`);
throw new Error('No password configured');
}
if (!credentials.email || !credentials.host) {
console.error(`Incomplete credentials for user ${userId}`);
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<EmailCredentials | null> {
const credentials = await prisma.mailCredentials.findUnique({
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 only the fields that exist in credentials
return {
email: credentials.email,
password: credentials.password,
host: credentials.host,
port: credentials.port,
...(credentials.secure !== undefined && { secure: credentials.secure }),
...(credentials.smtp_host && { smtp_host: credentials.smtp_host }),
...(credentials.smtp_port && { smtp_port: credentials.smtp_port }),
...(credentials.smtp_secure !== undefined && { smtp_secure: credentials.smtp_secure }),
...(credentials.display_name && { display_name: credentials.display_name }),
...(credentials.color && { color: credentials.color })
};
}
/**
* Save or update user's email credentials
*/
export async function saveUserEmailCredentials(
userId: string,
credentials: EmailCredentials
): Promise<void> {
console.log('Saving credentials for user:', userId);
// 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: { userId },
update: dbCredentials,
create: {
userId,
...dbCredentials
}
});
// Cache the full credentials object in Redis (with all fields)
await cacheEmailCredentials(userId, 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 = ''
): Promise<EmailListResult> {
// Try to get from cache first
if (!searchQuery) {
const cachedResult = await getCachedEmailList(userId, folder, page, perPage);
if (cachedResult) {
console.log(`Using cached email list for ${userId}:${folder}:${page}:${perPage}`);
return cachedResult;
}
}
console.log(`Cache miss for emails ${userId}:${folder}:${page}:${perPage}, fetching from IMAP`);
const 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<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: 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<EmailMessage> {
// 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<string>);
// 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<boolean> {
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', // Use custom SMTP or default
port: credentials.smtp_port || 587,
secure: credentials.smtp_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<string[]> {
try {
const list = await client.list();
return list.map(mailbox => mailbox.path);
} catch (error) {
console.error('Error listing mailboxes:', error);
return [];
}
}
/**
* Test email connections with given credentials
*/
export async function testEmailConnection(credentials: EmailCredentials): Promise<{
imap: boolean;
smtp: boolean;
error?: string;
}> {
// Test IMAP connection
let imapSuccess = false;
let smtpSuccess = false;
let errorMessage = '';
// First test IMAP
const imapClient = new ImapFlow({
host: credentials.host,
port: credentials.port,
secure: true,
auth: {
user: credentials.email,
pass: credentials.password,
},
logger: false,
tls: {
rejectUnauthorized: false
}
});
try {
await imapClient.connect();
await imapClient.mailboxOpen('INBOX');
imapSuccess = true;
} catch (error) {
console.error('IMAP connection test failed:', error);
errorMessage = error instanceof Error ? error.message : 'Unknown IMAP error';
return { imap: false, smtp: false, error: `IMAP connection failed: ${errorMessage}` };
} finally {
try {
await imapClient.logout();
} catch (e) {
// Ignore logout errors
}
}
// If IMAP successful and SMTP details provided, test SMTP
if (credentials.smtp_host && credentials.smtp_port) {
const transporter = nodemailer.createTransport({
host: credentials.smtp_host,
port: credentials.smtp_port,
secure: true,
auth: {
user: credentials.email,
pass: credentials.password,
},
tls: {
rejectUnauthorized: false
}
});
try {
await transporter.verify();
smtpSuccess = true;
} catch (error) {
console.error('SMTP connection test failed:', error);
errorMessage = error instanceof Error ? error.message : 'Unknown SMTP error';
return {
imap: imapSuccess,
smtp: false,
error: `SMTP connection failed: ${errorMessage}`
};
}
} else {
// If no SMTP details, just mark as successful
smtpSuccess = true;
}
return { imap: imapSuccess, smtp: smtpSuccess };
}
// Original simplified function for backward compatibility
export async function testImapConnection(credentials: EmailCredentials): Promise<boolean> {
const result = await testEmailConnection(credentials);
return result.imap;
}
// Email formatting functions have been moved to lib/utils/email-formatter.ts
// Use those functions instead of the ones previously defined here