347 lines
10 KiB
TypeScript
347 lines
10 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
|
|
let userEmail: string;
|
|
let password: string;
|
|
|
|
try {
|
|
const decrypted = decryptCredentials(encryptedCredentials);
|
|
userEmail = decrypted.email;
|
|
password = decrypted.password;
|
|
} catch (decryptError) {
|
|
console.error('Failed to decrypt credentials - session may be invalid after restart:', decryptError);
|
|
// Return empty results for invalid session
|
|
return {
|
|
success: true,
|
|
emails: [],
|
|
threads: []
|
|
};
|
|
}
|
|
|
|
// 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', () => {
|
|
// Search in both INBOX and Sent folders
|
|
const foldersToSearch = ['INBOX', 'Sent', 'Sent Items', 'Sent Mail'];
|
|
let currentFolderIndex = 0;
|
|
const allEmails: EmailMessage[] = [];
|
|
|
|
const searchNextFolder = () => {
|
|
if (currentFolderIndex >= foldersToSearch.length) {
|
|
cleanup();
|
|
resolve(allEmails);
|
|
return;
|
|
}
|
|
|
|
const folderName = foldersToSearch[currentFolderIndex];
|
|
currentFolderIndex++;
|
|
|
|
imap.openBox(folderName, true, (err, box) => {
|
|
if (err) {
|
|
console.log(`Folder ${folderName} not found, trying next...`);
|
|
searchNextFolder();
|
|
return;
|
|
}
|
|
|
|
console.log(`Searching in folder: ${folderName}`);
|
|
|
|
// Search for emails both sent and received with this client
|
|
const searchCriteria = [
|
|
'OR',
|
|
['FROM', clientEmail],
|
|
['TO', clientEmail],
|
|
['CC', clientEmail],
|
|
['BCC', clientEmail]
|
|
];
|
|
|
|
imap.search(searchCriteria, (err, results) => {
|
|
if (err) {
|
|
console.error(`Search error in ${folderName}:`, err);
|
|
searchNextFolder();
|
|
return;
|
|
}
|
|
|
|
if (!results || results.length === 0) {
|
|
console.log(`No emails found in ${folderName}`);
|
|
searchNextFolder();
|
|
return;
|
|
}
|
|
|
|
console.log(`Found ${results.length} emails in ${folderName}`);
|
|
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) {
|
|
searchNextFolder();
|
|
}
|
|
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.toLowerCase().includes(userEmail.toLowerCase()) ? 'sent' : 'received'
|
|
};
|
|
|
|
if (parsed.headers.has('in-reply-to')) {
|
|
email.threadId = parsed.headers.get('in-reply-to') as string;
|
|
}
|
|
|
|
allEmails.push(email);
|
|
messagesProcessed++;
|
|
|
|
if (messagesProcessed === messagesToFetch.length) {
|
|
searchNextFolder();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
fetch.once('error', (err) => {
|
|
console.error('Fetch error:', err);
|
|
searchNextFolder();
|
|
});
|
|
|
|
fetch.once('end', () => {
|
|
if (messagesProcessed === 0) {
|
|
searchNextFolder();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
searchNextFolder();
|
|
});
|
|
|
|
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
|
|
}));
|
|
}
|