Files
gowebmail/web/static/js/app.js

1594 lines
75 KiB
JavaScript
Raw Normal View History

2026-03-08 11:48:27 +00:00
// GoWebMail app.js — full client
2026-03-07 06:20:39 +00:00
// ── 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,
2026-03-08 11:48:27 +00:00
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
filterUnread: false, filterAttachment: false,
2026-03-07 16:49:23 +00:00
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
2026-03-07 06:20:39 +00:00
};
// ── Boot ───────────────────────────────────────────────────────────────────
async function init() {
const [me, providers, wl] = await Promise.all([
api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'),
]);
if (me) {
S.me = me;
document.getElementById('user-display').textContent = me.username || me.email;
if (me.role === 'admin') document.getElementById('admin-link').style.display = 'block';
}
if (providers) { S.providers = providers; updateProviderButtons(); }
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
await loadAccounts();
2026-03-07 09:33:42 +00:00
await loadFolders();
2026-03-07 06:20:39 +00:00
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));
}
2026-03-07 06:20:39 +00:00
const p = new URLSearchParams(location.search);
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); }
2026-03-07 06:20:39 +00:00
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
document.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
if (e.target.contentEditable === 'true') return;
if ((e.metaKey||e.ctrlKey) && e.key==='n') { e.preventDefault(); openCompose(); }
if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); document.getElementById('search-input').focus(); }
});
initComposeDragResize();
startPoller();
2026-03-07 06:20:39 +00:00
}
// ── Providers ──────────────────────────────────────────────────────────────
function updateProviderButtons() {
['gmail','outlook'].forEach(p => {
const btn = document.getElementById('btn-'+p);
if (!btn) return;
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; }
2026-03-07 06:20:39 +00:00
});
}
// ── Accounts popup ─────────────────────────────────────────────────────────
function toggleAccountsMenu(e) {
e.stopPropagation();
const popup = document.getElementById('accounts-popup');
const backdrop = document.getElementById('accounts-popup-backdrop');
if (popup.classList.contains('open')) {
closeAccountsMenu(); return;
}
renderAccountsPopup();
popup.classList.add('open');
backdrop.classList.add('open');
}
function closeAccountsMenu() {
document.getElementById('accounts-popup').classList.remove('open');
document.getElementById('accounts-popup-backdrop').classList.remove('open');
2026-03-07 06:20:39 +00:00
}
function renderAccountsPopup() {
const el = document.getElementById('accounts-popup-list');
if (!S.accounts.length) {
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
return;
}
2026-03-07 06:20:39 +00:00
el.innerHTML = S.accounts.map(a => `
<div class="acct-popup-item" title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
${a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
</button>
<button class="icon-btn" title="Settings" onclick="openEditAccount(${a.id})">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="icon-btn" title="Remove" onclick="deleteAccount(${a.id})" style="color:var(--danger)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
2026-03-07 06:20:39 +00:00
</div>`).join('');
}
// ── Accounts ───────────────────────────────────────────────────────────────
async function loadAccounts() {
const data = await api('GET','/accounts');
if (!data) return;
S.accounts = data;
renderAccountsPopup();
populateComposeFrom();
2026-03-07 06:20:39 +00:00
}
function connectOAuth(p) { location.href='/auth/'+p+'/connect'; }
function openAddAccountModal() {
['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
document.getElementById('imap-port').value='993';
document.getElementById('smtp-port').value='587';
const r=document.getElementById('test-result'); if(r){r.style.display='none';r.className='test-result';}
closeAccountsMenu();
2026-03-07 06:20:39 +00:00
openModal('add-account-modal');
}
async function testNewConnection() {
const btn=document.getElementById('test-btn'), result=document.getElementById('test-result');
const body={email:document.getElementById('imap-email').value.trim(),password:document.getElementById('imap-password').value,
imap_host:document.getElementById('imap-host').value.trim(),imap_port:parseInt(document.getElementById('imap-port').value)||993,
smtp_host:document.getElementById('smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('smtp-port').value)||587};
if (!body.email||!body.password||!body.imap_host){result.textContent='Email, password and IMAP host required.';result.className='test-result err';result.style.display='block';return;}
btn.innerHTML='<span class="spinner-inline"></span>Testing...';btn.disabled=true;
const r=await api('POST','/accounts/test',body);
btn.textContent='Test Connection';btn.disabled=false;
result.textContent=(r?.ok)?'✓ Connection successful!':((r?.error)||'Connection failed');
result.className='test-result '+((r?.ok)?'ok':'err'); result.style.display='block';
}
async function addIMAPAccount() {
const btn=document.getElementById('save-acct-btn');
const body={email:document.getElementById('imap-email').value.trim(),display_name:document.getElementById('imap-name').value.trim(),
password:document.getElementById('imap-password').value,imap_host:document.getElementById('imap-host').value.trim(),
imap_port:parseInt(document.getElementById('imap-port').value)||993,smtp_host:document.getElementById('smtp-host').value.trim(),
smtp_port:parseInt(document.getElementById('smtp-port').value)||587};
if (!body.email||!body.password||!body.imap_host){toast('Email, password and IMAP host required','error');return;}
btn.disabled=true;btn.textContent='Connecting...';
const r=await api('POST','/accounts',body);
btn.disabled=false;btn.textContent='Connect';
if (r?.ok){
toast('Account added — syncing…','success');
closeModal('add-account-modal');
await loadAccounts();
// Background sync takes a moment — reload folders/messages after a short wait
setTimeout(async ()=>{ await loadFolders(); await loadMessages(); toast('Sync complete','success'); }, 3000);
} else toast(r?.error||'Failed to add account','error');
}
async function detectMailSettings() {
const email=document.getElementById('imap-email').value.trim();
if (!email||!email.includes('@')){toast('Enter your email address first','error');return;}
const btn=document.getElementById('detect-btn');
btn.innerHTML='<span class="spinner-inline"></span>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');
2026-03-07 06:20:39 +00:00
}
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');
}
2026-03-07 06:20:39 +00:00
// ── Edit Account modal ─────────────────────────────────────────────────────
async function openEditAccount(id) {
closeAccountsMenu();
2026-03-07 06:20:39 +00:00
const r=await api('GET','/accounts/'+id);
if (!r) return;
document.getElementById('edit-account-id').value=id;
document.getElementById('edit-account-email').textContent=r.email_address;
document.getElementById('edit-name').value=r.display_name||'';
document.getElementById('edit-password').value='';
document.getElementById('edit-imap-host').value=r.imap_host||'';
document.getElementById('edit-imap-port').value=r.imap_port||993;
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
document.getElementById('edit-sync-days').value=r.sync_days||30;
2026-03-07 20:29:20 +00:00
// Restore sync mode select: map stored days/mode back to a preset option
const sel = document.getElementById('edit-sync-mode');
if (r.sync_mode==='all' || !r.sync_days) {
sel.value='all';
} else {
const presetMap={30:'preset-30',90:'preset-90',180:'preset-180',365:'preset-365',730:'preset-730',1825:'preset-1825'};
sel.value = presetMap[r.sync_days] || 'days';
}
2026-03-07 06:20:39 +00:00
toggleSyncDaysField();
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
connEl.style.display='none';
errEl.style.display=r.last_error?'block':'none';
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
// Load hidden folders for this account
const hiddenEl = document.getElementById('edit-hidden-folders');
const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
if (!hidden.length) {
hiddenEl.innerHTML='<span style="color:var(--muted);font-size:12px">No hidden folders.</span>';
} else {
hiddenEl.innerHTML = hidden.map(f=>`
<div style="display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border)">
<span style="font-size:13px">${esc(f.name)}</span>
<button class="btn-secondary" style="font-size:11px;padding:3px 10px" onclick="unhideFolder(${f.id})">Unhide</button>
</div>`).join('');
}
2026-03-07 06:20:39 +00:00
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='<span style="color:var(--muted);font-size:12px">No hidden folders.</span>';
else hiddenEl.innerHTML = hidden.map(f=>`
<div style="display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border)">
<span style="font-size:13px">${esc(f.name)}</span>
<button class="btn-secondary" style="font-size:11px;padding:3px 10px" onclick="unhideFolder(${f.id})">Unhide</button>
</div>`).join('');
}
} else toast('Failed to unhide folder','error');
}
2026-03-07 06:20:39 +00:00
function toggleSyncDaysField() {
const mode=document.getElementById('edit-sync-mode')?.value;
const row=document.getElementById('edit-sync-days-row');
2026-03-07 20:29:20 +00:00
if (row) row.style.display=(mode==='days')?'flex':'none';
2026-03-07 06:20:39 +00:00
}
async function testEditConnection() {
const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result');
const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim();
if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;}
btn.innerHTML='<span class="spinner-inline"></span>Testing...';btn.disabled=true;
const r=await api('POST','/accounts/test',{email,password:pw,
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993,
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587});
btn.textContent='Test Connection';btn.disabled=false;
connEl.textContent=(r?.ok)?'✓ Successful!':((r?.error)||'Failed');
connEl.className='test-result '+((r?.ok)?'ok':'err'); connEl.style.display='block';
}
async function saveAccountEdit() {
const id=document.getElementById('edit-account-id').value;
const body={display_name:document.getElementById('edit-name').value.trim(),
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993,
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587};
const pw=document.getElementById('edit-password').value;
if (pw) body.password=pw;
2026-03-07 20:29:20 +00:00
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
2026-03-07 06:20:39 +00:00
const [r1, r2] = await Promise.all([
api('PUT','/accounts/'+id, body),
api('PUT','/accounts/'+id+'/sync-settings',{
2026-03-07 20:29:20 +00:00
sync_mode: syncMode,
sync_days: syncDays,
2026-03-07 06:20:39 +00:00
}),
]);
if (r1?.ok){toast('Account updated','success');closeModal('edit-account-modal');loadAccounts();}
else toast(r1?.error||'Update failed','error');
}
async function deleteAccount(id) {
const a=S.accounts.find(a=>a.id===id);
inlineConfirm(
'Remove '+(a?a.email_address:'this account')+'? All synced messages will be deleted.',
async () => {
const r=await api('DELETE','/accounts/'+id);
if (r?.ok){toast('Account removed','success');closeAccountsMenu();loadAccounts();loadFolders();loadMessages();}
else toast('Remove failed','error');
}
);
}
// ── Inline confirm (replaces browser confirm()) ────────────────────────────
function inlineConfirm(message, onOk, onCancel) {
const el = document.getElementById('inline-confirm');
const msg = document.getElementById('inline-confirm-msg');
const ok = document.getElementById('inline-confirm-ok');
const cancel = document.getElementById('inline-confirm-cancel');
msg.textContent = message;
el.classList.add('open');
const cleanup = () => { el.classList.remove('open'); ok.onclick=null; cancel.onclick=null; };
ok.onclick = () => { cleanup(); onOk && onOk(); };
cancel.onclick = () => { cleanup(); onCancel && onCancel(); };
2026-03-07 06:20:39 +00:00
}
// ── Folders ────────────────────────────────────────────────────────────────
async function loadFolders() {
const data=await api('GET','/folders');
if (!data) return;
S.folders=data||[];
renderFolders();
updateUnreadBadge();
}
const FOLDER_ICONS = {
inbox:'<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>',
sent:'<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>',
drafts:'<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>',
trash:'<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>',
spam:'<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>',
archive:'<path d="M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z"/>',
custom:'<path d="M20 6h-2.18c.07-.44.18-.86.18-1 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .14.11.56.18 1H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z"/>',
};
function renderFolders() {
const el=document.getElementById('folders-by-account');
const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a);
const byAcc={};
S.folders.filter(f=>!f.is_hidden).forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);});
2026-03-07 06:20:39 +00:00
const prio=['inbox','sent','drafts','trash','spam','archive'];
el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{
2026-03-07 09:33:42 +00:00
const acc=accMap[parseInt(accId)];
const accColor = acc?.color || '#888';
const accEmail = acc?.email_address || 'Account '+accId;
if(!folders?.length) return '';
2026-03-07 06:20:39 +00:00
const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')];
return `<div class="nav-folder-header">
2026-03-07 09:33:42 +00:00
<span style="width:6px;height:6px;border-radius:50%;background:${accColor};display:inline-block;flex-shrink:0"></span>
2026-03-07 16:49:23 +00:00
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(accEmail)}</span>
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${parseInt(accId)},event)" style="margin-left:4px">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
</button>
2026-03-07 06:20:39 +00:00
</div>`+sorted.map(f=>`
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}" data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
oncontextmenu="showFolderMenu(event,${f.id})">
2026-03-07 06:20:39 +00:00
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
${esc(f.name)}
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">⊘</span>':''}
2026-03-07 06:20:39 +00:00
</div>`).join('');
}).join('');
}
function showFolderMenu(e, folderId) {
2026-03-07 06:20:39 +00:00
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=>
`<div class="ctx-item ctx-sub-item" onclick="moveFolderContents(${folderId},${x.id});closeMenu()">${esc(x.name)}</div>`
).join('');
const moveEntry = otherFolders.length ? `
<div class="ctx-item ctx-has-sub">📂 Move messages to
<span class="ctx-sub-arrow"></span>
<div class="ctx-submenu">${moveItems}</div>
</div>` : '';
const isTrashOrSpam = f.folder_type==='trash' || f.folder_type==='spam';
const emptyEntry = isTrashOrSpam
? `<div class="ctx-item danger" onclick="confirmEmptyFolder(${folderId});closeMenu()">🗑 Empty ${f.name}</div>` : '';
const disabledCount = S.folders.filter(x=>x.account_id===f.account_id&&!x.sync_enabled).length;
const enableAllEntry = disabledCount > 0
? `<div class="ctx-item" onclick="enableAllFolderSync(${f.account_id});closeMenu()">↻ Enable sync for all folders (${disabledCount})</div>` : '';
2026-03-07 06:20:39 +00:00
showCtxMenu(e, `
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()"> Sync this folder</div>
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
${enableAllEntry}
2026-03-08 11:48:27 +00:00
<div class="ctx-item" onclick="markFolderAllRead(${folderId});closeMenu()"> Mark all as read</div>
<div class="ctx-sep"></div>
${moveEntry}
${emptyEntry}
2026-03-07 16:49:23 +00:00
<div class="ctx-item" onclick="confirmHideFolder(${folderId});closeMenu()">👁 Hide from sidebar</div>
<div class="ctx-item danger" onclick="confirmDeleteFolder(${folderId});closeMenu()">🗑 Delete folder</div>`);
2026-03-07 06:20:39 +00:00
}
async function syncFolderNow(folderId) {
toast('Syncing folder…','info');
2026-03-07 06:20:39 +00:00
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');
}
2026-03-08 11:48:27 +00:00
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');
}
);
}
2026-03-07 16:49:23 +00:00
async function confirmHideFolder(folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
2026-03-07 16:49:23 +00:00
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');
}
);
}
2026-03-07 06:20:39 +00:00
function updateUnreadBadge() {
const total=S.folders.filter(f=>f.folder_type==='inbox').reduce((s,f)=>s+(f.unread_count||0),0);
const badge=document.getElementById('unread-total');
badge.textContent=total; badge.style.display=total>0?'':'none';
}
// ── Messages ───────────────────────────────────────────────────────────────
function selectFolder(folderId, folderName) {
S.currentFolder=folderId; S.currentFolderName=folderName||S.currentFolderName;
S.currentPage=1; S.messages=[]; S.searchQuery='';
document.getElementById('search-input').value='';
document.getElementById('panel-title').textContent=folderName||S.currentFolderName;
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
const navEl=folderId==='unified'?document.getElementById('nav-unified')
:folderId==='starred'?document.getElementById('nav-starred')
:document.getElementById('nav-f'+folderId);
if (navEl) navEl.classList.add('active');
loadMessages();
}
const handleSearch=debounce(q=>{
S.searchQuery=q.trim(); S.currentPage=1;
document.getElementById('panel-title').textContent=q.trim()?'Search: '+q.trim():S.currentFolderName;
loadMessages();
},350);
async function loadMessages(append) {
const list=document.getElementById('message-list');
if (!append) list.innerHTML='<div class="spinner" style="margin-top:60px"></div>';
let result;
if (S.searchQuery) result=await api('GET',`/search?q=${encodeURIComponent(S.searchQuery)}&page=${S.currentPage}&page_size=50`);
else if (S.currentFolder==='unified') result=await api('GET',`/messages/unified?page=${S.currentPage}&page_size=50`);
else if (S.currentFolder==='starred') result=await api('GET',`/messages/starred?page=${S.currentPage}&page_size=50`);
2026-03-07 06:20:39 +00:00
else result=await api('GET',`/messages?folder_id=${S.currentFolder}&page=${S.currentPage}&page_size=50`);
if (!result){list.innerHTML='<div class="empty-state"><p>Failed to load</p></div>';return;}
S.totalMessages=result.total||(result.messages||[]).length;
if (append) S.messages.push(...(result.messages||[]));
else S.messages=result.messages||[];
renderMessageList();
document.getElementById('panel-count').textContent=S.totalMessages>0?S.totalMessages+' messages':'';
}
2026-03-07 16:49:23 +00:00
function setFilter(mode) {
S.filterUnread = (mode === 'unread');
2026-03-08 11:48:27 +00:00
S.filterAttachment = (mode === 'attachment');
S.sortOrder = (mode === 'unread' || mode === 'default' || mode === 'attachment') ? 'date-desc' : mode;
2026-03-07 16:49:23 +00:00
// Update checkmarks
2026-03-08 11:48:27 +00:00
['default','unread','attachment','date-desc','date-asc','size-desc'].forEach(k => {
2026-03-07 16:49:23 +00:00
const el = document.getElementById('fopt-'+k);
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
});
// Update button label
const labels = {
2026-03-08 11:48:27 +00:00
'default':'Filter', 'unread':'Unread', 'attachment':'📎 Has Attachment',
'date-desc':'↓ Date', 'date-asc':'↑ Date', 'size-desc':'↓ Size'
2026-03-07 16:49:23 +00:00
};
const labelEl = document.getElementById('filter-label');
if (labelEl) {
labelEl.textContent = labels[mode] || 'Filter';
labelEl.style.color = mode !== 'default' ? 'var(--accent)' : '';
}
const menuEl = document.getElementById('filter-dropdown-menu');
if (menuEl) menuEl.style.display = 'none';
renderMessageList();
}
// Keep old names as aliases so nothing else breaks
function toggleFilterUnread() { setFilter(S.filterUnread ? 'default' : 'unread'); }
function setSortOrder(order) { setFilter(order); }
// ── Multi-select state ────────────────────────────────────────
if (!window.SEL) window.SEL = { ids: new Set(), lastIdx: -1 };
2026-03-07 06:20:39 +00:00
function renderMessageList() {
const list=document.getElementById('message-list');
2026-03-07 16:49:23 +00:00
let msgs = [...S.messages];
// Filter
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
2026-03-08 11:48:27 +00:00
if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment);
2026-03-07 16:49:23 +00:00
// Sort
if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
else if (S.sortOrder === 'size-desc') msgs.sort((a,b) => (b.size||0)-(a.size||0));
else msgs.sort((a,b) => new Date(b.date)-new Date(a.date));
2026-03-07 16:49:23 +00:00
if (!msgs.length){
2026-03-08 11:48:27 +00:00
const emptyMsg = S.filterUnread ? 'No unread messages' : S.filterAttachment ? 'No messages with attachments' : 'No messages';
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>${emptyMsg}</p></div>`;
2026-03-07 06:20:39 +00:00
return;
}
// Update bulk action bar
updateBulkBar();
list.innerHTML=msgs.map((m,i)=>`
<div class="message-item ${m.id===S.selectedMessageId&&!SEL.ids.size?'active':''} ${!m.is_read?'unread':''} ${SEL.ids.has(m.id)?'selected':''}"
data-id="${m.id}" data-idx="${i}"
draggable="true"
onclick="handleMsgClick(event,${m.id},${i})"
oncontextmenu="showMessageMenu(event,${m.id})"
ondragstart="handleMsgDragStart(event,${m.id})">
2026-03-07 06:20:39 +00:00
<div class="msg-top">
<span class="msg-from">${esc(m.from_name||m.from_email)}</span>
<span class="msg-date">${formatDate(m.date)}</span>
</div>
<div class="msg-subject">${esc(m.subject||'(no subject)')}</div>
<div class="msg-preview">${esc(m.preview||'')}</div>
<div class="msg-meta">
<span class="msg-dot" style="background:${m.account_color}"></span>
<span class="msg-acct">${esc(m.account_email||'')}</span>
2026-03-07 16:49:23 +00:00
${m.size?`<span style="font-size:10px;color:var(--muted);margin-left:4px">${formatSize(m.size)}</span>`:''}
2026-03-07 06:20:39 +00:00
${m.has_attachment?'<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--muted)" style="margin-left:4px"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>':''}
<span class="msg-star ${m.is_starred?'on':''}" onclick="toggleStar(${m.id},event)">${m.is_starred?'★':'☆'}</span>
</div>
</div>`).join('')+(S.messages.length<S.totalMessages
?`<div class="load-more"><button class="load-more-btn" onclick="loadMoreMessages()">Load more</button></div>`:'');
// Enable drag-drop onto folder nav items
document.querySelectorAll('.nav-item[data-fid]').forEach(el=>{
el.ondragover=e=>{e.preventDefault();el.classList.add('drag-over');};
el.ondragleave=()=>el.classList.remove('drag-over');
el.ondrop=e=>{
e.preventDefault(); el.classList.remove('drag-over');
const fid=parseInt(el.dataset.fid);
if (!fid) return;
const ids = SEL.ids.size ? [...SEL.ids] : [parseInt(e.dataTransfer.getData('text/plain'))];
ids.forEach(id=>moveMessage(id, fid, true));
SEL.ids.clear(); updateBulkBar(); renderMessageList();
};
});
}
function handleMsgClick(e, id, idx) {
if (e.ctrlKey || e.metaKey) {
// Toggle selection
SEL.ids.has(id) ? SEL.ids.delete(id) : SEL.ids.add(id);
SEL.lastIdx = idx;
renderMessageList(); return;
}
if (e.shiftKey && SEL.lastIdx >= 0) {
// Range select
const msgs = getFilteredSortedMsgs();
const lo=Math.min(SEL.lastIdx,idx), hi=Math.max(SEL.lastIdx,idx);
for (let i=lo;i<=hi;i++) SEL.ids.add(msgs[i].id);
renderMessageList(); return;
}
SEL.ids.clear(); SEL.lastIdx=idx;
openMessage(id);
}
function getFilteredSortedMsgs() {
let msgs=[...S.messages];
if (S.filterUnread) msgs=msgs.filter(m=>!m.is_read);
2026-03-08 11:48:27 +00:00
if (S.filterAttachment) msgs=msgs.filter(m=>m.has_attachment);
if (S.sortOrder==='date-asc') msgs.sort((a,b)=>new Date(a.date)-new Date(b.date));
else if (S.sortOrder==='size-desc') msgs.sort((a,b)=>(b.size||0)-(a.size||0));
else msgs.sort((a,b)=>new Date(b.date)-new Date(a.date));
return msgs;
}
function handleMsgDragStart(e, id) {
if (!SEL.ids.has(id)) { SEL.ids.clear(); SEL.ids.add(id); }
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed='move';
}
function updateBulkBar() {
let bar = document.getElementById('bulk-action-bar');
if (!bar) {
bar = document.createElement('div');
bar.id='bulk-action-bar';
bar.style.cssText='display:none;position:sticky;top:0;z-index:10;background:var(--accent);color:#fff;padding:6px 12px;font-size:12px;display:flex;align-items:center;gap:8px';
bar.innerHTML=`<span id="bulk-count"></span>
<button onclick="bulkMarkRead(true)" style="font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer">Mark read</button>
<button onclick="bulkMarkRead(false)" style="font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer">Mark unread</button>
<button onclick="bulkDelete()" style="font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer">Delete</button>
<button onclick="SEL.ids.clear();renderMessageList()" style="margin-left:auto;font-size:11px;padding:2px 8px;background:rgba(255,255,255,.2);border:none;border-radius:4px;color:#fff;cursor:pointer"> Clear</button>`;
document.getElementById('message-list').before(bar);
}
if (SEL.ids.size) {
bar.style.display='flex';
document.getElementById('bulk-count').textContent=SEL.ids.size+' selected';
} else {
bar.style.display='none';
}
}
async function bulkMarkRead(read) {
await Promise.all([...SEL.ids].map(id=>api('PUT','/messages/'+id+'/read',{read})));
SEL.ids.forEach(id=>{const m=S.messages.find(m=>m.id===id);if(m)m.is_read=read;});
SEL.ids.clear(); renderMessageList(); loadFolders();
}
async function bulkDelete() {
const count = SEL.ids.size;
inlineConfirm(
`Delete ${count} message${count===1?'':'s'}? This cannot be undone.`,
async () => {
const ids = [...SEL.ids];
await Promise.all(ids.map(id=>api('DELETE','/messages/'+id)));
ids.forEach(id=>{S.messages=S.messages.filter(m=>m.id!==id);});
SEL.ids.clear(); renderMessageList(); loadFolders();
}
);
2026-03-07 06:20:39 +00:00
}
function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
async function openMessage(id) {
S.selectedMessageId=id; renderMessageList();
const detail=document.getElementById('message-detail');
detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>';
const msg=await api('GET','/messages/'+id);
if (!msg){detail.innerHTML='<div class="no-message"><p>Failed to load</p></div>';return;}
S.currentMessage=msg;
renderMessageDetail(msg, false);
const li=S.messages.find(m=>m.id===id);
if (li&&!li.is_read){
li.is_read=true; renderMessageList();
// Sync read status to server (enqueues IMAP op via backend)
api('PUT','/messages/'+id+'/read',{read:true});
}
2026-03-07 06:20:39 +00:00
}
function renderMessageDetail(msg, showRemoteContent) {
const detail=document.getElementById('message-detail');
const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email);
// CSS injected into every iframe — forces white background so dark-themed emails
2026-03-08 11:48:27 +00:00
// don't inherit our app dark theme.
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
`img{max-width:100%;height:auto}</style>`;
2026-03-08 11:48:27 +00:00
// Script injected into srcdoc to report content height via postMessage.
// Required because removing allow-same-origin means contentDocument is null from parent.
const heightScript = `<script>
function _reportH(){parent.postMessage({type:'gomail-frame-h',h:document.documentElement.scrollHeight},'*');}
document.addEventListener('DOMContentLoaded',_reportH);
window.addEventListener('load',_reportH);
new MutationObserver(_reportH).observe(document.documentElement,{subtree:true,childList:true,attributes:true});
<\/script>`;
// NOTE: allow-scripts is needed for the height-reporting script above.
// allow-same-origin is intentionally excluded to prevent sandbox escape.
// Inline CID images are resolved to data: URIs during sync so no cid: scheme needed.
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
2026-03-07 06:20:39 +00:00
let bodyHtml='';
if (msg.body_html) {
if (allowed) {
2026-03-08 11:48:27 +00:00
const srcdoc = cssReset + heightScript + msg.body_html;
bodyHtml=`<iframe id="msg-frame" sandbox="${sandboxAttr}"
style="width:100%;border:none;min-height:200px;display:block"
srcdoc="${srcdoc.replace(/"/g,'&quot;')}"></iframe>`;
2026-03-07 06:20:39 +00:00
} else {
2026-03-08 11:48:27 +00:00
// Block external http(s) images but preserve data: URIs (inline/CID already resolved)
const stripped = msg.body_html
2026-03-08 11:48:27 +00:00
.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi, '<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi, 'url()')
.replace(/<link[^>]*>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const srcdoc = cssReset + stripped;
2026-03-07 06:20:39 +00:00
bodyHtml=`<div class="remote-content-banner">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
2026-03-07 06:20:39 +00:00
Remote images blocked.
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load images</button>
2026-03-07 06:20:39 +00:00
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
</div>
2026-03-08 11:48:27 +00:00
<iframe id="msg-frame" sandbox="${sandboxAttr}"
style="width:100%;border:none;min-height:200px;display:block"
srcdoc="${(cssReset + heightScript + stripped).replace(/"/g,'&quot;')}"></iframe>`;
2026-03-07 06:20:39 +00:00
}
} else {
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
}
let attachHtml='';
if (msg.attachments?.length) {
attachHtml=`<div class="attachments-bar">
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:8px">Attachments</span>
2026-03-08 11:48:27 +00:00
${msg.attachments.map(a=>{
const url=`/api/messages/${msg.id}/attachments/${a.id}`;
const viewable=/^(image\/|text\/|application\/pdf$|video\/|audio\/)/.test(a.content_type||'');
if(viewable){
return `<a class="attachment-chip" href="${url}" target="_blank" rel="noopener" title="Open ${esc(a.filename)}">
📎 <span>${esc(a.filename)}</span>
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
</a>`;
}
return `<a class="attachment-chip" href="${url}" download="${esc(a.filename)}" title="Download ${esc(a.filename)}">
📎 <span>${esc(a.filename)}</span>
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
</a>`;
}).join('')}
2026-03-07 06:20:39 +00:00
</div>`;
}
detail.innerHTML=`
<div class="detail-header">
<div class="detail-subject">${esc(msg.subject||'(no subject)')}</div>
<div class="detail-meta">
<div class="detail-from">
<strong>${esc(msg.from_name||msg.from_email)}</strong>
${msg.from_name?`<span style="color:var(--muted);font-size:12px"> &lt;${esc(msg.from_email)}&gt;</span>`:''}
${msg.to?`<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to)}</div>`:''}
${msg.cc?`<div style="font-size:12px;color:var(--muted)">CC: ${esc(msg.cc)}</div>`:''}
</div>
<div class="detail-date">${formatFullDate(msg.date)}</div>
</div>
</div>
<div class="detail-actions">
<button class="action-btn" onclick="openReply()"> Reply</button>
<button class="action-btn" onclick="openForward()"> Forward</button>
2026-03-08 11:48:27 +00:00
<button class="action-btn" onclick="openForwardAsAttachment()" title="Forward the original message as an .eml file attachment"> Fwd as Attachment</button>
2026-03-07 06:20:39 +00:00
<button class="action-btn" onclick="toggleStar(${msg.id})">${msg.is_starred?'★ Unstar':'☆ Star'}</button>
<button class="action-btn" onclick="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
<button class="action-btn" onclick="showMessageHeaders(${msg.id})"> Headers</button>
<button class="action-btn" onclick="downloadEML(${msg.id})"> Download</button>
2026-03-07 06:20:39 +00:00
<button class="action-btn danger" onclick="deleteMessage(${msg.id})">🗑 Delete</button>
</div>
${attachHtml}
<div class="detail-body">${bodyHtml}</div>`;
2026-03-08 11:48:27 +00:00
// Auto-size iframe via postMessage from injected height-reporting script.
// We cannot use contentDocument (null without allow-same-origin in sandbox).
if (msg.body_html) {
2026-03-08 11:48:27 +00:00
const frame = document.getElementById('msg-frame');
if (frame) {
2026-03-08 11:48:27 +00:00
// Clean up any previous listener
if (window._frameMsgHandler) window.removeEventListener('message', window._frameMsgHandler);
let lastH = 0;
window._frameMsgHandler = (e) => {
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
const h = e.data.h + 24;
if (Math.abs(h - lastH) > 4) { // avoid micro-flicker
lastH = h;
frame.style.height = h + 'px';
}
}
};
2026-03-08 11:48:27 +00:00
window.addEventListener('message', window._frameMsgHandler);
}
2026-03-07 06:20:39 +00:00
}
}
async function whitelistSender(sender) {
const r=await api('POST','/remote-content-whitelist',{sender});
if (r?.ok){S.remoteWhitelist.add(sender);toast('Always allowing content from '+sender,'success');if(S.currentMessage)renderMessageDetail(S.currentMessage,false);}
}
async function showMessageHeaders(id) {
const r=await api('GET','/messages/'+id+'/headers');
if (!r?.headers) return;
const rows=Object.entries(r.headers).filter(([,v])=>v)
.map(([k,v])=>`<tr><td style="color:var(--muted);padding:4px 12px 4px 0;font-size:12px;white-space:nowrap;vertical-align:top">${esc(k)}</td><td style="font-size:12px;word-break:break-all">${esc(v)}</td></tr>`).join('');
const rawText = r.raw||'';
2026-03-07 06:20:39 +00:00
const overlay=document.createElement('div');
overlay.className='modal-overlay open';
overlay.innerHTML=`<div class="modal" style="width:660px;max-height:85vh;display:flex;flex-direction:column">
2026-03-07 06:20:39 +00:00
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">Message Headers</h2>
<button class="icon-btn" onclick="this.closest('.modal-overlay').remove()"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
</div>
<div style="overflow-y:auto;flex:1">
<table style="width:100%;margin-bottom:16px"><tbody>${rows}</tbody></table>
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;margin-bottom:6px">Raw Headers</div>
<div style="position:relative">
<textarea id="raw-headers-ta" readonly style="width:100%;box-sizing:border-box;height:180px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text2);font-family:monospace;font-size:11px;padding:10px;resize:vertical;outline:none">${esc(rawText)}</textarea>
<button onclick="navigator.clipboard.writeText(document.getElementById('raw-headers-ta').value).then(()=>toast('Copied','success'))"
style="position:absolute;top:6px;right:8px;font-size:11px;padding:3px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--text2);cursor:pointer">Copy</button>
</div>
</div>
2026-03-07 06:20:39 +00:00
</div>`;
overlay.addEventListener('click',e=>{if(e.target===overlay)overlay.remove();});
document.body.appendChild(overlay);
}
function downloadEML(id) {
window.open('/api/messages/'+id+'/download.eml','_blank');
}
2026-03-07 06:20:39 +00:00
function showMessageMenu(e, id) {
e.preventDefault(); e.stopPropagation();
const msg = S.messages.find(m=>m.id===id);
const otherFolders = S.folders.filter(f=>!f.is_hidden&&f.id!==S.currentFolder).slice(0,16);
const moveItems = otherFolders.map(f=>`<div class="ctx-item ctx-sub-item" onclick="moveMessage(${id},${f.id});closeMenu()">${esc(f.name)}</div>`).join('');
const moveSub = otherFolders.length ? `
<div class="ctx-item ctx-has-sub">📂 Move to
<span class="ctx-sub-arrow"></span>
<div class="ctx-submenu">${moveItems}</div>
</div>` : '';
2026-03-07 06:20:39 +00:00
showCtxMenu(e,`
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()"> Reply</div>
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</div>
<div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div>
<div class="ctx-sep"></div>
${moveSub}
2026-03-07 06:20:39 +00:00
<div class="ctx-item" onclick="showMessageHeaders(${id});closeMenu()"> View headers</div>
<div class="ctx-item" onclick="downloadEML(${id});closeMenu()"> Download .eml</div>
2026-03-07 06:20:39 +00:00
<div class="ctx-sep"></div>
<div class="ctx-item danger" onclick="deleteMessage(${id});closeMenu()">🗑 Delete</div>`);
}
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, silent=false) {
2026-03-07 16:49:23 +00:00
const folder = S.folders.find(f=>f.id===folderId);
const doMove = async () => {
2026-03-07 16:49:23 +00:00
const r=await api('PUT','/messages/'+msgId+'/move',{folder_id:folderId});
if(r?.ok){if(!silent)toast('Moved','success');S.messages=S.messages.filter(m=>m.id!==msgId);
2026-03-07 16:49:23 +00:00
if(S.currentMessage?.id===msgId)resetDetail();loadFolders();}
else if(!silent) toast('Move failed','error');
};
if (silent) { doMove(); return; }
inlineConfirm(`Move this message to "${folder?.name||'selected folder'}"?`, doMove);
2026-03-07 06:20:39 +00:00
}
async function deleteMessage(id) {
inlineConfirm('Delete this message?', async () => {
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();}
else toast('Delete failed','error');
});
2026-03-07 06:20:39 +00:00
}
function resetDetail() {
S.currentMessage=null;S.selectedMessageId=null;
document.getElementById('message-detail').innerHTML=`<div class="no-message">
<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
<h3>Select a message</h3><p>Choose a message to read it</p></div>`;
}
function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)return Math.round(b/1024)+' KB';return(b/1048576).toFixed(1)+' MB';}
// ── Compose ────────────────────────────────────────────────────────────────
let composeAttachments=[];
function populateComposeFrom() {
const sel=document.getElementById('compose-from');
if(!sel) return;
sel.innerHTML=S.accounts.map(a=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} &lt;${esc(a.email_address)}&gt;</option>`).join('');
}
function openCompose(opts={}) {
S.composeMode=opts.mode||'new'; S.composeReplyToId=opts.replyId||null;
composeAttachments=[];
document.getElementById('compose-title').textContent=opts.title||'New Message';
document.getElementById('compose-minimised-label').textContent=opts.title||'New Message';
// Clear tag containers and re-init
['compose-to','compose-cc-tags','compose-bcc-tags'].forEach(id=>{
const c=document.getElementById(id);
if(c){ c.innerHTML=''; initTagField(id); }
});
2026-03-07 06:20:39 +00:00
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();
showCompose();
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80);
2026-03-07 06:20:39 +00:00
startDraftAutosave();
}
function showCompose() {
const d=document.getElementById('compose-dialog');
const m=document.getElementById('compose-minimised');
d.style.display='flex';
m.style.display='none';
S.composeVisible=true; S.composeMinimised=false;
2026-03-08 11:48:27 +00:00
initComposeDragDrop();
}
function minimizeCompose() {
document.getElementById('compose-dialog').style.display='none';
document.getElementById('compose-minimised').style.display='flex';
S.composeMinimised=true;
}
function restoreCompose() {
showCompose();
}
function closeCompose(skipCheck) {
if (!skipCheck && S.draftDirty) {
inlineConfirm('Save draft before closing?',
()=>{ saveDraft(); _closeCompose(); },
()=>{ _closeCompose(); }
);
return;
}
_closeCompose();
}
function _closeCompose() {
document.getElementById('compose-dialog').style.display='none';
document.getElementById('compose-minimised').style.display='none';
clearDraftAutosave();
S.composeVisible=false; S.composeMinimised=false; S.draftDirty=false;
}
function showCCRow() { document.getElementById('cc-row').style.display='flex'; }
function showBCCRow() { document.getElementById('bcc-row').style.display='flex'; }
2026-03-07 06:20:39 +00:00
function openReply() { if (S.currentMessage) openReplyTo(S.currentMessage.id); }
function openReplyTo(msgId) {
const msg=(S.currentMessage?.id===msgId)?S.currentMessage:S.messages.find(m=>m.id===msgId);
if (!msg) return;
openCompose({
mode:'reply', replyId:msgId, title:'Reply',
subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''),
body:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
});
addTag('compose-to', msg.from_email||'');
}
function openForward() {
if (!S.currentMessage) return;
const msg=S.currentMessage;
2026-03-08 11:48:27 +00:00
S.composeForwardFromId=msg.id;
2026-03-07 06:20:39 +00:00
openCompose({
2026-03-08 11:48:27 +00:00
mode:'forward', forwardId:msg.id, title:'Forward',
2026-03-07 06:20:39 +00:00
subject:'Fwd: '+(msg.subject||''),
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
});
}
2026-03-08 11:48:27 +00:00
function openForwardAsAttachment() {
if (!S.currentMessage) return;
const msg=S.currentMessage;
S.composeForwardFromId=msg.id;
openCompose({
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
subject:'Fwd: '+(msg.subject||''),
body:'',
});
// Add a visual placeholder chip (the actual EML is fetched server-side)
composeAttachments=[{name: sanitizeSubject(msg.subject||'message')+'.eml', size:0, isForward:true}];
updateAttachList();
}
function sanitizeSubject(s){return s.replace(/[/\\:*?"<>|]/g,'_').slice(0,60)||'message';}
2026-03-07 06:20:39 +00:00
// ── Email Tag Input ────────────────────────────────────────────────────────
function initTagField(containerId) {
const container=document.getElementById(containerId);
if (!container) return;
// Remove any existing input first
const old=container.querySelector('.tag-input');
if(old) old.remove();
2026-03-07 06:20:39 +00:00
const inp=document.createElement('input');
inp.type='text';
inp.className='tag-input';
inp.placeholder=containerId==='compose-to'?'recipient@example.com':'';
inp.setAttribute('autocomplete','off');
inp.setAttribute('spellcheck','false');
2026-03-07 06:20:39 +00:00
container.appendChild(inp);
const commit = () => {
const v=inp.value.trim().replace(/[,;\s]+$/,'');
if(v){ addTag(containerId,v); inp.value=''; }
};
2026-03-07 06:20:39 +00:00
inp.addEventListener('keydown', e=>{
if(e.key==='Enter'||e.key===','||e.key===';') { e.preventDefault(); commit(); }
else if(e.key===' ') {
// Space commits only if value looks like an email
const v=inp.value.trim();
if(v && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) { e.preventDefault(); commit(); }
} else if(e.key==='Backspace'&&!inp.value) {
2026-03-07 06:20:39 +00:00
const tags=container.querySelectorAll('.email-tag');
if(tags.length) tags[tags.length-1].remove();
2026-03-07 06:20:39 +00:00
}
S.draftDirty=true;
2026-03-07 06:20:39 +00:00
});
inp.addEventListener('blur', commit);
container.addEventListener('click', e=>{ if(e.target===container||e.target.tagName==='LABEL') inp.focus(); else if(!e.target.closest('.email-tag')) inp.focus(); });
2026-03-07 06:20:39 +00:00
}
function addTag(containerId, value) {
if (!value) return;
const container=document.getElementById(containerId);
if (!container) return;
const isValid=/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const tag=document.createElement('span');
tag.className='email-tag'+(isValid?'':' invalid');
tag.dataset.email=value;
const label=document.createElement('span');
label.textContent=value;
2026-03-07 06:20:39 +00:00
const remove=document.createElement('button');
remove.innerHTML='×'; remove.className='tag-remove'; remove.type='button';
2026-03-07 06:20:39 +00:00
remove.onclick=e=>{e.stopPropagation();tag.remove();S.draftDirty=true;};
tag.appendChild(label); tag.appendChild(remove);
2026-03-07 06:20:39 +00:00
const inp=container.querySelector('.tag-input');
container.insertBefore(tag, inp||null);
2026-03-07 06:20:39 +00:00
S.draftDirty=true;
}
function getTagValues(containerId) {
return Array.from(document.querySelectorAll('#'+containerId+' .email-tag'))
.map(t=>t.dataset.email||t.querySelector('span')?.textContent||'').filter(Boolean);
2026-03-07 06:20:39 +00:00
}
// ── Draft autosave ─────────────────────────────────────────────────────────
function startDraftAutosave() {
clearDraftAutosave();
S.draftTimer=setInterval(()=>{ if(S.draftDirty) saveDraft(true); }, 60000);
2026-03-07 06:20:39 +00:00
const editor=document.getElementById('compose-editor');
if(editor) editor.oninput=()=>S.draftDirty=true;
2026-03-07 06:20:39 +00:00
}
function clearDraftAutosave() {
if(S.draftTimer){ clearInterval(S.draftTimer); S.draftTimer=null; }
2026-03-07 06:20:39 +00:00
}
async function saveDraft(silent) {
S.draftDirty=false;
2026-03-08 11:48:27 +00:00
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
if(!accountId){ if(!silent) toast('Draft saved locally','success'); return; }
const editor=document.getElementById('compose-editor');
const meta={
account_id:accountId,
to:getTagValues('compose-to'),
subject:document.getElementById('compose-subject').value,
body_html:editor.innerHTML.trim(),
body_text:editor.innerText.trim(),
};
const r=await api('POST','/draft',meta);
if(!silent) toast('Draft saved','success');
2026-03-08 11:48:27 +00:00
else if(r?.ok) toast('Draft auto-saved to server','success');
2026-03-07 06:20:39 +00:00
}
// ── Compose formatting ─────────────────────────────────────────────────────
function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); }
2026-03-07 06:20:39 +00:00
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; }
2026-03-08 11:48:27 +00:00
function removeAttachment(i) {
// Don't remove EML forward placeholder (isForward) from UI; it's handled server-side
if(composeAttachments[i]?.isForward && S.composeMode==='forward-attachment'){
toast('The original message will be attached when sent','info'); return;
}
composeAttachments.splice(i,1); updateAttachList();
}
2026-03-07 06:20:39 +00:00
function updateAttachList() {
const el=document.getElementById('compose-attach-list');
if(!composeAttachments.length){el.innerHTML='';return;}
2026-03-07 06:20:39 +00:00
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
📎 <span>${esc(a.name)}</span>
2026-03-08 11:48:27 +00:00
<span style="color:var(--muted);font-size:10px">${a.size?formatSize(a.size):''}</span>
<button onclick="removeAttachment(${i})" class="tag-remove" type="button">×</button>
2026-03-07 06:20:39 +00:00
</div>`).join('');
}
2026-03-08 11:48:27 +00:00
// ── Compose drag-and-drop attachments ──────────────────────────────────────
function initComposeDragDrop() {
const dialog=document.getElementById('compose-dialog');
if(!dialog) return;
dialog.addEventListener('dragover', e=>{
e.preventDefault(); e.stopPropagation();
dialog.classList.add('drag-over');
});
dialog.addEventListener('dragleave', e=>{
if(!dialog.contains(e.relatedTarget)) dialog.classList.remove('drag-over');
});
dialog.addEventListener('drop', e=>{
e.preventDefault(); e.stopPropagation();
dialog.classList.remove('drag-over');
if(e.dataTransfer?.files?.length){
for(const file of e.dataTransfer.files) composeAttachments.push({file,name:file.name,size:file.size});
updateAttachList(); S.draftDirty=true;
toast(`${e.dataTransfer.files.length} file(s) attached`,'success');
}
});
}
2026-03-07 06:20:39 +00:00
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;}
2026-03-07 06:20:39 +00:00
const editor=document.getElementById('compose-editor');
const bodyHTML=editor.innerHTML.trim(), bodyText=editor.innerText.trim();
2026-03-07 06:20:39 +00:00
const btn=document.getElementById('send-btn');
btn.disabled=true; btn.textContent='Sending…';
2026-03-08 11:48:27 +00:00
const endpoint=S.composeMode==='reply'?'/reply'
:S.composeMode==='forward'?'/forward'
:S.composeMode==='forward-attachment'?'/forward-attachment'
:'/send';
const meta={
2026-03-07 06:20:39 +00:00
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,
2026-03-08 11:48:27 +00:00
forward_from_id:(S.composeMode==='forward'||S.composeMode==='forward-attachment')?S.composeForwardFromId:0,
};
let r;
// Use FormData when there are real file attachments, OR when forwarding as attachment
// (server needs multipart so it can read forward_from_id from meta and fetch the EML itself)
const hasRealFiles = composeAttachments.some(a => a.file instanceof Blob);
const needsFormData = hasRealFiles || S.composeMode === 'forward-attachment';
if(needsFormData){
const fd=new FormData();
fd.append('meta', JSON.stringify(meta));
for(const a of composeAttachments){
if(a.file instanceof Blob){ // only append real File/Blob objects
fd.append('file', a.file, a.name);
}
// isForward placeholders are intentionally skipped — the EML is fetched server-side
}
try{
const resp=await fetch('/api'+endpoint,{method:'POST',body:fd});
r=await resp.json();
}catch(e){ r={error:String(e)}; }
} else {
r=await api('POST',endpoint,meta);
}
btn.disabled=false; btn.textContent='Send';
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
2026-03-07 06:20:39 +00:00
else toast(r?.error||'Send failed','error');
}
// ── Compose drag + all-edge resize ─────────────────────────────────────────
2026-03-07 16:49:23 +00:00
function saveComposeGeometry(dlg) {
const r = dlg.getBoundingClientRect();
document.cookie = `compose_geo=${JSON.stringify({l:Math.round(r.left),t:Math.round(r.top),w:Math.round(r.width),h:Math.round(r.height)})};path=/;max-age=31536000`;
}
function loadComposeGeometry(dlg) {
try {
const m = document.cookie.match(/compose_geo=([^;]+)/);
if (!m) return false;
const g = JSON.parse(decodeURIComponent(m[1]));
if (!g.w||!g.h) return false;
const maxL = window.innerWidth - Math.max(360, g.w);
const maxT = window.innerHeight - Math.max(280, g.h);
dlg.style.left = Math.max(0, Math.min(g.l, maxL)) + 'px';
dlg.style.top = Math.max(0, Math.min(g.t, maxT)) + 'px';
dlg.style.width = Math.max(360, g.w) + 'px';
dlg.style.height = Math.max(280, g.h) + 'px';
dlg.style.right = 'auto'; dlg.style.bottom = 'auto';
const editor = document.getElementById('compose-editor');
if (editor) editor.style.height = (Math.max(280,g.h) - 242) + 'px';
return true;
} catch(e) { return false; }
}
function initComposeDragResize() {
const dlg=document.getElementById('compose-dialog');
if(!dlg) return;
2026-03-07 16:49:23 +00:00
// Restore saved position/size, or fall back to default bottom-right
if (!loadComposeGeometry(dlg)) {
dlg.style.right='24px'; dlg.style.bottom='20px';
dlg.style.left='auto'; dlg.style.top='auto';
}
// Drag by header
const header=document.getElementById('compose-drag-handle');
if(header) {
let ox,oy,startL,startT;
header.addEventListener('mousedown', e=>{
if(e.target.closest('button')) return;
const r=dlg.getBoundingClientRect();
ox=e.clientX; oy=e.clientY; startL=r.left; startT=r.top;
dlg.style.left=startL+'px'; dlg.style.top=startT+'px';
dlg.style.right='auto'; dlg.style.bottom='auto';
const mm=ev=>{
dlg.style.left=Math.max(0,Math.min(window.innerWidth-dlg.offsetWidth, startL+(ev.clientX-ox)))+'px';
dlg.style.top= Math.max(0,Math.min(window.innerHeight-30, startT+(ev.clientY-oy)))+'px';
};
2026-03-07 16:49:23 +00:00
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); saveComposeGeometry(dlg); };
document.addEventListener('mousemove',mm);
document.addEventListener('mouseup',mu);
e.preventDefault();
});
2026-03-07 06:20:39 +00:00
}
// Resize handles
dlg.querySelectorAll('.compose-resize').forEach(handle=>{
const dir=handle.dataset.dir;
handle.addEventListener('mousedown', e=>{
const rect=dlg.getBoundingClientRect();
const startX=e.clientX,startY=e.clientY;
const startW=rect.width,startH=rect.height,startL=rect.left,startT=rect.top;
const mm=ev=>{
let w=startW,h=startH,l=startL,t=startT;
const dx=ev.clientX-startX, dy=ev.clientY-startY;
if(dir.includes('e')) w=Math.max(360,startW+dx);
if(dir.includes('w')){ w=Math.max(360,startW-dx); l=startL+startW-w; }
if(dir.includes('s')) h=Math.max(280,startH+dy);
if(dir.includes('n')){ h=Math.max(280,startH-dy); t=startT+startH-h; }
dlg.style.width=w+'px'; dlg.style.height=h+'px';
dlg.style.left=l+'px'; dlg.style.top=t+'px';
dlg.style.right='auto'; dlg.style.bottom='auto';
const editor=document.getElementById('compose-editor');
if(editor) editor.style.height=(h-242)+'px';
};
2026-03-07 16:49:23 +00:00
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); saveComposeGeometry(dlg); };
document.addEventListener('mousemove',mm);
document.addEventListener('mouseup',mu);
e.preventDefault();
});
});
2026-03-07 06:20:39 +00:00
}
// ── 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);
2026-03-07 06:20:39 +00:00
}
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('Sync interval saved','success'); else toast('Failed','error');
2026-03-07 06:20:39 +00:00
}
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;}
2026-03-07 06:20:39 +00:00
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='';}
2026-03-07 06:20:39 +00:00
else toast(r?.error||'Failed','error');
}
async function renderMFAPanel() {
const me=await api('GET','/me'); if(!me) return;
2026-03-07 06:20:39 +00:00
const badge=document.getElementById('mfa-badge'), panel=document.getElementById('mfa-panel');
if(me.mfa_enabled) {
2026-03-07 06:20:39 +00:00
badge.innerHTML='<span class="badge green">Enabled</span>';
panel.innerHTML=`<p style="font-size:13px;color:var(--muted);margin-bottom:12px">TOTP active. Enter code to disable.</p>
<div class="modal-field"><label>Code</label><input type="text" id="mfa-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
<button class="btn-danger" onclick="disableMFA()">Disable MFA</button>`;
} else {
badge.innerHTML='<span class="badge red">Disabled</span>';
panel.innerHTML='<button class="btn-primary" onclick="beginMFASetup()">Set up Authenticator App</button>';
}
}
async function beginMFASetup() {
const r=await api('POST','/mfa/setup'); if(!r) return;
2026-03-07 06:20:39 +00:00
document.getElementById('mfa-panel').innerHTML=`
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Scan with your authenticator app.</p>
<div style="text-align:center;margin-bottom:14px"><img src="${r.qr_url}" style="border-radius:8px;background:white;padding:8px"></div>
<p style="font-size:11px;color:var(--muted);margin-bottom:12px;word-break:break-all">Key: <strong>${r.secret}</strong></p>
<div class="modal-field"><label>Confirm code</label><input type="text" id="mfa-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
<button class="btn-primary" onclick="confirmMFASetup()">Activate MFA</button>`;
}
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');
2026-03-07 06:20:39 +00:00
}
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');
2026-03-07 06:20:39 +00:00
}
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';
});
}
2026-03-07 16:49:23 +00:00
// ── Init tag fields and filter dropdown ───────────────────────────────────
// app.js loads at the bottom of <body> so the DOM is already ready here —
// we must NOT wrap in DOMContentLoaded (that event has already fired).
function _bootApp() {
2026-03-07 06:20:39 +00:00
initTagField('compose-to');
initTagField('compose-cc-tags');
initTagField('compose-bcc-tags');
2026-03-07 16:49:23 +00:00
// Filter dropdown
const dropBtn = document.getElementById('filter-dropdown-btn');
const dropMenu = document.getElementById('filter-dropdown-menu');
if (dropBtn && dropMenu) {
dropBtn.addEventListener('click', e => {
e.stopPropagation();
const isOpen = dropMenu.classList.contains('open');
dropMenu.classList.toggle('open', !isOpen);
if (!isOpen) {
document.addEventListener('click', () => dropMenu.classList.remove('open'), {once:true});
}
});
['default','unread','date-desc','date-asc','size-desc'].forEach(mode => {
const el = document.getElementById('fopt-'+mode);
if (el) el.addEventListener('click', e => { e.stopPropagation(); setFilter(mode); });
});
}
init();
2026-03-07 16:49:23 +00:00
}
// Run immediately — DOM is ready since this script is at end of <body>
_bootApp();
// ── Real-time poller + notifications ────────────────────────────────────────
// Polls /api/poll every 20s for unread count changes and new message detection.
// When new messages arrive: updates badge instantly, shows corner toast,
// and fires a browser OS notification if permission granted.
const POLLER = {
lastKnownID: 0, // highest message ID we've seen
timer: null,
active: false,
notifGranted: false,
};
async function startPoller() {
// Request browser notification permission (non-blocking)
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then(p => {
POLLER.notifGranted = p === 'granted';
});
} else if ('Notification' in window) {
POLLER.notifGranted = Notification.permission === 'granted';
}
POLLER.active = true;
schedulePoll();
}
function schedulePoll() {
if (!POLLER.active) return;
POLLER.timer = setTimeout(runPoll, 20000); // 20 second interval
}
async function runPoll() {
if (!POLLER.active) return;
try {
const data = await api('GET', '/poll?since=' + POLLER.lastKnownID);
if (!data) { schedulePoll(); return; }
// Update badge immediately without full loadFolders()
updateUnreadBadgeFromPoll(data.inbox_unread);
// New messages arrived
if (data.has_new && data.newest_id > POLLER.lastKnownID) {
const prevID = POLLER.lastKnownID;
POLLER.lastKnownID = data.newest_id;
// Fetch new message details for notifications
const newData = await api('GET', '/new-messages?since=' + prevID);
const newMsgs = newData?.messages || [];
if (newMsgs.length > 0) {
showNewMailToast(newMsgs);
sendOSNotification(newMsgs);
}
// Refresh current view if we're looking at inbox/unified
const isInboxView = S.currentFolder === 'unified' ||
S.folders.find(f => f.id === S.currentFolder && f.folder_type === 'inbox');
if (isInboxView) {
await loadMessages();
await loadFolders();
} else {
await loadFolders(); // update counts in sidebar
}
}
} catch(e) {
// Network error — silent, retry next cycle
}
schedulePoll();
}
// Update the unread badge in the sidebar and browser tab title
// without triggering a full folder reload
function updateUnreadBadgeFromPoll(inboxUnread) {
const badge = document.getElementById('unread-total');
if (!badge) return;
if (inboxUnread > 0) {
badge.textContent = inboxUnread > 99 ? '99+' : inboxUnread;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
// Update browser tab title
2026-03-08 11:48:27 +00:00
const base = 'GoWebMail';
document.title = inboxUnread > 0 ? `(${inboxUnread}) ${base}` : base;
}
// Corner toast notification for new mail
function showNewMailToast(msgs) {
const existing = document.getElementById('newmail-toast');
if (existing) existing.remove();
const count = msgs.length;
const first = msgs[0];
const fromLabel = first.from_name || first.from_email || 'Unknown';
const subject = first.subject || '(no subject)';
const text = count === 1
? `<strong>${escHtml(fromLabel)}</strong><br><span>${escHtml(subject)}</span>`
: `<strong>${count} new messages</strong><br><span>${escHtml(fromLabel)}: ${escHtml(subject)}</span>`;
const el = document.createElement('div');
el.id = 'newmail-toast';
el.className = 'newmail-toast';
el.innerHTML = `
<div class="newmail-toast-icon"></div>
<div class="newmail-toast-body">${text}</div>
<button class="newmail-toast-close" onclick="this.parentElement.remove()"></button>`;
// Click to open the message
el.addEventListener('click', (e) => {
if (e.target.classList.contains('newmail-toast-close')) return;
el.remove();
if (count === 1) {
selectFolder(
S.folders.find(f=>f.folder_type==='inbox')?.id || 'unified',
'Inbox'
);
setTimeout(()=>openMessage(first.id), 400);
} else {
selectFolder('unified', 'Unified Inbox');
}
});
document.body.appendChild(el);
// Auto-dismiss after 6s
setTimeout(() => { if (el.parentElement) el.remove(); }, 6000);
}
function escHtml(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// OS / browser notification
function sendOSNotification(msgs) {
if (!POLLER.notifGranted || !('Notification' in window)) return;
const count = msgs.length;
const first = msgs[0];
const title = count === 1
? (first.from_name || first.from_email || 'New message')
2026-03-08 11:48:27 +00:00
: `${count} new messages in GoWebMail`;
const body = count === 1
? (first.subject || '(no subject)')
: `${first.from_name || first.from_email}: ${first.subject || '(no subject)'}`;
try {
const n = new Notification(title, {
body,
icon: '/static/icons/icon-192.png', // use if you have one, else falls back gracefully
tag: 'gowebmail-new', // replaces previous if still visible
});
n.onclick = () => {
window.focus();
n.close();
if (count === 1) {
selectFolder(S.folders.find(f=>f.folder_type==='inbox')?.id||'unified','Inbox');
setTimeout(()=>openMessage(first.id), 400);
}
};
// Auto-close OS notification after 8s
setTimeout(()=>n.close(), 8000);
} catch(e) {
// Some browsers block even with granted permission in certain contexts
}
}