// GoWebMail app.js — full client // ── State ────────────────────────────────────────────────────────────────── const S = { me: null, accounts: [], providers: {gmail:false,outlook:false}, signatures: [], folders: [], messages: [], totalMessages: 0, labels: [], currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox', currentMessage: null, selectedMessageId: null, searchQuery: '', composeMode: 'new', composeReplyToId: null, filterUnread: false, filterAttachment: false, sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc' uiPrefs: {}, // server-persisted UI preferences (collapsed accounts/folders etc.) // Advanced search filters (Filter icon next to the search box) — cleared by clearSearchFilters(). searchFilters: { scope:'', hasAttachment:'', dateFrom:'', dateTo:'', minSizeKB:'', maxSizeKB:'', accountId:'', folderId:'' }, }; function hasActiveSearchFilters() { const f = S.searchFilters; return !!(f.scope || f.hasAttachment !== '' || f.dateFrom || f.dateTo || f.minSizeKB || f.maxSizeKB || f.accountId || f.folderId); } // ── UI Preferences (server-persisted, cross-device) ───────────────────────── let _uiPrefsSaveTimer = null; function uiPrefsGet(key, def) { return (key in S.uiPrefs) ? S.uiPrefs[key] : def; } function uiPrefsSet(key, val) { S.uiPrefs[key] = val; clearTimeout(_uiPrefsSaveTimer); _uiPrefsSaveTimer = setTimeout(() => { api('PUT', '/ui-prefs', S.uiPrefs); }, 600); // debounce 600ms } function isAccountCollapsed(accId) { return uiPrefsGet('ac_'+accId, false); } function setAccountCollapsed(accId, v) { uiPrefsSet('ac_'+accId, v); } // ── View settings (density / reading pane position / sidebar mode) ────────── function applyViewPrefs() { const density = uiPrefsGet('density', 'compact'); const pane = uiPrefsGet('readingPane', 'right'); const sidebarMode = uiPrefsGet('sidebarMode', 'expanded'); const root = document.getElementById('app-root'); if (root) { root.dataset.density = density; root.dataset.readingPane = pane; root.dataset.sidebar = sidebarMode; } [['compact','Compact'],['comfortable','Comfortable']].forEach(([v,label]) => { const el = document.getElementById('vopt-density-'+v); if (el) el.textContent = (v===density?'✓ ':'○ ') + label; }); [['right','Right'],['bottom','Bottom']].forEach(([v,label]) => { const el = document.getElementById('vopt-pane-'+v); if (el) el.textContent = (v===pane?'✓ ':'○ ') + label; }); [['expanded','Pinned (always visible)'],['collapsed','Minimized'],['auto','Auto-hide (peek on hover)']].forEach(([v,label]) => { const el = document.getElementById('vopt-sidebar-'+v); if (el) el.textContent = (v===sidebarMode?'✓ ':'○ ') + label; }); const listPanel = document.querySelector('.message-list-panel'); if (listPanel) { const widthPx = uiPrefsGet('panelWidthPx', null); const heightPct = uiPrefsGet('panelHeightPct', null); if (widthPx) listPanel.style.width = widthPx + 'px'; if (heightPct) listPanel.style.height = heightPct + '%'; } } // ── Draggable list/reading-pane divider (desktop only) ─────────────────────── // Persisted via uiPrefs (server-side, cross-device) rather than a cookie. function initPanelResize() { const handle = document.getElementById('panel-resize-handle'); const listPanel = document.querySelector('.message-list-panel'); if (!handle || !listPanel) return; let dragging = false; handle.addEventListener('mousedown', e => { dragging = true; handle.classList.add('dragging'); document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!dragging) return; const mailView = document.getElementById('mail-view'); const r = mailView.getBoundingClientRect(); if (document.getElementById('app-root').dataset.readingPane === 'bottom') { const pct = Math.min(80, Math.max(15, ((e.clientY - r.top) / r.height) * 100)); listPanel.style.height = pct + '%'; } else { const w = Math.min(680, Math.max(220, e.clientX - r.left)); listPanel.style.width = w + 'px'; } }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; handle.classList.remove('dragging'); document.body.style.userSelect = ''; if (document.getElementById('app-root').dataset.readingPane === 'bottom') { uiPrefsSet('panelHeightPct', parseFloat(listPanel.style.height)); } else { uiPrefsSet('panelWidthPx', parseInt(listPanel.style.width)); } }); } function setViewPref(key, val) { uiPrefsSet(key, val); applyViewPrefs(); closeViewDropdown(); } // Quick manual toggle (header button + edge tab): flip between fully visible // and hidden. Doesn't touch 'auto' as a saved preference — pick that from the // View menu; this button just pins/unpins whatever's currently hidden. function toggleSidebarCollapse() { const cur = uiPrefsGet('sidebarMode', 'expanded'); setViewPref('sidebarMode', cur === 'expanded' ? 'collapsed' : 'expanded'); } function toggleViewDropdown(e) { e.stopPropagation(); const menu = document.getElementById('view-dropdown-menu'); if (!menu) return; const isOpen = menu.style.display !== 'none'; menu.style.display = isOpen ? 'none' : 'block'; document.getElementById('view-dropdown-btn')?.setAttribute('aria-expanded', String(!isOpen)); if (!isOpen) setTimeout(() => document.addEventListener('click', closeViewDropdown, { once: true }), 0); } function closeViewDropdown() { const menu = document.getElementById('view-dropdown-menu'); if (menu) menu.style.display = 'none'; document.getElementById('view-dropdown-btn')?.setAttribute('aria-expanded', 'false'); } // ── Boot ─────────────────────────────────────────────────────────────────── async function init() { const [me, providers, wl, uiPrefsRaw] = await Promise.all([ api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'), api('GET','/ui-prefs'), ]); 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); if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw; applyViewPrefs(); ensureContactsCache(); // warms the cache isRemoteContentAllowed()'s "Only from Contacts" check reads synchronously await loadAccounts(); await loadFolders(); await loadLabels(); 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! Loading…', 'success'); history.replaceState({},'','/'); await loadAccounts(); pollForNewFolders(); } 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(); return; } if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); document.getElementById('search-input').focus(); return; } if (e.metaKey||e.ctrlKey||e.altKey) return; // don't shadow browser/OS shortcuts below if (e.key==='j' || e.key==='k') { e.preventDefault(); const msgs = getFilteredSortedMsgs(); if (!msgs.length) return; let idx = msgs.findIndex(m=>m.id===S.selectedMessageId); idx = e.key==='j' ? Math.min(idx+1, msgs.length-1) : Math.max(idx-1, 0); openMessage(msgs[idx].id); } else if (e.key==='r' && S.currentMessage) { e.preventDefault(); openReplyTo(S.currentMessage.id); } else if (e.key==='f' && S.currentMessage) { e.preventDefault(); openForward(); } else if ((e.key==='#'||e.key==='Delete') && S.currentMessage) { e.preventDefault(); deleteMessage(S.currentMessage.id); } else if (e.key==='Escape') { if (S.composeVisible) { e.preventDefault(); closeCompose(); } else if (S.currentMessage) { e.preventDefault(); resetDetail(); renderMessageList(); mobBack(); } } }); initComposeDragResize(); initPanelResize(); startPoller(); mobSetView('list'); // initialise mobile view state } // ── Providers ────────────────────────────────────────────────────────────── function updateProviderButtons() { ['gmail','outlook','outlook_personal'].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'; } }); } // ── Settings: connected-accounts list ──────────────────────────────────────── function renderAccountsSettingsList() { const el = document.getElementById('settings-accounts-list'); if (!el) return; // Settings modal not open yet — nothing to render into if (!S.accounts.length) { el.innerHTML = '
No accounts connected.
'; return; } el.innerHTML = S.accounts.map(a => { const hasWarning = a.last_error || a.token_expired; const warningTitle = a.token_expired ? 'OAuth token expired — click Manage to reconnect' : (a.last_error ? '⚠ '+a.last_error : ''); return `
${esc(a.display_name||a.email_address)}
${esc(a.email_address)}
${a.token_expired?'🔑': a.last_error?'':''}
`; }).join(''); } // ── Accounts ─────────────────────────────────────────────────────────────── async function loadAccounts() { const data = await api('GET','/accounts'); if (!data) return; S.accounts = data; renderAccountsSettingsList(); populateComposeFrom(); loadSignatures(); } // ── Signatures (compose prefill) ──────────────────────────────────────────── async function loadSignatures() { const data = await api('GET','/signatures'); if (data) S.signatures = data; } // Returns the HTML for an account's default-for-new or default-for-reply signature, or ''. function getSignatureHTML(accountId, forReply) { const acc = S.accounts.find(a => a.id === accountId); if (!acc) return ''; const sigId = forReply ? acc.default_signature_reply_id : acc.default_signature_new_id; if (!sigId) return ''; const sig = S.signatures.find(s => s.id === sigId); return sig ? sig.content_html : ''; } // Renders the (possibly empty) signature block with a stable id so it can be swapped // live if the From-account changes mid-compose, without touching the rest of the body. function signatureBlockHTML(accountId, forReply) { const html = getSignatureHTML(accountId, forReply); // A block-level "

" renders as exactly one blank line in every browser; // a bare

doesn't — its rendered height depends on what precedes/follows it // (e.g. it collapses to nothing right before another block element), which is exactly // the inconsistency this was written to avoid. return `
${html ? '

' + html : ''}
`; } // Bound to #compose-from's change event — swaps just the signature block. function onComposeFromChange() { const accountId = parseInt(document.getElementById('compose-from')?.value || 0); const forReply = S.composeMode !== 'new'; const block = document.getElementById('sig-block'); if (block) block.outerHTML = signatureBlockHTML(accountId, forReply); } function connectOAuth(p) { if (p === 'outlook_personal') { location.href = '/auth/outlook-personal/connect'; } else { location.href = '/auth/' + p + '/connect'; } } function openAddAccountModal() { ['imap-email','imap-name','imap-password','imap-host','smtp-host','imap-caldav-url','imap-carddav-url'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; }); document.getElementById('imap-port').value='993'; document.getElementById('smtp-port').value='587'; document.getElementById('use-jmap').checked=false; toggleJMAPFields(); const r=document.getElementById('test-result'); if(r){r.style.display='none';r.className='test-result';} openModal('add-account-modal'); } // Toggles the Add Account modal between IMAP/SMTP fields and a single JMAP // server URL field (JMAP has no separate SMTP host/port or IMAP port — one // base URL covers both mail access and sending). function toggleJMAPFields() { const jmap=document.getElementById('use-jmap').checked; document.getElementById('imap-port-field').style.display=jmap?'none':''; document.getElementById('smtp-fields').style.display=jmap?'none':''; document.getElementById('imap-hint').style.display=jmap?'none':''; const label=document.getElementById('imap-host-label'), host=document.getElementById('imap-host'); label.textContent=jmap?'JMAP Server URL':'IMAP Host'; host.placeholder=jmap?'https://mail.example.com:8443':'imap.example.com'; } async function testNewConnection() { const btn=document.getElementById('test-btn'), result=document.getElementById('test-result'); const jmap=document.getElementById('use-jmap').checked; const body={email:document.getElementById('imap-email').value.trim(),password:document.getElementById('imap-password').value, imap_host:document.getElementById('imap-host').value.trim()}; if (jmap) { body.provider='jmap'; } else { body.imap_port=parseInt(document.getElementById('imap-port').value)||993; body.smtp_host=document.getElementById('smtp-host').value.trim(); body.smtp_port=parseInt(document.getElementById('smtp-port').value)||587; } if (!body.email||!body.password||!body.imap_host){result.textContent=(jmap?'Email, password and JMAP server URL required.':'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,20000); btn.textContent='Test Connection';btn.disabled=false; result.textContent=(r?.ok)?'✓ Connection successful!':((r?.error?.message||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 jmap=document.getElementById('use-jmap').checked; 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()}; if (jmap) { body.provider='jmap'; } else { body.imap_port=parseInt(document.getElementById('imap-port').value)||993; body.smtp_host=document.getElementById('smtp-host').value.trim(); body.smtp_port=parseInt(document.getElementById('smtp-port').value)||587; } body.caldav_url=document.getElementById('imap-caldav-url').value.trim(); body.carddav_url=document.getElementById('imap-carddav-url').value.trim(); if (!body.email||!body.password||!body.imap_host){toast((jmap?'Email, password and JMAP server URL required':'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(); pollForNewFolders(); } else toast(r?.error||'Failed to add account','error'); } // Repeatedly reloads folders/accounts until the newly-added account's folders show up // (initial IMAP sync takes a few seconds), instead of guessing a fixed delay. Also // refreshes messages/unread counts so the sidebar updates without a manual page reload. function pollForNewFolders() { let tries = 0; const poll = setInterval(async () => { tries++; await loadAccounts(); await loadFolders(); await loadMessages(); const hasFolders = S.accounts.some(a => S.folders.some(f => f.account_id === a.id)); if (hasFolders || tries >= 12) { clearInterval(poll); if (hasFolders) toast('Account ready!', 'success'); } }, 2500); } 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 ───────────────────────────────────────────────────── // Opened from the Settings modal's Accounts tab; stacks above it (see // #edit-account-modal z-index in CSS) rather than closing Settings, so // Cancel/Save land the user back in Settings automatically. async function openEditAccount(id) { 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||''; const isOAuth = r.provider==='gmail' || r.provider==='outlook' || r.provider==='outlook_personal'; // Show/hide credential section and test button based on provider type document.getElementById('edit-creds-section').style.display = isOAuth ? 'none' : ''; document.getElementById('edit-test-btn').style.display = isOAuth ? 'none' : ''; const oauthSection = document.getElementById('edit-oauth-section'); if (oauthSection) oauthSection.style.display = isOAuth ? '' : 'none'; if (isOAuth) { const providerLabel = r.provider==='gmail' ? 'Google' : r.provider==='outlook_personal' ? 'Microsoft (Personal)' : 'Microsoft'; const lbl = document.getElementById('edit-oauth-provider-label'); const lblBtn = document.getElementById('edit-oauth-provider-label-btn'); const expWarn = document.getElementById('edit-oauth-expired-warning'); if (lbl) lbl.textContent = providerLabel; if (lblBtn) lblBtn.textContent = providerLabel; if (expWarn) expWarn.style.display = r.token_expired ? '' : 'none'; const reconnectBtn = document.getElementById('edit-oauth-reconnect-btn'); if (reconnectBtn) reconnectBtn.onclick = () => { closeModal('edit-account-modal'); connectOAuth(r.provider); }; } const isJMAP = r.provider==='jmap'; if (!isOAuth) { 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-imap-port-field').style.display=isJMAP?'none':''; document.getElementById('edit-smtp-fields').style.display=isJMAP?'none':''; document.getElementById('edit-imap-host-label').textContent=isJMAP?'JMAP Server URL':'IMAP Host'; } document.getElementById('edit-caldav-url').value=r.caldav_url||''; document.getElementById('edit-carddav-url').value=r.carddav_url||''; document.getElementById('edit-sync-days').value=r.sync_days||30; 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; 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() { // Only relevant for IMAP/SMTP accounts — OAuth accounts reconnect via the button if (document.getElementById('edit-creds-section').style.display === 'none') { return; } 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;} const isJMAP=document.getElementById('edit-imap-port-field').style.display==='none'; const testBody={email,password:pw,imap_host:document.getElementById('edit-imap-host').value.trim()}; if (isJMAP) { testBody.provider='jmap'; } else { testBody.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993; testBody.smtp_host=document.getElementById('edit-smtp-host').value.trim(); testBody.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587; } btn.innerHTML='Testing...';btn.disabled=true; const r=await api('POST','/accounts/test',testBody,20000); btn.textContent='Test Connection';btn.disabled=false; connEl.textContent=(r?.ok)?'✓ Successful!':((r?.error?.message||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 isOAuth = document.getElementById('edit-creds-section').style.display === 'none'; const body={display_name:document.getElementById('edit-name').value.trim()}; if (!isOAuth) { body.imap_host=document.getElementById('edit-imap-host').value.trim(); body.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993; body.smtp_host=document.getElementById('edit-smtp-host').value.trim(); body.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587; const pw=document.getElementById('edit-password').value; if (pw) body.password=pw; } body.caldav_url=document.getElementById('edit-caldav-url').value.trim(); body.carddav_url=document.getElementById('edit-carddav-url').value.trim(); 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');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(); }; } // ── Undo toast: run onCommit after `duration` unless the user clicks Undo first ── function undoToast(msg, {onUndo, onCommit, duration=5000}) { let container = document.getElementById('toast-container'); if (!container) { container = document.createElement('div'); container.id = 'toast-container'; container.className = 'toast-container'; document.body.appendChild(container); } const el = document.createElement('div'); el.className = 'toast toast-undo'; const span = document.createElement('span'); span.textContent = msg; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'toast-undo-btn'; btn.textContent = 'Undo'; el.appendChild(span); el.appendChild(btn); container.appendChild(el); let done = false; const dismiss = () => { el.style.opacity='0'; el.style.transition='opacity .3s'; setTimeout(()=>el.remove(),300); }; const timer = setTimeout(() => { if(done) return; done=true; dismiss(); onCommit && onCommit(); }, duration); btn.onclick = () => { if(done) return; done=true; clearTimeout(timer); dismiss(); onUndo && onUndo(); }; } // ── Inline prompt (replaces browser prompt()) ────────────────────────────── function inlinePrompt(message, onOk, defaultValue) { const el = document.getElementById('inline-prompt'); const msg = document.getElementById('inline-prompt-msg'); const input = document.getElementById('inline-prompt-input'); const ok = document.getElementById('inline-prompt-ok'); const cancel = document.getElementById('inline-prompt-cancel'); msg.textContent = message; input.value = defaultValue || ''; el.classList.add('open'); setTimeout(() => { input.focus(); input.select(); }, 50); const cleanup = () => { el.classList.remove('open'); ok.onclick=null; cancel.onclick=null; }; ok.onclick = () => { const v=input.value.trim(); if(!v) return; cleanup(); onOk && onOk(v); }; cancel.onclick = () => { cleanup(); }; } // ── Inline date/time prompt (snooze / send later) ────────────────────────── function inlineDateTimePrompt(message, onOk, okLabel) { const el = document.getElementById('inline-datetime'); const msg = document.getElementById('inline-datetime-msg'); const input = document.getElementById('inline-datetime-input'); const presetsEl= document.getElementById('inline-datetime-presets'); const ok = document.getElementById('inline-datetime-ok'); const cancel = document.getElementById('inline-datetime-cancel'); msg.textContent = message; ok.textContent = okLabel || 'Set'; const pad = n => String(n).padStart(2,'0'); const toLocalInput = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; const now = new Date(); input.min = toLocalInput(new Date(now.getTime()+60000)); const presets = [ ['Later today', () => { const d=new Date(); d.setHours(18,0,0,0); if(d<=now) d.setDate(d.getDate()+1); return d; }], ['Tomorrow 9am', () => { const d=new Date(); d.setDate(d.getDate()+1); d.setHours(9,0,0,0); return d; }], ['Next week', () => { const d=new Date(); d.setDate(d.getDate()+(((8-d.getDay())%7)||7)); d.setHours(9,0,0,0); return d; }], ]; presetsEl.innerHTML = presets.map(([label],i)=>``).join(''); presetsEl.querySelectorAll('.datetime-preset-btn').forEach((btn,i)=>{ btn.onclick = () => { input.value = toLocalInput(presets[i][1]()); }; }); input.value = toLocalInput(presets[1][1]()); el.classList.add('open'); setTimeout(() => input.focus(), 50); const cleanup = () => { el.classList.remove('open'); ok.onclick=null; cancel.onclick=null; }; ok.onclick = () => { if (!input.value) return; const d = new Date(input.value); if (isNaN(d.getTime()) || d <= new Date()) { toast('Pick a time in the future','error'); return; } cleanup(); onOk(d.toISOString()); }; cancel.onclick = () => cleanup(); } // ── 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']; const orderedAccounts = [...S.accounts].sort((a,b) => (a.sort_order||0) - (b.sort_order||0)); el.innerHTML = orderedAccounts.map(acc => { const folders = byAcc[acc.id]; // Show account even if no folders yet — it was just added and syncer hasn't run if (!folders?.length) { const statusHtml = acc.last_error ? `
⚠ ${esc(acc.last_error)}
` : `
⏳ Syncing folders…
`; return ``; } const accId = acc.id; const collapsed = isAccountCollapsed(accId); const sorted = [ ...prio.map(t => folders.find(f => f.folder_type===t)).filter(Boolean), ...folders.filter(f => f.folder_type==='custom') ]; const totalUnread = folders.reduce((s,f) => s+(f.unread_count||0), 0); const chevron = collapsed ? '' : ''; const folderRows = collapsed ? '' : sorted.map(f => ` `).join(''); return ``; }).join(''); // Re-wire drag-drop onto folder rows for message-to-folder moves el.querySelectorAll('.nav-item[data-fid]').forEach(item => { item.ondragover = e => { e.preventDefault(); item.classList.add('drag-over'); }; item.ondragleave = () => item.classList.remove('drag-over'); item.ondrop = e => { e.preventDefault(); item.classList.remove('drag-over'); const fid = parseInt(item.dataset.fid); const mid = parseInt(e.dataTransfer.getData('text/plain')); if (mid && fid) moveMessage(mid, fid); }; }); } function toggleAccountCollapse(accId) { setAccountCollapsed(accId, !isAccountCollapsed(accId)); renderFolders(); } // ── Account drag-to-reorder ───────────────────────────────────────────────── let _dragSrcAccId = null; function accDragStart(e, accId) { _dragSrcAccId = accId; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(accId)); setTimeout(() => e.currentTarget?.classList.add('acc-dragging'), 0); } function accDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const g = e.currentTarget; if (g && parseInt(g.dataset.accId) !== _dragSrcAccId) g.classList.add('acc-drag-target'); } function accDragLeave(e) { e.currentTarget?.classList.remove('acc-drag-target'); } async function accDrop(e, targetAccId) { e.preventDefault(); e.currentTarget?.classList.remove('acc-drag-target'); document.querySelectorAll('.acc-dragging').forEach(el => el.classList.remove('acc-dragging')); if (_dragSrcAccId === null || _dragSrcAccId === targetAccId) { _dragSrcAccId = null; return; } const ordered = [...S.accounts].sort((a,b) => (a.sort_order||0)-(b.sort_order||0)); const srcIdx = ordered.findIndex(a => a.id === _dragSrcAccId); const dstIdx = ordered.findIndex(a => a.id === targetAccId); if (srcIdx === -1 || dstIdx === -1) { _dragSrcAccId = null; return; } const [moved] = ordered.splice(srcIdx, 1); ordered.splice(dstIdx, 0, moved); ordered.forEach((a, i) => { a.sort_order = i; }); S.accounts = ordered; _dragSrcAccId = null; renderFolders(); await api('PUT', '/accounts/sort-order', { order: ordered.map(a => a.id) }); } 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})
` : ''; // Default folders (inbox/sent/drafts/trash/spam) are provider-managed — deleting them // would break sync. Only user-created "custom" folders can be deleted. const deleteEntry = f.folder_type==='custom' ? `
🗑 Delete folder
` : ''; showCtxMenu(e, `
📁 New folder
↻ Sync this folder
${syncLabel}
${enableAllEntry}
✓ Mark all as read
⬇ Export folder
as .zip (.eml files)
as .mbox
${moveEntry} ${emptyEntry}
👁 Hide from sidebar
${deleteEntry}`); } function showAccountMenu(e, accountId) { e.preventDefault(); e.stopPropagation(); showCtxMenu(e, `
📁 New folder
`); } function createFolderPrompt(accountId) { inlinePrompt('New folder name:', async (name) => { const r = await api('POST', '/accounts/'+accountId+'/folders', { name }); if (r?.ok) { S.folders.push(r.folder); toast('Folder created', 'success'); renderFolders(); } else toast(r?.error || 'Create failed', 'error'); }); } 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'); } function exportFolder(folderId, format) { window.open('/api/folders/'+folderId+'/export?format='+format, '_blank'); } 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'; } // ── Labels ───────────────────────────────────────────────────────────────── // Local to gowebmail only — not synced to Gmail/Outlook/IMAP (see server-side comment on // models.Label for why a reliable cross-provider equivalent doesn't exist). const LABEL_PALETTE = ['#e5484d','#f5a623','#f7ce46','#3dd68c','#12b886','#2bb3d6', '#5b8def','#7c5cfc','#c25ee0','#ec4899','#8a8f98','#64748b']; async function loadLabels() { const data = await api('GET','/labels'); S.labels = data || []; renderLabelsDropdown(); } // Renders the Labels dropdown (panel-header, next to Filter): click a label to view its // messages, or use the inline ✎/🗑 to rename/recolor or delete right from the list. function renderLabelsDropdown() { const el = document.getElementById('labels-dropdown-menu'); if (!el) return; const rows = S.labels.map(l => `
${esc(l.name)}
`).join(''); el.innerHTML = rows + `
+ New label
`; } function toggleLabelsDropdown(e) { e.stopPropagation(); const menu = document.getElementById('labels-dropdown-menu'); if (!menu) return; const isOpen = menu.style.display !== 'none'; menu.style.display = isOpen ? 'none' : 'block'; document.getElementById('labels-dropdown-btn')?.setAttribute('aria-expanded', String(!isOpen)); if (!isOpen) setTimeout(() => document.addEventListener('click', closeLabelsDropdown, { once: true }), 0); } function closeLabelsDropdown() { const menu = document.getElementById('labels-dropdown-menu'); if (menu) menu.style.display = 'none'; document.getElementById('labels-dropdown-btn')?.setAttribute('aria-expanded', 'false'); } function toggleThreadPanel(e) { if (e) e.stopPropagation(); const panel = document.getElementById('thread-panel'); if (!panel) return; const isOpen = panel.style.display !== 'none'; panel.style.display = isOpen ? 'none' : 'block'; if (!isOpen) setTimeout(() => document.addEventListener('click', closeThreadPanel, { once: true }), 0); } function closeThreadPanel() { const panel = document.getElementById('thread-panel'); if (panel) panel.style.display = 'none'; } function selectLabel(labelId, name) { closeLabelsDropdown(); selectFolder('label:'+labelId, name); } function openLabelEditor(labelId) { const label = labelId ? S.labels.find(l=>l.id===labelId) : null; document.getElementById('label-editor-title').textContent = label ? 'Edit Label' : 'New Label'; document.getElementById('label-editor-id').value = labelId || ''; document.getElementById('label-editor-name').value = label ? label.name : ''; const chosen = label ? label.color : LABEL_PALETTE[0]; document.getElementById('label-editor-swatches').innerHTML = LABEL_PALETTE.map(c => `` ).join(''); document.getElementById('label-editor-custom-color').value = chosen; openModal('label-editor-modal'); setTimeout(()=>document.getElementById('label-editor-name').focus(), 50); } function pickLabelColor(color) { document.getElementById('label-editor-custom-color').value = color; document.querySelectorAll('#label-editor-swatches .label-swatch').forEach(s => s.classList.toggle('selected', s.dataset.color.toLowerCase() === color.toLowerCase())); } async function saveLabelEditor() { const id = document.getElementById('label-editor-id').value; const name = document.getElementById('label-editor-name').value.trim(); const color = document.getElementById('label-editor-custom-color').value; if (!name) { toast('Label name required','error'); return; } const r = id ? await api('PUT','/labels/'+id,{name,color}) : await api('POST','/labels',{name,color}); if (r?.ok || r?.id) { closeModal('label-editor-modal'); toast(id?'Label updated':'Label created','success'); await loadLabels(); await loadMessages(); // refresh label chips/dots on visible messages } else toast(r?.error || 'Failed to save label','error'); } function deleteLabelConfirm(labelId) { const label = S.labels.find(l=>l.id===labelId); inlineConfirm(`Delete label "${label?.name||''}"? It will be removed from all messages.`, async () => { const r = await api('DELETE','/labels/'+labelId); if (r?.ok) { toast('Label deleted','success'); if (S.currentFolder === 'label:'+labelId) selectFolder('unified','Unified Inbox'); await loadLabels(); await loadMessages(); } else toast('Delete failed','error'); }); } // Assigns/unassigns a label on a message and updates local state (list row + open detail) // without a full reload. async function toggleMessageLabel(msgId, labelId) { const msg = S.messages.find(m=>m.id===msgId) || (S.currentMessage?.id===msgId ? S.currentMessage : null); const has = msg?.labels?.some(l=>l.id===labelId); const r = has ? await api('DELETE','/messages/'+msgId+'/labels/'+labelId) : await api('POST','/messages/'+msgId+'/labels/'+labelId); if (!r?.ok) { toast('Failed to update label','error'); return; } const label = S.labels.find(l=>l.id===labelId); [S.messages.find(m=>m.id===msgId), S.currentMessage?.id===msgId?S.currentMessage:null].forEach(m => { if (!m) return; m.labels = m.labels || []; if (has) m.labels = m.labels.filter(l=>l.id!==labelId); else if (label) m.labels.push(label); }); renderMessageList(); if (S.currentMessage?.id===msgId) renderMessageDetail(S.currentMessage,false); } function toggleLabelPicker(e, msgId) { e.stopPropagation(); const msg = S.currentMessage?.id===msgId ? S.currentMessage : S.messages.find(m=>m.id===msgId); const items = S.labels.map(l => { const on = msg?.labels?.some(x=>x.id===l.id); return `
${on?'✓ ':'○ '}${esc(l.name)}
`; }).join('') || '
No labels yet
'; showCtxMenu(e, items); } // ── 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=''; if (hasActiveSearchFilters()) clearSearchFiltersQuiet(); const sfp=document.getElementById('search-filters-panel'); if (sfp) sfp.style.display='none'; 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') :folderId==='snoozed'?document.getElementById('nav-snoozed') :document.getElementById('nav-f'+folderId); if (navEl) navEl.classList.add('active'); mobCloseNav(); mobSetView('list'); 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); // ── Advanced search filters (scope, attachment, date range, size) ─────────── // Lives at the end of (position:fixed), same reasoning as #compose-dropdown — // its old spot inside .message-list-panel got clipped/overflowed in reading-pane // "bottom" mode (fixed-height list panel). Positioned via JS under the search bar. function toggleSearchFilters(e) { if (e) e.stopPropagation(); const panel = document.getElementById('search-filters-panel'); const bar = document.querySelector('.search-bar'); if (!panel || !bar) return; const isOpen = panel.style.display !== 'none'; document.removeEventListener('click', closeSearchFiltersOutside); if (isOpen) { panel.style.display = 'none'; return; } populateMailboxScopeOptions(); const r = bar.getBoundingClientRect(); panel.style.display = 'block'; panel.style.width = r.width + 'px'; panel.style.left = r.left + 'px'; panel.style.top = Math.min(r.bottom + 4, window.innerHeight - 60) + 'px'; setTimeout(() => document.addEventListener('click', closeSearchFiltersOutside), 0); } function closeSearchFiltersOutside(e) { const panel = document.getElementById('search-filters-panel'); const btn = document.getElementById('search-filters-btn'); if (!panel || panel.contains(e.target) || (btn && btn.contains(e.target))) return; panel.style.display = 'none'; document.removeEventListener('click', closeSearchFiltersOutside); } // Offers "this account" / "this folder" only when a real folder is currently selected // (not Unified Inbox / Starred, which don't map to a single account or folder). function populateMailboxScopeOptions() { const sel = document.getElementById('sf-mailbox-scope'); if (!sel) return; const prev = sel.value; sel.innerHTML = ''; const cur = S.folders.find(f => String(f.id) === String(S.currentFolder)); if (cur) { const acc = S.accounts.find(a => a.id === cur.account_id); const accLabel = acc ? (acc.display_name || acc.email_address) : 'this account'; sel.innerHTML += ``; sel.innerHTML += ``; } if ([...sel.options].some(o => o.value === prev)) sel.value = prev; } function applySearchFilters() { const f = S.searchFilters; f.scope = document.getElementById('sf-scope').value; f.hasAttachment = document.getElementById('sf-attachment').value; f.dateFrom = document.getElementById('sf-date-from').value; f.dateTo = document.getElementById('sf-date-to').value; const olderDays = document.getElementById('sf-older-days').value; if (olderDays) { const cutoff = new Date(Date.now() - olderDays * 86400000); f.dateTo = cutoff.toISOString().slice(0, 10); document.getElementById('sf-date-to').value = f.dateTo; } f.minSizeKB = document.getElementById('sf-min-size').value; f.maxSizeKB = document.getElementById('sf-max-size').value; const mb = document.getElementById('sf-mailbox-scope').value; // "" | "account:" | "folder:" f.accountId = mb.startsWith('account:') ? mb.slice(8) : ''; f.folderId = mb.startsWith('folder:') ? mb.slice(7) : ''; document.getElementById('search-filters-btn').classList.toggle('active', hasActiveSearchFilters()); S.currentPage = 1; loadMessages(); } // Resets filter state/UI without triggering a reload — for callers (like selectFolder) // that are about to call loadMessages() themselves right after. function clearSearchFiltersQuiet() { S.searchFilters = { scope:'', hasAttachment:'', dateFrom:'', dateTo:'', minSizeKB:'', maxSizeKB:'', accountId:'', folderId:'' }; ['sf-attachment','sf-date-from','sf-date-to','sf-older-days','sf-min-size','sf-max-size','sf-mailbox-scope'] .forEach(id => { const el=document.getElementById(id); if (el) el.value=''; }); const scopeEl=document.getElementById('sf-scope'); if (scopeEl) scopeEl.value='all'; // "" isn't a valid