// GoWebMail 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, composeForwardFromId: null, filterUnread: false, filterAttachment: false, sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc' }; // ── 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); await loadAccounts(); await loadFolders(); await loadMessages(); // 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)); } 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(); } }); initComposeDragResize(); startPoller(); } // ── Providers ────────────────────────────────────────────────────────────── function updateProviderButtons() { ['gmail','outlook'].forEach(p => { const btn = document.getElementById('btn-'+p); if (!btn) return; if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; } }); } // ── 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'); } function renderAccountsPopup() { const el = document.getElementById('accounts-popup-list'); if (!S.accounts.length) { el.innerHTML = '
No accounts connected.
'; return; } el.innerHTML = S.accounts.map(a => `
${esc(a.display_name||a.email_address)} ${a.last_error?'':''}
`).join(''); } // ── Accounts ─────────────────────────────────────────────────────────────── async function loadAccounts() { const data = await api('GET','/accounts'); if (!data) return; S.accounts = data; renderAccountsPopup(); populateComposeFrom(); } 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';} closeAccountsMenu(); 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='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 — 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='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'); } 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'); } // ── Edit Account modal ───────────────────────────────────────────────────── async function openEditAccount(id) { closeAccountsMenu(); 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; // 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'; } 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; // 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='No hidden folders.'; } else { hiddenEl.innerHTML = hidden.map(f=>`
${esc(f.name)}
`).join(''); } openModal('edit-account-modal'); } 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='No hidden folders.'; else hiddenEl.innerHTML = hidden.map(f=>`
${esc(f.name)}
`).join(''); } } else toast('Failed to unhide folder','error'); } function toggleSyncDaysField() { const mode=document.getElementById('edit-sync-mode')?.value; const row=document.getElementById('edit-sync-days-row'); if (row) row.style.display=(mode==='days')?'flex':'none'; } 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='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 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 const [r1, r2] = await Promise.all([ api('PUT','/accounts/'+id, body), api('PUT','/accounts/'+id+'/sync-settings',{ sync_mode: syncMode, sync_days: syncDays, }), ]); 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); 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(); }; } // ── Folders ──────────────────────────────────────────────────────────────── async function loadFolders() { const data=await api('GET','/folders'); if (!data) return; S.folders=data||[]; renderFolders(); updateUnreadBadge(); } const FOLDER_ICONS = { inbox:'', sent:'', drafts:'', trash:'', spam:'', archive:'', custom:'', }; function renderFolders() { const el=document.getElementById('folders-by-account'); const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a); const byAcc={}; S.folders.filter(f=>!f.is_hidden).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])=>{ const acc=accMap[parseInt(accId)]; const accColor = acc?.color || '#888'; const accEmail = acc?.email_address || 'Account '+accId; if(!folders?.length) return ''; const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')]; return ``+sorted.map(f=>` `).join(''); }).join(''); } function showFolderMenu(e, folderId) { e.preventDefault(); e.stopPropagation(); const f = S.folders.find(f=>f.id===folderId); if (!f) return; const syncLabel = f.sync_enabled ? '⊘ Disable sync' : '↻ Enable sync'; 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=> `
${esc(x.name)}
` ).join(''); const moveEntry = otherFolders.length ? `
📂 Move messages to
${moveItems}
` : ''; const isTrashOrSpam = f.folder_type==='trash' || f.folder_type==='spam'; const emptyEntry = isTrashOrSpam ? `
🗑 Empty ${f.name}
` : ''; const disabledCount = S.folders.filter(x=>x.account_id===f.account_id&&!x.sync_enabled).length; const enableAllEntry = disabledCount > 0 ? `
↻ Enable sync for all folders (${disabledCount})
` : ''; showCtxMenu(e, `
↻ Sync this folder
${syncLabel}
${enableAllEntry}
✓ Mark all as read
${moveEntry} ${emptyEntry}
👁 Hide from sidebar
🗑 Delete folder
`); } async function syncFolderNow(folderId) { toast('Syncing folder…','info'); 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'); } 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'); } 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'); } 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'); } ); } async function confirmHideFolder(folderId) { const f = S.folders.find(f=>f.id===folderId); if (!f) return; 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'); } ); } 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='
'; 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 if (S.currentFolder==='starred') result=await api('GET',`/messages/starred?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='

Failed to load

';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 setFilter(mode) { S.filterUnread = (mode === 'unread'); S.filterAttachment = (mode === 'attachment'); S.sortOrder = (mode === 'unread' || mode === 'default' || mode === 'attachment') ? 'date-desc' : mode; // Update checkmarks ['default','unread','attachment','date-desc','date-asc','size-desc'].forEach(k => { const el = document.getElementById('fopt-'+k); if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2); }); // Update button label const labels = { 'default':'Filter', 'unread':'Unread', 'attachment':'📎 Has Attachment', 'date-desc':'↓ Date', 'date-asc':'↑ Date', 'size-desc':'↓ Size' }; 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); } // ── Multi-select state ──────────────────────────────────────── if (!window.SEL) window.SEL = { ids: new Set(), lastIdx: -1 }; function renderMessageList() { const list=document.getElementById('message-list'); let msgs = [...S.messages]; // Filter if (S.filterUnread) msgs = msgs.filter(m => !m.is_read); if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment); // 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)); else msgs.sort((a,b) => new Date(b.date)-new Date(a.date)); if (!msgs.length){ const emptyMsg = S.filterUnread ? 'No unread messages' : S.filterAttachment ? 'No messages with attachments' : 'No messages'; list.innerHTML=`

${emptyMsg}

`; return; } // Update bulk action bar updateBulkBar(); list.innerHTML=msgs.map((m,i)=>`
${esc(m.from_name||m.from_email)} ${formatDate(m.date)}
${esc(m.subject||'(no subject)')}
${esc(m.preview||'')}
${esc(m.account_email||'')} ${m.size?`${formatSize(m.size)}`:''} ${m.has_attachment?'':''} ${m.is_starred?'★':'☆'}
`).join('')+(S.messages.length`:''); // 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); if (S.filterAttachment) msgs=msgs.filter(m=>m.has_attachment); 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=` `; 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() { 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(); } ); } function loadMoreMessages(){ S.currentPage++; loadMessages(true); } async function openMessage(id) { S.selectedMessageId=id; renderMessageList(); const detail=document.getElementById('message-detail'); detail.innerHTML='
'; const msg=await api('GET','/messages/'+id); if (!msg){detail.innerHTML='

Failed to load

';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(); // Sync read status to server (enqueues IMAP op via backend) api('PUT','/messages/'+id+'/read',{read:true}); } } function renderMessageDetail(msg, showRemoteContent) { const detail=document.getElementById('message-detail'); const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email); // CSS injected into every iframe — forces white background so dark-themed emails // don't inherit our app dark theme. const cssReset = ``; // Script injected into srcdoc to report content height via postMessage. // Required because removing allow-same-origin means contentDocument is null from parent. const heightScript = `