2026-03-08 11:48:27 +00:00
// GoWebMail app.js — full client
2026-03-07 06:20:39 +00:00
// ── State ──────────────────────────────────────────────────────────────────
const S = {
me : null , accounts : [ ] , providers : { gmail : false , outlook : false } ,
folders : [ ] , messages : [ ] , totalMessages : 0 ,
currentPage : 1 , currentFolder : 'unified' , currentFolderName : 'Unified Inbox' ,
currentMessage : null , selectedMessageId : null ,
2026-03-08 11:48:27 +00:00
searchQuery : '' , composeMode : 'new' , composeReplyToId : null , composeForwardFromId : null ,
filterUnread : false , filterAttachment : false ,
2026-03-07 16:49:23 +00:00
sortOrder : 'date-desc' , // 'date-desc' | 'date-asc' | 'size-desc'
2026-03-07 06:20:39 +00:00
} ;
// ── Boot ───────────────────────────────────────────────────────────────────
async function init ( ) {
const [ me , providers , wl ] = await Promise . all ( [
api ( 'GET' , '/me' ) , api ( 'GET' , '/providers' ) , api ( 'GET' , '/remote-content-whitelist' ) ,
] ) ;
if ( me ) {
S . me = me ;
document . getElementById ( 'user-display' ) . textContent = me . username || me . email ;
if ( me . role === 'admin' ) document . getElementById ( 'admin-link' ) . style . display = 'block' ;
}
if ( providers ) { S . providers = providers ; updateProviderButtons ( ) ; }
if ( wl ? . whitelist ) S . remoteWhitelist = new Set ( wl . whitelist ) ;
2026-03-07 15:14:57 +00:00
await loadAccounts ( ) ;
2026-03-07 09:33:42 +00:00
await loadFolders ( ) ;
2026-03-07 06:20:39 +00:00
await loadMessages ( ) ;
2026-03-08 06:06:38 +00:00
// Seed poller ID so we don't notify on initial load
if ( S . messages . length > 0 ) {
POLLER . lastKnownID = Math . max ( ... S . messages . map ( m => m . id ) ) ;
}
2026-03-07 06:20:39 +00:00
const p = new URLSearchParams ( location . search ) ;
2026-03-07 15:14:57 +00:00
if ( p . get ( 'connected' ) ) { toast ( 'Account connected!' , 'success' ) ; history . replaceState ( { } , '' , '/' ) ; }
2026-03-07 06:20:39 +00:00
if ( p . get ( 'error' ) ) { toast ( 'Connection failed: ' + p . get ( 'error' ) , 'error' ) ; history . replaceState ( { } , '' , '/' ) ; }
document . addEventListener ( 'keydown' , e => {
if ( [ 'INPUT' , 'TEXTAREA' , 'SELECT' ] . includes ( e . target . tagName ) ) return ;
if ( e . target . contentEditable === 'true' ) return ;
if ( ( e . metaKey || e . ctrlKey ) && e . key === 'n' ) { e . preventDefault ( ) ; openCompose ( ) ; }
if ( ( e . metaKey || e . ctrlKey ) && e . key === 'k' ) { e . preventDefault ( ) ; document . getElementById ( 'search-input' ) . focus ( ) ; }
} ) ;
2026-03-07 15:14:57 +00:00
initComposeDragResize ( ) ;
2026-03-08 06:06:38 +00:00
startPoller ( ) ;
2026-03-07 06:20:39 +00:00
}
// ── Providers ──────────────────────────────────────────────────────────────
function updateProviderButtons ( ) {
[ 'gmail' , 'outlook' ] . forEach ( p => {
const btn = document . getElementById ( 'btn-' + p ) ;
2026-03-07 15:14:57 +00:00
if ( ! btn ) return ;
if ( ! S . providers [ p ] ) { btn . disabled = true ; btn . classList . add ( 'unavailable' ) ; btn . title = 'Not configured' ; }
2026-03-07 06:20:39 +00:00
} ) ;
}
2026-03-07 15:14:57 +00:00
// ── Accounts popup ─────────────────────────────────────────────────────────
function toggleAccountsMenu ( e ) {
e . stopPropagation ( ) ;
const popup = document . getElementById ( 'accounts-popup' ) ;
const backdrop = document . getElementById ( 'accounts-popup-backdrop' ) ;
if ( popup . classList . contains ( 'open' ) ) {
closeAccountsMenu ( ) ; return ;
}
renderAccountsPopup ( ) ;
popup . classList . add ( 'open' ) ;
backdrop . classList . add ( 'open' ) ;
}
function closeAccountsMenu ( ) {
document . getElementById ( 'accounts-popup' ) . classList . remove ( 'open' ) ;
document . getElementById ( 'accounts-popup-backdrop' ) . classList . remove ( 'open' ) ;
2026-03-07 06:20:39 +00:00
}
2026-03-07 15:14:57 +00:00
function renderAccountsPopup ( ) {
const el = document . getElementById ( 'accounts-popup-list' ) ;
if ( ! S . accounts . length ) {
el . innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>' ;
return ;
}
2026-03-07 06:20:39 +00:00
el . innerHTML = S . accounts . map ( a => `
2026-03-07 15:14:57 +00:00
< div class = "acct-popup-item" title = "${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}" >
< div style = "display:flex;align-items:center;gap:8px;flex:1;min-width:0" >
< span class = "account-dot" style = "background:${a.color};flex-shrink:0" > < / s p a n >
< span style = "font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" > $ { esc ( a . display _name || a . email _address ) } < / s p a n >
$ { a . last _error ? '<span style="color:var(--danger);font-size:11px">⚠</span>' : '' }
< / d i v >
< div style = "display:flex;gap:4px;flex-shrink:0" >
< button class = "icon-btn" title = "Sync now" onclick = "syncNow(${a.id},event)" >
< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "currentColor" > < path d = "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" / > < / s v g >
< / b u t t o n >
< button class = "icon-btn" title = "Settings" onclick = "openEditAccount(${a.id})" >
< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "currentColor" > < path d = "M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" / > < / s v g >
< / b u t t o n >
< button class = "icon-btn" title = "Remove" onclick = "deleteAccount(${a.id})" style = "color:var(--danger)" >
< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "currentColor" > < path d = "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" / > < / s v g >
< / b u t t o n >
< / d i v >
2026-03-07 06:20:39 +00:00
< / d i v > ` ) . j o i n ( ' ' ) ;
}
2026-03-07 15:14:57 +00:00
// ── Accounts ───────────────────────────────────────────────────────────────
async function loadAccounts ( ) {
const data = await api ( 'GET' , '/accounts' ) ;
if ( ! data ) return ;
S . accounts = data ;
renderAccountsPopup ( ) ;
populateComposeFrom ( ) ;
2026-03-07 06:20:39 +00:00
}
function connectOAuth ( p ) { location . href = '/auth/' + p + '/connect' ; }
function openAddAccountModal ( ) {
[ 'imap-email' , 'imap-name' , 'imap-password' , 'imap-host' , 'smtp-host' ] . forEach ( id => { const el = document . getElementById ( id ) ; if ( el ) el . value = '' ; } ) ;
document . getElementById ( 'imap-port' ) . value = '993' ;
document . getElementById ( 'smtp-port' ) . value = '587' ;
const r = document . getElementById ( 'test-result' ) ; if ( r ) { r . style . display = 'none' ; r . className = 'test-result' ; }
2026-03-07 15:14:57 +00:00
closeAccountsMenu ( ) ;
2026-03-07 06:20:39 +00:00
openModal ( 'add-account-modal' ) ;
}
async function testNewConnection ( ) {
const btn = document . getElementById ( 'test-btn' ) , result = document . getElementById ( 'test-result' ) ;
const body = { email : document . getElementById ( 'imap-email' ) . value . trim ( ) , password : document . getElementById ( 'imap-password' ) . value ,
imap _host : document . getElementById ( 'imap-host' ) . value . trim ( ) , imap _port : parseInt ( document . getElementById ( 'imap-port' ) . value ) || 993 ,
smtp _host : document . getElementById ( 'smtp-host' ) . value . trim ( ) , smtp _port : parseInt ( document . getElementById ( 'smtp-port' ) . value ) || 587 } ;
if ( ! body . email || ! body . password || ! body . imap _host ) { result . textContent = 'Email, password and IMAP host required.' ; result . className = 'test-result err' ; result . style . display = 'block' ; return ; }
btn . innerHTML = '<span class="spinner-inline"></span>Testing...' ; btn . disabled = true ;
const r = await api ( 'POST' , '/accounts/test' , body ) ;
btn . textContent = 'Test Connection' ; btn . disabled = false ;
result . textContent = ( r ? . ok ) ? '✓ Connection successful!' : ( ( r ? . error ) || 'Connection failed' ) ;
result . className = 'test-result ' + ( ( r ? . ok ) ? 'ok' : 'err' ) ; result . style . display = 'block' ;
}
async function addIMAPAccount ( ) {
const btn = document . getElementById ( 'save-acct-btn' ) ;
const body = { email : document . getElementById ( 'imap-email' ) . value . trim ( ) , display _name : document . getElementById ( 'imap-name' ) . value . trim ( ) ,
password : document . getElementById ( 'imap-password' ) . value , imap _host : document . getElementById ( 'imap-host' ) . value . trim ( ) ,
imap _port : parseInt ( document . getElementById ( 'imap-port' ) . value ) || 993 , smtp _host : document . getElementById ( 'smtp-host' ) . value . trim ( ) ,
smtp _port : parseInt ( document . getElementById ( 'smtp-port' ) . value ) || 587 } ;
if ( ! body . email || ! body . password || ! body . imap _host ) { toast ( 'Email, password and IMAP host required' , 'error' ) ; return ; }
btn . disabled = true ; btn . textContent = 'Connecting...' ;
const r = await api ( 'POST' , '/accounts' , body ) ;
btn . disabled = false ; btn . textContent = 'Connect' ;
2026-03-07 15:20:49 +00:00
if ( r ? . ok ) {
toast ( 'Account added — syncing…' , 'success' ) ;
closeModal ( 'add-account-modal' ) ;
await loadAccounts ( ) ;
// Background sync takes a moment — reload folders/messages after a short wait
setTimeout ( async ( ) => { await loadFolders ( ) ; await loadMessages ( ) ; toast ( 'Sync complete' , 'success' ) ; } , 3000 ) ;
} else toast ( r ? . error || 'Failed to add account' , 'error' ) ;
}
async function detectMailSettings ( ) {
const email = document . getElementById ( 'imap-email' ) . value . trim ( ) ;
if ( ! email || ! email . includes ( '@' ) ) { toast ( 'Enter your email address first' , 'error' ) ; return ; }
const btn = document . getElementById ( 'detect-btn' ) ;
btn . innerHTML = '<span class="spinner-inline"></span>Detecting…' ; btn . disabled = true ;
const r = await api ( 'POST' , '/accounts/detect' , { email } ) ;
btn . textContent = 'Auto-detect' ; btn . disabled = false ;
if ( ! r ) { toast ( 'Detection failed' , 'error' ) ; return ; }
document . getElementById ( 'imap-host' ) . value = r . imap _host || '' ;
document . getElementById ( 'imap-port' ) . value = r . imap _port || 993 ;
document . getElementById ( 'smtp-host' ) . value = r . smtp _host || '' ;
document . getElementById ( 'smtp-port' ) . value = r . smtp _port || 587 ;
if ( r . detected ) toast ( ` Detected ${ r . imap _host } / ${ r . smtp _host } ` , 'success' ) ;
else toast ( 'No servers found — filled with defaults based on domain' , 'info' ) ;
2026-03-07 06:20:39 +00:00
}
2026-03-07 15:14:57 +00:00
async function syncNow ( id , e ) {
if ( e ) e . stopPropagation ( ) ;
toast ( 'Syncing…' , 'info' ) ;
const r = await api ( 'POST' , '/accounts/' + id + '/sync' ) ;
if ( r ? . ok ) { toast ( 'Synced ' + ( r . synced || 0 ) + ' messages' , 'success' ) ; loadAccounts ( ) ; loadFolders ( ) ; loadMessages ( ) ; }
else toast ( r ? . error || 'Sync failed' , 'error' ) ;
}
2026-03-07 06:20:39 +00:00
// ── Edit Account modal ─────────────────────────────────────────────────────
2026-03-07 15:14:57 +00:00
async function openEditAccount ( id ) {
closeAccountsMenu ( ) ;
2026-03-07 06:20:39 +00:00
const r = await api ( 'GET' , '/accounts/' + id ) ;
if ( ! r ) return ;
document . getElementById ( 'edit-account-id' ) . value = id ;
document . getElementById ( 'edit-account-email' ) . textContent = r . email _address ;
document . getElementById ( 'edit-name' ) . value = r . display _name || '' ;
document . getElementById ( 'edit-password' ) . value = '' ;
document . getElementById ( 'edit-imap-host' ) . value = r . imap _host || '' ;
document . getElementById ( 'edit-imap-port' ) . value = r . imap _port || 993 ;
document . getElementById ( 'edit-smtp-host' ) . value = r . smtp _host || '' ;
document . getElementById ( 'edit-smtp-port' ) . value = r . smtp _port || 587 ;
document . getElementById ( 'edit-sync-days' ) . value = r . sync _days || 30 ;
2026-03-07 20:29:20 +00:00
// Restore sync mode select: map stored days/mode back to a preset option
const sel = document . getElementById ( 'edit-sync-mode' ) ;
if ( r . sync _mode === 'all' || ! r . sync _days ) {
sel . value = 'all' ;
} else {
const presetMap = { 30 : 'preset-30' , 90 : 'preset-90' , 180 : 'preset-180' , 365 : 'preset-365' , 730 : 'preset-730' , 1825 : 'preset-1825' } ;
sel . value = presetMap [ r . sync _days ] || 'days' ;
}
2026-03-07 06:20:39 +00:00
toggleSyncDaysField ( ) ;
const errEl = document . getElementById ( 'edit-last-error' ) , connEl = document . getElementById ( 'edit-conn-result' ) ;
connEl . style . display = 'none' ;
errEl . style . display = r . last _error ? 'block' : 'none' ;
if ( r . last _error ) errEl . textContent = 'Last sync error: ' + r . last _error ;
2026-03-07 17:09:41 +00:00
// Load hidden folders for this account
const hiddenEl = document . getElementById ( 'edit-hidden-folders' ) ;
const hidden = S . folders . filter ( f => f . account _id === id && f . is _hidden ) ;
if ( ! hidden . length ) {
hiddenEl . innerHTML = '<span style="color:var(--muted);font-size:12px">No hidden folders.</span>' ;
} else {
hiddenEl . innerHTML = hidden . map ( f => `
< div style = "display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border)" >
< span style = "font-size:13px" > $ { esc ( f . name ) } < / s p a n >
< button class = "btn-secondary" style = "font-size:11px;padding:3px 10px" onclick = "unhideFolder(${f.id})" > Unhide < / b u t t o n >
< / d i v > ` ) . j o i n ( ' ' ) ;
}
2026-03-07 06:20:39 +00:00
openModal ( 'edit-account-modal' ) ;
}
2026-03-07 17:09:41 +00:00
async function unhideFolder ( folderId ) {
const f = S . folders . find ( f => f . id === folderId ) ;
if ( ! f ) return ;
const r = await api ( 'PUT' , '/folders/' + folderId + '/visibility' , { is _hidden : false , sync _enabled : true } ) ;
if ( r ? . ok ) {
toast ( 'Folder restored to sidebar' , 'success' ) ;
await loadFolders ( ) ;
// Refresh hidden list in modal
const accId = parseInt ( document . getElementById ( 'edit-account-id' ) . value ) ;
if ( accId ) {
const hiddenEl = document . getElementById ( 'edit-hidden-folders' ) ;
const hidden = S . folders . filter ( f => f . account _id === accId && f . is _hidden ) ;
if ( ! hidden . length ) hiddenEl . innerHTML = '<span style="color:var(--muted);font-size:12px">No hidden folders.</span>' ;
else hiddenEl . innerHTML = hidden . map ( f => `
< div style = "display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border)" >
< span style = "font-size:13px" > $ { esc ( f . name ) } < / s p a n >
< button class = "btn-secondary" style = "font-size:11px;padding:3px 10px" onclick = "unhideFolder(${f.id})" > Unhide < / b u t t o n >
< / d i v > ` ) . j o i n ( ' ' ) ;
}
} else toast ( 'Failed to unhide folder' , 'error' ) ;
}
2026-03-07 06:20:39 +00:00
function toggleSyncDaysField ( ) {
const mode = document . getElementById ( 'edit-sync-mode' ) ? . value ;
const row = document . getElementById ( 'edit-sync-days-row' ) ;
2026-03-07 20:29:20 +00:00
if ( row ) row . style . display = ( mode === 'days' ) ? 'flex' : 'none' ;
2026-03-07 06:20:39 +00:00
}
async function testEditConnection ( ) {
const btn = document . getElementById ( 'edit-test-btn' ) , connEl = document . getElementById ( 'edit-conn-result' ) ;
const pw = document . getElementById ( 'edit-password' ) . value , email = document . getElementById ( 'edit-account-email' ) . textContent . trim ( ) ;
if ( ! pw ) { connEl . textContent = 'Enter new password to test.' ; connEl . className = 'test-result err' ; connEl . style . display = 'block' ; return ; }
btn . innerHTML = '<span class="spinner-inline"></span>Testing...' ; btn . disabled = true ;
const r = await api ( 'POST' , '/accounts/test' , { email , password : pw ,
imap _host : document . getElementById ( 'edit-imap-host' ) . value . trim ( ) , imap _port : parseInt ( document . getElementById ( 'edit-imap-port' ) . value ) || 993 ,
smtp _host : document . getElementById ( 'edit-smtp-host' ) . value . trim ( ) , smtp _port : parseInt ( document . getElementById ( 'edit-smtp-port' ) . value ) || 587 } ) ;
btn . textContent = 'Test Connection' ; btn . disabled = false ;
connEl . textContent = ( r ? . ok ) ? '✓ Successful!' : ( ( r ? . error ) || 'Failed' ) ;
connEl . className = 'test-result ' + ( ( r ? . ok ) ? 'ok' : 'err' ) ; connEl . style . display = 'block' ;
}
async function saveAccountEdit ( ) {
const id = document . getElementById ( 'edit-account-id' ) . value ;
const body = { display _name : document . getElementById ( 'edit-name' ) . value . trim ( ) ,
imap _host : document . getElementById ( 'edit-imap-host' ) . value . trim ( ) , imap _port : parseInt ( document . getElementById ( 'edit-imap-port' ) . value ) || 993 ,
smtp _host : document . getElementById ( 'edit-smtp-host' ) . value . trim ( ) , smtp _port : parseInt ( document . getElementById ( 'edit-smtp-port' ) . value ) || 587 } ;
const pw = document . getElementById ( 'edit-password' ) . value ;
if ( pw ) body . password = pw ;
2026-03-07 20:29:20 +00:00
const modeVal = document . getElementById ( 'edit-sync-mode' ) . value ;
let syncMode = 'all' , syncDays = 0 ;
if ( modeVal === 'days' ) {
syncMode = 'days' ; syncDays = parseInt ( document . getElementById ( 'edit-sync-days' ) . value ) || 30 ;
} else if ( modeVal . startsWith ( 'preset-' ) ) {
syncMode = 'days' ; syncDays = parseInt ( modeVal . replace ( 'preset-' , '' ) ) ;
} // else 'all': syncMode='all', syncDays=0
2026-03-07 06:20:39 +00:00
const [ r1 , r2 ] = await Promise . all ( [
api ( 'PUT' , '/accounts/' + id , body ) ,
api ( 'PUT' , '/accounts/' + id + '/sync-settings' , {
2026-03-07 20:29:20 +00:00
sync _mode : syncMode ,
sync _days : syncDays ,
2026-03-07 06:20:39 +00:00
} ) ,
] ) ;
if ( r1 ? . ok ) { toast ( 'Account updated' , 'success' ) ; closeModal ( 'edit-account-modal' ) ; loadAccounts ( ) ; }
else toast ( r1 ? . error || 'Update failed' , 'error' ) ;
}
async function deleteAccount ( id ) {
const a = S . accounts . find ( a => a . id === id ) ;
2026-03-07 15:14:57 +00:00
inlineConfirm (
'Remove ' + ( a ? a . email _address : 'this account' ) + '? All synced messages will be deleted.' ,
async ( ) => {
const r = await api ( 'DELETE' , '/accounts/' + id ) ;
if ( r ? . ok ) { toast ( 'Account removed' , 'success' ) ; closeAccountsMenu ( ) ; loadAccounts ( ) ; loadFolders ( ) ; loadMessages ( ) ; }
else toast ( 'Remove failed' , 'error' ) ;
}
) ;
}
// ── Inline confirm (replaces browser confirm()) ────────────────────────────
function inlineConfirm ( message , onOk , onCancel ) {
const el = document . getElementById ( 'inline-confirm' ) ;
const msg = document . getElementById ( 'inline-confirm-msg' ) ;
const ok = document . getElementById ( 'inline-confirm-ok' ) ;
const cancel = document . getElementById ( 'inline-confirm-cancel' ) ;
msg . textContent = message ;
el . classList . add ( 'open' ) ;
const cleanup = ( ) => { el . classList . remove ( 'open' ) ; ok . onclick = null ; cancel . onclick = null ; } ;
ok . onclick = ( ) => { cleanup ( ) ; onOk && onOk ( ) ; } ;
cancel . onclick = ( ) => { cleanup ( ) ; onCancel && onCancel ( ) ; } ;
2026-03-07 06:20:39 +00:00
}
// ── Folders ────────────────────────────────────────────────────────────────
async function loadFolders ( ) {
const data = await api ( 'GET' , '/folders' ) ;
if ( ! data ) return ;
S . folders = data || [ ] ;
renderFolders ( ) ;
updateUnreadBadge ( ) ;
}
const FOLDER _ICONS = {
inbox : '<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>' ,
sent : '<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>' ,
drafts : '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>' ,
trash : '<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>' ,
spam : '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>' ,
archive : '<path d="M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z"/>' ,
custom : '<path d="M20 6h-2.18c.07-.44.18-.86.18-1 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .14.11.56.18 1H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z"/>' ,
} ;
function renderFolders ( ) {
const el = document . getElementById ( 'folders-by-account' ) ;
const accMap = { } ; S . accounts . forEach ( a => accMap [ a . id ] = a ) ;
const byAcc = { } ;
2026-03-07 15:14:57 +00:00
S . folders . filter ( f => ! f . is _hidden ) . forEach ( f => { ( byAcc [ f . account _id ] = byAcc [ f . account _id ] || [ ] ) . push ( f ) ; } ) ;
2026-03-07 06:20:39 +00:00
const prio = [ 'inbox' , 'sent' , 'drafts' , 'trash' , 'spam' , 'archive' ] ;
el . innerHTML = Object . entries ( byAcc ) . map ( ( [ accId , folders ] ) => {
2026-03-07 09:33:42 +00:00
const acc = accMap [ parseInt ( accId ) ] ;
const accColor = acc ? . color || '#888' ;
const accEmail = acc ? . email _address || 'Account ' + accId ;
if ( ! folders ? . length ) return '' ;
2026-03-07 06:20:39 +00:00
const sorted = [ ... prio . map ( t => folders . find ( f => f . folder _type === t ) ) . filter ( Boolean ) , ... folders . filter ( f => f . folder _type === 'custom' ) ] ;
return ` <div class="nav-folder-header">
2026-03-07 09:33:42 +00:00
< span style = "width:6px;height:6px;border-radius:50%;background:${accColor};display:inline-block;flex-shrink:0" > < / s p a n >
2026-03-07 16:49:23 +00:00
< span style = "flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" > $ { esc ( accEmail ) } < / s p a n >
< button class = "icon-sync-btn" title = "Sync account" onclick = "syncNow(${parseInt(accId)},event)" style = "margin-left:4px" >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "currentColor" > < path d = "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" / > < / s v g >
< / b u t t o n >
2026-03-07 06:20:39 +00:00
< / d i v > ` + s o r t e d . m a p ( f = > `
2026-03-07 20:00:15 +00:00
< div class = "nav-item${f.sync_enabled?'':' folder-nosync'}" id = "nav-f${f.id}" data - fid = "${f.id}" onclick = "selectFolder(${f.id},'${esc(f.name)}')"
2026-03-07 15:14:57 +00:00
oncontextmenu = "showFolderMenu(event,${f.id})" >
2026-03-07 06:20:39 +00:00
< svg viewBox = "0 0 24 24" fill = "currentColor" > $ { FOLDER _ICONS [ f . folder _type ] || FOLDER _ICONS . custom } < / s v g >
$ { esc ( f . name ) }
$ { f . unread _count > 0 ? ` <span class="unread-badge"> ${ f . unread _count } </span> ` : '' }
2026-03-07 15:14:57 +00:00
$ { ! f . sync _enabled ? '<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">⊘</span>' : '' }
2026-03-07 06:20:39 +00:00
< / d i v > ` ) . j o i n ( ' ' ) ;
} ) . join ( '' ) ;
}
2026-03-07 15:14:57 +00:00
function showFolderMenu ( e , folderId ) {
2026-03-07 06:20:39 +00:00
e . preventDefault ( ) ; e . stopPropagation ( ) ;
2026-03-07 15:14:57 +00:00
const f = S . folders . find ( f => f . id === folderId ) ;
if ( ! f ) return ;
const syncLabel = f . sync _enabled ? '⊘ Disable sync' : '↻ Enable sync' ;
2026-03-07 17:09:41 +00:00
const otherFolders = S . folders . filter ( x => x . id !== folderId && x . account _id === f . account _id && ! x . is _hidden ) . slice ( 0 , 16 ) ;
const moveItems = otherFolders . map ( x =>
` <div class="ctx-item ctx-sub-item" onclick="moveFolderContents( ${ folderId } , ${ x . id } );closeMenu()"> ${ esc ( x . name ) } </div> `
) . join ( '' ) ;
const moveEntry = otherFolders . length ? `
< div class = "ctx-item ctx-has-sub" > 📂 Move messages to
< span class = "ctx-sub-arrow" > › < / s p a n >
< div class = "ctx-submenu" > $ { moveItems } < / d i v >
< / d i v > ` : ' ' ;
2026-03-08 06:06:38 +00:00
const isTrashOrSpam = f . folder _type === 'trash' || f . folder _type === 'spam' ;
const emptyEntry = isTrashOrSpam
? ` <div class="ctx-item danger" onclick="confirmEmptyFolder( ${ folderId } );closeMenu()">🗑 Empty ${ f . name } </div> ` : '' ;
const disabledCount = S . folders . filter ( x => x . account _id === f . account _id && ! x . sync _enabled ) . length ;
const enableAllEntry = disabledCount > 0
? ` <div class="ctx-item" onclick="enableAllFolderSync( ${ f . account _id } );closeMenu()">↻ Enable sync for all folders ( ${ disabledCount } )</div> ` : '' ;
2026-03-07 06:20:39 +00:00
showCtxMenu ( e , `
< div class = "ctx-item" onclick = "syncFolderNow(${folderId});closeMenu()" > ↻ Sync this folder < / d i v >
2026-03-07 15:14:57 +00:00
< div class = "ctx-item" onclick = "toggleFolderSync(${folderId});closeMenu()" > $ { syncLabel } < / d i v >
2026-03-08 06:06:38 +00:00
$ { enableAllEntry }
2026-03-08 11:48:27 +00:00
< div class = "ctx-item" onclick = "markFolderAllRead(${folderId});closeMenu()" > ✓ Mark all as read < / d i v >
2026-03-07 15:14:57 +00:00
< div class = "ctx-sep" > < / d i v >
2026-03-07 17:09:41 +00:00
$ { moveEntry }
2026-03-08 06:06:38 +00:00
$ { emptyEntry }
2026-03-07 16:49:23 +00:00
< div class = "ctx-item" onclick = "confirmHideFolder(${folderId});closeMenu()" > 👁 Hide from sidebar < / d i v >
< div class = "ctx-item danger" onclick = "confirmDeleteFolder(${folderId});closeMenu()" > 🗑 Delete folder < / d i v > ` ) ;
2026-03-07 06:20:39 +00:00
}
async function syncFolderNow ( folderId ) {
2026-03-07 15:14:57 +00:00
toast ( 'Syncing folder…' , 'info' ) ;
2026-03-07 06:20:39 +00:00
const r = await api ( 'POST' , '/folders/' + folderId + '/sync' ) ;
if ( r ? . ok ) { toast ( 'Synced ' + ( r . synced || 0 ) + ' messages' , 'success' ) ; loadFolders ( ) ; loadMessages ( ) ; }
else toast ( r ? . error || 'Sync failed' , 'error' ) ;
}
2026-03-08 11:48:27 +00:00
async function markFolderAllRead ( folderId ) {
const r = await api ( 'POST' , '/folders/' + folderId + '/mark-all-read' ) ;
if ( r ? . ok ) {
toast ( ` Marked ${ r . marked || 0 } message(s) as read ` , 'success' ) ;
loadFolders ( ) ;
loadMessages ( ) ;
} else toast ( r ? . error || 'Failed' , 'error' ) ;
}
2026-03-07 15:14:57 +00:00
async function toggleFolderSync ( folderId ) {
const f = S . folders . find ( f => f . id === folderId ) ;
if ( ! f ) return ;
const newSync = ! f . sync _enabled ;
const r = await api ( 'PUT' , '/folders/' + folderId + '/visibility' , { is _hidden : f . is _hidden , sync _enabled : newSync } ) ;
if ( r ? . ok ) {
f . sync _enabled = newSync ;
toast ( newSync ? 'Folder sync enabled' : 'Folder sync disabled' , 'success' ) ;
renderFolders ( ) ;
} else toast ( 'Update failed' , 'error' ) ;
}
2026-03-08 06:06:38 +00:00
async function enableAllFolderSync ( accountId ) {
const r = await api ( 'POST' , '/accounts/' + accountId + '/enable-all-sync' ) ;
if ( r ? . ok ) {
// Update local state
S . folders . forEach ( f => { if ( f . account _id === accountId ) f . sync _enabled = true ; } ) ;
toast ( ` Sync enabled for ${ r . enabled || 0 } folder ${ r . enabled === 1 ? '' : 's' } ` , 'success' ) ;
renderFolders ( ) ;
} else toast ( 'Failed to enable sync' , 'error' ) ;
}
async function confirmEmptyFolder ( folderId ) {
const f = S . folders . find ( f => f . id === folderId ) ;
if ( ! f ) return ;
const label = f . folder _type === 'trash' ? 'Trash' : 'Spam' ;
inlineConfirm (
` Permanently delete all messages in ${ label } ? This cannot be undone. ` ,
async ( ) => {
const r = await api ( 'POST' , '/folders/' + folderId + '/empty' ) ;
if ( r ? . ok ) {
toast ( ` Emptied ${ label } ( ${ r . deleted || 0 } messages) ` , 'success' ) ;
// Remove locally
S . messages = S . messages . filter ( m => m . folder _id !== folderId ) ;
if ( S . currentMessage && S . currentFolder === folderId ) resetDetail ( ) ;
await loadFolders ( ) ;
if ( S . currentFolder === folderId ) renderMessageList ( ) ;
} else toast ( 'Failed to empty folder' , 'error' ) ;
}
) ;
}
2026-03-07 16:49:23 +00:00
async function confirmHideFolder ( folderId ) {
2026-03-07 15:14:57 +00:00
const f = S . folders . find ( f => f . id === folderId ) ;
if ( ! f ) return ;
2026-03-07 16:49:23 +00:00
inlineConfirm (
` Hide " ${ f . name } " from sidebar? You can unhide it from account settings. ` ,
async ( ) => {
const r = await api ( 'PUT' , '/folders/' + folderId + '/visibility' , { is _hidden : true , sync _enabled : false } ) ;
if ( r ? . ok ) { toast ( 'Folder hidden' , 'success' ) ; await loadFolders ( ) ; }
else toast ( 'Update failed' , 'error' ) ;
}
) ;
}
async function confirmDeleteFolder ( folderId ) {
const f = S . folders . find ( f => f . id === folderId ) ;
if ( ! f ) return ;
const countRes = await api ( 'GET' , '/folders/' + folderId + '/count' ) ;
const count = countRes ? . count ? ? '?' ;
inlineConfirm (
` Delete folder " ${ f . name } "? This will permanently delete all ${ count } message ${ count === 1 ? '' : 's' } inside it. This cannot be undone. ` ,
async ( ) => {
const r = await api ( 'DELETE' , '/folders/' + folderId ) ;
if ( r ? . ok ) {
toast ( 'Folder deleted' , 'success' ) ;
S . folders = S . folders . filter ( x => x . id !== folderId ) ;
if ( S . currentFolder === folderId ) selectFolder ( 'unified' , 'Unified Inbox' ) ;
renderFolders ( ) ; loadMessages ( ) ;
} else toast ( r ? . error || 'Delete failed' , 'error' ) ;
}
) ;
}
async function moveFolderContents ( fromId , toId ) {
const from = S . folders . find ( f => f . id === fromId ) ;
const to = S . folders . find ( f => f . id === toId ) ;
if ( ! from || ! to ) return ;
inlineConfirm (
` Move all messages from " ${ from . name } " into " ${ to . name } "? ` ,
async ( ) => {
const r = await api ( 'POST' , '/folders/' + fromId + '/move-to/' + toId ) ;
if ( r ? . ok ) { toast ( ` Moved ${ r . moved || 0 } messages ` , 'success' ) ; loadFolders ( ) ; loadMessages ( ) ; }
else toast ( r ? . error || 'Move failed' , 'error' ) ;
}
) ;
2026-03-07 15:14:57 +00:00
}
2026-03-07 06:20:39 +00:00
function updateUnreadBadge ( ) {
const total = S . folders . filter ( f => f . folder _type === 'inbox' ) . reduce ( ( s , f ) => s + ( f . unread _count || 0 ) , 0 ) ;
const badge = document . getElementById ( 'unread-total' ) ;
badge . textContent = total ; badge . style . display = total > 0 ? '' : 'none' ;
}
// ── Messages ───────────────────────────────────────────────────────────────
function selectFolder ( folderId , folderName ) {
S . currentFolder = folderId ; S . currentFolderName = folderName || S . currentFolderName ;
S . currentPage = 1 ; S . messages = [ ] ; S . searchQuery = '' ;
document . getElementById ( 'search-input' ) . value = '' ;
document . getElementById ( 'panel-title' ) . textContent = folderName || S . currentFolderName ;
document . querySelectorAll ( '.nav-item' ) . forEach ( n => n . classList . remove ( 'active' ) ) ;
const navEl = folderId === 'unified' ? document . getElementById ( 'nav-unified' )
: folderId === 'starred' ? document . getElementById ( 'nav-starred' )
: document . getElementById ( 'nav-f' + folderId ) ;
if ( navEl ) navEl . classList . add ( 'active' ) ;
loadMessages ( ) ;
}
const handleSearch = debounce ( q => {
S . searchQuery = q . trim ( ) ; S . currentPage = 1 ;
document . getElementById ( 'panel-title' ) . textContent = q . trim ( ) ? 'Search: ' + q . trim ( ) : S . currentFolderName ;
loadMessages ( ) ;
} , 350 ) ;
async function loadMessages ( append ) {
const list = document . getElementById ( 'message-list' ) ;
if ( ! append ) list . innerHTML = '<div class="spinner" style="margin-top:60px"></div>' ;
let result ;
if ( S . searchQuery ) result = await api ( 'GET' , ` /search?q= ${ encodeURIComponent ( S . searchQuery ) } &page= ${ S . currentPage } &page_size=50 ` ) ;
else if ( S . currentFolder === 'unified' ) result = await api ( 'GET' , ` /messages/unified?page= ${ S . currentPage } &page_size=50 ` ) ;
2026-03-07 20:00:15 +00:00
else if ( S . currentFolder === 'starred' ) result = await api ( 'GET' , ` /messages/starred?page= ${ S . currentPage } &page_size=50 ` ) ;
2026-03-07 06:20:39 +00:00
else result = await api ( 'GET' , ` /messages?folder_id= ${ S . currentFolder } &page= ${ S . currentPage } &page_size=50 ` ) ;
if ( ! result ) { list . innerHTML = '<div class="empty-state"><p>Failed to load</p></div>' ; return ; }
S . totalMessages = result . total || ( result . messages || [ ] ) . length ;
if ( append ) S . messages . push ( ... ( result . messages || [ ] ) ) ;
else S . messages = result . messages || [ ] ;
renderMessageList ( ) ;
document . getElementById ( 'panel-count' ) . textContent = S . totalMessages > 0 ? S . totalMessages + ' messages' : '' ;
}
2026-03-07 16:49:23 +00:00
function setFilter ( mode ) {
S . filterUnread = ( mode === 'unread' ) ;
2026-03-08 11:48:27 +00:00
S . filterAttachment = ( mode === 'attachment' ) ;
S . sortOrder = ( mode === 'unread' || mode === 'default' || mode === 'attachment' ) ? 'date-desc' : mode ;
2026-03-07 16:49:23 +00:00
// Update checkmarks
2026-03-08 11:48:27 +00:00
[ 'default' , 'unread' , 'attachment' , 'date-desc' , 'date-asc' , 'size-desc' ] . forEach ( k => {
2026-03-07 16:49:23 +00:00
const el = document . getElementById ( 'fopt-' + k ) ;
if ( el ) el . textContent = ( k === mode ? '✓ ' : '○ ' ) + el . textContent . slice ( 2 ) ;
} ) ;
// Update button label
const labels = {
2026-03-08 11:48:27 +00:00
'default' : 'Filter' , 'unread' : 'Unread' , 'attachment' : '📎 Has Attachment' ,
'date-desc' : '↓ Date' , 'date-asc' : '↑ Date' , 'size-desc' : '↓ Size'
2026-03-07 16:49:23 +00:00
} ;
const labelEl = document . getElementById ( 'filter-label' ) ;
if ( labelEl ) {
labelEl . textContent = labels [ mode ] || 'Filter' ;
labelEl . style . color = mode !== 'default' ? 'var(--accent)' : '' ;
}
const menuEl = document . getElementById ( 'filter-dropdown-menu' ) ;
if ( menuEl ) menuEl . style . display = 'none' ;
renderMessageList ( ) ;
}
// Keep old names as aliases so nothing else breaks
function toggleFilterUnread ( ) { setFilter ( S . filterUnread ? 'default' : 'unread' ) ; }
function setSortOrder ( order ) { setFilter ( order ) ; }
2026-03-07 20:00:15 +00:00
// ── Multi-select state ────────────────────────────────────────
if ( ! window . SEL ) window . SEL = { ids : new Set ( ) , lastIdx : - 1 } ;
2026-03-07 06:20:39 +00:00
function renderMessageList ( ) {
const list = document . getElementById ( 'message-list' ) ;
2026-03-07 16:49:23 +00:00
let msgs = [ ... S . messages ] ;
// Filter
if ( S . filterUnread ) msgs = msgs . filter ( m => ! m . is _read ) ;
2026-03-08 11:48:27 +00:00
if ( S . filterAttachment ) msgs = msgs . filter ( m => m . has _attachment ) ;
2026-03-07 16:49:23 +00:00
// Sort
if ( S . sortOrder === 'date-asc' ) msgs . sort ( ( a , b ) => new Date ( a . date ) - new Date ( b . date ) ) ;
else if ( S . sortOrder === 'size-desc' ) msgs . sort ( ( a , b ) => ( b . size || 0 ) - ( a . size || 0 ) ) ;
2026-03-07 20:00:15 +00:00
else msgs . sort ( ( a , b ) => new Date ( b . date ) - new Date ( a . date ) ) ;
2026-03-07 16:49:23 +00:00
if ( ! msgs . length ) {
2026-03-08 11:48:27 +00:00
const emptyMsg = S . filterUnread ? 'No unread messages' : S . filterAttachment ? 'No messages with attachments' : 'No messages' ;
list . innerHTML = ` <div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p> ${ emptyMsg } </p></div> ` ;
2026-03-07 06:20:39 +00:00
return ;
}
2026-03-07 20:00:15 +00:00
// Update bulk action bar
updateBulkBar ( ) ;
list . innerHTML = msgs . map ( ( m , i ) => `
< div class = "message-item ${m.id===S.selectedMessageId&&!SEL.ids.size?'active':''} ${!m.is_read?'unread':''} ${SEL.ids.has(m.id)?'selected':''}"
data - id = "${m.id}" data - idx = "${i}"
draggable = "true"
onclick = "handleMsgClick(event,${m.id},${i})"
oncontextmenu = "showMessageMenu(event,${m.id})"
ondragstart = "handleMsgDragStart(event,${m.id})" >
2026-03-07 06:20:39 +00:00
< div class = "msg-top" >
< span class = "msg-from" > $ { esc ( m . from _name || m . from _email ) } < / s p a n >
< span class = "msg-date" > $ { formatDate ( m . date ) } < / s p a n >
< / d i v >
< div class = "msg-subject" > $ { esc ( m . subject || '(no subject)' ) } < / d i v >
< div class = "msg-preview" > $ { esc ( m . preview || '' ) } < / d i v >
< div class = "msg-meta" >
< span class = "msg-dot" style = "background:${m.account_color}" > < / s p a n >
< span class = "msg-acct" > $ { esc ( m . account _email || '' ) } < / s p a n >
2026-03-07 16:49:23 +00:00
$ { m . size ? ` <span style="font-size:10px;color:var(--muted);margin-left:4px"> ${ formatSize ( m . size ) } </span> ` : '' }
2026-03-07 06:20:39 +00:00
$ { m . has _attachment ? '<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--muted)" style="margin-left:4px"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>' : '' }
< span class = "msg-star ${m.is_starred?'on':''}" onclick = "toggleStar(${m.id},event)" > $ { m . is _starred ? '★' : '☆' } < / s p a n >
< / d i v >
< / d i v > ` ) . j o i n ( ' ' ) + ( S . m e s s a g e s . l e n g t h < S . t o t a l M e s s a g e s
? ` <div class="load-more"><button class="load-more-btn" onclick="loadMoreMessages()">Load more</button></div> ` : '' ) ;
2026-03-07 20:00:15 +00:00
// Enable drag-drop onto folder nav items
document . querySelectorAll ( '.nav-item[data-fid]' ) . forEach ( el => {
el . ondragover = e => { e . preventDefault ( ) ; el . classList . add ( 'drag-over' ) ; } ;
el . ondragleave = ( ) => el . classList . remove ( 'drag-over' ) ;
el . ondrop = e => {
e . preventDefault ( ) ; el . classList . remove ( 'drag-over' ) ;
const fid = parseInt ( el . dataset . fid ) ;
if ( ! fid ) return ;
const ids = SEL . ids . size ? [ ... SEL . ids ] : [ parseInt ( e . dataTransfer . getData ( 'text/plain' ) ) ] ;
ids . forEach ( id => moveMessage ( id , fid , true ) ) ;
SEL . ids . clear ( ) ; updateBulkBar ( ) ; renderMessageList ( ) ;
} ;
} ) ;
}
function handleMsgClick ( e , id , idx ) {
if ( e . ctrlKey || e . metaKey ) {
// Toggle selection
SEL . ids . has ( id ) ? SEL . ids . delete ( id ) : SEL . ids . add ( id ) ;
SEL . lastIdx = idx ;
renderMessageList ( ) ; return ;
}
if ( e . shiftKey && SEL . lastIdx >= 0 ) {
// Range select
const msgs = getFilteredSortedMsgs ( ) ;
const lo = Math . min ( SEL . lastIdx , idx ) , hi = Math . max ( SEL . lastIdx , idx ) ;
for ( let i = lo ; i <= hi ; i ++ ) SEL . ids . add ( msgs [ i ] . id ) ;
renderMessageList ( ) ; return ;
}
SEL . ids . clear ( ) ; SEL . lastIdx = idx ;
openMessage ( id ) ;
}
function getFilteredSortedMsgs ( ) {
let msgs = [ ... S . messages ] ;
if ( S . filterUnread ) msgs = msgs . filter ( m => ! m . is _read ) ;
2026-03-08 11:48:27 +00:00
if ( S . filterAttachment ) msgs = msgs . filter ( m => m . has _attachment ) ;
2026-03-07 20:00:15 +00:00
if ( S . sortOrder === 'date-asc' ) msgs . sort ( ( a , b ) => new Date ( a . date ) - new Date ( b . date ) ) ;
else if ( S . sortOrder === 'size-desc' ) msgs . sort ( ( a , b ) => ( b . size || 0 ) - ( a . size || 0 ) ) ;
else msgs . sort ( ( a , b ) => new Date ( b . date ) - new Date ( a . date ) ) ;
return msgs ;
}
function handleMsgDragStart ( e , id ) {
if ( ! SEL . ids . has ( id ) ) { SEL . ids . clear ( ) ; SEL . ids . add ( id ) ; }
e . dataTransfer . setData ( 'text/plain' , id ) ;
e . dataTransfer . effectAllowed = 'move' ;
}
function updateBulkBar ( ) {
let bar = document . getElementById ( 'bulk-action-bar' ) ;
if ( ! bar ) {
bar = document . createElement ( 'div' ) ;
bar . id = 'bulk-action-bar' ;
bar . style . cssText = 'display:none;position:sticky;top:0;z-index:10;background:var(--accent);color:#fff;padding:6px 12px;font-size:12px;display:flex;align-items:center;gap:8px' ;
bar . innerHTML = ` <span id="bulk-count"></span>
< button onclick = "bulkMarkRead(true)" style = "font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer" > Mark read < / b u t t o n >
< button onclick = "bulkMarkRead(false)" style = "font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer" > Mark unread < / b u t t o n >
< button onclick = "bulkDelete()" style = "font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer" > Delete < / b u t t o n >
< button onclick = "SEL.ids.clear();renderMessageList()" style = "margin-left:auto;font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer" > ✕ Clear < / b u t t o n > ` ;
document . getElementById ( 'message-list' ) . before ( bar ) ;
}
if ( SEL . ids . size ) {
bar . style . display = 'flex' ;
document . getElementById ( 'bulk-count' ) . textContent = SEL . ids . size + ' selected' ;
} else {
bar . style . display = 'none' ;
}
}
async function bulkMarkRead ( read ) {
await Promise . all ( [ ... SEL . ids ] . map ( id => api ( 'PUT' , '/messages/' + id + '/read' , { read } ) ) ) ;
SEL . ids . forEach ( id => { const m = S . messages . find ( m => m . id === id ) ; if ( m ) m . is _read = read ; } ) ;
SEL . ids . clear ( ) ; renderMessageList ( ) ; loadFolders ( ) ;
}
async function bulkDelete ( ) {
2026-03-08 06:06:38 +00:00
const count = SEL . ids . size ;
inlineConfirm (
` Delete ${ count } message ${ count === 1 ? '' : 's' } ? This cannot be undone. ` ,
async ( ) => {
const ids = [ ... SEL . ids ] ;
await Promise . all ( ids . map ( id => api ( 'DELETE' , '/messages/' + id ) ) ) ;
ids . forEach ( id => { S . messages = S . messages . filter ( m => m . id !== id ) ; } ) ;
SEL . ids . clear ( ) ; renderMessageList ( ) ; loadFolders ( ) ;
}
) ;
2026-03-07 06:20:39 +00:00
}
function loadMoreMessages ( ) { S . currentPage ++ ; loadMessages ( true ) ; }
async function openMessage ( id ) {
S . selectedMessageId = id ; renderMessageList ( ) ;
const detail = document . getElementById ( 'message-detail' ) ;
detail . innerHTML = '<div class="spinner" style="margin-top:100px"></div>' ;
const msg = await api ( 'GET' , '/messages/' + id ) ;
if ( ! msg ) { detail . innerHTML = '<div class="no-message"><p>Failed to load</p></div>' ; return ; }
S . currentMessage = msg ;
renderMessageDetail ( msg , false ) ;
const li = S . messages . find ( m => m . id === id ) ;
2026-03-08 06:06:38 +00:00
if ( li && ! li . is _read ) {
li . is _read = true ; renderMessageList ( ) ;
// Sync read status to server (enqueues IMAP op via backend)
api ( 'PUT' , '/messages/' + id + '/read' , { read : true } ) ;
}
2026-03-07 06:20:39 +00:00
}
2026-03-08 12:14:58 +00:00
// ── External link navigation whitelist ───────────────────────────────────────
// Persisted in sessionStorage so it resets on tab close (safety default).
const _extNavOk = new Set ( JSON . parse ( sessionStorage . getItem ( 'extNavOk' ) || '[]' ) ) ;
function _saveExtNavOk ( ) { sessionStorage . setItem ( 'extNavOk' , JSON . stringify ( [ ... _extNavOk ] ) ) ; }
function confirmExternalNav ( url ) {
const origin = ( ( ) => { try { return new URL ( url ) . origin ; } catch ( e ) { return url ; } } ) ( ) ;
if ( _extNavOk . has ( origin ) ) { window . open ( url , '_blank' , 'noopener,noreferrer' ) ; return ; }
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'modal-overlay open' ;
overlay . innerHTML = ` <div class="modal" style="max-width:480px">
< h2 style = "margin:0 0 12px" > Open external link ? < / h 2 >
< div style = "word-break:break-all;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;font-family:monospace;margin-bottom:16px;color:var(--text2)" > $ { esc ( url ) } < / d i v >
< p style = "margin:0 0 20px;font-size:13px;color:var(--text2)" > This link was in a received email . Opening it will take you to an external website . < / p >
< div style = "display:flex;gap:8px;flex-wrap:wrap" >
< button class = "btn-primary" id = "enav-once" > Open once < / b u t t o n >
< button class = "btn-primary" id = "enav-always" style = "background:var(--accent2,#2a7)" > Always allow $ { esc ( origin ) } < / b u t t o n >
< button class = "action-btn" id = "enav-cancel" > Cancel < / b u t t o n >
< / d i v >
< / d i v > ` ;
document . body . appendChild ( overlay ) ;
overlay . querySelector ( '#enav-once' ) . onclick = ( ) => { overlay . remove ( ) ; window . open ( url , '_blank' , 'noopener,noreferrer' ) ; } ;
overlay . querySelector ( '#enav-always' ) . onclick = ( ) => { _extNavOk . add ( origin ) ; _saveExtNavOk ( ) ; overlay . remove ( ) ; window . open ( url , '_blank' , 'noopener,noreferrer' ) ; } ;
overlay . querySelector ( '#enav-cancel' ) . onclick = ( ) => overlay . remove ( ) ;
overlay . onclick = e => { if ( e . target === overlay ) overlay . remove ( ) ; } ;
}
2026-03-07 06:20:39 +00:00
function renderMessageDetail ( msg , showRemoteContent ) {
const detail = document . getElementById ( 'message-detail' ) ;
const allowed = showRemoteContent || S . remoteWhitelist . has ( msg . from _email ) ;
2026-03-07 17:09:41 +00:00
const cssReset = ` <style>html,body{background:#ffffff!important;color:#1a1a1a!important; ` +
2026-03-07 20:00:15 +00:00
` font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4} ` +
2026-03-08 12:14:58 +00:00
` img{max-width:100%;height:auto}iframe{display:none!important}</style> ` ;
2026-03-07 17:09:41 +00:00
2026-03-08 12:14:58 +00:00
// Injected into srcdoc: reports height + intercepts all link clicks → postMessage to parent
2026-03-08 11:48:27 +00:00
const heightScript = ` <script>
function _reportH ( ) { parent . postMessage ( { type : 'gomail-frame-h' , h : document . documentElement . scrollHeight } , '*' ) ; }
document . addEventListener ( 'DOMContentLoaded' , _reportH ) ;
window . addEventListener ( 'load' , _reportH ) ;
new MutationObserver ( _reportH ) . observe ( document . documentElement , { subtree : true , childList : true , attributes : true } ) ;
2026-03-08 12:14:58 +00:00
document . addEventListener ( 'click' , function ( e ) {
var el = e . target ; while ( el && el . tagName !== 'A' ) el = el . parentElement ;
if ( ! el ) return ;
var href = el . getAttribute ( 'href' ) ;
if ( ! href || href . startsWith ( '#' ) || href . startsWith ( 'mailto:' ) ) return ;
e . preventDefault ( ) ; e . stopPropagation ( ) ;
parent . postMessage ( { type : 'gomail-open-url' , url : href } , '*' ) ;
} , true ) ;
2026-03-08 11:48:27 +00:00
< \ / script > ` ;
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox' ;
2026-03-08 12:14:58 +00:00
function stripUnresolvedCID ( h ) { return h . replace ( /src\s*=\s*(['"])cid:[^'"]*\1/gi , 'src=""' ) . replace ( /src\s*=\s*cid:\S+/gi , 'src=""' ) ; }
function stripEmbeddedFrames ( h ) { return h . replace ( /<iframe[\s\S]*?<\/iframe>/gi , '' ) . replace ( /<iframe[^>]*>/gi , '' ) ; }
function stripRemoteImages ( h ) {
return h . replace ( /<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi , '<img$1src="" data-blocked-src="$3"' )
. replace ( /url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi , 'url()' )
. replace ( /<link[^>]*>/gi , '' ) . replace ( /<script[\s\S]*?<\/script>/gi , '' ) ;
}
2026-03-07 06:20:39 +00:00
let bodyHtml = '' ;
if ( msg . body _html ) {
2026-03-08 12:14:58 +00:00
let html = stripUnresolvedCID ( stripEmbeddedFrames ( msg . body _html ) ) ;
2026-03-07 06:20:39 +00:00
if ( allowed ) {
2026-03-08 12:14:58 +00:00
const srcdoc = cssReset + heightScript + html ;
2026-03-08 11:48:27 +00:00
bodyHtml = ` <iframe id="msg-frame" sandbox=" ${ sandboxAttr } "
style = "width:100%;border:none;min-height:200px;display:block"
2026-03-07 17:09:41 +00:00
srcdoc = "${srcdoc.replace(/" / g , '"' ) } " > < / i f r a m e > ` ;
2026-03-07 06:20:39 +00:00
} else {
2026-03-08 12:14:58 +00:00
const stripped = stripRemoteImages ( html ) ;
2026-03-07 06:20:39 +00:00
bodyHtml = ` <div class="remote-content-banner">
2026-03-08 12:14:58 +00:00
< svg viewBox = "0 0 24 24" width = "14" height = "14" fill = "currentColor" > < path d = "M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" / > < / s v g >
2026-03-07 06:20:39 +00:00
Remote images blocked .
2026-03-07 17:09:41 +00:00
< button class = "rcb-btn" onclick = "renderMessageDetail(S.currentMessage,true)" > Load images < / b u t t o n >
2026-03-07 06:20:39 +00:00
< button class = "rcb-btn" onclick = "whitelistSender('${esc(msg.from_email)}')" > Always allow from $ { esc ( msg . from _email ) } < / b u t t o n >
< / d i v >
2026-03-08 11:48:27 +00:00
< iframe id = "msg-frame" sandbox = "${sandboxAttr}"
style = "width:100%;border:none;min-height:200px;display:block"
srcdoc = "${(cssReset + heightScript + stripped).replace(/" / g , '"' ) } " > < / i f r a m e > ` ;
2026-03-07 06:20:39 +00:00
}
} else {
bodyHtml = ` <div class="detail-body-text"> ${ esc ( msg . body _text || '(empty)' ) } </div> ` ;
}
let attachHtml = '' ;
if ( msg . attachments ? . length ) {
2026-03-08 12:14:58 +00:00
const chips = msg . attachments . map ( a => {
const url = ` /api/messages/ ${ msg . id } /attachments/ ${ a . id } ` ;
const ct = a . content _type || '' ;
const viewable = /^(image\/|text\/|application\/pdf$|video\/|audio\/)/ . test ( ct ) ;
const icon = ct . startsWith ( 'image/' ) ? '🖼' : ct === 'application/pdf' ? '📄' : ct . startsWith ( 'video/' ) ? '🎬' : ct . startsWith ( 'audio/' ) ? '🎵' : '📎' ;
if ( viewable ) {
return ` <a class="attachment-chip" href=" ${ url } " target="_blank" rel="noopener" title="Open ${ esc ( a . filename ) } "> ${ icon } <span> ${ esc ( a . filename ) } </span><span style="color:var(--muted);font-size:10px"> ${ formatSize ( a . size ) } </span></a> ` ;
}
return ` <a class="attachment-chip" href=" ${ url } " download=" ${ esc ( a . filename ) } " title="Download ${ esc ( a . filename ) } "> ${ icon } <span> ${ esc ( a . filename ) } </span><span style="color:var(--muted);font-size:10px"> ${ formatSize ( a . size ) } </span></a> ` ;
} ) . join ( '' ) ;
const dlAll = ` <button class="attachment-chip" onclick="downloadAllAttachments( ${ msg . id } )" style="cursor:pointer;border:1px solid var(--border)">⬇ <span>Download all</span></button> ` ;
attachHtml = ` <div class="attachments-bar"> ${ dlAll } ${ chips } </div> ` ;
2026-03-07 06:20:39 +00:00
}
2026-03-08 12:14:58 +00:00
2026-03-07 06:20:39 +00:00
detail . innerHTML = `
< div class = "detail-header" >
< div class = "detail-subject" > $ { esc ( msg . subject || '(no subject)' ) } < / d i v >
< div class = "detail-meta" >
< div class = "detail-from" >
< strong > $ { esc ( msg . from _name || msg . from _email ) } < / s t r o n g >
$ { msg . from _name ? ` <span style="color:var(--muted);font-size:12px"> < ${ esc ( msg . from _email ) } ></span> ` : '' }
$ { msg . to ? ` <div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${ esc ( msg . to ) } </div> ` : '' }
$ { msg . cc ? ` <div style="font-size:12px;color:var(--muted)">CC: ${ esc ( msg . cc ) } </div> ` : '' }
< / d i v >
< div class = "detail-date" > $ { formatFullDate ( msg . date ) } < / d i v >
< / d i v >
< / d i v >
< div class = "detail-actions" >
< button class = "action-btn" onclick = "openReply()" > ↩ Reply < / b u t t o n >
< button class = "action-btn" onclick = "openForward()" > ↪ Forward < / b u t t o n >
2026-03-08 11:48:27 +00:00
< button class = "action-btn" onclick = "openForwardAsAttachment()" title = "Forward the original message as an .eml file attachment" > ↪ Fwd as Attachment < / b u t t o n >
2026-03-07 06:20:39 +00:00
< button class = "action-btn" onclick = "toggleStar(${msg.id})" > $ { msg . is _starred ? '★ Unstar' : '☆ Star' } < / b u t t o n >
< button class = "action-btn" onclick = "markRead(${msg.id},${!msg.is_read})" > $ { msg . is _read ? 'Mark unread' : 'Mark read' } < / b u t t o n >
< button class = "action-btn" onclick = "showMessageHeaders(${msg.id})" > ⋮ Headers < / b u t t o n >
2026-03-07 20:00:15 +00:00
< button class = "action-btn" onclick = "downloadEML(${msg.id})" > ⬇ Download < / b u t t o n >
2026-03-07 06:20:39 +00:00
< button class = "action-btn danger" onclick = "deleteMessage(${msg.id})" > 🗑 Delete < / b u t t o n >
< / d i v >
$ { attachHtml }
< div class = "detail-body" > $ { bodyHtml } < / d i v > ` ;
2026-03-08 11:48:27 +00:00
// Auto-size iframe via postMessage from injected height-reporting script.
// We cannot use contentDocument (null without allow-same-origin in sandbox).
2026-03-07 20:00:15 +00:00
if ( msg . body _html ) {
2026-03-08 11:48:27 +00:00
const frame = document . getElementById ( 'msg-frame' ) ;
2026-03-07 20:00:15 +00:00
if ( frame ) {
2026-03-08 11:48:27 +00:00
// Clean up any previous listener
if ( window . _frameMsgHandler ) window . removeEventListener ( 'message' , window . _frameMsgHandler ) ;
let lastH = 0 ;
window . _frameMsgHandler = ( e ) => {
if ( e . data ? . type === 'gomail-frame-h' && e . data . h > 50 ) {
const h = e . data . h + 24 ;
2026-03-08 12:14:58 +00:00
if ( Math . abs ( h - lastH ) > 4 ) {
2026-03-08 11:48:27 +00:00
lastH = h ;
frame . style . height = h + 'px' ;
}
2026-03-08 12:14:58 +00:00
} else if ( e . data ? . type === 'gomail-open-url' && e . data . url ) {
confirmExternalNav ( e . data . url ) ;
2026-03-08 11:48:27 +00:00
}
2026-03-07 20:00:15 +00:00
} ;
2026-03-08 11:48:27 +00:00
window . addEventListener ( 'message' , window . _frameMsgHandler ) ;
2026-03-07 20:00:15 +00:00
}
2026-03-07 06:20:39 +00:00
}
}
2026-03-08 12:14:58 +00:00
// Download all attachments for a message sequentially
async function downloadAllAttachments ( msgId ) {
const msg = S . currentMessage ;
if ( ! msg ? . attachments ? . length ) return ;
for ( const a of msg . attachments ) {
const url = ` /api/messages/ ${ msgId } /attachments/ ${ a . id } ` ;
try {
const resp = await fetch ( url ) ;
const blob = await resp . blob ( ) ;
const tmp = document . createElement ( 'a' ) ;
tmp . href = URL . createObjectURL ( blob ) ;
tmp . download = a . filename || 'attachment' ;
tmp . click ( ) ;
URL . revokeObjectURL ( tmp . href ) ;
// Small delay to avoid browser throttling sequential downloads
await new Promise ( r => setTimeout ( r , 400 ) ) ;
} catch ( e ) { toast ( 'Failed to download ' + esc ( a . filename ) , 'error' ) ; }
}
}
2026-03-07 06:20:39 +00:00
async function whitelistSender ( sender ) {
const r = await api ( 'POST' , '/remote-content-whitelist' , { sender } ) ;
if ( r ? . ok ) { S . remoteWhitelist . add ( sender ) ; toast ( 'Always allowing content from ' + sender , 'success' ) ; if ( S . currentMessage ) renderMessageDetail ( S . currentMessage , false ) ; }
}
async function showMessageHeaders ( id ) {
const r = await api ( 'GET' , '/messages/' + id + '/headers' ) ;
if ( ! r ? . headers ) return ;
const rows = Object . entries ( r . headers ) . filter ( ( [ , v ] ) => v )
. map ( ( [ k , v ] ) => ` <tr><td style="color:var(--muted);padding:4px 12px 4px 0;font-size:12px;white-space:nowrap;vertical-align:top"> ${ esc ( k ) } </td><td style="font-size:12px;word-break:break-all"> ${ esc ( v ) } </td></tr> ` ) . join ( '' ) ;
2026-03-07 20:00:15 +00:00
const rawText = r . raw || '' ;
2026-03-07 06:20:39 +00:00
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'modal-overlay open' ;
2026-03-07 20:00:15 +00:00
overlay . innerHTML = ` <div class="modal" style="width:660px;max-height:85vh;display:flex;flex-direction:column">
2026-03-07 06:20:39 +00:00
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:16px" >
< h2 style = "margin:0" > Message Headers < / h 2 >
< button class = "icon-btn" onclick = "this.closest('.modal-overlay').remove()" > < svg viewBox = "0 0 24 24" > < path d = "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" / > < / s v g > < / b u t t o n >
< / d i v >
2026-03-07 20:00:15 +00:00
< div style = "overflow-y:auto;flex:1" >
< table style = "width:100%;margin-bottom:16px" > < tbody > $ { rows } < / t b o d y > < / t a b l e >
< div style = "font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;margin-bottom:6px" > Raw Headers < / d i v >
< div style = "position:relative" >
< textarea id = "raw-headers-ta" readonly style = "width:100%;box-sizing:border-box;height:180px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text2);font-family:monospace;font-size:11px;padding:10px;resize:vertical;outline:none" > $ { esc ( rawText ) } < / t e x t a r e a >
< button onclick = "navigator.clipboard.writeText(document.getElementById('raw-headers-ta').value).then(()=>toast('Copied','success'))"
style = "position:absolute;top:6px;right:8px;font-size:11px;padding:3px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--text2);cursor:pointer" > Copy < / b u t t o n >
< / d i v >
< / d i v >
2026-03-07 06:20:39 +00:00
< / d i v > ` ;
overlay . addEventListener ( 'click' , e => { if ( e . target === overlay ) overlay . remove ( ) ; } ) ;
document . body . appendChild ( overlay ) ;
}
2026-03-07 20:00:15 +00:00
function downloadEML ( id ) {
window . open ( '/api/messages/' + id + '/download.eml' , '_blank' ) ;
}
2026-03-07 06:20:39 +00:00
function showMessageMenu ( e , id ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
2026-03-07 20:00:15 +00:00
const msg = S . messages . find ( m => m . id === id ) ;
const otherFolders = S . folders . filter ( f => ! f . is _hidden && f . id !== S . currentFolder ) . slice ( 0 , 16 ) ;
const moveItems = otherFolders . map ( f => ` <div class="ctx-item ctx-sub-item" onclick="moveMessage( ${ id } , ${ f . id } );closeMenu()"> ${ esc ( f . name ) } </div> ` ) . join ( '' ) ;
const moveSub = otherFolders . length ? `
< div class = "ctx-item ctx-has-sub" > 📂 Move to
< span class = "ctx-sub-arrow" > › < / s p a n >
< div class = "ctx-submenu" > $ { moveItems } < / d i v >
< / d i v > ` : ' ' ;
2026-03-07 06:20:39 +00:00
showCtxMenu ( e , `
< div class = "ctx-item" onclick = "openReplyTo(${id});closeMenu()" > ↩ Reply < / d i v >
2026-03-07 20:00:15 +00:00
< div class = "ctx-item" onclick = "toggleStar(${id});closeMenu()" > $ { msg ? . is _starred ? '★ Unstar' : '☆ Star' } < / d i v >
< div class = "ctx-item" onclick = "markRead(${id},${msg?.is_read?'false':'true'});closeMenu()" > $ { msg ? . is _read ? 'Mark unread' : 'Mark read' } < / d i v >
< div class = "ctx-sep" > < / d i v >
$ { moveSub }
2026-03-07 06:20:39 +00:00
< div class = "ctx-item" onclick = "showMessageHeaders(${id});closeMenu()" > ⋮ View headers < / d i v >
2026-03-07 20:00:15 +00:00
< div class = "ctx-item" onclick = "downloadEML(${id});closeMenu()" > ⬇ Download . eml < / d i v >
2026-03-07 06:20:39 +00:00
< div class = "ctx-sep" > < / d i v >
< div class = "ctx-item danger" onclick = "deleteMessage(${id});closeMenu()" > 🗑 Delete < / d i v > ` ) ;
}
async function toggleStar ( id , e ) {
if ( e ) e . stopPropagation ( ) ;
const r = await api ( 'PUT' , '/messages/' + id + '/star' ) ;
if ( r ) { const m = S . messages . find ( m => m . id === id ) ; if ( m ) m . is _starred = r . starred ; renderMessageList ( ) ;
if ( S . currentMessage ? . id === id ) { S . currentMessage . is _starred = r . starred ; renderMessageDetail ( S . currentMessage , false ) ; } }
}
async function markRead ( id , read ) {
await api ( 'PUT' , '/messages/' + id + '/read' , { read } ) ;
const m = S . messages . find ( m => m . id === id ) ; if ( m ) { m . is _read = read ; renderMessageList ( ) ; }
loadFolders ( ) ;
}
2026-03-07 20:00:15 +00:00
async function moveMessage ( msgId , folderId , silent = false ) {
2026-03-07 16:49:23 +00:00
const folder = S . folders . find ( f => f . id === folderId ) ;
2026-03-07 20:00:15 +00:00
const doMove = async ( ) => {
2026-03-07 16:49:23 +00:00
const r = await api ( 'PUT' , '/messages/' + msgId + '/move' , { folder _id : folderId } ) ;
2026-03-07 20:00:15 +00:00
if ( r ? . ok ) { if ( ! silent ) toast ( 'Moved' , 'success' ) ; S . messages = S . messages . filter ( m => m . id !== msgId ) ;
2026-03-07 16:49:23 +00:00
if ( S . currentMessage ? . id === msgId ) resetDetail ( ) ; loadFolders ( ) ; }
2026-03-07 20:00:15 +00:00
else if ( ! silent ) toast ( 'Move failed' , 'error' ) ;
} ;
if ( silent ) { doMove ( ) ; return ; }
inlineConfirm ( ` Move this message to " ${ folder ? . name || 'selected folder' } "? ` , doMove ) ;
2026-03-07 06:20:39 +00:00
}
async function deleteMessage ( id ) {
2026-03-07 15:14:57 +00:00
inlineConfirm ( 'Delete this message?' , async ( ) => {
const r = await api ( 'DELETE' , '/messages/' + id ) ;
if ( r ? . ok ) { toast ( 'Deleted' , 'success' ) ; S . messages = S . messages . filter ( m => m . id !== id ) ; renderMessageList ( ) ;
if ( S . currentMessage ? . id === id ) resetDetail ( ) ; loadFolders ( ) ; }
else toast ( 'Delete failed' , 'error' ) ;
} ) ;
2026-03-07 06:20:39 +00:00
}
function resetDetail ( ) {
S . currentMessage = null ; S . selectedMessageId = null ;
document . getElementById ( 'message-detail' ) . innerHTML = ` <div class="no-message">
< svg viewBox = "0 0 24 24" > < path d = "M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" / > < / s v g >
< h3 > Select a message < / h 3 > < p > C h o o s e a m e s s a g e t o r e a d i t < / p > < / d i v > ` ;
}
function formatSize ( b ) { if ( ! b ) return '' ; if ( b < 1024 ) return b + ' B' ; if ( b < 1048576 ) return Math . round ( b / 1024 ) + ' KB' ; return ( b / 1048576 ) . toFixed ( 1 ) + ' MB' ; }
// ── Compose ────────────────────────────────────────────────────────────────
let composeAttachments = [ ] ;
function populateComposeFrom ( ) {
const sel = document . getElementById ( 'compose-from' ) ;
if ( ! sel ) return ;
sel . innerHTML = S . accounts . map ( a => ` <option value=" ${ a . id } "> ${ esc ( a . display _name || a . email _address ) } < ${ esc ( a . email _address ) } ></option> ` ) . join ( '' ) ;
}
function openCompose ( opts = { } ) {
S . composeMode = opts . mode || 'new' ; S . composeReplyToId = opts . replyId || null ;
composeAttachments = [ ] ;
document . getElementById ( 'compose-title' ) . textContent = opts . title || 'New Message' ;
2026-03-07 15:14:57 +00:00
document . getElementById ( 'compose-minimised-label' ) . textContent = opts . title || 'New Message' ;
// Clear tag containers and re-init
[ 'compose-to' , 'compose-cc-tags' , 'compose-bcc-tags' ] . forEach ( id => {
const c = document . getElementById ( id ) ;
if ( c ) { c . innerHTML = '' ; initTagField ( id ) ; }
} ) ;
2026-03-07 06:20:39 +00:00
document . getElementById ( 'compose-subject' ) . value = opts . subject || '' ;
document . getElementById ( 'cc-row' ) . style . display = 'none' ;
document . getElementById ( 'bcc-row' ) . style . display = 'none' ;
const editor = document . getElementById ( 'compose-editor' ) ;
editor . innerHTML = opts . body || '' ;
S . draftDirty = false ;
updateAttachList ( ) ;
2026-03-07 15:14:57 +00:00
showCompose ( ) ;
setTimeout ( ( ) => { const inp = document . querySelector ( '#compose-to .tag-input' ) ; if ( inp ) inp . focus ( ) ; } , 80 ) ;
2026-03-07 06:20:39 +00:00
startDraftAutosave ( ) ;
}
2026-03-07 15:14:57 +00:00
function showCompose ( ) {
const d = document . getElementById ( 'compose-dialog' ) ;
const m = document . getElementById ( 'compose-minimised' ) ;
d . style . display = 'flex' ;
m . style . display = 'none' ;
S . composeVisible = true ; S . composeMinimised = false ;
2026-03-08 11:48:27 +00:00
initComposeDragDrop ( ) ;
2026-03-07 15:14:57 +00:00
}
function minimizeCompose ( ) {
document . getElementById ( 'compose-dialog' ) . style . display = 'none' ;
document . getElementById ( 'compose-minimised' ) . style . display = 'flex' ;
S . composeMinimised = true ;
}
function restoreCompose ( ) {
showCompose ( ) ;
}
function closeCompose ( skipCheck ) {
if ( ! skipCheck && S . draftDirty ) {
inlineConfirm ( 'Save draft before closing?' ,
( ) => { saveDraft ( ) ; _closeCompose ( ) ; } ,
( ) => { _closeCompose ( ) ; }
) ;
return ;
}
_closeCompose ( ) ;
}
function _closeCompose ( ) {
document . getElementById ( 'compose-dialog' ) . style . display = 'none' ;
document . getElementById ( 'compose-minimised' ) . style . display = 'none' ;
clearDraftAutosave ( ) ;
S . composeVisible = false ; S . composeMinimised = false ; S . draftDirty = false ;
}
function showCCRow ( ) { document . getElementById ( 'cc-row' ) . style . display = 'flex' ; }
function showBCCRow ( ) { document . getElementById ( 'bcc-row' ) . style . display = 'flex' ; }
2026-03-07 06:20:39 +00:00
function openReply ( ) { if ( S . currentMessage ) openReplyTo ( S . currentMessage . id ) ; }
function openReplyTo ( msgId ) {
const msg = ( S . currentMessage ? . id === msgId ) ? S . currentMessage : S . messages . find ( m => m . id === msgId ) ;
if ( ! msg ) return ;
openCompose ( {
mode : 'reply' , replyId : msgId , title : 'Reply' ,
subject : msg . subject && ! msg . subject . startsWith ( 'Re:' ) ? 'Re: ' + msg . subject : ( msg . subject || '' ) ,
body : ` <br><br><div class="quote-divider">—— Original message ——</div><blockquote> ${ msg . body _html || ( '<pre>' + esc ( msg . body _text || '' ) + '</pre>' ) } </blockquote> ` ,
} ) ;
addTag ( 'compose-to' , msg . from _email || '' ) ;
}
function openForward ( ) {
if ( ! S . currentMessage ) return ;
const msg = S . currentMessage ;
2026-03-08 11:48:27 +00:00
S . composeForwardFromId = msg . id ;
2026-03-07 06:20:39 +00:00
openCompose ( {
2026-03-08 11:48:27 +00:00
mode : 'forward' , forwardId : msg . id , title : 'Forward' ,
2026-03-07 06:20:39 +00:00
subject : 'Fwd: ' + ( msg . subject || '' ) ,
body : ` <br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${ esc ( msg . from _email || '' ) } </div><blockquote> ${ msg . body _html || ( '<pre>' + esc ( msg . body _text || '' ) + '</pre>' ) } </blockquote> ` ,
} ) ;
}
2026-03-08 11:48:27 +00:00
function openForwardAsAttachment ( ) {
if ( ! S . currentMessage ) return ;
const msg = S . currentMessage ;
S . composeForwardFromId = msg . id ;
openCompose ( {
mode : 'forward-attachment' , forwardId : msg . id , title : 'Forward as Attachment' ,
subject : 'Fwd: ' + ( msg . subject || '' ) ,
body : '' ,
} ) ;
// Add a visual placeholder chip (the actual EML is fetched server-side)
composeAttachments = [ { name : sanitizeSubject ( msg . subject || 'message' ) + '.eml' , size : 0 , isForward : true } ] ;
updateAttachList ( ) ;
}
function sanitizeSubject ( s ) { return s . replace ( /[/\\:*?"<>|]/g , '_' ) . slice ( 0 , 60 ) || 'message' ; }
2026-03-07 06:20:39 +00:00
// ── Email Tag Input ────────────────────────────────────────────────────────
function initTagField ( containerId ) {
const container = document . getElementById ( containerId ) ;
if ( ! container ) return ;
2026-03-07 15:14:57 +00:00
// Remove any existing input first
const old = container . querySelector ( '.tag-input' ) ;
if ( old ) old . remove ( ) ;
2026-03-07 06:20:39 +00:00
const inp = document . createElement ( 'input' ) ;
2026-03-07 15:14:57 +00:00
inp . type = 'text' ;
inp . className = 'tag-input' ;
inp . placeholder = containerId === 'compose-to' ? 'recipient@example.com' : '' ;
inp . setAttribute ( 'autocomplete' , 'off' ) ;
inp . setAttribute ( 'spellcheck' , 'false' ) ;
2026-03-07 06:20:39 +00:00
container . appendChild ( inp ) ;
2026-03-07 15:14:57 +00:00
const commit = ( ) => {
const v = inp . value . trim ( ) . replace ( /[,;\s]+$/ , '' ) ;
if ( v ) { addTag ( containerId , v ) ; inp . value = '' ; }
} ;
2026-03-07 06:20:39 +00:00
inp . addEventListener ( 'keydown' , e => {
2026-03-07 15:14:57 +00:00
if ( e . key === 'Enter' || e . key === ',' || e . key === ';' ) { e . preventDefault ( ) ; commit ( ) ; }
else if ( e . key === ' ' ) {
// Space commits only if value looks like an email
const v = inp . value . trim ( ) ;
if ( v && /^[^\s@]+@[^\s@]+\.[^\s@]+$/ . test ( v ) ) { e . preventDefault ( ) ; commit ( ) ; }
} else if ( e . key === 'Backspace' && ! inp . value ) {
2026-03-07 06:20:39 +00:00
const tags = container . querySelectorAll ( '.email-tag' ) ;
2026-03-07 15:14:57 +00:00
if ( tags . length ) tags [ tags . length - 1 ] . remove ( ) ;
2026-03-07 06:20:39 +00:00
}
2026-03-07 15:14:57 +00:00
S . draftDirty = true ;
2026-03-07 06:20:39 +00:00
} ) ;
2026-03-07 15:14:57 +00:00
inp . addEventListener ( 'blur' , commit ) ;
container . addEventListener ( 'click' , e => { if ( e . target === container || e . target . tagName === 'LABEL' ) inp . focus ( ) ; else if ( ! e . target . closest ( '.email-tag' ) ) inp . focus ( ) ; } ) ;
2026-03-07 06:20:39 +00:00
}
function addTag ( containerId , value ) {
if ( ! value ) return ;
const container = document . getElementById ( containerId ) ;
if ( ! container ) return ;
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ . test ( value ) ;
const tag = document . createElement ( 'span' ) ;
tag . className = 'email-tag' + ( isValid ? '' : ' invalid' ) ;
2026-03-07 15:14:57 +00:00
tag . dataset . email = value ;
const label = document . createElement ( 'span' ) ;
label . textContent = value ;
2026-03-07 06:20:39 +00:00
const remove = document . createElement ( 'button' ) ;
2026-03-07 15:14:57 +00:00
remove . innerHTML = '× ' ; remove . className = 'tag-remove' ; remove . type = 'button' ;
2026-03-07 06:20:39 +00:00
remove . onclick = e => { e . stopPropagation ( ) ; tag . remove ( ) ; S . draftDirty = true ; } ;
2026-03-07 15:14:57 +00:00
tag . appendChild ( label ) ; tag . appendChild ( remove ) ;
2026-03-07 06:20:39 +00:00
const inp = container . querySelector ( '.tag-input' ) ;
2026-03-07 15:14:57 +00:00
container . insertBefore ( tag , inp || null ) ;
2026-03-07 06:20:39 +00:00
S . draftDirty = true ;
}
function getTagValues ( containerId ) {
return Array . from ( document . querySelectorAll ( '#' + containerId + ' .email-tag' ) )
2026-03-07 15:14:57 +00:00
. map ( t => t . dataset . email || t . querySelector ( 'span' ) ? . textContent || '' ) . filter ( Boolean ) ;
2026-03-07 06:20:39 +00:00
}
// ── Draft autosave ─────────────────────────────────────────────────────────
function startDraftAutosave ( ) {
clearDraftAutosave ( ) ;
2026-03-07 15:14:57 +00:00
S . draftTimer = setInterval ( ( ) => { if ( S . draftDirty ) saveDraft ( true ) ; } , 60000 ) ;
2026-03-07 06:20:39 +00:00
const editor = document . getElementById ( 'compose-editor' ) ;
2026-03-07 15:14:57 +00:00
if ( editor ) editor . oninput = ( ) => S . draftDirty = true ;
2026-03-07 06:20:39 +00:00
}
function clearDraftAutosave ( ) {
2026-03-07 15:14:57 +00:00
if ( S . draftTimer ) { clearInterval ( S . draftTimer ) ; S . draftTimer = null ; }
2026-03-07 06:20:39 +00:00
}
async function saveDraft ( silent ) {
S . draftDirty = false ;
2026-03-08 11:48:27 +00:00
const accountId = parseInt ( document . getElementById ( 'compose-from' ) ? . value || 0 ) ;
if ( ! accountId ) { if ( ! silent ) toast ( 'Draft saved locally' , 'success' ) ; return ; }
const editor = document . getElementById ( 'compose-editor' ) ;
const meta = {
account _id : accountId ,
to : getTagValues ( 'compose-to' ) ,
subject : document . getElementById ( 'compose-subject' ) . value ,
body _html : editor . innerHTML . trim ( ) ,
body _text : editor . innerText . trim ( ) ,
} ;
const r = await api ( 'POST' , '/draft' , meta ) ;
2026-03-07 15:14:57 +00:00
if ( ! silent ) toast ( 'Draft saved' , 'success' ) ;
2026-03-08 11:48:27 +00:00
else if ( r ? . ok ) toast ( 'Draft auto-saved to server' , 'success' ) ;
2026-03-07 06:20:39 +00:00
}
// ── Compose formatting ─────────────────────────────────────────────────────
2026-03-07 15:14:57 +00:00
function execFmt ( cmd , val ) { document . getElementById ( 'compose-editor' ) . focus ( ) ; document . execCommand ( cmd , false , val || null ) ; }
2026-03-07 06:20:39 +00:00
function triggerAttach ( ) { document . getElementById ( 'compose-attach-input' ) . click ( ) ; }
2026-03-07 15:14:57 +00:00
function handleAttachFiles ( input ) { for ( const file of input . files ) composeAttachments . push ( { file , name : file . name , size : file . size } ) ; input . value = '' ; updateAttachList ( ) ; S . draftDirty = true ; }
2026-03-08 11:48:27 +00:00
function removeAttachment ( i ) {
// Don't remove EML forward placeholder (isForward) from UI; it's handled server-side
if ( composeAttachments [ i ] ? . isForward && S . composeMode === 'forward-attachment' ) {
toast ( 'The original message will be attached when sent' , 'info' ) ; return ;
}
composeAttachments . splice ( i , 1 ) ; updateAttachList ( ) ;
}
2026-03-07 06:20:39 +00:00
function updateAttachList ( ) {
const el = document . getElementById ( 'compose-attach-list' ) ;
2026-03-07 15:14:57 +00:00
if ( ! composeAttachments . length ) { el . innerHTML = '' ; return ; }
2026-03-07 06:20:39 +00:00
el . innerHTML = composeAttachments . map ( ( a , i ) => ` <div class="attachment-chip">
📎 < span > $ { esc ( a . name ) } < / s p a n >
2026-03-08 11:48:27 +00:00
< span style = "color:var(--muted);font-size:10px" > $ { a . size ? formatSize ( a . size ) : '' } < / s p a n >
2026-03-07 15:14:57 +00:00
< button onclick = "removeAttachment(${i})" class = "tag-remove" type = "button" > × < / b u t t o n >
2026-03-07 06:20:39 +00:00
< / d i v > ` ) . j o i n ( ' ' ) ;
}
2026-03-08 11:48:27 +00:00
// ── Compose drag-and-drop attachments ──────────────────────────────────────
function initComposeDragDrop ( ) {
const dialog = document . getElementById ( 'compose-dialog' ) ;
if ( ! dialog ) return ;
dialog . addEventListener ( 'dragover' , e => {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
dialog . classList . add ( 'drag-over' ) ;
} ) ;
dialog . addEventListener ( 'dragleave' , e => {
if ( ! dialog . contains ( e . relatedTarget ) ) dialog . classList . remove ( 'drag-over' ) ;
} ) ;
dialog . addEventListener ( 'drop' , e => {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
dialog . classList . remove ( 'drag-over' ) ;
if ( e . dataTransfer ? . files ? . length ) {
for ( const file of e . dataTransfer . files ) composeAttachments . push ( { file , name : file . name , size : file . size } ) ;
updateAttachList ( ) ; S . draftDirty = true ;
toast ( ` ${ e . dataTransfer . files . length } file(s) attached ` , 'success' ) ;
}
} ) ;
}
2026-03-07 06:20:39 +00:00
async function sendMessage ( ) {
const accountId = parseInt ( document . getElementById ( 'compose-from' ) ? . value || 0 ) ;
const to = getTagValues ( 'compose-to' ) ;
2026-03-07 15:14:57 +00:00
if ( ! accountId || ! to . length ) { toast ( 'From account and To address required' , 'error' ) ; return ; }
2026-03-07 06:20:39 +00:00
const editor = document . getElementById ( 'compose-editor' ) ;
2026-03-07 15:14:57 +00:00
const bodyHTML = editor . innerHTML . trim ( ) , bodyText = editor . innerText . trim ( ) ;
2026-03-07 06:20:39 +00:00
const btn = document . getElementById ( 'send-btn' ) ;
2026-03-07 15:14:57 +00:00
btn . disabled = true ; btn . textContent = 'Sending…' ;
2026-03-08 11:48:27 +00:00
const endpoint = S . composeMode === 'reply' ? '/reply'
: S . composeMode === 'forward' ? '/forward'
: S . composeMode === 'forward-attachment' ? '/forward-attachment'
: '/send' ;
const meta = {
2026-03-07 06:20:39 +00:00
account _id : accountId , to ,
cc : getTagValues ( 'compose-cc-tags' ) ,
bcc : getTagValues ( 'compose-bcc-tags' ) ,
subject : document . getElementById ( 'compose-subject' ) . value ,
body _text : bodyText , body _html : bodyHTML ,
in _reply _to _id : S . composeMode === 'reply' ? S . composeReplyToId : 0 ,
2026-03-08 11:48:27 +00:00
forward _from _id : ( S . composeMode === 'forward' || S . composeMode === 'forward-attachment' ) ? S . composeForwardFromId : 0 ,
} ;
let r ;
// Use FormData when there are real file attachments, OR when forwarding as attachment
// (server needs multipart so it can read forward_from_id from meta and fetch the EML itself)
const hasRealFiles = composeAttachments . some ( a => a . file instanceof Blob ) ;
const needsFormData = hasRealFiles || S . composeMode === 'forward-attachment' ;
if ( needsFormData ) {
const fd = new FormData ( ) ;
fd . append ( 'meta' , JSON . stringify ( meta ) ) ;
for ( const a of composeAttachments ) {
if ( a . file instanceof Blob ) { // only append real File/Blob objects
fd . append ( 'file' , a . file , a . name ) ;
}
// isForward placeholders are intentionally skipped — the EML is fetched server-side
}
try {
const resp = await fetch ( '/api' + endpoint , { method : 'POST' , body : fd } ) ;
r = await resp . json ( ) ;
} catch ( e ) { r = { error : String ( e ) } ; }
} else {
r = await api ( 'POST' , endpoint , meta ) ;
}
2026-03-07 15:14:57 +00:00
btn . disabled = false ; btn . textContent = 'Send' ;
if ( r ? . ok ) { toast ( 'Message sent!' , 'success' ) ; clearDraftAutosave ( ) ; _closeCompose ( ) ; }
2026-03-07 06:20:39 +00:00
else toast ( r ? . error || 'Send failed' , 'error' ) ;
}
2026-03-07 15:14:57 +00:00
// ── Compose drag + all-edge resize ─────────────────────────────────────────
2026-03-07 16:49:23 +00:00
function saveComposeGeometry ( dlg ) {
const r = dlg . getBoundingClientRect ( ) ;
document . cookie = ` compose_geo= ${ JSON . stringify ( { l : Math . round ( r . left ) , t : Math . round ( r . top ) , w : Math . round ( r . width ) , h : Math . round ( r . height ) } )};path=/;max-age=31536000 ` ;
}
function loadComposeGeometry ( dlg ) {
try {
const m = document . cookie . match ( /compose_geo=([^;]+)/ ) ;
if ( ! m ) return false ;
const g = JSON . parse ( decodeURIComponent ( m [ 1 ] ) ) ;
if ( ! g . w || ! g . h ) return false ;
const maxL = window . innerWidth - Math . max ( 360 , g . w ) ;
const maxT = window . innerHeight - Math . max ( 280 , g . h ) ;
dlg . style . left = Math . max ( 0 , Math . min ( g . l , maxL ) ) + 'px' ;
dlg . style . top = Math . max ( 0 , Math . min ( g . t , maxT ) ) + 'px' ;
dlg . style . width = Math . max ( 360 , g . w ) + 'px' ;
dlg . style . height = Math . max ( 280 , g . h ) + 'px' ;
dlg . style . right = 'auto' ; dlg . style . bottom = 'auto' ;
const editor = document . getElementById ( 'compose-editor' ) ;
if ( editor ) editor . style . height = ( Math . max ( 280 , g . h ) - 242 ) + 'px' ;
return true ;
} catch ( e ) { return false ; }
}
2026-03-07 15:14:57 +00:00
function initComposeDragResize ( ) {
const dlg = document . getElementById ( 'compose-dialog' ) ;
if ( ! dlg ) return ;
2026-03-07 16:49:23 +00:00
// Restore saved position/size, or fall back to default bottom-right
if ( ! loadComposeGeometry ( dlg ) ) {
dlg . style . right = '24px' ; dlg . style . bottom = '20px' ;
dlg . style . left = 'auto' ; dlg . style . top = 'auto' ;
}
2026-03-07 15:14:57 +00:00
// Drag by header
const header = document . getElementById ( 'compose-drag-handle' ) ;
if ( header ) {
let ox , oy , startL , startT ;
header . addEventListener ( 'mousedown' , e => {
if ( e . target . closest ( 'button' ) ) return ;
const r = dlg . getBoundingClientRect ( ) ;
ox = e . clientX ; oy = e . clientY ; startL = r . left ; startT = r . top ;
dlg . style . left = startL + 'px' ; dlg . style . top = startT + 'px' ;
dlg . style . right = 'auto' ; dlg . style . bottom = 'auto' ;
const mm = ev => {
dlg . style . left = Math . max ( 0 , Math . min ( window . innerWidth - dlg . offsetWidth , startL + ( ev . clientX - ox ) ) ) + 'px' ;
dlg . style . top = Math . max ( 0 , Math . min ( window . innerHeight - 30 , startT + ( ev . clientY - oy ) ) ) + 'px' ;
} ;
2026-03-07 16:49:23 +00:00
const mu = ( ) => { document . removeEventListener ( 'mousemove' , mm ) ; document . removeEventListener ( 'mouseup' , mu ) ; saveComposeGeometry ( dlg ) ; } ;
2026-03-07 15:14:57 +00:00
document . addEventListener ( 'mousemove' , mm ) ;
document . addEventListener ( 'mouseup' , mu ) ;
e . preventDefault ( ) ;
} ) ;
2026-03-07 06:20:39 +00:00
}
2026-03-07 15:14:57 +00:00
// Resize handles
dlg . querySelectorAll ( '.compose-resize' ) . forEach ( handle => {
const dir = handle . dataset . dir ;
handle . addEventListener ( 'mousedown' , e => {
const rect = dlg . getBoundingClientRect ( ) ;
const startX = e . clientX , startY = e . clientY ;
const startW = rect . width , startH = rect . height , startL = rect . left , startT = rect . top ;
const mm = ev => {
let w = startW , h = startH , l = startL , t = startT ;
const dx = ev . clientX - startX , dy = ev . clientY - startY ;
if ( dir . includes ( 'e' ) ) w = Math . max ( 360 , startW + dx ) ;
if ( dir . includes ( 'w' ) ) { w = Math . max ( 360 , startW - dx ) ; l = startL + startW - w ; }
if ( dir . includes ( 's' ) ) h = Math . max ( 280 , startH + dy ) ;
if ( dir . includes ( 'n' ) ) { h = Math . max ( 280 , startH - dy ) ; t = startT + startH - h ; }
dlg . style . width = w + 'px' ; dlg . style . height = h + 'px' ;
dlg . style . left = l + 'px' ; dlg . style . top = t + 'px' ;
dlg . style . right = 'auto' ; dlg . style . bottom = 'auto' ;
const editor = document . getElementById ( 'compose-editor' ) ;
if ( editor ) editor . style . height = ( h - 242 ) + 'px' ;
} ;
2026-03-07 16:49:23 +00:00
const mu = ( ) => { document . removeEventListener ( 'mousemove' , mm ) ; document . removeEventListener ( 'mouseup' , mu ) ; saveComposeGeometry ( dlg ) ; } ;
2026-03-07 15:14:57 +00:00
document . addEventListener ( 'mousemove' , mm ) ;
document . addEventListener ( 'mouseup' , mu ) ;
e . preventDefault ( ) ;
} ) ;
} ) ;
2026-03-07 06:20:39 +00:00
}
// ── Settings ───────────────────────────────────────────────────────────────
async function openSettings ( ) {
openModal ( 'settings-modal' ) ;
loadSyncInterval ( ) ;
renderMFAPanel ( ) ;
2026-03-08 17:54:13 +00:00
loadIPRules ( ) ;
// Pre-fill profile fields with current values
const me = await api ( 'GET' , '/me' ) ;
if ( me ) {
document . getElementById ( 'profile-username' ) . placeholder = me . username || 'New username' ;
document . getElementById ( 'profile-email' ) . placeholder = me . email || 'New email' ;
}
}
async function updateProfile ( field ) {
const value = document . getElementById ( 'profile-' + field ) . value . trim ( ) ;
const password = document . getElementById ( 'profile-confirm-pw' ) . value ;
if ( ! value ) { toast ( 'Please enter a new ' + field , 'error' ) ; return ; }
if ( ! password ) { toast ( 'Current password required to confirm changes' , 'error' ) ; return ; }
const r = await api ( 'PUT' , '/profile' , { field , value , password } ) ;
if ( r ? . ok ) {
toast ( field . charAt ( 0 ) . toUpperCase ( ) + field . slice ( 1 ) + ' updated' , 'success' ) ;
document . getElementById ( 'profile-' + field ) . value = '' ;
document . getElementById ( 'profile-confirm-pw' ) . value = '' ;
} else {
toast ( r ? . error || 'Update failed' , 'error' ) ;
}
2026-03-07 06:20:39 +00:00
}
async function loadSyncInterval ( ) {
const r = await api ( 'GET' , '/sync-interval' ) ;
2026-03-07 15:14:57 +00:00
if ( r ) document . getElementById ( 'sync-interval-select' ) . value = String ( r . sync _interval || 15 ) ;
2026-03-07 06:20:39 +00:00
}
async function saveSyncInterval ( ) {
const val = parseInt ( document . getElementById ( 'sync-interval-select' ) . value ) || 0 ;
const r = await api ( 'PUT' , '/sync-interval' , { sync _interval : val } ) ;
2026-03-07 15:14:57 +00:00
if ( r ? . ok ) toast ( 'Sync interval saved' , 'success' ) ; else toast ( 'Failed' , 'error' ) ;
2026-03-07 06:20:39 +00:00
}
async function changePassword ( ) {
const cur = document . getElementById ( 'cur-pw' ) . value , nw = document . getElementById ( 'new-pw' ) . value ;
2026-03-07 15:14:57 +00:00
if ( ! cur || ! nw ) { toast ( 'Both fields required' , 'error' ) ; return ; }
2026-03-07 06:20:39 +00:00
const r = await api ( 'POST' , '/change-password' , { current _password : cur , new _password : nw } ) ;
2026-03-07 15:14:57 +00:00
if ( r ? . ok ) { toast ( 'Password updated' , 'success' ) ; document . getElementById ( 'cur-pw' ) . value = '' ; document . getElementById ( 'new-pw' ) . value = '' ; }
2026-03-07 06:20:39 +00:00
else toast ( r ? . error || 'Failed' , 'error' ) ;
}
async function renderMFAPanel ( ) {
2026-03-07 15:14:57 +00:00
const me = await api ( 'GET' , '/me' ) ; if ( ! me ) return ;
2026-03-07 06:20:39 +00:00
const badge = document . getElementById ( 'mfa-badge' ) , panel = document . getElementById ( 'mfa-panel' ) ;
2026-03-07 15:14:57 +00:00
if ( me . mfa _enabled ) {
2026-03-07 06:20:39 +00:00
badge . innerHTML = '<span class="badge green">Enabled</span>' ;
panel . innerHTML = ` <p style="font-size:13px;color:var(--muted);margin-bottom:12px">TOTP active. Enter code to disable.</p>
< div class = "modal-field" > < label > Code < / l a b e l > < i n p u t t y p e = " t e x t " i d = " m f a - c o d e " p l a c e h o l d e r = " 0 0 0 0 0 0 " m a x l e n g t h = " 6 " i n p u t m o d e = " n u m e r i c " > < / d i v >
< button class = "btn-danger" onclick = "disableMFA()" > Disable MFA < / b u t t o n > ` ;
} else {
badge . innerHTML = '<span class="badge red">Disabled</span>' ;
panel . innerHTML = '<button class="btn-primary" onclick="beginMFASetup()">Set up Authenticator App</button>' ;
}
}
async function beginMFASetup ( ) {
2026-03-07 15:14:57 +00:00
const r = await api ( 'POST' , '/mfa/setup' ) ; if ( ! r ) return ;
2026-03-07 06:20:39 +00:00
document . getElementById ( 'mfa-panel' ) . innerHTML = `
< p style = "font-size:13px;color:var(--muted);margin-bottom:12px" > Scan with your authenticator app . < / p >
< div style = "text-align:center;margin-bottom:14px" > < img src = "${r.qr_url}" style = "border-radius:8px;background:white;padding:8px" > < / d i v >
< p style = "font-size:11px;color:var(--muted);margin-bottom:12px;word-break:break-all" > Key : < strong > $ { r . secret } < / s t r o n g > < / p >
< div class = "modal-field" > < label > Confirm code < / l a b e l > < i n p u t t y p e = " t e x t " i d = " m f a - c o d e " p l a c e h o l d e r = " 0 0 0 0 0 0 " m a x l e n g t h = " 6 " i n p u t m o d e = " n u m e r i c " > < / d i v >
< button class = "btn-primary" onclick = "confirmMFASetup()" > Activate MFA < / b u t t o n > ` ;
}
async function confirmMFASetup ( ) {
const r = await api ( 'POST' , '/mfa/confirm' , { code : document . getElementById ( 'mfa-code' ) . value } ) ;
2026-03-07 15:14:57 +00:00
if ( r ? . ok ) { toast ( 'MFA enabled' , 'success' ) ; renderMFAPanel ( ) ; } else toast ( r ? . error || 'Invalid code' , 'error' ) ;
2026-03-07 06:20:39 +00:00
}
async function disableMFA ( ) {
const r = await api ( 'POST' , '/mfa/disable' , { code : document . getElementById ( 'mfa-code' ) . value } ) ;
2026-03-07 15:14:57 +00:00
if ( r ? . ok ) { toast ( 'MFA disabled' , 'success' ) ; renderMFAPanel ( ) ; } else toast ( r ? . error || 'Invalid code' , 'error' ) ;
2026-03-07 06:20:39 +00:00
}
2026-03-08 17:54:13 +00:00
async function loadIPRules ( ) {
const r = await api ( 'GET' , '/ip-rules' ) ;
if ( ! r ) return ;
document . getElementById ( 'ip-rule-mode' ) . value = r . mode || 'disabled' ;
document . getElementById ( 'ip-rule-list' ) . value = r . ip _list || '' ;
toggleIPRuleHelp ( ) ;
}
function toggleIPRuleHelp ( ) {
const mode = document . getElementById ( 'ip-rule-mode' ) . value ;
const helpEl = document . getElementById ( 'ip-rule-help' ) ;
const listField = document . getElementById ( 'ip-rule-list-field' ) ;
const helps = {
disabled : '' ,
brute _skip : 'IPs in the list below will never be locked out of your account, even after many failed attempts. All other IPs are subject to global brute-force protection.' ,
allow _only : '⚠ Only IPs in the list below will be able to log into your account. All other IPs will see an "Access not authorized" error. Make sure to include your current IP before saving.' ,
} ;
helpEl . textContent = helps [ mode ] || '' ;
helpEl . style . display = mode !== 'disabled' ? 'block' : 'none' ;
listField . style . display = mode !== 'disabled' ? 'block' : 'none' ;
}
async function saveIPRules ( ) {
const mode = document . getElementById ( 'ip-rule-mode' ) . value ;
const ip _list = document . getElementById ( 'ip-rule-list' ) . value . trim ( ) ;
if ( mode !== 'disabled' && ! ip _list ) {
toast ( 'Please enter at least one IP address' , 'error' ) ; return ;
}
const r = await api ( 'PUT' , '/ip-rules' , { mode , ip _list } ) ;
if ( r ? . ok ) toast ( 'IP rules saved' , 'success' ) ;
else toast ( r ? . error || 'Save failed' , 'error' ) ;
}
2026-03-07 06:20:39 +00:00
async function doLogout ( ) { await fetch ( '/auth/logout' , { method : 'POST' } ) ; location . href = '/auth/login' ; }
// ── Context menu helper ────────────────────────────────────────────────────
function showCtxMenu ( e , html ) {
const menu = document . getElementById ( 'ctx-menu' ) ;
menu . innerHTML = html ; menu . classList . add ( 'open' ) ;
requestAnimationFrame ( ( ) => {
menu . style . left = Math . min ( e . clientX , window . innerWidth - menu . offsetWidth - 8 ) + 'px' ;
menu . style . top = Math . min ( e . clientY , window . innerHeight - menu . offsetHeight - 8 ) + 'px' ;
} ) ;
}
2026-03-07 16:49:23 +00:00
// ── Init tag fields and filter dropdown ───────────────────────────────────
// app.js loads at the bottom of <body> so the DOM is already ready here —
// we must NOT wrap in DOMContentLoaded (that event has already fired).
function _bootApp ( ) {
2026-03-07 06:20:39 +00:00
initTagField ( 'compose-to' ) ;
initTagField ( 'compose-cc-tags' ) ;
initTagField ( 'compose-bcc-tags' ) ;
2026-03-07 16:49:23 +00:00
// Filter dropdown
const dropBtn = document . getElementById ( 'filter-dropdown-btn' ) ;
const dropMenu = document . getElementById ( 'filter-dropdown-menu' ) ;
if ( dropBtn && dropMenu ) {
dropBtn . addEventListener ( 'click' , e => {
e . stopPropagation ( ) ;
const isOpen = dropMenu . classList . contains ( 'open' ) ;
dropMenu . classList . toggle ( 'open' , ! isOpen ) ;
if ( ! isOpen ) {
document . addEventListener ( 'click' , ( ) => dropMenu . classList . remove ( 'open' ) , { once : true } ) ;
}
} ) ;
[ 'default' , 'unread' , 'date-desc' , 'date-asc' , 'size-desc' ] . forEach ( mode => {
const el = document . getElementById ( 'fopt-' + mode ) ;
if ( el ) el . addEventListener ( 'click' , e => { e . stopPropagation ( ) ; setFilter ( mode ) ; } ) ;
} ) ;
}
2026-03-07 15:14:57 +00:00
init ( ) ;
2026-03-07 16:49:23 +00:00
}
// Run immediately — DOM is ready since this script is at end of <body>
_bootApp ( ) ;
2026-03-08 06:06:38 +00:00
// ── Real-time poller + notifications ────────────────────────────────────────
// Polls /api/poll every 20s for unread count changes and new message detection.
// When new messages arrive: updates badge instantly, shows corner toast,
// and fires a browser OS notification if permission granted.
const POLLER = {
lastKnownID : 0 , // highest message ID we've seen
timer : null ,
active : false ,
notifGranted : false ,
} ;
async function startPoller ( ) {
// Request browser notification permission (non-blocking)
if ( 'Notification' in window && Notification . permission === 'default' ) {
Notification . requestPermission ( ) . then ( p => {
POLLER . notifGranted = p === 'granted' ;
} ) ;
} else if ( 'Notification' in window ) {
POLLER . notifGranted = Notification . permission === 'granted' ;
}
POLLER . active = true ;
schedulePoll ( ) ;
}
function schedulePoll ( ) {
if ( ! POLLER . active ) return ;
POLLER . timer = setTimeout ( runPoll , 20000 ) ; // 20 second interval
}
async function runPoll ( ) {
if ( ! POLLER . active ) return ;
try {
const data = await api ( 'GET' , '/poll?since=' + POLLER . lastKnownID ) ;
if ( ! data ) { schedulePoll ( ) ; return ; }
// Update badge immediately without full loadFolders()
updateUnreadBadgeFromPoll ( data . inbox _unread ) ;
// New messages arrived
if ( data . has _new && data . newest _id > POLLER . lastKnownID ) {
const prevID = POLLER . lastKnownID ;
POLLER . lastKnownID = data . newest _id ;
// Fetch new message details for notifications
const newData = await api ( 'GET' , '/new-messages?since=' + prevID ) ;
const newMsgs = newData ? . messages || [ ] ;
if ( newMsgs . length > 0 ) {
showNewMailToast ( newMsgs ) ;
sendOSNotification ( newMsgs ) ;
}
// Refresh current view if we're looking at inbox/unified
const isInboxView = S . currentFolder === 'unified' ||
S . folders . find ( f => f . id === S . currentFolder && f . folder _type === 'inbox' ) ;
if ( isInboxView ) {
await loadMessages ( ) ;
await loadFolders ( ) ;
} else {
await loadFolders ( ) ; // update counts in sidebar
}
}
} catch ( e ) {
// Network error — silent, retry next cycle
}
schedulePoll ( ) ;
}
// Update the unread badge in the sidebar and browser tab title
// without triggering a full folder reload
function updateUnreadBadgeFromPoll ( inboxUnread ) {
const badge = document . getElementById ( 'unread-total' ) ;
if ( ! badge ) return ;
if ( inboxUnread > 0 ) {
badge . textContent = inboxUnread > 99 ? '99+' : inboxUnread ;
badge . style . display = '' ;
} else {
badge . style . display = 'none' ;
}
// Update browser tab title
2026-03-08 11:48:27 +00:00
const base = 'GoWebMail' ;
2026-03-08 06:06:38 +00:00
document . title = inboxUnread > 0 ? ` ( ${ inboxUnread } ) ${ base } ` : base ;
}
// Corner toast notification for new mail
function showNewMailToast ( msgs ) {
const existing = document . getElementById ( 'newmail-toast' ) ;
if ( existing ) existing . remove ( ) ;
const count = msgs . length ;
const first = msgs [ 0 ] ;
const fromLabel = first . from _name || first . from _email || 'Unknown' ;
const subject = first . subject || '(no subject)' ;
const text = count === 1
? ` <strong> ${ escHtml ( fromLabel ) } </strong><br><span> ${ escHtml ( subject ) } </span> `
: ` <strong> ${ count } new messages</strong><br><span> ${ escHtml ( fromLabel ) } : ${ escHtml ( subject ) } </span> ` ;
const el = document . createElement ( 'div' ) ;
el . id = 'newmail-toast' ;
el . className = 'newmail-toast' ;
el . innerHTML = `
< div class = "newmail-toast-icon" > ✉ < / d i v >
< div class = "newmail-toast-body" > $ { text } < / d i v >
< button class = "newmail-toast-close" onclick = "this.parentElement.remove()" > ✕ < / b u t t o n > ` ;
// Click to open the message
el . addEventListener ( 'click' , ( e ) => {
if ( e . target . classList . contains ( 'newmail-toast-close' ) ) return ;
el . remove ( ) ;
if ( count === 1 ) {
selectFolder (
S . folders . find ( f => f . folder _type === 'inbox' ) ? . id || 'unified' ,
'Inbox'
) ;
setTimeout ( ( ) => openMessage ( first . id ) , 400 ) ;
} else {
selectFolder ( 'unified' , 'Unified Inbox' ) ;
}
} ) ;
document . body . appendChild ( el ) ;
// Auto-dismiss after 6s
setTimeout ( ( ) => { if ( el . parentElement ) el . remove ( ) ; } , 6000 ) ;
}
function escHtml ( s ) {
return String ( s || '' ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ;
}
// OS / browser notification
function sendOSNotification ( msgs ) {
if ( ! POLLER . notifGranted || ! ( 'Notification' in window ) ) return ;
const count = msgs . length ;
const first = msgs [ 0 ] ;
const title = count === 1
? ( first . from _name || first . from _email || 'New message' )
2026-03-08 11:48:27 +00:00
: ` ${ count } new messages in GoWebMail ` ;
2026-03-08 06:06:38 +00:00
const body = count === 1
? ( first . subject || '(no subject)' )
: ` ${ first . from _name || first . from _email } : ${ first . subject || '(no subject)' } ` ;
try {
const n = new Notification ( title , {
body ,
icon : '/static/icons/icon-192.png' , // use if you have one, else falls back gracefully
tag : 'gowebmail-new' , // replaces previous if still visible
} ) ;
n . onclick = ( ) => {
window . focus ( ) ;
n . close ( ) ;
if ( count === 1 ) {
selectFolder ( S . folders . find ( f => f . folder _type === 'inbox' ) ? . id || 'unified' , 'Inbox' ) ;
setTimeout ( ( ) => openMessage ( first . id ) , 400 ) ;
}
} ;
// Auto-close OS notification after 8s
setTimeout ( ( ) => n . close ( ) , 8000 ) ;
} catch ( e ) {
// Some browsers block even with granted permission in certain contexts
}
}