port-nimara-client-portal/server/api/email/fetch-thread.ts

310 lines
8.8 KiB
TypeScript

import Imap from 'imap';
import { simpleParser } from 'mailparser';
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
import { listFiles, getFileStats } from '~/server/utils/minio';
interface EmailMessage {
id: string;
from: string;
to: string | string[];
subject: string;
body: string;
html?: string;
timestamp: string;
direction: 'sent' | 'received';
threadId?: string;
}
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
try {
const body = await readBody(event);
const { clientEmail, interestId, sessionId, limit = 20 } = body;
if (!clientEmail || !sessionId) {
throw createError({
statusCode: 400,
statusMessage: "Client email and sessionId are required"
});
}
// Get encrypted credentials from session
const encryptedCredentials = getCredentialsFromSession(sessionId);
if (!encryptedCredentials) {
// Return empty results instead of throwing error
return {
success: true,
emails: [],
threads: []
};
}
// Decrypt credentials
const { email: userEmail, password } = decryptCredentials(encryptedCredentials);
// First, get emails from MinIO cache if available
const cachedEmails: EmailMessage[] = [];
if (interestId) {
try {
const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[];
console.log('Found cached email files:', files.length);
for (const file of files) {
if (file.name.endsWith('.json') && !file.isFolder) {
try {
// Use the getDownloadUrl function to get a proper presigned URL
const { getDownloadUrl } = await import('~/server/utils/minio');
const downloadUrl = await getDownloadUrl(file.name);
const response = await fetch(downloadUrl);
const emailData = await response.json();
cachedEmails.push(emailData);
} catch (err) {
console.error('Failed to read cached email:', file.name, err);
}
}
}
} catch (err) {
console.error('Failed to list cached emails:', err);
}
}
// Configure IMAP
const imapConfig = {
user: userEmail,
password: password,
host: process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com',
port: parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993'),
tls: true,
tlsOptions: {
rejectUnauthorized: false
},
connTimeout: 10000, // 10 seconds connection timeout
authTimeout: 5000 // 5 seconds auth timeout
};
// Fetch emails from IMAP with timeout
let imapEmails: EmailMessage[] = [];
const timeoutPromise = new Promise<EmailMessage[]>((_, reject) =>
setTimeout(() => reject(new Error('IMAP connection timeout')), 15000)
);
try {
imapEmails = await Promise.race([
fetchImapEmails(imapConfig, userEmail, clientEmail, limit),
timeoutPromise
]);
} catch (imapError) {
console.error('IMAP fetch failed:', imapError);
// Continue with cached emails only
}
// Combine cached and IMAP emails, remove duplicates
const allEmails = [...cachedEmails, ...imapEmails];
const uniqueEmails = Array.from(
new Map(allEmails.map(email => [email.id, email])).values()
);
// Sort by timestamp
uniqueEmails.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Group into threads
const threads = groupIntoThreads(uniqueEmails);
return {
success: true,
emails: uniqueEmails,
threads: threads
};
} catch (error) {
console.error('Failed to fetch email thread:', error);
if (error instanceof Error) {
throw createError({
statusCode: 500,
statusMessage: `Failed to fetch emails: ${error.message}`
});
} else {
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
});
}
}
});
// Separate function for IMAP fetching with proper cleanup
async function fetchImapEmails(
imapConfig: any,
userEmail: string,
clientEmail: string,
limit: number
): Promise<EmailMessage[]> {
return new Promise((resolve, reject) => {
const emails: EmailMessage[] = [];
const imap = new Imap(imapConfig);
let isResolved = false;
const cleanup = () => {
if (!isResolved) {
isResolved = true;
try {
imap.end();
} catch (e) {
console.error('Error closing IMAP connection:', e);
}
}
};
imap.once('ready', () => {
imap.openBox('INBOX', true, (err, box) => {
if (err) {
cleanup();
reject(err);
return;
}
const searchCriteria = [
['OR', ['FROM', clientEmail], ['TO', clientEmail]]
];
imap.search(searchCriteria, (err, results) => {
if (err) {
cleanup();
reject(err);
return;
}
if (!results || results.length === 0) {
cleanup();
resolve(emails);
return;
}
const messagesToFetch = results.slice(-limit);
let messagesProcessed = 0;
const fetch = imap.fetch(messagesToFetch, {
bodies: '',
struct: true,
envelope: true
});
fetch.on('message', (msg, seqno) => {
msg.on('body', (stream, info) => {
simpleParser(stream as any, async (err: any, parsed: any) => {
if (err) {
console.error('Parse error:', err);
messagesProcessed++;
if (messagesProcessed === messagesToFetch.length) {
cleanup();
resolve(emails);
}
return;
}
const email: EmailMessage = {
id: parsed.messageId || `${Date.now()}-${seqno}`,
from: parsed.from?.text || '',
to: Array.isArray(parsed.to)
? parsed.to.map((addr: any) => addr.text).join(', ')
: parsed.to?.text || '',
subject: parsed.subject || '',
body: parsed.text || '',
html: parsed.html || undefined,
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
direction: parsed.from?.text.includes(userEmail) ? 'sent' : 'received'
};
if (parsed.headers.has('in-reply-to')) {
email.threadId = parsed.headers.get('in-reply-to') as string;
}
emails.push(email);
messagesProcessed++;
if (messagesProcessed === messagesToFetch.length) {
cleanup();
resolve(emails);
}
});
});
});
fetch.once('error', (err) => {
console.error('Fetch error:', err);
cleanup();
reject(err);
});
fetch.once('end', () => {
if (messagesProcessed === 0) {
cleanup();
resolve(emails);
}
});
});
});
});
imap.once('error', (err: any) => {
cleanup();
reject(err);
});
imap.once('end', () => {
if (!isResolved) {
isResolved = true;
resolve(emails);
}
});
imap.connect();
});
}
// Group emails into threads based on subject and references
function groupIntoThreads(emails: EmailMessage[]): any[] {
const threads = new Map<string, EmailMessage[]>();
emails.forEach(email => {
// Normalize subject by removing Re:, Fwd:, etc.
const normalizedSubject = email.subject
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
.trim();
// Find existing thread or create new one
let threadFound = false;
for (const [threadId, threadEmails] of threads.entries()) {
const threadSubject = threadEmails[0].subject
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
.trim();
if (threadSubject === normalizedSubject) {
threadEmails.push(email);
threadFound = true;
break;
}
}
if (!threadFound) {
threads.set(email.id, [email]);
}
});
// Convert to array format
return Array.from(threads.entries()).map(([threadId, emails]) => ({
id: threadId,
subject: emails[0].subject,
emailCount: emails.length,
latestTimestamp: emails[emails.length - 1].timestamp,
emails: emails
}));
}