2025-06-15 16:32:34 +02:00
|
|
|
import { requireAuth } from '~/server/utils/auth';
|
2025-06-12 18:05:42 +02:00
|
|
|
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
|
|
|
|
import { getCachedEmails, syncEmailsWithRetry, getSyncMetadata } from '~/server/utils/email-sync';
|
|
|
|
|
|
|
|
|
|
interface EmailMessage {
|
|
|
|
|
id: string;
|
|
|
|
|
from: string;
|
|
|
|
|
to: string | string[];
|
|
|
|
|
subject: string;
|
|
|
|
|
body: string;
|
|
|
|
|
html?: string;
|
|
|
|
|
timestamp: string;
|
|
|
|
|
direction: 'sent' | 'received';
|
|
|
|
|
threadId?: string;
|
|
|
|
|
attachments?: any[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EmailThread {
|
|
|
|
|
id: string;
|
|
|
|
|
subject: string;
|
|
|
|
|
emailCount: number;
|
|
|
|
|
latestTimestamp: string;
|
|
|
|
|
emails: EmailMessage[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default defineEventHandler(async (event) => {
|
2025-06-15 16:32:34 +02:00
|
|
|
// Check authentication (x-tag header OR Keycloak session)
|
|
|
|
|
await requireAuth(event);
|
2025-06-12 18:05:42 +02:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const body = await readBody(event);
|
|
|
|
|
const { clientEmail, interestId, sessionId } = body;
|
|
|
|
|
|
|
|
|
|
if (!clientEmail || !sessionId || !interestId) {
|
|
|
|
|
throw createError({
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
statusMessage: "Client email, interestId and sessionId are required"
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get encrypted credentials from session
|
|
|
|
|
const encryptedCredentials = getCredentialsFromSession(sessionId);
|
|
|
|
|
if (!encryptedCredentials) {
|
|
|
|
|
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('[Email V2] Failed to decrypt credentials:', decryptError);
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
emails: [],
|
|
|
|
|
threads: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First, get emails from MinIO cache (instant response)
|
|
|
|
|
const cachedEmails = await getCachedEmails(interestId);
|
|
|
|
|
|
|
|
|
|
// Get sync metadata
|
|
|
|
|
const metadata = await getSyncMetadata(interestId);
|
|
|
|
|
|
|
|
|
|
// Trigger background sync if not currently syncing and last sync was over 5 minutes ago
|
|
|
|
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
|
|
|
const lastSync = new Date(metadata.lastSyncTime);
|
|
|
|
|
|
|
|
|
|
if (metadata.syncStatus !== 'syncing' && lastSync < fiveMinutesAgo) {
|
|
|
|
|
// Fire and forget - don't wait for sync to complete
|
|
|
|
|
syncEmailsWithRetry(sessionId, userEmail, clientEmail, interestId).catch(err => {
|
|
|
|
|
console.error('[Email V2] Background sync failed:', err);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process cached emails
|
|
|
|
|
const emails: EmailMessage[] = cachedEmails.map(email => ({
|
|
|
|
|
id: email.id || email.messageId || `${Date.now()}-${Math.random()}`,
|
|
|
|
|
from: email.from || '',
|
|
|
|
|
to: email.to || '',
|
|
|
|
|
subject: email.subject || '',
|
|
|
|
|
body: email.body || email.text || '',
|
|
|
|
|
html: email.html,
|
|
|
|
|
timestamp: email.timestamp || new Date().toISOString(),
|
|
|
|
|
direction: email.direction || (email.from?.toLowerCase().includes(userEmail.toLowerCase()) ? 'sent' : 'received'),
|
|
|
|
|
threadId: email.threadId,
|
|
|
|
|
attachments: email.attachments
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Filter to only include emails involving the client
|
|
|
|
|
const filteredEmails = emails.filter(email => {
|
|
|
|
|
const fromEmail = email.from.toLowerCase();
|
|
|
|
|
const toEmails = Array.isArray(email.to) ? email.to.join(' ').toLowerCase() : email.to.toLowerCase();
|
|
|
|
|
|
|
|
|
|
return fromEmail.includes(clientEmail.toLowerCase()) ||
|
|
|
|
|
toEmails.includes(clientEmail.toLowerCase()) ||
|
|
|
|
|
fromEmail.includes(userEmail.toLowerCase()) ||
|
|
|
|
|
toEmails.includes(userEmail.toLowerCase());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Sort by timestamp (newest first)
|
|
|
|
|
filteredEmails.sort((a, b) =>
|
|
|
|
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Group into threads
|
|
|
|
|
const threads = groupIntoThreads(filteredEmails);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
emails: filteredEmails,
|
|
|
|
|
threads: threads,
|
|
|
|
|
syncStatus: metadata.syncStatus,
|
|
|
|
|
lastSync: metadata.lastSyncTime,
|
|
|
|
|
totalEmails: metadata.totalEmails
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[Email V2] 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",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Group emails into threads based on subject and references
|
|
|
|
|
function groupIntoThreads(emails: EmailMessage[]): EmailThread[] {
|
|
|
|
|
const threads = new Map<string, EmailMessage[]>();
|
|
|
|
|
const emailById = new Map<string, EmailMessage>();
|
|
|
|
|
|
|
|
|
|
// First pass: index all emails by ID
|
|
|
|
|
emails.forEach(email => {
|
|
|
|
|
emailById.set(email.id, email);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Second pass: group emails into threads
|
|
|
|
|
emails.forEach(email => {
|
|
|
|
|
// Normalize subject by removing Re:, Fwd:, etc.
|
|
|
|
|
const normalizedSubject = email.subject
|
|
|
|
|
.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '')
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
.trim()
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Check if this email belongs to an existing thread
|
|
|
|
|
let threadFound = false;
|
|
|
|
|
|
|
|
|
|
// First, check if it has a threadId (in-reply-to header)
|
|
|
|
|
if (email.threadId) {
|
|
|
|
|
// Look for the parent email
|
|
|
|
|
const parentEmail = emailById.get(email.threadId);
|
|
|
|
|
if (parentEmail) {
|
|
|
|
|
// Find which thread the parent belongs to
|
|
|
|
|
for (const [threadId, threadEmails] of threads.entries()) {
|
|
|
|
|
if (threadEmails.some(e => e.id === parentEmail.id)) {
|
|
|
|
|
threadEmails.push(email);
|
|
|
|
|
threadFound = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If not found by threadId, try to match by subject
|
|
|
|
|
if (!threadFound) {
|
|
|
|
|
for (const [threadId, threadEmails] of threads.entries()) {
|
|
|
|
|
const threadSubject = threadEmails[0].subject
|
|
|
|
|
.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '')
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
.trim()
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
|
|
|
|
if (threadSubject === normalizedSubject) {
|
|
|
|
|
threadEmails.push(email);
|
|
|
|
|
threadFound = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If still not found, create a new thread
|
|
|
|
|
if (!threadFound) {
|
|
|
|
|
threads.set(email.id, [email]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Convert to array format and sort emails within each thread
|
|
|
|
|
return Array.from(threads.entries())
|
|
|
|
|
.map(([threadId, threadEmails]) => {
|
|
|
|
|
// Sort emails within thread by timestamp (oldest first for chronological order)
|
|
|
|
|
threadEmails.sort((a, b) =>
|
|
|
|
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: threadId,
|
|
|
|
|
subject: threadEmails[0].subject,
|
|
|
|
|
emailCount: threadEmails.length,
|
|
|
|
|
latestTimestamp: threadEmails[threadEmails.length - 1].timestamp,
|
|
|
|
|
emails: threadEmails
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
// Sort threads by latest activity (newest first)
|
|
|
|
|
.sort((a, b) => new Date(b.latestTimestamp).getTime() - new Date(a.latestTimestamp).getTime());
|
|
|
|
|
}
|