2026-03-07 06:20:39 +00:00
// GoMail app.js — full client
// ── 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 ,
searchQuery : '' , composeMode : 'new' , composeReplyToId : null ,
remoteWhitelist : new Set ( ) ,
draftTimer : null , draftDirty : false ,
} ;
// ── 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 ( me . compose _popup ) document . getElementById ( 'compose-popup-toggle' ) . checked = true ;
}
if ( providers ) { S . providers = providers ; updateProviderButtons ( ) ; }
if ( wl ? . whitelist ) S . remoteWhitelist = new Set ( wl . whitelist ) ;
2026-03-07 09:33:42 +00:00
await loadAccounts ( ) ; // must complete before loadFolders so colors are available
await loadFolders ( ) ;
2026-03-07 06:20:39 +00:00
await loadMessages ( ) ;
const p = new URLSearchParams ( location . search ) ;
if ( p . get ( 'connected' ) ) { toast ( 'Account connected!' , 'success' ) ; history . replaceState ( { } , '' , ' /' ) ; }
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 ( ) ; }
} ) ;
// Resizable compose
initComposeResize ( ) ;
}
// ── Providers ──────────────────────────────────────────────────────────────
function updateProviderButtons ( ) {
[ 'gmail' , 'outlook' ] . forEach ( p => {
const btn = document . getElementById ( 'btn-' + p ) ;
if ( ! S . providers [ p ] ) { btn . disabled = true ; btn . classList . add ( 'unavailable' ) ; btn . title = p + ' OAuth not configured' ; }
} ) ;
}
// ── Accounts ───────────────────────────────────────────────────────────────
async function loadAccounts ( ) {
const data = await api ( 'GET' , '/accounts' ) ;
if ( ! data ) return ;
S . accounts = data ;
renderAccounts ( ) ;
populateComposeFrom ( ) ;
}
function renderAccounts ( ) {
const el = document . getElementById ( 'accounts-list' ) ;
el . innerHTML = S . accounts . map ( a => `
< div class = "account-item" oncontextmenu = "showAccountMenu(event,${a.id})"
title = "${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}" >
< div class = "account-dot" style = "background:${a.color}" > < / d i v >
< span class = "account-email" > $ { esc ( a . email _address ) } < / s p a n >
$ { a . last _error ? '<div class="account-error-dot"></div>' : '' }
< button onclick = "syncNow(${a.id},event)" id = "sync-btn-${a.id}" class = "icon-sync-btn" title = "Sync now" >
< svg width = "12" height = "12" 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 >
< / d i v > ` ) . j o i n ( ' ' ) ;
}
function showAccountMenu ( e , id ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
const a = S . accounts . find ( a => a . id === id ) ;
showCtxMenu ( e , `
< div class = "ctx-item" onclick = "syncNow(${id});closeMenu()" > ↻ Sync now < / d i v >
< div class = "ctx-item" onclick = "openEditAccount(${id},true);closeMenu()" > ⚡ Test connection < / d i v >
< div class = "ctx-item" onclick = "openEditAccount(${id});closeMenu()" > ✎ Edit credentials < / d i v >
$ { a ? . last _error ? ` <div class="ctx-item" onclick="toast(' ${ esc ( a . last _error ) } ','error');closeMenu()">⚠ View last error</div> ` : '' }
< div class = "ctx-sep" > < / d i v >
< div class = "ctx-item danger" onclick = "deleteAccount(${id});closeMenu()" > 🗑 Remove account < / d i v > ` ) ;
}
async function syncNow ( id , e ) {
if ( e ) e . stopPropagation ( ) ;
const btn = document . getElementById ( 'sync-btn-' + id ) ;
if ( btn ) { btn . style . opacity = '0.3' ; btn . style . pointerEvents = 'none' ; }
const r = await api ( 'POST' , '/accounts/' + id + '/sync' ) ;
if ( btn ) { btn . style . opacity = '' ; btn . style . pointerEvents = '' ; }
if ( r ? . ok ) { toast ( 'Synced ' + ( r . synced || 0 ) + ' messages' , 'success' ) ; loadAccounts ( ) ; loadFolders ( ) ; loadMessages ( ) ; }
else toast ( r ? . error || 'Sync failed' , 'error' ) ;
}
function connectOAuth ( p ) { location . href = '/auth/' + p + '/connect' ; }
// ── Add Account modal ──────────────────────────────────────────────────────
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' ; }
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' ;
if ( r ? . ok ) { toast ( 'Account added!' , 'success' ) ; closeModal ( 'add-account-modal' ) ; loadAccounts ( ) ; loadFolders ( ) ; loadMessages ( ) ; }
else toast ( r ? . error || 'Failed to add account' , 'error' ) ;
}
// ── Edit Account modal ─────────────────────────────────────────────────────
async function openEditAccount ( id , testAfterOpen ) {
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 ;
// Sync settings
document . getElementById ( 'edit-sync-mode' ) . value = r . sync _mode || 'days' ;
document . getElementById ( 'edit-sync-days' ) . value = r . sync _days || 30 ;
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 ;
openModal ( 'edit-account-modal' ) ;
if ( testAfterOpen ) setTimeout ( testEditConnection , 200 ) ;
}
function toggleSyncDaysField ( ) {
const mode = document . getElementById ( 'edit-sync-mode' ) ? . value ;
const row = document . getElementById ( 'edit-sync-days-row' ) ;
if ( row ) row . style . display = ( mode === 'all' ) ? 'none' : 'flex' ;
}
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 ;
const [ r1 , r2 ] = await Promise . all ( [
api ( 'PUT' , '/accounts/' + id , body ) ,
api ( 'PUT' , '/accounts/' + id + '/sync-settings' , {
sync _mode : document . getElementById ( 'edit-sync-mode' ) . value ,
sync _days : parseInt ( document . getElementById ( 'edit-sync-days' ) . value ) || 30 ,
} ) ,
] ) ;
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 ) ;
if ( ! confirm ( 'Remove ' + ( a ? a . email _address : id ) + '?\nAll synced messages will be deleted.' ) ) return ;
const r = await api ( 'DELETE' , '/accounts/' + id ) ;
if ( r ? . ok ) { toast ( 'Account removed' , 'success' ) ; loadAccounts ( ) ; loadFolders ( ) ; loadMessages ( ) ; }
else toast ( 'Remove failed' , 'error' ) ;
}
// ── 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 = { } ;
S . folders . forEach ( f => { ( byAcc [ f . account _id ] = byAcc [ f . account _id ] || [ ] ) . push ( f ) ; } ) ;
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 >
$ { esc ( accEmail ) }
2026-03-07 06:20:39 +00:00
< / d i v > ` + s o r t e d . m a p ( f = > `
< div class = "nav-item" id = "nav-f${f.id}" onclick = "selectFolder(${f.id},'${esc(f.name)}')"
oncontextmenu = "showFolderMenu(event,${f.id},${acc.id})" >
< 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> ` : '' }
< / d i v > ` ) . j o i n ( ' ' ) ;
} ) . join ( '' ) ;
}
function showFolderMenu ( e , folderId , accountId ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
showCtxMenu ( e , `
< div class = "ctx-item" onclick = "syncFolderNow(${folderId});closeMenu()" > ↻ Sync this folder < / d i v >
< div class = "ctx-item" onclick = "selectFolder(${folderId});closeMenu()" > 📂 Open folder < / d i v > ` ) ;
}
async function syncFolderNow ( folderId ) {
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' ) ;
}
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 ` ) ;
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' : '' ;
}
function renderMessageList ( ) {
const list = document . getElementById ( 'message-list' ) ;
if ( ! S . messages . length ) {
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>No messages</p></div> ` ;
return ;
}
list . innerHTML = S . messages . map ( m => `
< div class = "message-item ${m.id===S.selectedMessageId?'active':''} ${!m.is_read?'unread':''}"
onclick = "openMessage(${m.id})" oncontextmenu = "showMessageMenu(event,${m.id})" >
< 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 >
$ { 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> ` : '' ) ;
}
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 ) ;
if ( li && ! li . is _read ) { li . is _read = true ; renderMessageList ( ) ; }
}
function renderMessageDetail ( msg , showRemoteContent ) {
const detail = document . getElementById ( 'message-detail' ) ;
const allowed = showRemoteContent || S . remoteWhitelist . has ( msg . from _email ) ;
let bodyHtml = '' ;
if ( msg . body _html ) {
if ( allowed ) {
bodyHtml = ` <iframe id="msg-frame" sandbox="allow-same-origin allow-popups"
style = "width:100%;border:none;min-height:300px;display:block"
srcdoc = "${msg.body_html.replace(/" / g , '"' ) } " > < / i f r a m e > ` ;
} else {
bodyHtml = ` <div class="remote-content-banner">
< svg viewBox = "0 0 24 24" width = "14" height = "14" fill = "currentColor" > < 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" / > < / s v g >
Remote images blocked .
< button class = "rcb-btn" onclick = "renderMessageDetail(S.currentMessage,true)" > Load content < / b u t t o n >
< 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 >
< div class = "detail-body-text" > $ { esc ( msg . body _text || '(empty)' ) } < / d i v > ` ;
}
} else {
bodyHtml = ` <div class="detail-body-text"> ${ esc ( msg . body _text || '(empty)' ) } </div> ` ;
}
let attachHtml = '' ;
if ( msg . attachments ? . length ) {
attachHtml = ` <div class="attachments-bar">
< span style = "font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:8px" > Attachments < / s p a n >
$ { msg . attachments . map ( a => ` <div class="attachment-chip">
📎 < span > $ { esc ( a . filename ) } < / s p a n >
< span style = "color:var(--muted);font-size:10px" > $ { formatSize ( a . size ) } < / s p a n >
< / d i v > ` ) . j o i n ( ' ' ) }
< / d i v > ` ;
}
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 >
< 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 >
< 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 > ` ;
if ( msg . body _html && allowed ) {
const frame = document . getElementById ( 'msg-frame' ) ;
if ( frame ) frame . onload = ( ) => { try { const h = frame . contentDocument . documentElement . scrollHeight ; frame . style . height = ( h + 30 ) + 'px' ; } catch ( e ) { } } ;
}
}
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 ( '' ) ;
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'modal-overlay open' ;
overlay . innerHTML = ` <div class="modal" style="width:600px;max-height:80vh;overflow-y:auto">
< 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 >
< table style = "width:100%" > < tbody > $ { rows } < / t b o d y > < / t a b l e >
< / d i v > ` ;
overlay . addEventListener ( 'click' , e => { if ( e . target === overlay ) overlay . remove ( ) ; } ) ;
document . body . appendChild ( overlay ) ;
}
function showMessageMenu ( e , id ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
const moveFolders = S . folders . slice ( 0 , 8 ) . map ( f => ` <div class="ctx-item" onclick="moveMessage( ${ id } , ${ f . id } );closeMenu()"> ${ esc ( f . name ) } </div> ` ) . join ( '' ) ;
showCtxMenu ( e , `
< div class = "ctx-item" onclick = "openReplyTo(${id});closeMenu()" > ↩ Reply < / d i v >
< div class = "ctx-item" onclick = "toggleStar(${id});closeMenu()" > ★ Toggle star < / d i v >
< div class = "ctx-item" onclick = "showMessageHeaders(${id});closeMenu()" > ⋮ View headers < / d i v >
$ { moveFolders ? ` <div class="ctx-sep"></div><div style="font-size:10px;color:var(--muted);padding:4px 12px;text-transform:uppercase;letter-spacing:.8px">Move to</div> ${ moveFolders } ` : '' }
< 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 ( ) ;
}
async function moveMessage ( msgId , folderId ) {
const r = await api ( 'PUT' , '/messages/' + msgId + '/move' , { folder _id : folderId } ) ;
if ( r ? . ok ) { toast ( 'Moved' , 'success' ) ; S . messages = S . messages . filter ( m => m . id !== msgId ) ; renderMessageList ( ) ;
if ( S . currentMessage ? . id === msgId ) resetDetail ( ) ; loadFolders ( ) ; }
}
async function deleteMessage ( id ) {
if ( ! confirm ( 'Delete this message?' ) ) return ;
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 ( ) ; }
}
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' ;
document . getElementById ( 'compose-to' ) . innerHTML = '' ;
document . getElementById ( 'compose-cc-tags' ) . innerHTML = '' ;
document . getElementById ( 'compose-bcc-tags' ) . innerHTML = '' ;
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 ( ) ;
if ( S . me ? . compose _popup ) {
openComposePopup ( ) ;
} else {
document . getElementById ( 'compose-overlay' ) . classList . add ( 'open' ) ;
// Focus the To field's input
setTimeout ( ( ) => { const inp = document . querySelector ( '#compose-to .tag-input' ) ; if ( inp ) inp . focus ( ) ; } , 50 ) ;
}
startDraftAutosave ( ) ;
}
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> ` ,
} ) ;
// Pre-fill To
addTag ( 'compose-to' , msg . from _email || '' ) ;
}
function openForward ( ) {
if ( ! S . currentMessage ) return ;
const msg = S . currentMessage ;
openCompose ( {
mode : 'forward' , title : 'Forward' ,
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> ` ,
} ) ;
}
function closeCompose ( skipDraftCheck ) {
if ( ! skipDraftCheck && S . draftDirty ) {
const choice = confirm ( 'Save draft before closing?' ) ;
if ( choice ) { saveDraft ( ) ; return ; }
}
clearDraftAutosave ( ) ;
if ( S . me ? . compose _popup ) {
const win = window . _composeWin ;
if ( win && ! win . closed ) win . close ( ) ;
} else {
document . getElementById ( 'compose-overlay' ) . classList . remove ( 'open' ) ;
}
S . draftDirty = false ;
}
// ── Email Tag Input ────────────────────────────────────────────────────────
function initTagField ( containerId ) {
const container = document . getElementById ( containerId ) ;
if ( ! container ) return ;
const inp = document . createElement ( 'input' ) ;
inp . type = 'text' ; inp . className = 'tag-input' ; inp . placeholder = containerId === 'compose-to' ? 'recipient@example.com' : '' ;
container . appendChild ( inp ) ;
inp . addEventListener ( 'keydown' , e => {
if ( ( e . key === ' ' || e . key === 'Enter' || e . key === ',' || e . key === ';' ) && inp . value . trim ( ) ) {
e . preventDefault ( ) ;
addTag ( containerId , inp . value . trim ( ) . replace ( /[,;]$/ , '' ) ) ;
inp . value = '' ;
} else if ( e . key === 'Backspace' && ! inp . value ) {
const tags = container . querySelectorAll ( '.email-tag' ) ;
if ( tags . length ) tags [ tags . length - 1 ] . remove ( ) ;
}
} ) ;
inp . addEventListener ( 'blur' , ( ) => {
if ( inp . value . trim ( ) ) { addTag ( containerId , inp . value . trim ( ) ) ; inp . value = '' ; }
} ) ;
container . addEventListener ( 'click' , ( ) => inp . focus ( ) ) ;
}
function addTag ( containerId , value ) {
if ( ! value ) return ;
const container = document . getElementById ( containerId ) ;
if ( ! container ) return ;
// Basic email validation
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ . test ( value ) ;
const tag = document . createElement ( 'span' ) ;
tag . className = 'email-tag' + ( isValid ? '' : ' invalid' ) ;
tag . textContent = value ;
const remove = document . createElement ( 'button' ) ;
remove . innerHTML = '× ' ; remove . className = 'tag-remove' ;
remove . onclick = e => { e . stopPropagation ( ) ; tag . remove ( ) ; S . draftDirty = true ; } ;
tag . appendChild ( remove ) ;
const inp = container . querySelector ( '.tag-input' ) ;
container . insertBefore ( tag , inp ) ;
S . draftDirty = true ;
}
function getTagValues ( containerId ) {
return Array . from ( document . querySelectorAll ( '#' + containerId + ' .email-tag' ) )
. map ( t => t . textContent . replace ( '× ' , '' ) . trim ( ) ) . filter ( Boolean ) ;
}
// ── Draft autosave ─────────────────────────────────────────────────────────
function startDraftAutosave ( ) {
clearDraftAutosave ( ) ;
S . draftTimer = setInterval ( ( ) => {
if ( S . draftDirty ) saveDraft ( true ) ;
} , 60000 ) ; // every 60s
// Mark dirty on any edit
const editor = document . getElementById ( 'compose-editor' ) ;
if ( editor ) editor . oninput = ( ) => S . draftDirty = true ;
[ 'compose-subject' ] . forEach ( id => {
const el = document . getElementById ( id ) ;
if ( el ) el . oninput = ( ) => S . draftDirty = true ;
} ) ;
}
function clearDraftAutosave ( ) {
if ( S . draftTimer ) { clearInterval ( S . draftTimer ) ; S . draftTimer = null ; }
}
async function saveDraft ( silent ) {
const accountId = parseInt ( document . getElementById ( 'compose-from' ) ? . value || 0 ) ;
if ( ! accountId ) return ;
const to = getTagValues ( 'compose-to' ) ;
const editor = document . getElementById ( 'compose-editor' ) ;
// For now save as a local note — a real IMAP APPEND to Drafts would be ideal
// but for MVP we just suppress the dirty flag and toast
S . draftDirty = false ;
if ( ! silent ) toast ( 'Draft saved' , 'success' ) ;
else toast ( 'Draft auto-saved' , 'success' ) ;
}
// ── Compose formatting ─────────────────────────────────────────────────────
function execFmt ( cmd , val ) {
document . getElementById ( 'compose-editor' ) . focus ( ) ;
document . execCommand ( cmd , false , val || null ) ;
}
function triggerAttach ( ) { document . getElementById ( 'compose-attach-input' ) . click ( ) ; }
function handleAttachFiles ( input ) {
for ( const file of input . files ) composeAttachments . push ( { file , name : file . name , size : file . size } ) ;
input . value = '' ; updateAttachList ( ) ; S . draftDirty = true ;
}
function removeAttachment ( i ) { composeAttachments . splice ( i , 1 ) ; updateAttachList ( ) ; }
function updateAttachList ( ) {
const el = document . getElementById ( 'compose-attach-list' ) ;
if ( ! composeAttachments . length ) { el . innerHTML = '' ; return ; }
el . innerHTML = composeAttachments . map ( ( a , i ) => ` <div class="attachment-chip">
📎 < span > $ { esc ( a . name ) } < / s p a n >
< span style = "color:var(--muted);font-size:10px" > $ { formatSize ( a . size ) } < / s p a n >
< button onclick = "removeAttachment(${i})" class = "tag-remove" > × < / b u t t o n >
< / d i v > ` ) . j o i n ( ' ' ) ;
}
async function sendMessage ( ) {
const accountId = parseInt ( document . getElementById ( 'compose-from' ) ? . value || 0 ) ;
const to = getTagValues ( 'compose-to' ) ;
if ( ! accountId || ! to . length ) { toast ( 'From account and To address required' , 'error' ) ; return ; }
const editor = document . getElementById ( 'compose-editor' ) ;
const bodyHTML = editor . innerHTML . trim ( ) ;
const bodyText = editor . innerText . trim ( ) ;
const btn = document . getElementById ( 'send-btn' ) ;
btn . disabled = true ; btn . textContent = 'Sending...' ;
const endpoint = S . composeMode === 'reply' ? '/reply' : S . composeMode === 'forward' ? '/forward' : '/send' ;
const r = await api ( 'POST' , endpoint , {
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 ,
} ) ;
btn . disabled = false ; btn . textContent = 'Send' ;
if ( r ? . ok ) { toast ( 'Sent!' , 'success' ) ; clearDraftAutosave ( ) ; S . draftDirty = false ;
document . getElementById ( 'compose-overlay' ) . classList . remove ( 'open' ) ; }
else toast ( r ? . error || 'Send failed' , 'error' ) ;
}
// ── Resizable compose ──────────────────────────────────────────────────────
function initComposeResize ( ) {
const win = document . getElementById ( 'compose-window' ) ;
if ( ! win ) return ;
let resizing = false , startX , startY , startW , startH ;
const handle = document . getElementById ( 'compose-resize-handle' ) ;
if ( ! handle ) return ;
handle . addEventListener ( 'mousedown' , e => {
resizing = true ; startX = e . clientX ; startY = e . clientY ;
startW = win . offsetWidth ; startH = win . offsetHeight ;
document . addEventListener ( 'mousemove' , onMouseMove ) ;
document . addEventListener ( 'mouseup' , ( ) => { resizing = false ; document . removeEventListener ( 'mousemove' , onMouseMove ) ; } ) ;
e . preventDefault ( ) ;
} ) ;
function onMouseMove ( e ) {
if ( ! resizing ) return ;
const newW = Math . max ( 360 , startW + ( e . clientX - startX ) ) ;
const newH = Math . max ( 280 , startH - ( e . clientY - startY ) ) ;
win . style . width = newW + 'px' ;
win . style . height = newH + 'px' ;
document . getElementById ( 'compose-editor' ) . style . height = ( newH - 240 ) + 'px' ;
}
}
// ── Compose popup window ───────────────────────────────────────────────────
function openComposePopup ( ) {
const popup = window . open ( '' , '_blank' , 'width=640,height=520,resizable=yes,scrollbars=yes' ) ;
window . _composeWin = popup ;
// Simpler: just use the in-page compose anyway for now; popup would need full HTML
// Fall back to in-page for robustness
document . getElementById ( 'compose-overlay' ) . classList . add ( 'open' ) ;
}
// ── Settings ───────────────────────────────────────────────────────────────
async function openSettings ( ) {
openModal ( 'settings-modal' ) ;
loadSyncInterval ( ) ;
renderMFAPanel ( ) ;
}
async function loadSyncInterval ( ) {
const r = await api ( 'GET' , '/sync-interval' ) ;
if ( r ) document . getElementById ( 'sync-interval-select' ) . value = String ( r . sync _interval || 15 ) ;
}
async function saveSyncInterval ( ) {
const val = parseInt ( document . getElementById ( 'sync-interval-select' ) . value ) || 0 ;
const r = await api ( 'PUT' , '/sync-interval' , { sync _interval : val } ) ;
if ( r ? . ok ) toast ( 'Saved' , 'success' ) ; else toast ( 'Failed' , 'error' ) ;
}
async function saveComposePopupPref ( ) {
const val = document . getElementById ( 'compose-popup-toggle' ) . checked ;
await api ( 'PUT' , '/compose-popup' , { compose _popup : val } ) ;
if ( S . me ) S . me . compose _popup = val ;
}
async function changePassword ( ) {
const cur = document . getElementById ( 'cur-pw' ) . value , nw = document . getElementById ( 'new-pw' ) . value ;
if ( ! cur || ! nw ) { toast ( 'Both fields required' , 'error' ) ; return ; }
const r = await api ( 'POST' , '/change-password' , { current _password : cur , new _password : nw } ) ;
if ( r ? . ok ) { toast ( 'Password updated' , 'success' ) ; document . getElementById ( 'cur-pw' ) . value = '' ; document . getElementById ( 'new-pw' ) . value = '' ; }
else toast ( r ? . error || 'Failed' , 'error' ) ;
}
async function renderMFAPanel ( ) {
const me = await api ( 'GET' , '/me' ) ;
if ( ! me ) return ;
const badge = document . getElementById ( 'mfa-badge' ) , panel = document . getElementById ( 'mfa-panel' ) ;
if ( me . mfa _enabled ) {
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 ( ) {
const r = await api ( 'POST' , '/mfa/setup' ) ; if ( ! r ) return ;
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 } ) ;
if ( r ? . ok ) { toast ( 'MFA enabled' , 'success' ) ; renderMFAPanel ( ) ; } else toast ( r ? . error || 'Invalid code' , 'error' ) ;
}
async function disableMFA ( ) {
const r = await api ( 'POST' , '/mfa/disable' , { code : document . getElementById ( 'mfa-code' ) . value } ) ;
if ( r ? . ok ) { toast ( 'MFA disabled' , 'success' ) ; renderMFAPanel ( ) ; } else toast ( r ? . error || 'Invalid code' , 'error' ) ;
}
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' ;
} ) ;
}
// Close compose on overlay click
document . addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'compose-overlay' ) ) {
if ( S . draftDirty ) { if ( confirm ( 'Save draft before closing?' ) ) { saveDraft ( ) ; return ; } }
closeCompose ( true ) ;
}
} ) ;
// Init tag fields after DOM is ready
document . addEventListener ( 'DOMContentLoaded' , ( ) => {
initTagField ( 'compose-to' ) ;
initTagField ( 'compose-cc-tags' ) ;
initTagField ( 'compose-bcc-tags' ) ;
} ) ;
init ( ) ;