// 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 loadAccounts(); // must complete before loadFolders so colors are available
await 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)];
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=>`
${esc(f.name)}
${f.unread_count>0?`${f.unread_count}`:''}
`).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='';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=``;
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||'')}
`).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='';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=`
${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();