UNPKG

@postalsys/ee-client

Version:

EmailEngine client library for browser and Node.js applications

1,522 lines (1,298 loc) • 67.8 kB
export class EmailEngineClient { constructor(options = {}) { this.apiUrl = options.apiUrl || 'http://127.0.0.1:3000'; this.account = options.account; this.accessToken = options.accessToken; this.container = options.container; this.confirmMethod = options.confirmMethod || ((message, _title = 'Confirm', _cancelText = 'Cancel', _okText = 'OK') => confirm(message)); this.alertMethod = options.alertMethod || ((message, _title = 'Notice', _cancelText = null, _okText = 'OK') => alert(message)); this.currentFolder = null; this.currentMessage = null; this.folders = []; this.messages = []; this.nextPageCursor = null; this.prevPageCursor = null; // Keep-alive timer for sess_ tokens this.keepAliveTimer = null; this.lastActivity = Date.now(); // Dark mode state this.darkMode = false; if (typeof window !== 'undefined' && window.localStorage) { this.darkMode = localStorage.getItem('ee-client-dark-mode') === 'true'; } // Get page size from localStorage or options or default const savedPageSize = typeof window !== 'undefined' && window.localStorage ? localStorage.getItem('ee-client-page-size') : null; this.pageSize = savedPageSize ? parseInt(savedPageSize) : options.pageSize || 20; if (this.container) { this.init(); } // Start keep-alive timer for sess_ tokens this._startKeepAliveTimer(); } async apiRequest(method, endpoint, data = null) { // Update activity timestamp for keep-alive this._updateActivity(); const url = `${this.apiUrl}${endpoint}`; const options = { method: method, headers: { 'Content-Type': 'application/json' } }; if (this.accessToken) { options.headers['Authorization'] = `Bearer ${this.accessToken}`; } if (data) { options.body = JSON.stringify(data); } let fetchFn = globalThis.fetch; if (!fetchFn && typeof require !== 'undefined') { try { const nodeFetch = await import('node-fetch'); fetchFn = nodeFetch.default; } catch (err) { throw new Error( 'fetch is not available. In Node.js environments, please install node-fetch: npm install node-fetch' ); } } if (!fetchFn) { throw new Error('fetch is not available'); } const response = await fetchFn(url, options); if (!response.ok) { let errorDetails; try { errorDetails = await response.json(); } catch (parseError) { // If JSON parsing fails, fall back to status text errorDetails = { message: response.statusText }; } const error = new Error(`API request failed: ${response.statusText}`); error.statusCode = response.status; error.details = errorDetails; throw error; } return await response.json(); } async loadFolders() { try { const data = await this.apiRequest('GET', `/v1/account/${this.account}/mailboxes`); this.folders = data.mailboxes || []; if (this.container) { this.renderFolderList(); } return this.folders; } catch (error) { console.error('Failed to load folders:', error); throw error; } } async loadMessages(path, cursor = null) { if (this.container) { const messageList = this.container.querySelector('.ee-message-list'); if (messageList) { messageList.innerHTML = '<div class="ee-loading">Loading messages...</div>'; } } try { const params = new URLSearchParams({ path: path, pageSize: this.pageSize }); if (cursor) { params.set('cursor', cursor); } const data = await this.apiRequest('GET', `/v1/account/${this.account}/messages?${params}`); this.messages = data.messages || []; this.currentFolder = path; this.nextPageCursor = data.nextPageCursor || null; this.prevPageCursor = data.prevPageCursor || null; // Clear active email selection when folder changes this.currentMessage = null; if (this.container) { this.renderMessageList(); this.renderFolderList(); // Re-render to update active state this.renderMessage(); // Clear message viewer } return { messages: this.messages, nextPageCursor: this.nextPageCursor, prevPageCursor: this.prevPageCursor }; } catch (error) { console.error('Failed to load messages:', error); if (this.container) { const messageList = this.container.querySelector('.ee-message-list'); if (messageList) { messageList.innerHTML = '<div class="ee-error">Failed to load messages</div>'; } } throw error; } } async loadMessage(messageId) { if (this.container) { const viewer = this.container.querySelector('.ee-message-viewer'); if (viewer) { viewer.innerHTML = '<div class="ee-loading">Loading message...</div>'; } } try { const params = new URLSearchParams({ webSafeHtml: true, markAsSeen: true }); const data = await this.apiRequest('GET', `/v1/account/${this.account}/message/${messageId}?${params}`); this.currentMessage = data; this.currentMessage.unseen = false; const msg = this.messages.find(m => m.id === messageId); if (msg) { msg.unseen = false; if (this.container) { this.renderMessageList(); } } if (this.container) { this.renderMessage(); // Scroll to top of the email client container this.container.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Also scroll the window to ensure visibility if (typeof window !== 'undefined') { const containerTop = this.container.getBoundingClientRect().top + window.pageYOffset; window.scrollTo({ top: containerTop, behavior: 'smooth' }); } } return this.currentMessage; } catch (error) { console.error('Failed to load message:', error); if (this.container) { const viewer = this.container.querySelector('.ee-message-viewer'); if (viewer) { viewer.innerHTML = '<div class="ee-error">Failed to load message</div>'; } } throw error; } } async markAsRead(messageId, seen = true) { try { const flagUpdate = seen ? { flags: { add: ['\\Seen'] } } : { flags: { delete: ['\\Seen'] } }; await this.apiRequest('PUT', `/v1/account/${this.account}/message/${messageId}`, flagUpdate); const msg = this.messages.find(m => m.id === messageId); if (msg) { msg.unseen = !seen; if (this.container) { this.renderMessageList(); } } if (this.currentMessage && this.currentMessage.id === messageId) { this.currentMessage.unseen = !seen; if (this.container) { this.renderMessage(); } } return true; } catch (error) { console.error('Failed to update message flags:', error); throw error; } } async deleteMessage(messageId) { try { await this.apiRequest('DELETE', `/v1/account/${this.account}/message/${messageId}`); this.messages = this.messages.filter(m => m.id !== messageId); if (this.container) { this.renderMessageList(); } if (this.currentMessage && this.currentMessage.id === messageId) { this.currentMessage = null; if (this.container) { this.renderMessage(); } } return true; } catch (error) { console.error('Failed to delete message:', error); throw error; } } async moveMessage(messageId, targetPath) { try { await this.apiRequest('PUT', `/v1/account/${this.account}/message/${messageId}/move`, { path: targetPath }); this.messages = this.messages.filter(m => m.id !== messageId); if (this.container) { this.renderMessageList(); } if (this.currentMessage && this.currentMessage.id === messageId) { this.currentMessage = null; if (this.container) { this.renderMessage(); } } return true; } catch (error) { console.error('Failed to move message:', error); throw error; } } async sendMessage(to, subject, text) { try { const toAddresses = Array.isArray(to) ? to.map(addr => (typeof addr === 'string' ? { address: addr } : addr)) : [typeof to === 'string' ? { address: to } : to]; const messageData = { to: toAddresses, subject: subject, text: text }; const response = await this.apiRequest('POST', `/v1/account/${this.account}/submit`, messageData); return response; } catch (error) { console.error('Failed to send message:', error); // Try to parse detailed error information this._parseApiError(error); throw error; } } _parseApiError(error) { try { // If error has response text, try to parse it if (error.message && error.message.includes('API request failed:')) { // This is our custom error from apiRequest, the actual response might have more details error.isDetailedError = false; } } catch (parseError) { // If parsing fails, just use the original error console.error('Failed to parse error details:', parseError); } } _formatSendError(error) { // If we have detailed error information from the API if (error.details && error.details.fields && Array.isArray(error.details.fields)) { const fieldErrors = error.details.fields.map(field => { // Try to make field errors more user-friendly let message = field.message; // Map technical field names to user-friendly names if (message.includes('to[0].address') || message.includes('"address"')) { message = message.replace(/to\[\d+\]\.address|"address"/g, 'email address'); } if (message.includes('"subject"')) { message = message.replace('"subject"', 'subject'); } if (message.includes('"text"')) { message = message.replace('"text"', 'message'); } return message; }); const mainMessage = error.details.message || 'Failed to send email'; if (fieldErrors.length > 0) { return `${mainMessage}:\n\n• ${fieldErrors.join('\n• ')}`; } return mainMessage; } // Fallback to generic error message return 'Failed to send email. Please check your input and try again.'; } formatDate(dateStr) { const date = new Date(dateStr); const now = new Date(); const diff = now - date; if (diff < 86400000) { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else if (diff < 604800000) { return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }); } else { return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); } } formatFileSize(bytes) { if (!bytes) { return ''; } const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i]; } async downloadAttachment(attachmentId, suggestedFilename = null) { try { const headers = {}; if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; } const response = await fetch(`${this.apiUrl}/v1/account/${this.account}/attachment/${attachmentId}`, { headers, credentials: 'include' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // Get filename from Content-Disposition header if available const contentDisposition = response.headers.get('content-disposition'); let filename = suggestedFilename || 'attachment'; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (filenameMatch && filenameMatch[1]) { filename = filenameMatch[1].replace(/['"]/g, ''); } } // Get the attachment data const blob = await response.blob(); // Create a download link const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); } catch (error) { console.error('Failed to download attachment:', error); this.alertMethod('Failed to download attachment. Please try again.', 'Download Error', null, 'OK'); } } async downloadOriginalMessage(messageId, subject = null) { try { const headers = {}; if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; } const response = await fetch(`${this.apiUrl}/v1/account/${this.account}/message/${messageId}/source`, { headers, credentials: 'include' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // Get the email data const blob = await response.blob(); // Create filename based on subject and date const now = new Date(); const dateStr = now.toISOString().split('T')[0]; const safeSubject = subject ? subject.replace(/[^a-z0-9]/gi, '_').substring(0, 50) : 'email'; const filename = `${dateStr}_${safeSubject}.eml`; // Create a download link const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); } catch (error) { console.error('Failed to download original message:', error); this.alertMethod('Failed to download original message. Please try again.', 'Download Error', null, 'OK'); } } createStyles() { if (typeof document === 'undefined') { return; } const style = document.createElement('style'); style.textContent = ` .ee-client { display: flex; height: 100%; min-height: 400px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-size: 14px; line-height: 1.5; color: #333; background: #fff; border: 1px solid #ddd; position: relative; } .ee-client * { box-sizing: border-box; } .ee-sidebar { width: 200px; background: #ffffff; border-right: 1px solid #ddd; display: flex; flex-direction: column; } .ee-folder-list { list-style: none; margin: 0; padding: 0; } .ee-folder-item { cursor: pointer; border-bottom: 1px solid #e0e0e0; position: relative; } .ee-folder-item:hover { background: #e8e8e8; } .ee-folder-item.active { background: #007bff; color: white; } .ee-folder-item.active::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: #0056b3; } .ee-folder-content { padding: 8px 16px 8px 0px; display: flex; align-items: center; position: relative; } .ee-folder-indent { color: #999; font-size: 12px; margin-right: 4px; font-family: monospace; } .ee-folder-name { font-weight: 500; flex: 1; } .ee-folder-name.has-children { font-weight: 600; } .ee-folder-item.active .ee-folder-indent { color: rgba(255, 255, 255, 0.7); } .ee-folder-count { font-size: 12px; opacity: 0.7; margin-left: 8px; flex-shrink: 0; } .ee-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .ee-message-list { width: 350px; border-right: 1px solid #ddd; background: #ffffff; display: flex; flex-direction: column; } .ee-message-item { padding: 12px 16px; border-bottom: 1px solid #e0e0e0; cursor: pointer; } .ee-message-item:hover { background: #f8f8f8; } .ee-message-item.active { background: #e3f2fd; } .ee-message-item.unread { font-weight: 600; } .ee-message-header { display: flex; justify-content: space-between; margin-bottom: 4px; } .ee-message-from { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ee-message-date { font-size: 12px; color: #666; flex-shrink: 0; margin-left: 8px; } .ee-message-subject { display: flex; align-items: center; margin-bottom: 2px; } .ee-message-subject-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } .ee-message-preview { font-size: 12px; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: normal; } .ee-attachment-indicator { display: inline-block; font-size: 11px; color: #666; margin-left: 8px; } .ee-attachment-indicator::before { content: "šŸ“Ž "; } .ee-message-viewer { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .ee-message-actions { padding: 10px 16px; background: #e9ecef; background: linear-gradient(to bottom, #f8f9fa, #e9ecef); border-bottom: 2px solid #dee2e6; display: flex; gap: 8px; height: 44px; align-items: center; flex-shrink: 0; box-sizing: border-box; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .ee-button { padding: 4px 10px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; font-size: 12px; height: 24px; line-height: 1; box-sizing: border-box; } select.ee-button { padding: 3px 10px; height: 24px; } .ee-button:hover:not(:disabled) { background: #f0f0f0; } .ee-button:disabled { background: #e9ecef; color: #6c757d; cursor: not-allowed; opacity: 0.6; } .ee-message-content { flex: 1; padding: 16px; overflow-y: auto; } .ee-message-meta { margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #e0e0e0; } .ee-message-meta-row { margin-bottom: 4px; } .ee-message-meta-label { display: inline-block; width: 60px; color: #666; font-weight: 500; } .ee-message-body { line-height: 1.6; } .ee-message-body img { max-width: 100%; height: auto; } .ee-attachments { margin-top: 16px; padding-top: 16px; border-top: 1px solid #e0e0e0; } .ee-attachments-title { font-weight: 500; margin-bottom: 8px; color: #333; } .ee-attachment-item { display: flex; align-items: center; padding: 8px; margin-bottom: 4px; background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px; cursor: pointer; transition: background 0.2s; } .ee-attachment-item:hover { background: #f0f0f0; } .ee-attachment-icon { margin-right: 8px; font-size: 18px; } .ee-attachment-info { flex: 1; } .ee-attachment-name { font-weight: 500; color: #333; } .ee-attachment-size { font-size: 12px; color: #666; } .ee-empty-state { display: flex; align-items: center; justify-content: center; height: 100%; color: #999; } .ee-loading { display: flex; align-items: center; justify-content: center; padding: 20px; color: #666; } .ee-error { padding: 16px; background: #fee; color: #c00; border: 1px solid #fcc; border-radius: 4px; margin: 16px; } .ee-flag { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4CAF50; margin-right: 4px; } .ee-flag.unread { background: #2196F3; } .ee-pane-header { padding: 10px 16px; background: #e9ecef; background: linear-gradient(to bottom, #f8f9fa, #e9ecef); border-bottom: 2px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; height: 44px; flex-shrink: 0; box-sizing: border-box; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .ee-pane-title { font-weight: 500; font-size: 14px; color: #333; } .ee-folder-tree { flex: 1; overflow-y: auto; } .ee-pagination-controls { display: flex; gap: 8px; align-items: center; } .ee-page-size-selector { display: flex; gap: 4px; align-items: center; margin-left: auto; } .ee-page-size-label { font-size: 12px; color: #666; } .ee-page-size-select { font-size: 11px; padding: 2px 4px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; height: 22px; } .ee-pagination-btn { font-size: 11px; padding: 3px 8px; height: 22px; line-height: 1; } .ee-message-items { flex: 1; overflow-y: auto; } .ee-compose-button { position: fixed; bottom: 20px; right: 20px; width: 56px; height: 56px; background: #007bff; border: none; border-radius: 50%; color: white; font-size: 24px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,123,255,0.3); z-index: 1000; transition: all 0.2s ease; } .ee-compose-button:hover { background: #0056b3; transform: scale(1.05); box-shadow: 0 6px 16px rgba(0,123,255,0.4); } .ee-compose-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 2000; } .ee-compose-modal.show { display: flex; align-items: center; justify-content: center; } .ee-compose-dialog { background: white; border-radius: 8px; width: 90%; max-width: 600px; max-height: 80vh; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); display: flex; flex-direction: column; } .ee-compose-header { padding: 16px 20px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; } .ee-compose-title { font-size: 18px; font-weight: 600; margin: 0; } .ee-compose-close { background: none; border: none; font-size: 24px; color: #666; cursor: pointer; padding: 0; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .ee-compose-close:hover { background: #f0f0f0; } .ee-compose-form { padding: 20px; display: flex; flex-direction: column; gap: 16px; flex: 1; overflow-y: auto; } .ee-compose-field { display: flex; flex-direction: column; gap: 4px; } .ee-compose-label { font-weight: 500; color: #333; } .ee-compose-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; font-family: inherit; } .ee-compose-input:focus { outline: none; border-color: #007bff; box-shadow: 0 0 0 2px rgba(0,123,255,0.1); } .ee-compose-textarea { padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; font-family: inherit; resize: vertical; min-height: 200px; } .ee-compose-textarea:focus { outline: none; border-color: #007bff; box-shadow: 0 0 0 2px rgba(0,123,255,0.1); } .ee-compose-actions { padding: 16px 20px; border-top: 1px solid #e0e0e0; display: flex; gap: 12px; justify-content: flex-end; flex-shrink: 0; } .ee-compose-send { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; } .ee-compose-send:hover:not(:disabled) { background: #0056b3; } .ee-compose-send:disabled { background: #6c757d; cursor: not-allowed; opacity: 0.6; } .ee-compose-cancel { background: #6c757d; color: white; border: none; padding: 10px 20px; border-radius: 4px; font-size: 14px; cursor: pointer; } .ee-compose-cancel:hover { background: #545b62; } /* Dark mode toggle button */ .ee-dark-mode-toggle { position: absolute; top: 8px; right: 16px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 3px; padding: 6px 10px; font-size: 12px; cursor: pointer; z-index: 100; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; color: #495057; font-weight: 500; } .ee-dark-mode-toggle:hover { background: #e9ecef; border-color: #adb5bd; } .ee-dark-mode-icon { font-size: 14px; line-height: 1; } /* Dark mode styles */ .ee-dark-mode { background: #1a1a1a; color: #e0e0e0; } .ee-dark-mode .ee-dark-mode-toggle { background: #333; border-color: #444; color: #e0e0e0; } .ee-dark-mode .ee-dark-mode-toggle:hover { background: #444; border-color: #555; } .ee-dark-mode .ee-sidebar { background: #202020; border-color: #333; } .ee-dark-mode .ee-folder-item { border-color: #333; } .ee-dark-mode .ee-folder-item:hover { background: #2a2a2a; } .ee-dark-mode .ee-folder-item.active { background: #0056b3; } .ee-dark-mode .ee-message-list { background: #202020; border-color: #333; } .ee-dark-mode .ee-message-item { border-color: #333; } .ee-dark-mode .ee-message-item:hover { background: #2a2a2a; } .ee-dark-mode .ee-message-item.active { background: #1a3d5c; } .ee-dark-mode .ee-message-date, .ee-dark-mode .ee-message-preview, .ee-dark-mode .ee-attachment-indicator { color: #999; } .ee-dark-mode .ee-pane-header { background: linear-gradient(to bottom, #2a2a2a, #252525); border-color: #333; color: #e0e0e0; } .ee-dark-mode .ee-pane-title { color: #e0e0e0; } .ee-dark-mode .ee-page-size-label { color: #e0e0e0; } .ee-dark-mode .ee-message-viewer { background: #1a1a1a; } .ee-dark-mode .ee-message-actions { background: linear-gradient(to bottom, #2a2a2a, #252525); border-color: #333; } .ee-dark-mode .ee-button { background: #333; border-color: #444; color: #e0e0e0; } .ee-dark-mode .ee-button:hover { background: #444; border-color: #555; } .ee-dark-mode .ee-button:disabled { background: #222; color: #666; } .ee-dark-mode select { background: #2a2a2a; border-color: #444; color: #e0e0e0; } .ee-dark-mode .ee-message-content { background: #1a1a1a; color: #e0e0e0; } .ee-dark-mode .ee-attachments { background: #252525; border-color: #333; } .ee-dark-mode .ee-attachment-item { background: #2a2a2a; border-color: #333; } .ee-dark-mode .ee-attachment-item:hover { background: #333; } .ee-dark-mode .ee-loading, .ee-dark-mode .ee-empty-state, .ee-dark-mode .ee-error { color: #999; } .ee-dark-mode .ee-pagination { background: #252525; border-color: #333; } .ee-dark-mode .ee-compose-button { background: #0056b3; } .ee-dark-mode .ee-compose-modal { background: rgba(0, 0, 0, 0.7); } .ee-dark-mode .ee-compose-content { background: #202020; color: #e0e0e0; } .ee-dark-mode .ee-compose-header { background: linear-gradient(to bottom, #2a2a2a, #252525); border-color: #333; } .ee-dark-mode .ee-compose-close { color: #999; } .ee-dark-mode .ee-compose-close:hover { color: #fff; } .ee-dark-mode .ee-compose-input, .ee-dark-mode .ee-compose-textarea { background: #1a1a1a; border-color: #444; color: #e0e0e0; } .ee-dark-mode .ee-compose-input:focus, .ee-dark-mode .ee-compose-textarea:focus { border-color: #0056b3; box-shadow: 0 0 0 2px rgba(0,86,179,0.2); } .ee-dark-mode .ee-compose-actions { border-color: #333; } .ee-dark-mode .ee-compose-cancel { background: #444; } .ee-dark-mode .ee-compose-cancel:hover { background: #555; } `; document.head.appendChild(style); } calculateFolderDepth(folder) { if (!folder.parentPath || !folder.delimiter) { return 0; } const pathParts = folder.path.split(folder.delimiter); return Math.max(0, pathParts.length - 1); } buildFolderTree() { const specialFolders = []; const regularFolders = []; this.folders.forEach(folder => { if (folder.specialUse) { specialFolders.push(folder); } else { regularFolders.push(folder); } }); const specialOrder = ['\\Inbox', '\\Drafts', '\\Sent', '\\Trash', '\\Junk', '\\Archive']; specialFolders.sort((a, b) => { if (a.specialUse === '\\Inbox' || a.name.toLowerCase() === 'inbox') { return -1; } if (b.specialUse === '\\Inbox' || b.name.toLowerCase() === 'inbox') { return 1; } const aIndex = specialOrder.indexOf(a.specialUse); const bIndex = specialOrder.indexOf(b.specialUse); if (aIndex !== -1 && bIndex !== -1) { return aIndex - bIndex; } if (aIndex !== -1) { return -1; } if (bIndex !== -1) { return 1; } return a.name.localeCompare(b.name); }); const buildHierarchy = (folders, parentPath = null, depth = 0) => { const result = []; const children = folders.filter(f => { if (parentPath === null) { return f.parentPath === 'INBOX' || !f.parentPath || f.parentPath === ''; } else { return f.parentPath === parentPath; } }); children.sort((a, b) => a.name.localeCompare(b.name)); children.forEach(folder => { result.push(folder); result.push(...buildHierarchy(folders, folder.path, depth + 1)); }); return result; }; const hierarchicalRegular = buildHierarchy(regularFolders); return [...specialFolders, ...hierarchicalRegular]; } renderFolderList() { if (typeof document === 'undefined' || !this.container) { return; } const folderTree = this.container.querySelector('.ee-folder-tree'); if (!folderTree) { return; } const sortedFolders = this.buildFolderTree(); const html = ` <ul class="ee-folder-list"> ${sortedFolders .map(folder => { const depth = this.calculateFolderDepth(folder); const hasChildren = this.folders.some(f => f.parentPath === folder.path); return ` <li class="ee-folder-item ${folder.path === this.currentFolder ? 'active' : ''}" data-path="${folder.path}" data-depth="${depth}"> <div class="ee-folder-content" style="padding-left: ${8 + depth * 12}px;"> ${depth > 0 ? '<span class="ee-folder-indent">ā”” </span>' : ''} <span class="ee-folder-name ${hasChildren ? 'has-children' : ''}">${folder.name}</span> ${folder.status && folder.status.messages > 0 ? `<span class="ee-folder-count">${folder.status.messages}</span>` : ''} </div> </li> `; }) .join('')} </ul> `; folderTree.innerHTML = html; folderTree.querySelectorAll('.ee-folder-item').forEach(item => { item.addEventListener('click', () => { const path = item.getAttribute('data-path'); this.loadMessages(path); }); }); } renderMessageList() { if (typeof document === 'undefined' || !this.container) { return; } const messageList = this.container.querySelector('.ee-message-list'); if (!messageList) { return; } if (!this.messages.length) { messageList.innerHTML = ` <div class="ee-pane-header"> <span class="ee-pane-title">Messages</span> </div> <div class="ee-empty-state">No messages</div> `; return; } const hasPagination = this.nextPageCursor || this.prevPageCursor; const html = ` <div class="ee-pane-header"> <span class="ee-pane-title">Messages</span> <div class="ee-pagination-controls"> ${ hasPagination ? ` ${this.prevPageCursor ? `<button class="ee-button ee-pagination-btn" data-action="prev-page">← Previous</button>` : ''} ${this.nextPageCursor ? `<button class="ee-button ee-pagination-btn" data-action="next-page">Next →</button>` : ''} ` : '' } <div class="ee-page-size-selector"> <span class="ee-page-size-label">Show:</span> <select class="ee-page-size-select" data-action="page-size"> <option value="10" ${this.pageSize === 10 ? 'selected' : ''}>10</option> <option value="20" ${this.pageSize === 20 ? 'selected' : ''}>20</option> <option value="30" ${this.pageSize === 30 ? 'selected' : ''}>30</option> <option value="50" ${this.pageSize === 50 ? 'selected' : ''}>50</option> <option value="100" ${this.pageSize === 100 ? 'selected' : ''}>100</option> </select> </div> </div> </div> <div class="ee-message-items"> ${this.messages .map( msg => ` <div class="ee-message-item ${msg.unseen ? 'unread' : ''} ${msg.id === (this.currentMessage && this.currentMessage.id) ? 'active' : ''}" data-id="${msg.id}"> <div class="ee-message-header"> <span class="ee-message-from">${msg.from ? msg.from.name || msg.from.address : 'Unknown'}</span> <span class="ee-message-date">${this.formatDate(msg.date)}</span> </div> <div class="ee-message-subject"> <span class="ee-message-subject-text">${msg.subject || '(no subject)'}</span> ${msg.attachments && msg.attachments.length > 0 ? `<span class="ee-attachment-indicator">${msg.attachments.length}</span>` : ''} </div> <div class="ee-message-preview">${msg.intro || ''}</div> </div> ` ) .join('')} </div> `; messageList.innerHTML = html; messageList.querySelectorAll('.ee-message-item').forEach(item => { item.addEventListener('click', () => { const messageId = item.getAttribute('data-id'); this.loadMessage(messageId); }); }); messageList.querySelectorAll('[data-action="prev-page"]').forEach(btn => { btn.addEventListener('click', () => { this.loadMessages(this.currentFolder, this.prevPageCursor); }); }); messageList.querySelectorAll('[data-action="next-page"]').forEach(btn => { btn.addEventListener('click', () => { this.loadMessages(this.currentFolder, this.nextPageCursor); }); }); const pageSizeSelect = messageList.querySelector('[data-action="page-size"]'); if (pageSizeSelect) { pageSizeSelect.addEventListener('change', e => { this.pageSize = parseInt(e.target.value); // Save to localStorage if (typeof window !== 'undefined' && window.localStorage) { localStorage.setItem('ee-client-page-size', this.pageSize.toString()); } this.loadMessages(this.currentFolder); }); } } renderMessage() { if (typeof document === 'undefined' || !this.container) { return; } const viewer = this.container.querySelector('.ee-message-viewer'); if (!viewer) { return; } if (!this.currentMessage) { viewer.innerHTML = '<div class="ee-empty-state">Select a message to view</div>'; return; } const msg = this.currentMessage; const isUnseen = msg.unseen; const html = ` <div class="ee-message-actions"> <button class="ee-button" data-action="toggle-read">Mark as ${isUnseen ? 'seen' : 'unseen'}</button> <button class="ee-button" data-action="delete">Delete</button> <button class="ee-button" data-action="download-original">Download Original</button> <select class="ee-button" data-action="move"> <option value="">Move to...</option> ${this.buildFolderTree() .map(folder => { const depth = this.calculateFolderDepth(folder); const indent = '怀'.repeat(depth); const prefix = depth > 0 ? 'ā”” ' : ''; return `<option value="${folder.path}" ${folder.path === this.currentFolder ? 'disabled' : ''}>${indent}${prefix}${folder.name}</option>`; }) .join('')} </select> </div> <div class="ee-message-content"> <div class="ee-message-meta"> <div class="ee-message-meta-row"> <span class="ee-message-meta-label">From:</span> ${msg.from ? `${msg.from.name || ''} &lt;${msg.from.address}&gt;` : 'Unknown'} </