745 lines
19 KiB
TypeScript
745 lines
19 KiB
TypeScript
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<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> {
|
|
// 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<EmailCredentials | null> {
|
|
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<void> {
|
|
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<EmailListResult> {
|
|
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<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 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<EmailMessage> {
|
|
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 || '';
|
|
|
|
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<boolean> {
|
|
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<string[]> {
|
|
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<boolean> {
|
|
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 = `
|
|
<div class="forwarded-message">
|
|
<p>---------- Forwarded message ---------</p>
|
|
<p>From: ${decoded.from || ''}</p>
|
|
<p>Date: ${formatDate(decoded.date ? new Date(decoded.date) : null)}</p>
|
|
<p>Subject: ${decoded.subject || ''}</p>
|
|
<p>To: ${decoded.to || ''}</p>
|
|
<br>
|
|
${decoded.html || `<pre>${decoded.text || ''}</pre>`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Format the email body with quote
|
|
const body = `
|
|
<div>
|
|
<br/>
|
|
<br/>
|
|
<div>${quoteHeader}</div>
|
|
<blockquote style="border-left: 2px solid #ccc; padding-left: 10px; margin-left: 10px; color: #777;">
|
|
${quotedContent}
|
|
</blockquote>
|
|
</div>`;
|
|
|
|
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 `<div>On ${formattedDate}, ${fromText} wrote:</div>`;
|
|
}
|