diff --git a/components/EmailThreadView.vue b/components/EmailThreadView.vue index 981926b..a5b8ccf 100644 --- a/components/EmailThreadView.vue +++ b/components/EmailThreadView.vue @@ -5,15 +5,13 @@ Email History - mdi-refresh - + Refresh emails @@ -95,6 +93,34 @@ Show less + + +
+ +
+ mdi-paperclip + {{ email.attachments.length }} Attachment{{ email.attachments.length > 1 ? 's' : '' }} +
+
+ + + {{ attachment.filename }} + + ({{ formatFileSize(attachment.size) }}) + + {{ attachment.error }} + + +
+
@@ -136,6 +162,16 @@ interface EmailMessage { timestamp: string; direction: 'sent' | 'received'; threadId?: string; + attachments?: Array<{ + id?: string; + filename: string; + originalName?: string; + contentType: string; + size: number; + path?: string; + bucket?: string; + error?: string; + }>; } interface EmailThread { @@ -269,6 +305,57 @@ const reloadEmails = () => { loadEmails(); }; +// Get icon for attachment based on content type +const getAttachmentIcon = (contentType: string) => { + if (!contentType) return 'mdi-file'; + + if (contentType.startsWith('image/')) return 'mdi-file-image'; + if (contentType.startsWith('video/')) return 'mdi-file-video'; + if (contentType.startsWith('audio/')) return 'mdi-file-music'; + if (contentType.includes('pdf')) return 'mdi-file-pdf-box'; + if (contentType.includes('word') || contentType.includes('document')) return 'mdi-file-word'; + if (contentType.includes('sheet') || contentType.includes('excel')) return 'mdi-file-excel'; + if (contentType.includes('powerpoint') || contentType.includes('presentation')) return 'mdi-file-powerpoint'; + if (contentType.includes('zip') || contentType.includes('compressed')) return 'mdi-folder-zip'; + + return 'mdi-file'; +}; + +// Format file size for display +const formatFileSize = (bytes: number) => { + if (!bytes || bytes === 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +}; + +// Download attachment +const downloadAttachment = async (attachment: any) => { + if (!attachment.path || !attachment.bucket) { + toast.error('Attachment information is missing'); + return; + } + + try { + // Use the proxy download endpoint + const downloadUrl = `/api/files/proxy-download?bucket=${attachment.bucket}&fileName=${encodeURIComponent(attachment.path)}`; + + // Create a temporary link and trigger download + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = attachment.originalName || attachment.filename; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + console.error('Failed to download attachment:', error); + toast.error('Failed to download attachment'); + } +}; + // Load emails on mount onMounted(() => { loadEmails(); diff --git a/docs/comprehensive-fixes-implementation-plan.md b/docs/comprehensive-fixes-implementation-plan.md new file mode 100644 index 0000000..04f3156 --- /dev/null +++ b/docs/comprehensive-fixes-implementation-plan.md @@ -0,0 +1,268 @@ +# Comprehensive Implementation Plan for Portal Fixes + +## Overview +This document outlines the implementation plan for all requested fixes and improvements to the client portal system. Each section includes the problem, solution approach, and implementation details. + +## 1. Hide Berth Recommendations + +### Problem +Berth recommendations are shown across all instances and need to be hidden. + +### Solution +- Remove or hide all UI elements related to berth recommendations +- Clean up related API calls +- Update the InterestDetailsModal and other components + +### Implementation +```vue + + + +``` + +## 2. EOI Fixes + +### 2.1 Hide Debug Console + +#### Problem +Debug console is visible in production + +#### Solution +- Remove all console.log statements from EOI-related components +- Add environment check for debug outputs + +### 2.2 Slider Bubble Positioning + +#### Problem +Slider bubble doesn't fit entirely within the slider bar + +#### Solution +- Adjust CSS for the slider component to ensure bubble stays within bounds +- Calculate bubble position based on slider width + +### 2.3 EOI Deletion Requires Multiple Clicks + +#### Problem +EOI deletion fails unless clicked multiple times + +#### Solution +- Add proper loading states and disable button during deletion +- Implement proper error handling and retry logic +- Use operation locks to prevent concurrent operations + +### 2.4 Clean Database on EOI Deletion + +#### Problem +Signature links and ID numbers remain in NocoDB after deletion + +#### Solution +```typescript +// When deleting EOI: +// 1. Delete from Documenso +// 2. Clear all EOI-related fields in NocoDB: +// - EOI Document +// - EOI Client Link +// - EOI Oscar Link +// - EOI David Link +// - EOI ID +// - EOI Time Created +// - EOI Time Sent +// - EOI Status +// 3. Reset Sales Process Level if needed +``` + +### 2.5 EOI Regeneration Confirmation + +#### Problem +No warning when regenerating EOI + +#### Solution +- Add confirmation dialog with warning message +- Remind user to verify all information +- Delete old EOI data before regenerating + +## 3. Email Refresh Button Styling + +### Problem +Refresh button needs to be round and moved away from compose button + +### Solution +```vue + +``` + +## 4. Session Management + +### Problem +Email credentials persist incorrectly between sessions + +### Solution +- Clear sessionStorage on page load/unload +- Implement session validation on each API call +- Add session expiry timestamps +- Force re-authentication on page reload + +## 5. Phone Input Country Issue + +### Problem +USA selection switches to American Samoa + +### Solution +- Fix country code sorting in PhoneInput component +- Ensure US (United States) appears before AS (American Samoa) +- Set explicit country code for USA (+1) + +## 6. Email Attachments + +### Problem +Attachments not displayed or downloadable + +### Solution (Already Implemented) +- Added attachment display UI in EmailThreadView +- Created helper functions for icon display and file size formatting +- Implemented download functionality via proxy endpoint + +## 7. IMAP Connection Reliability + +### Problem +IMAP requires multiple connection attempts + +### Solution (Already Implemented) +- Created email-sync service with exponential backoff retry +- Implemented MinIO-based caching for offline access +- Added connection pooling with health checks + +## 8. Berth Assignment Concurrency + +### Problem +Multiple berth selections cause errors + +### Solution +- Implement operation locking for berth assignments +- Add debouncing to prevent rapid clicks +- Queue berth operations sequentially + +## 9. 502 Gateway Errors + +### Problem +Frequent 502 errors requiring multiple retries + +### Root Causes +1. Connection pool exhaustion +2. Database connection limits +3. Memory/process issues +4. No retry logic in API calls + +### Solutions +1. **Connection Pool Management** + - Implement proper connection pooling for IMAP + - Add connection recycling and health checks + - Set maximum connection limits + +2. **API Retry Logic** + - Add exponential backoff retry to all API calls + - Implement circuit breaker pattern + - Cache responses where appropriate + +3. **Resource Optimization** + - Reduce concurrent database connections + - Implement request queuing + - Add memory monitoring + +## 10. Performance Improvements + +### Email System +- **MinIO-First Architecture**: Load cached emails instantly +- **Background Sync**: Update emails asynchronously +- **Incremental Updates**: Only fetch new emails +- **Connection Reuse**: Pool IMAP connections + +### API Optimization +- **Request Batching**: Combine multiple API calls +- **Response Caching**: Cache frequently accessed data +- **Lazy Loading**: Load data only when needed +- **Debouncing**: Prevent excessive API calls + +### Database Optimization +- **Connection Pooling**: Reuse database connections +- **Query Optimization**: Optimize NocoDB queries +- **Batch Operations**: Update multiple records at once + +## Implementation Priority + +### Phase 1: Critical Fixes (Immediate) +1. EOI deletion and database cleanup +2. Session management fixes +3. 502 error mitigation + +### Phase 2: User Experience (This Week) +1. Hide berth recommendations +2. Fix phone input country selection +3. Email attachment display +4. UI improvements (buttons, sliders) + +### Phase 3: Performance (Next Week) +1. Complete MinIO email implementation +2. API optimization +3. Connection pooling improvements + +## Testing Requirements + +### Unit Tests +- EOI deletion flow +- Email sync service +- Session management + +### Integration Tests +- IMAP connection with retry +- MinIO caching +- Concurrent operations + +### Performance Tests +- API response times +- Connection pool limits +- Memory usage under load + +## Monitoring + +### Metrics to Track +- 502 error frequency +- API response times +- IMAP connection success rate +- Memory/CPU usage +- Active connections count + +### Alerts +- 502 errors above threshold +- Connection pool exhaustion +- High memory usage +- Failed email syncs + +## Additional Recommendations + +1. **Error Handling** + - Implement global error handler + - User-friendly error messages + - Automatic error reporting + +2. **Logging** + - Structured logging for debugging + - Request/response logging + - Performance metrics logging + +3. **Caching Strategy** + - Redis for session management + - MinIO for email caching + - Memory cache for frequent queries + +4. **Architecture Improvements** + - Message queue for async operations + - WebSocket for real-time updates + - Service worker for offline support diff --git a/server/api/email/fetch-thread-v2.ts b/server/api/email/fetch-thread-v2.ts new file mode 100644 index 0000000..7aa80d8 --- /dev/null +++ b/server/api/email/fetch-thread-v2.ts @@ -0,0 +1,223 @@ +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) => { + 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 } = 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(); + const emailById = new Map(); + + // 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()); +} diff --git a/server/utils/email-sync.ts b/server/utils/email-sync.ts new file mode 100644 index 0000000..3281473 --- /dev/null +++ b/server/utils/email-sync.ts @@ -0,0 +1,371 @@ +import { getMinioClient } from './minio'; +import { getIMAPPool } from './imap-pool'; +import type { ParsedMail } from 'mailparser'; +import { simpleParser } from 'mailparser'; + +// Email sync service for MinIO-based email management +export interface EmailSyncMetadata { + lastSyncTime: string; + totalEmails: number; + lastError?: string; + syncStatus: 'idle' | 'syncing' | 'error'; +} + +export interface EmailThreadIndex { + threads: Array<{ + id: string; + subject: string; + participants: string[]; + emailCount: number; + lastActivity: string; + hasAttachments: boolean; + }>; + lastUpdated: string; +} + +// Get or create sync metadata +export async function getSyncMetadata(interestId: string): Promise { + const client = getMinioClient(); + const objectName = `interest-${interestId}/metadata.json`; + + try { + const stream = await client.getObject('client-emails', objectName); + let data = ''; + + await new Promise((resolve, reject) => { + stream.on('data', (chunk) => { data += chunk; }); + stream.on('end', resolve); + stream.on('error', reject); + }); + + return JSON.parse(data); + } catch (error: any) { + // If not found, create default metadata + if (error.code === 'NoSuchKey') { + const defaultMetadata: EmailSyncMetadata = { + lastSyncTime: new Date(0).toISOString(), // Start from beginning + totalEmails: 0, + syncStatus: 'idle' + }; + + await saveSyncMetadata(interestId, defaultMetadata); + return defaultMetadata; + } + + throw error; + } +} + +// Save sync metadata +export async function saveSyncMetadata(interestId: string, metadata: EmailSyncMetadata): Promise { + const client = getMinioClient(); + const objectName = `interest-${interestId}/metadata.json`; + const buffer = Buffer.from(JSON.stringify(metadata, null, 2)); + + await client.putObject('client-emails', objectName, buffer, buffer.length, { + 'Content-Type': 'application/json' + }); +} + +// Get thread index +export async function getThreadIndex(interestId: string): Promise { + const client = getMinioClient(); + const objectName = `interest-${interestId}/threads/index.json`; + + try { + const stream = await client.getObject('client-emails', objectName); + let data = ''; + + await new Promise((resolve, reject) => { + stream.on('data', (chunk) => { data += chunk; }); + stream.on('end', resolve); + stream.on('error', reject); + }); + + return JSON.parse(data); + } catch (error: any) { + if (error.code === 'NoSuchKey') { + return { + threads: [], + lastUpdated: new Date().toISOString() + }; + } + throw error; + } +} + +// Save thread index +export async function saveThreadIndex(interestId: string, index: EmailThreadIndex): Promise { + const client = getMinioClient(); + const objectName = `interest-${interestId}/threads/index.json`; + const buffer = Buffer.from(JSON.stringify(index, null, 2)); + + await client.putObject('client-emails', objectName, buffer, buffer.length, { + 'Content-Type': 'application/json' + }); +} + +// Sync emails with exponential backoff retry +export async function syncEmailsWithRetry( + sessionId: string, + userEmail: string, + clientEmail: string, + interestId: string, + maxRetries: number = 3 +): Promise { + let retryCount = 0; + let lastError: Error | null = null; + + while (retryCount <= maxRetries) { + try { + await syncEmails(sessionId, userEmail, clientEmail, interestId); + return; // Success + } catch (error: any) { + lastError = error; + retryCount++; + + if (retryCount > maxRetries) { + throw error; + } + + // Exponential backoff: 1s, 2s, 4s, 8s + const waitTime = Math.pow(2, retryCount - 1) * 1000; + console.log(`[EmailSync] Retry ${retryCount}/${maxRetries} after ${waitTime}ms`); + + await new Promise(resolve => setTimeout(resolve, waitTime)); + } + } + + throw lastError || new Error('Failed to sync emails after retries'); +} + +// Main sync function +async function syncEmails( + sessionId: string, + userEmail: string, + clientEmail: string, + interestId: string +): Promise { + const metadata = await getSyncMetadata(interestId); + + // Update status to syncing + metadata.syncStatus = 'syncing'; + await saveSyncMetadata(interestId, metadata); + + try { + const pool = getIMAPPool(); + const imap = await pool.getConnection(sessionId); + + // Fetch emails newer than last sync + const lastSyncDate = new Date(metadata.lastSyncTime); + const newEmails = await fetchNewEmails(imap, userEmail, clientEmail, lastSyncDate); + + if (newEmails.length > 0) { + // Save new emails to MinIO + for (const email of newEmails) { + const emailId = email.messageId || `${Date.now()}-${Math.random()}`; + const objectName = `interest-${interestId}/emails/${emailId}.json`; + const buffer = Buffer.from(JSON.stringify(email, null, 2)); + + const client = getMinioClient(); + await client.putObject('client-emails', objectName, buffer, buffer.length, { + 'Content-Type': 'application/json' + }); + } + + // Update thread index + await updateThreadIndex(interestId, newEmails); + + // Update metadata + metadata.lastSyncTime = new Date().toISOString(); + metadata.totalEmails += newEmails.length; + metadata.syncStatus = 'idle'; + await saveSyncMetadata(interestId, metadata); + } + } catch (error: any) { + // Update metadata with error + metadata.syncStatus = 'error'; + metadata.lastError = error.message; + await saveSyncMetadata(interestId, metadata); + + throw error; + } +} + +// Fetch new emails from IMAP +async function fetchNewEmails( + imap: any, + userEmail: string, + clientEmail: string, + since: Date +): Promise { + return new Promise((resolve, reject) => { + const emails: any[] = []; + + imap.openBox('INBOX', true, (err: any) => { + if (err) { + reject(err); + return; + } + + // Search for emails newer than last sync + const searchCriteria = [ + ['SINCE', since.toISOString().split('T')[0]], + ['OR', + ['FROM', clientEmail], + ['TO', clientEmail] + ] + ]; + + imap.search(searchCriteria, (err: any, results: number[]) => { + if (err) { + reject(err); + return; + } + + if (results.length === 0) { + resolve([]); + return; + } + + const fetch = imap.fetch(results, { + bodies: '', + struct: true, + envelope: true + }); + + fetch.on('message', (msg: any) => { + msg.on('body', (stream: any) => { + simpleParser(stream, (err: any, parsed: ParsedMail) => { + if (!err && parsed) { + // Handle from/to addresses which can be single or array + const fromText = Array.isArray(parsed.from) + ? parsed.from.map(addr => addr.text).join(', ') + : parsed.from?.text || ''; + + const toText = Array.isArray(parsed.to) + ? parsed.to.map(addr => addr.text).join(', ') + : parsed.to?.text || ''; + + emails.push({ + id: parsed.messageId, + from: fromText, + to: toText, + subject: parsed.subject, + body: parsed.text, + html: parsed.html, + timestamp: parsed.date?.toISOString(), + attachments: parsed.attachments?.map(att => ({ + filename: att.filename, + contentType: att.contentType, + size: att.size + })) + }); + } + }); + }); + }); + + fetch.once('end', () => { + resolve(emails); + }); + + fetch.once('error', (err: any) => { + reject(err); + }); + }); + }); + }); +} + +// Update thread index with new emails +async function updateThreadIndex(interestId: string, newEmails: any[]): Promise { + const index = await getThreadIndex(interestId); + + // Group emails by thread (simplified - by subject) + for (const email of newEmails) { + const normalizedSubject = email.subject + ?.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '') + .trim() || 'No Subject'; + + let thread = index.threads.find(t => + t.subject.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '').trim() === normalizedSubject + ); + + if (!thread) { + thread = { + id: `thread-${Date.now()}-${Math.random()}`, + subject: email.subject || 'No Subject', + participants: [], + emailCount: 0, + lastActivity: email.timestamp, + hasAttachments: false + }; + index.threads.push(thread); + } + + // Update thread + thread.emailCount++; + thread.lastActivity = email.timestamp; + if (email.attachments?.length > 0) { + thread.hasAttachments = true; + } + + // Add participants + const from = email.from?.match(/<(.+)>/)?.[1] || email.from; + if (from && !thread.participants.includes(from)) { + thread.participants.push(from); + } + } + + // Sort threads by last activity + index.threads.sort((a, b) => + new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() + ); + + index.lastUpdated = new Date().toISOString(); + await saveThreadIndex(interestId, index); +} + +// Get emails from MinIO cache +export async function getCachedEmails(interestId: string): Promise { + const client = getMinioClient(); + const emails: any[] = []; + + try { + const stream = client.listObjectsV2('client-emails', `interest-${interestId}/emails/`, true); + const files: any[] = []; + + await new Promise((resolve, reject) => { + stream.on('data', (obj) => { + if (obj && obj.name && obj.name.endsWith('.json')) { + files.push(obj.name); + } + }); + stream.on('error', reject); + stream.on('end', resolve); + }); + + // Load each email + for (const fileName of files) { + try { + const objStream = await client.getObject('client-emails', fileName); + let data = ''; + + await new Promise((resolve, reject) => { + objStream.on('data', (chunk) => { data += chunk; }); + objStream.on('end', resolve); + objStream.on('error', reject); + }); + + emails.push(JSON.parse(data)); + } catch (error) { + console.error(`Failed to load email ${fileName}:`, error); + } + } + } catch (error) { + console.error('Failed to list cached emails:', error); + } + + return emails; +}