@postalsys/ee-client
Version:
EmailEngine client library for browser and Node.js applications
1,522 lines (1,298 loc) ⢠67.8 kB
JavaScript
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 || ''} <${msg.from.address}>` : 'Unknown'}
</