// 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); await Promise.all([loadAccounts(), loadFolders()]); 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 => `
${esc(a.email_address)} ${a.last_error?'
':''}
`).join(''); } function showAccountMenu(e, id) { e.preventDefault(); e.stopPropagation(); const a = S.accounts.find(a=>a.id===id); showCtxMenu(e, `
↻ Sync now
⚡ Test connection
✎ Edit credentials
${a?.last_error?`
⚠ View last error
`:''}
🗑 Remove account
`); } 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='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='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:'', 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.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)]; if(!acc) 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, accountId) { e.preventDefault(); e.stopPropagation(); showCtxMenu(e, `
↻ Sync this folder
📂 Open folder
`); } 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='
'; 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='

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 renderMessageList() { const list=document.getElementById('message-list'); if (!S.messages.length){ list.innerHTML=`

No messages

`; return; } list.innerHTML=S.messages.map(m=>`
${esc(m.from_name||m.from_email)} ${formatDate(m.date)}
${esc(m.subject||'(no subject)')}
${esc(m.preview||'')}
${esc(m.account_email||'')} ${m.has_attachment?'':''} ${m.is_starred?'★':'☆'}
`).join('')+(S.messages.length`:''); } 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();} } 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=``; } else { bodyHtml=`
Remote images blocked.
${esc(msg.body_text||'(empty)')}
`; } } else { bodyHtml=`
${esc(msg.body_text||'(empty)')}
`; } let attachHtml=''; if (msg.attachments?.length) { attachHtml=`
Attachments ${msg.attachments.map(a=>`
📎 ${esc(a.filename)} ${formatSize(a.size)}
`).join('')}
`; } detail.innerHTML=`
${esc(msg.subject||'(no subject)')}
${esc(msg.from_name||msg.from_email)} ${msg.from_name?` <${esc(msg.from_email)}>`:''} ${msg.to?`
To: ${esc(msg.to)}
`:''} ${msg.cc?`
CC: ${esc(msg.cc)}
`:''}
${formatFullDate(msg.date)}
${attachHtml}
${bodyHtml}
`; 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])=>`${esc(k)}${esc(v)}`).join(''); const overlay=document.createElement('div'); overlay.className='modal-overlay open'; overlay.innerHTML=``; 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=>`
${esc(f.name)}
`).join(''); showCtxMenu(e,`
↩ Reply
★ Toggle star
⋮ View headers
${moveFolders?`
Move to
${moveFolders}`:''}
🗑 Delete
`); } 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=`

Select a message

Choose a message to read it

`; } 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=>``).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:`

—— Original message ——
${msg.body_html||('
'+esc(msg.body_text||'')+'
')}
`, }); // 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:`

—— Forwarded message ——
From: ${esc(msg.from_email||'')}
${msg.body_html||('
'+esc(msg.body_text||'')+'
')}
`, }); } 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)=>`
📎 ${esc(a.name)} ${formatSize(a.size)}
`).join(''); } 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='Enabled'; panel.innerHTML=`

TOTP active. Enter code to disable.

`; } else { badge.innerHTML='Disabled'; panel.innerHTML=''; } } async function beginMFASetup() { const r=await api('POST','/mfa/setup'); if (!r) return; document.getElementById('mfa-panel').innerHTML=`

Scan with your authenticator app.

Key: ${r.secret}

`; } 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();