Files
gowebmail/web/static/js/app.js
T
2026-08-29 11:59:47 +01:00

2828 lines
136 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GoWebMail app.js — full client
// ── State ──────────────────────────────────────────────────────────────────
const S = {
me: null, accounts: [], providers: {gmail:false,outlook:false}, signatures: [],
folders: [], messages: [], totalMessages: 0, labels: [],
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
currentMessage: null, selectedMessageId: null,
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
filterUnread: false, filterAttachment: false,
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
uiPrefs: {}, // server-persisted UI preferences (collapsed accounts/folders etc.)
// Advanced search filters (Filter icon next to the search box) — cleared by clearSearchFilters().
searchFilters: { scope:'', hasAttachment:'', dateFrom:'', dateTo:'', minSizeKB:'', maxSizeKB:'', accountId:'', folderId:'' },
};
function hasActiveSearchFilters() {
const f = S.searchFilters;
return !!(f.scope || f.hasAttachment !== '' || f.dateFrom || f.dateTo || f.minSizeKB || f.maxSizeKB || f.accountId || f.folderId);
}
// ── UI Preferences (server-persisted, cross-device) ─────────────────────────
let _uiPrefsSaveTimer = null;
function uiPrefsGet(key, def) { return (key in S.uiPrefs) ? S.uiPrefs[key] : def; }
function uiPrefsSet(key, val) {
S.uiPrefs[key] = val;
clearTimeout(_uiPrefsSaveTimer);
_uiPrefsSaveTimer = setTimeout(() => {
api('PUT', '/ui-prefs', S.uiPrefs);
}, 600); // debounce 600ms
}
function isAccountCollapsed(accId) { return uiPrefsGet('ac_'+accId, false); }
function setAccountCollapsed(accId, v) { uiPrefsSet('ac_'+accId, v); }
// ── View settings (density / reading pane position / sidebar mode) ──────────
function applyViewPrefs() {
const density = uiPrefsGet('density', 'compact');
const pane = uiPrefsGet('readingPane', 'right');
const sidebarMode = uiPrefsGet('sidebarMode', 'expanded');
const root = document.getElementById('app-root');
if (root) { root.dataset.density = density; root.dataset.readingPane = pane; root.dataset.sidebar = sidebarMode; }
[['compact','Compact'],['comfortable','Comfortable']].forEach(([v,label]) => {
const el = document.getElementById('vopt-density-'+v);
if (el) el.textContent = (v===density?'✓ ':'○ ') + label;
});
[['right','Right'],['bottom','Bottom']].forEach(([v,label]) => {
const el = document.getElementById('vopt-pane-'+v);
if (el) el.textContent = (v===pane?'✓ ':'○ ') + label;
});
[['expanded','Pinned (always visible)'],['collapsed','Minimized'],['auto','Auto-hide (peek on hover)']].forEach(([v,label]) => {
const el = document.getElementById('vopt-sidebar-'+v);
if (el) el.textContent = (v===sidebarMode?'✓ ':'○ ') + label;
});
const listPanel = document.querySelector('.message-list-panel');
if (listPanel) {
const widthPx = uiPrefsGet('panelWidthPx', null);
const heightPct = uiPrefsGet('panelHeightPct', null);
if (widthPx) listPanel.style.width = widthPx + 'px';
if (heightPct) listPanel.style.height = heightPct + '%';
}
}
// ── Draggable list/reading-pane divider (desktop only) ───────────────────────
// Persisted via uiPrefs (server-side, cross-device) rather than a cookie.
function initPanelResize() {
const handle = document.getElementById('panel-resize-handle');
const listPanel = document.querySelector('.message-list-panel');
if (!handle || !listPanel) return;
let dragging = false;
handle.addEventListener('mousedown', e => {
dragging = true;
handle.classList.add('dragging');
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
const mailView = document.getElementById('mail-view');
const r = mailView.getBoundingClientRect();
if (document.getElementById('app-root').dataset.readingPane === 'bottom') {
const pct = Math.min(80, Math.max(15, ((e.clientY - r.top) / r.height) * 100));
listPanel.style.height = pct + '%';
} else {
const w = Math.min(680, Math.max(220, e.clientX - r.left));
listPanel.style.width = w + 'px';
}
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
handle.classList.remove('dragging');
document.body.style.userSelect = '';
if (document.getElementById('app-root').dataset.readingPane === 'bottom') {
uiPrefsSet('panelHeightPct', parseFloat(listPanel.style.height));
} else {
uiPrefsSet('panelWidthPx', parseInt(listPanel.style.width));
}
});
}
function setViewPref(key, val) {
uiPrefsSet(key, val);
applyViewPrefs();
closeViewDropdown();
}
// Quick manual toggle (header button + edge tab): flip between fully visible
// and hidden. Doesn't touch 'auto' as a saved preference — pick that from the
// View menu; this button just pins/unpins whatever's currently hidden.
function toggleSidebarCollapse() {
const cur = uiPrefsGet('sidebarMode', 'expanded');
setViewPref('sidebarMode', cur === 'expanded' ? 'collapsed' : 'expanded');
}
function toggleViewDropdown(e) {
e.stopPropagation();
const menu = document.getElementById('view-dropdown-menu');
if (!menu) return;
const isOpen = menu.style.display !== 'none';
menu.style.display = isOpen ? 'none' : 'block';
if (!isOpen) setTimeout(() => document.addEventListener('click', closeViewDropdown, { once: true }), 0);
}
function closeViewDropdown() {
const menu = document.getElementById('view-dropdown-menu');
if (menu) menu.style.display = 'none';
}
// ── Boot ───────────────────────────────────────────────────────────────────
async function init() {
const [me, providers, wl, uiPrefsRaw] = await Promise.all([
api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'),
api('GET','/ui-prefs'),
]);
if (me) {
S.me = me;
document.getElementById('user-display').textContent = me.username || me.email;
if (me.role === 'admin') document.getElementById('admin-link').style.display = 'block';
}
if (providers) { S.providers = providers; updateProviderButtons(); }
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw;
applyViewPrefs();
await loadAccounts();
await loadFolders();
await loadLabels();
await loadMessages();
// Seed poller ID so we don't notify on initial load
if (S.messages.length > 0) {
POLLER.lastKnownID = Math.max(...S.messages.map(m=>m.id));
}
const p = new URLSearchParams(location.search);
if (p.get('connected')) {
toast('Account connected! Loading…', 'success');
history.replaceState({},'','/');
await loadAccounts();
pollForNewFolders();
}
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
// Handle actions from full-page message/compose views
if (p.get('action') === 'reply' && p.get('id')) {
history.replaceState({},'','/');
const id = parseInt(p.get('id'));
// Load the message then open reply
setTimeout(async () => {
const msg = await api('GET', '/messages/'+id);
if (msg) { S.currentMessage = msg; openReplyTo(id); }
}, 500);
}
if (p.get('action') === 'forward' && p.get('id')) {
history.replaceState({},'','/');
const id = parseInt(p.get('id'));
setTimeout(async () => {
const msg = await api('GET', '/messages/'+id);
if (msg) { S.currentMessage = msg; openForward(); }
}, 500);
}
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();
initPanelResize();
startPoller();
mobSetView('list'); // initialise mobile view state
}
// ── Providers ──────────────────────────────────────────────────────────────
function updateProviderButtons() {
['gmail','outlook','outlook_personal'].forEach(p => {
const btn = document.getElementById('btn-'+p);
if (!btn) return;
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; }
});
}
// ── Settings: connected-accounts list ────────────────────────────────────────
function renderAccountsSettingsList() {
const el = document.getElementById('settings-accounts-list');
if (!el) return; // Settings modal not open yet — nothing to render into
if (!S.accounts.length) {
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
return;
}
el.innerHTML = S.accounts.map(a => {
const hasWarning = a.last_error || a.token_expired;
const warningTitle = a.token_expired ? 'OAuth token expired — click Manage to reconnect' : (a.last_error ? '⚠ '+a.last_error : '');
return `
<div class="acct-row" title="${esc(a.email_address)}${hasWarning?' — '+warningTitle:''}">
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
<div style="flex:1;min-width:0">
<div style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</div>
<div style="font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.email_address)}</div>
</div>
${a.token_expired?'<span style="color:var(--danger);font-size:11px" title="OAuth token expired">🔑</span>':
a.last_error?'<span style="color:var(--danger);font-size:11px" title="'+esc(a.last_error)+'">⚠</span>':''}
<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="btn-secondary" style="font-size:11px;padding:3px 10px" onclick="openEditAccount(${a.id})">Manage</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>
</div>`;
}).join('');
}
// ── Accounts ───────────────────────────────────────────────────────────────
async function loadAccounts() {
const data = await api('GET','/accounts');
if (!data) return;
S.accounts = data;
renderAccountsSettingsList();
populateComposeFrom();
loadSignatures();
}
// ── Signatures (compose prefill) ────────────────────────────────────────────
async function loadSignatures() {
const data = await api('GET','/signatures');
if (data) S.signatures = data;
}
// Returns the HTML for an account's default-for-new or default-for-reply signature, or ''.
function getSignatureHTML(accountId, forReply) {
const acc = S.accounts.find(a => a.id === accountId);
if (!acc) return '';
const sigId = forReply ? acc.default_signature_reply_id : acc.default_signature_new_id;
if (!sigId) return '';
const sig = S.signatures.find(s => s.id === sigId);
return sig ? sig.content_html : '';
}
// Renders the (possibly empty) signature block with a stable id so it can be swapped
// live if the From-account changes mid-compose, without touching the rest of the body.
function signatureBlockHTML(accountId, forReply) {
const html = getSignatureHTML(accountId, forReply);
return `<div id="sig-block">${html ? '<br>' + html : ''}</div>`;
}
// Bound to #compose-from's change event — swaps just the signature block.
function onComposeFromChange() {
const accountId = parseInt(document.getElementById('compose-from')?.value || 0);
const forReply = S.composeMode !== 'new';
const block = document.getElementById('sig-block');
if (block) block.outerHTML = signatureBlockHTML(accountId, forReply);
}
function connectOAuth(p) {
if (p === 'outlook_personal') {
location.href = '/auth/outlook-personal/connect';
} else {
location.href = '/auth/' + p + '/connect';
}
}
function openAddAccountModal() {
['imap-email','imap-name','imap-password','imap-host','smtp-host','imap-caldav-url','imap-carddav-url'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
document.getElementById('imap-port').value='993';
document.getElementById('smtp-port').value='587';
document.getElementById('use-jmap').checked=false;
toggleJMAPFields();
const r=document.getElementById('test-result'); if(r){r.style.display='none';r.className='test-result';}
openModal('add-account-modal');
}
// Toggles the Add Account modal between IMAP/SMTP fields and a single JMAP
// server URL field (JMAP has no separate SMTP host/port or IMAP port — one
// base URL covers both mail access and sending).
function toggleJMAPFields() {
const jmap=document.getElementById('use-jmap').checked;
document.getElementById('imap-port-field').style.display=jmap?'none':'';
document.getElementById('smtp-fields').style.display=jmap?'none':'';
document.getElementById('imap-hint').style.display=jmap?'none':'';
const label=document.getElementById('imap-host-label'), host=document.getElementById('imap-host');
label.textContent=jmap?'JMAP Server URL':'IMAP Host';
host.placeholder=jmap?'https://mail.example.com:8443':'imap.example.com';
}
async function testNewConnection() {
const btn=document.getElementById('test-btn'), result=document.getElementById('test-result');
const jmap=document.getElementById('use-jmap').checked;
const body={email:document.getElementById('imap-email').value.trim(),password:document.getElementById('imap-password').value,
imap_host:document.getElementById('imap-host').value.trim()};
if (jmap) { body.provider='jmap'; }
else {
body.imap_port=parseInt(document.getElementById('imap-port').value)||993;
body.smtp_host=document.getElementById('smtp-host').value.trim();
body.smtp_port=parseInt(document.getElementById('smtp-port').value)||587;
}
if (!body.email||!body.password||!body.imap_host){result.textContent=(jmap?'Email, password and JMAP server URL required.':'Email, password and IMAP host required.');result.className='test-result err';result.style.display='block';return;}
btn.innerHTML='<span class="spinner-inline"></span>Testing...';btn.disabled=true;
const r=await api('POST','/accounts/test',body,20000);
btn.textContent='Test Connection';btn.disabled=false;
result.textContent=(r?.ok)?'✓ Connection successful!':((r?.error?.message||r?.error)||'Connection failed');
result.className='test-result '+((r?.ok)?'ok':'err'); result.style.display='block';
}
async function addIMAPAccount() {
const btn=document.getElementById('save-acct-btn');
const jmap=document.getElementById('use-jmap').checked;
const body={email:document.getElementById('imap-email').value.trim(),display_name:document.getElementById('imap-name').value.trim(),
password:document.getElementById('imap-password').value,imap_host:document.getElementById('imap-host').value.trim()};
if (jmap) { body.provider='jmap'; }
else {
body.imap_port=parseInt(document.getElementById('imap-port').value)||993;
body.smtp_host=document.getElementById('smtp-host').value.trim();
body.smtp_port=parseInt(document.getElementById('smtp-port').value)||587;
}
body.caldav_url=document.getElementById('imap-caldav-url').value.trim();
body.carddav_url=document.getElementById('imap-carddav-url').value.trim();
if (!body.email||!body.password||!body.imap_host){toast((jmap?'Email, password and JMAP server URL required':'Email, password and IMAP host required'),'error');return;}
btn.disabled=true;btn.textContent='Connecting...';
const r=await api('POST','/accounts',body);
btn.disabled=false;btn.textContent='Connect';
if (r?.ok){
toast('Account added — syncing…','success');
closeModal('add-account-modal');
await loadAccounts();
pollForNewFolders();
} else toast(r?.error||'Failed to add account','error');
}
// Repeatedly reloads folders/accounts until the newly-added account's folders show up
// (initial IMAP sync takes a few seconds), instead of guessing a fixed delay. Also
// refreshes messages/unread counts so the sidebar updates without a manual page reload.
function pollForNewFolders() {
let tries = 0;
const poll = setInterval(async () => {
tries++;
await loadAccounts();
await loadFolders();
await loadMessages();
const hasFolders = S.accounts.some(a => S.folders.some(f => f.account_id === a.id));
if (hasFolders || tries >= 12) {
clearInterval(poll);
if (hasFolders) toast('Account ready!', 'success');
}
}, 2500);
}
async function detectMailSettings() {
const email=document.getElementById('imap-email').value.trim();
if (!email||!email.includes('@')){toast('Enter your email address first','error');return;}
const btn=document.getElementById('detect-btn');
btn.innerHTML='<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');
}
async function syncNow(id, e) {
if (e) e.stopPropagation();
toast('Syncing…','info');
const r = await api('POST','/accounts/'+id+'/sync');
if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadAccounts(); loadFolders(); loadMessages(); }
else toast(r?.error||'Sync failed','error');
}
// ── Edit Account modal ─────────────────────────────────────────────────────
// Opened from the Settings modal's Accounts tab; stacks above it (see
// #edit-account-modal z-index in CSS) rather than closing Settings, so
// Cancel/Save land the user back in Settings automatically.
async function openEditAccount(id) {
const r=await api('GET','/accounts/'+id);
if (!r) return;
document.getElementById('edit-account-id').value=id;
document.getElementById('edit-account-email').textContent=r.email_address;
document.getElementById('edit-name').value=r.display_name||'';
const isOAuth = r.provider==='gmail' || r.provider==='outlook' || r.provider==='outlook_personal';
// Show/hide credential section and test button based on provider type
document.getElementById('edit-creds-section').style.display = isOAuth ? 'none' : '';
document.getElementById('edit-test-btn').style.display = isOAuth ? 'none' : '';
const oauthSection = document.getElementById('edit-oauth-section');
if (oauthSection) oauthSection.style.display = isOAuth ? '' : 'none';
if (isOAuth) {
const providerLabel = r.provider==='gmail' ? 'Google' : r.provider==='outlook_personal' ? 'Microsoft (Personal)' : 'Microsoft';
const lbl = document.getElementById('edit-oauth-provider-label');
const lblBtn = document.getElementById('edit-oauth-provider-label-btn');
const expWarn = document.getElementById('edit-oauth-expired-warning');
if (lbl) lbl.textContent = providerLabel;
if (lblBtn) lblBtn.textContent = providerLabel;
if (expWarn) expWarn.style.display = r.token_expired ? '' : 'none';
const reconnectBtn = document.getElementById('edit-oauth-reconnect-btn');
if (reconnectBtn) reconnectBtn.onclick = () => {
closeModal('edit-account-modal');
connectOAuth(r.provider);
};
}
const isJMAP = r.provider==='jmap';
if (!isOAuth) {
document.getElementById('edit-password').value='';
document.getElementById('edit-imap-host').value=r.imap_host||'';
document.getElementById('edit-imap-port').value=r.imap_port||993;
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
document.getElementById('edit-imap-port-field').style.display=isJMAP?'none':'';
document.getElementById('edit-smtp-fields').style.display=isJMAP?'none':'';
document.getElementById('edit-imap-host-label').textContent=isJMAP?'JMAP Server URL':'IMAP Host';
}
document.getElementById('edit-caldav-url').value=r.caldav_url||'';
document.getElementById('edit-carddav-url').value=r.carddav_url||'';
document.getElementById('edit-sync-days').value=r.sync_days||30;
const sel = document.getElementById('edit-sync-mode');
if (r.sync_mode==='all' || !r.sync_days) {
sel.value='all';
} else {
const presetMap={30:'preset-30',90:'preset-90',180:'preset-180',365:'preset-365',730:'preset-730',1825:'preset-1825'};
sel.value = presetMap[r.sync_days] || 'days';
}
toggleSyncDaysField();
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
connEl.style.display='none';
errEl.style.display=r.last_error?'block':'none';
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
const hiddenEl = document.getElementById('edit-hidden-folders');
const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
if (!hidden.length) {
hiddenEl.innerHTML='<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('');
}
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');
}
function toggleSyncDaysField() {
const mode=document.getElementById('edit-sync-mode')?.value;
const row=document.getElementById('edit-sync-days-row');
if (row) row.style.display=(mode==='days')?'flex':'none';
}
async function testEditConnection() {
// Only relevant for IMAP/SMTP accounts — OAuth accounts reconnect via the button
if (document.getElementById('edit-creds-section').style.display === 'none') {
return;
}
const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result');
const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim();
if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;}
const isJMAP=document.getElementById('edit-imap-port-field').style.display==='none';
const testBody={email,password:pw,imap_host:document.getElementById('edit-imap-host').value.trim()};
if (isJMAP) { testBody.provider='jmap'; }
else {
testBody.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993;
testBody.smtp_host=document.getElementById('edit-smtp-host').value.trim();
testBody.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587;
}
btn.innerHTML='<span class="spinner-inline"></span>Testing...';btn.disabled=true;
const r=await api('POST','/accounts/test',testBody,20000);
btn.textContent='Test Connection';btn.disabled=false;
connEl.textContent=(r?.ok)?'✓ Successful!':((r?.error?.message||r?.error)||'Failed');
connEl.className='test-result '+((r?.ok)?'ok':'err'); connEl.style.display='block';
}
async function saveAccountEdit() {
const id=document.getElementById('edit-account-id').value;
const isOAuth = document.getElementById('edit-creds-section').style.display === 'none';
const body={display_name:document.getElementById('edit-name').value.trim()};
if (!isOAuth) {
body.imap_host=document.getElementById('edit-imap-host').value.trim();
body.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993;
body.smtp_host=document.getElementById('edit-smtp-host').value.trim();
body.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587;
const pw=document.getElementById('edit-password').value;
if (pw) body.password=pw;
}
body.caldav_url=document.getElementById('edit-caldav-url').value.trim();
body.carddav_url=document.getElementById('edit-carddav-url').value.trim();
const modeVal = document.getElementById('edit-sync-mode').value;
let syncMode='all', syncDays=0;
if (modeVal==='days') {
syncMode='days'; syncDays=parseInt(document.getElementById('edit-sync-days').value)||30;
} else if (modeVal.startsWith('preset-')) {
syncMode='days'; syncDays=parseInt(modeVal.replace('preset-',''));
} // else 'all': syncMode='all', syncDays=0
const [r1, r2] = await Promise.all([
api('PUT','/accounts/'+id, body),
api('PUT','/accounts/'+id+'/sync-settings',{
sync_mode: syncMode,
sync_days: syncDays,
}),
]);
if (r1?.ok){toast('Account updated','success');closeModal('edit-account-modal');loadAccounts();}
else toast(r1?.error||'Update failed','error');
}
async function deleteAccount(id) {
const a=S.accounts.find(a=>a.id===id);
inlineConfirm(
'Remove '+(a?a.email_address:'this account')+'? All synced messages will be deleted.',
async () => {
const r=await api('DELETE','/accounts/'+id);
if (r?.ok){toast('Account removed','success');loadAccounts();loadFolders();loadMessages();}
else toast('Remove failed','error');
}
);
}
// ── Inline confirm (replaces browser confirm()) ────────────────────────────
function inlineConfirm(message, onOk, onCancel) {
const el = document.getElementById('inline-confirm');
const msg = document.getElementById('inline-confirm-msg');
const ok = document.getElementById('inline-confirm-ok');
const cancel = document.getElementById('inline-confirm-cancel');
msg.textContent = message;
el.classList.add('open');
const cleanup = () => { el.classList.remove('open'); ok.onclick=null; cancel.onclick=null; };
ok.onclick = () => { cleanup(); onOk && onOk(); };
cancel.onclick = () => { cleanup(); onCancel && onCancel(); };
}
// ── Inline prompt (replaces browser prompt()) ──────────────────────────────
function inlinePrompt(message, onOk, defaultValue) {
const el = document.getElementById('inline-prompt');
const msg = document.getElementById('inline-prompt-msg');
const input = document.getElementById('inline-prompt-input');
const ok = document.getElementById('inline-prompt-ok');
const cancel = document.getElementById('inline-prompt-cancel');
msg.textContent = message;
input.value = defaultValue || '';
el.classList.add('open');
setTimeout(() => { input.focus(); input.select(); }, 50);
const cleanup = () => { el.classList.remove('open'); ok.onclick=null; cancel.onclick=null; };
ok.onclick = () => { const v=input.value.trim(); if(!v) return; cleanup(); onOk && onOk(v); };
cancel.onclick = () => { cleanup(); };
}
// ── 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);
});
const prio = ['inbox','sent','drafts','trash','spam','archive'];
const orderedAccounts = [...S.accounts].sort((a,b) => (a.sort_order||0) - (b.sort_order||0));
el.innerHTML = orderedAccounts.map(acc => {
const folders = byAcc[acc.id];
// Show account even if no folders yet — it was just added and syncer hasn't run
if (!folders?.length) {
const statusHtml = acc.last_error
? `<div style="padding:6px 10px 8px;font-size:11px;color:var(--danger);background:rgba(239,68,68,.08);border-radius:0 0 6px 6px;line-height:1.4">
${esc(acc.last_error)}
</div>`
: `<div style="padding:6px 12px 8px;font-size:11px;color:var(--muted)">⏳ Syncing folders…</div>`;
return `<div class="nav-account-group" data-acc-id="${acc.id}">
<div class="nav-folder-header" style="cursor:default">
<span class="acc-drag-handle">&#8942;</span>
<span style="width:7px;height:7px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${esc(acc.email_address)}">${esc(acc.display_name||acc.email_address)}</span>
<button class="icon-sync-btn" title="Retry sync" onclick="syncNow(${acc.id},event)" style="flex-shrink:0">
<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>
</div>
${statusHtml}
</div>`;
}
const accId = acc.id;
const collapsed = isAccountCollapsed(accId);
const sorted = [
...prio.map(t => folders.find(f => f.folder_type===t)).filter(Boolean),
...folders.filter(f => f.folder_type==='custom')
];
const totalUnread = folders.reduce((s,f) => s+(f.unread_count||0), 0);
const chevron = collapsed
? '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>'
: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>';
const folderRows = collapsed ? '' : 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})">
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
${esc(f.name)}
<span class="folder-count-group">
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
${f.total_count>0?`<span class="folder-total-count">${f.unread_count>0?'/':''}${f.total_count}</span>`:''}
</span>
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted)" title="Sync disabled">\u29b8</span>':''}
</div>`).join('');
return `<div class="nav-account-group" data-acc-id="${accId}"
draggable="true"
ondragstart="accDragStart(event,${accId})"
ondragover="accDragOver(event)"
ondragleave="accDragLeave(event)"
ondrop="accDrop(event,${accId})">
<div class="nav-folder-header" onclick="toggleAccountCollapse(${accId})"
oncontextmenu="showAccountMenu(event,${accId})">
<span class="acc-drag-handle" title="Drag to reorder" onclick="event.stopPropagation()">&#8942;</span>
<span style="width:7px;height:7px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${esc(acc.email_address)}">${esc(acc.display_name||acc.email_address)}</span>
${totalUnread>0&&collapsed?`<span class="unread-badge" style="margin-left:auto">${totalUnread}</span>`:''}
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${accId},event)" style="flex-shrink:0">
<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>
<span class="acc-chevron">${chevron}</span>
</div>
${folderRows}
</div>`;
}).join('');
// Re-wire drag-drop onto folder rows for message-to-folder moves
el.querySelectorAll('.nav-item[data-fid]').forEach(item => {
item.ondragover = e => { e.preventDefault(); item.classList.add('drag-over'); };
item.ondragleave = () => item.classList.remove('drag-over');
item.ondrop = e => {
e.preventDefault(); item.classList.remove('drag-over');
const fid = parseInt(item.dataset.fid);
const mid = parseInt(e.dataTransfer.getData('text/plain'));
if (mid && fid) moveMessage(mid, fid);
};
});
}
function toggleAccountCollapse(accId) {
setAccountCollapsed(accId, !isAccountCollapsed(accId));
renderFolders();
}
// ── Account drag-to-reorder ─────────────────────────────────────────────────
let _dragSrcAccId = null;
function accDragStart(e, accId) {
_dragSrcAccId = accId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(accId));
setTimeout(() => e.currentTarget?.classList.add('acc-dragging'), 0);
}
function accDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const g = e.currentTarget;
if (g && parseInt(g.dataset.accId) !== _dragSrcAccId) g.classList.add('acc-drag-target');
}
function accDragLeave(e) { e.currentTarget?.classList.remove('acc-drag-target'); }
async function accDrop(e, targetAccId) {
e.preventDefault();
e.currentTarget?.classList.remove('acc-drag-target');
document.querySelectorAll('.acc-dragging').forEach(el => el.classList.remove('acc-dragging'));
if (_dragSrcAccId === null || _dragSrcAccId === targetAccId) { _dragSrcAccId = null; return; }
const ordered = [...S.accounts].sort((a,b) => (a.sort_order||0)-(b.sort_order||0));
const srcIdx = ordered.findIndex(a => a.id === _dragSrcAccId);
const dstIdx = ordered.findIndex(a => a.id === targetAccId);
if (srcIdx === -1 || dstIdx === -1) { _dragSrcAccId = null; return; }
const [moved] = ordered.splice(srcIdx, 1);
ordered.splice(dstIdx, 0, moved);
ordered.forEach((a, i) => { a.sort_order = i; });
S.accounts = ordered;
_dragSrcAccId = null;
renderFolders();
await api('PUT', '/accounts/sort-order', { order: ordered.map(a => a.id) });
}
function showFolderMenu(e, folderId) {
e.preventDefault(); e.stopPropagation();
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
const syncLabel = f.sync_enabled ? '⊘ Disable sync' : '↻ Enable sync';
const otherFolders = S.folders.filter(x=>x.id!==folderId&&x.account_id===f.account_id&&!x.is_hidden).slice(0,16);
const moveItems = otherFolders.map(x=>
`<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>` : '';
// Default folders (inbox/sent/drafts/trash/spam) are provider-managed — deleting them
// would break sync. Only user-created "custom" folders can be deleted.
const deleteEntry = f.folder_type==='custom'
? `<div class="ctx-item danger" onclick="confirmDeleteFolder(${folderId});closeMenu()">🗑 Delete folder</div>` : '';
showCtxMenu(e, `
<div class="ctx-item" onclick="createFolderPrompt(${f.account_id});closeMenu()">📁 New folder</div>
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
${enableAllEntry}
<div class="ctx-item" onclick="markFolderAllRead(${folderId});closeMenu()">✓ Mark all as read</div>
<div class="ctx-sep"></div>
${moveEntry}
${emptyEntry}
<div class="ctx-item" onclick="confirmHideFolder(${folderId});closeMenu()">👁 Hide from sidebar</div>
${deleteEntry}`);
}
function showAccountMenu(e, accountId) {
e.preventDefault(); e.stopPropagation();
showCtxMenu(e, `
<div class="ctx-item" onclick="createFolderPrompt(${accountId});closeMenu()">📁 New folder</div>`);
}
function createFolderPrompt(accountId) {
inlinePrompt('New folder name:', async (name) => {
const r = await api('POST', '/accounts/'+accountId+'/folders', { name });
if (r?.ok) {
S.folders.push(r.folder);
toast('Folder created', 'success');
renderFolders();
} else toast(r?.error || 'Create failed', 'error');
});
}
async function syncFolderNow(folderId) {
toast('Syncing folder…','info');
const r=await api('POST','/folders/'+folderId+'/sync');
if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadFolders(); loadMessages(); }
else toast(r?.error||'Sync failed','error');
}
async function markFolderAllRead(folderId) {
const r=await api('POST','/folders/'+folderId+'/mark-all-read');
if(r?.ok){
toast(`Marked ${r.marked||0} message(s) as read`,'success');
loadFolders();
loadMessages();
} else toast(r?.error||'Failed','error');
}
async function toggleFolderSync(folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
const newSync = !f.sync_enabled;
const r = await api('PUT','/folders/'+folderId+'/visibility',{is_hidden:f.is_hidden, sync_enabled:newSync});
if (r?.ok) {
f.sync_enabled = newSync;
toast(newSync?'Folder sync enabled':'Folder sync disabled', 'success');
renderFolders();
} else toast('Update failed','error');
}
async function enableAllFolderSync(accountId) {
const r = await api('POST','/accounts/'+accountId+'/enable-all-sync');
if (r?.ok) {
// Update local state
S.folders.forEach(f=>{ if(f.account_id===accountId) f.sync_enabled=true; });
toast(`Sync enabled for ${r.enabled||0} folder${r.enabled===1?'':'s'}`, 'success');
renderFolders();
} else toast('Failed to enable sync', 'error');
}
async function confirmEmptyFolder(folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
const label = f.folder_type==='trash' ? 'Trash' : 'Spam';
inlineConfirm(
`Permanently delete all messages in ${label}? This cannot be undone.`,
async () => {
const r = await api('POST','/folders/'+folderId+'/empty');
if (r?.ok) {
toast(`Emptied ${label} (${r.deleted||0} messages)`, 'success');
// Remove locally
S.messages = S.messages.filter(m=>m.folder_id!==folderId);
if (S.currentMessage && S.currentFolder===folderId) resetDetail();
await loadFolders();
if (S.currentFolder===folderId) renderMessageList();
} else toast('Failed to empty folder','error');
}
);
}
async function confirmHideFolder(folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
inlineConfirm(
`Hide "${f.name}" from sidebar? You can unhide it from account settings.`,
async () => {
const r = await api('PUT','/folders/'+folderId+'/visibility',{is_hidden:true, sync_enabled:false});
if (r?.ok) { toast('Folder hidden','success'); await loadFolders(); }
else toast('Update failed','error');
}
);
}
async function confirmDeleteFolder(folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
const countRes = await api('GET','/folders/'+folderId+'/count');
const count = countRes?.count ?? '?';
inlineConfirm(
`Delete folder "${f.name}"? This will permanently delete all ${count} message${count===1?'':'s'} inside it. This cannot be undone.`,
async () => {
const r = await api('DELETE','/folders/'+folderId);
if (r?.ok) {
toast('Folder deleted','success');
S.folders = S.folders.filter(x=>x.id!==folderId);
if (S.currentFolder===folderId) selectFolder('unified','Unified Inbox');
renderFolders(); loadMessages();
} else toast(r?.error||'Delete failed','error');
}
);
}
async function moveFolderContents(fromId, toId) {
const from = S.folders.find(f=>f.id===fromId);
const to = S.folders.find(f=>f.id===toId);
if (!from||!to) return;
inlineConfirm(
`Move all messages from "${from.name}" into "${to.name}"?`,
async () => {
const r = await api('POST','/folders/'+fromId+'/move-to/'+toId);
if (r?.ok) { toast(`Moved ${r.moved||0} messages`,'success'); loadFolders(); loadMessages(); }
else toast(r?.error||'Move failed','error');
}
);
}
function updateUnreadBadge() {
const total=S.folders.filter(f=>f.folder_type==='inbox').reduce((s,f)=>s+(f.unread_count||0),0);
const badge=document.getElementById('unread-total');
badge.textContent=total; badge.style.display=total>0?'':'none';
}
// ── Labels ─────────────────────────────────────────────────────────────────
// Local to gowebmail only — not synced to Gmail/Outlook/IMAP (see server-side comment on
// models.Label for why a reliable cross-provider equivalent doesn't exist).
const LABEL_PALETTE = ['#e5484d','#f5a623','#f7ce46','#3dd68c','#12b886','#2bb3d6',
'#5b8def','#7c5cfc','#c25ee0','#ec4899','#8a8f98','#64748b'];
async function loadLabels() {
const data = await api('GET','/labels');
S.labels = data || [];
renderLabelsDropdown();
}
// Renders the Labels dropdown (panel-header, next to Filter): click a label to view its
// messages, or use the inline ✎/🗑 to rename/recolor or delete right from the list.
function renderLabelsDropdown() {
const el = document.getElementById('labels-dropdown-menu');
if (!el) return;
const rows = S.labels.map(l => `
<div class="label-dropdown-row">
<span class="nav-label-dot" style="background:${l.color};cursor:pointer" onclick="selectLabel(${l.id},'${esc(l.name)}')"></span>
<span class="label-dropdown-name" onclick="selectLabel(${l.id},'${esc(l.name)}')">${esc(l.name)}</span>
<span class="label-dropdown-actions">
<button onclick="event.stopPropagation();closeLabelsDropdown();openLabelEditor(${l.id})" title="Rename / recolor">✎</button>
<button onclick="event.stopPropagation();closeLabelsDropdown();deleteLabelConfirm(${l.id})" title="Delete">🗑</button>
</span>
</div>`).join('');
el.innerHTML = rows + `
<div class="filter-sep-line"></div>
<div class="label-dropdown-new" onclick="closeLabelsDropdown();openLabelEditor()">+ New label</div>`;
}
function toggleLabelsDropdown(e) {
e.stopPropagation();
const menu = document.getElementById('labels-dropdown-menu');
if (!menu) return;
const isOpen = menu.style.display !== 'none';
menu.style.display = isOpen ? 'none' : 'block';
if (!isOpen) setTimeout(() => document.addEventListener('click', closeLabelsDropdown, { once: true }), 0);
}
function closeLabelsDropdown() {
const menu = document.getElementById('labels-dropdown-menu');
if (menu) menu.style.display = 'none';
}
function selectLabel(labelId, name) {
closeLabelsDropdown();
selectFolder('label:'+labelId, name);
}
function openLabelEditor(labelId) {
const label = labelId ? S.labels.find(l=>l.id===labelId) : null;
document.getElementById('label-editor-title').textContent = label ? 'Edit Label' : 'New Label';
document.getElementById('label-editor-id').value = labelId || '';
document.getElementById('label-editor-name').value = label ? label.name : '';
const chosen = label ? label.color : LABEL_PALETTE[0];
document.getElementById('label-editor-swatches').innerHTML = LABEL_PALETTE.map(c =>
`<span class="label-swatch${c===chosen?' selected':''}" style="background:${c}" data-color="${c}" onclick="pickLabelColor('${c}')"></span>`
).join('');
document.getElementById('label-editor-custom-color').value = chosen;
openModal('label-editor-modal');
setTimeout(()=>document.getElementById('label-editor-name').focus(), 50);
}
function pickLabelColor(color) {
document.getElementById('label-editor-custom-color').value = color;
document.querySelectorAll('#label-editor-swatches .label-swatch').forEach(s =>
s.classList.toggle('selected', s.dataset.color.toLowerCase() === color.toLowerCase()));
}
async function saveLabelEditor() {
const id = document.getElementById('label-editor-id').value;
const name = document.getElementById('label-editor-name').value.trim();
const color = document.getElementById('label-editor-custom-color').value;
if (!name) { toast('Label name required','error'); return; }
const r = id
? await api('PUT','/labels/'+id,{name,color})
: await api('POST','/labels',{name,color});
if (r?.ok || r?.id) {
closeModal('label-editor-modal');
toast(id?'Label updated':'Label created','success');
await loadLabels();
await loadMessages(); // refresh label chips/dots on visible messages
} else toast(r?.error || 'Failed to save label','error');
}
function deleteLabelConfirm(labelId) {
const label = S.labels.find(l=>l.id===labelId);
inlineConfirm(`Delete label "${label?.name||''}"? It will be removed from all messages.`, async () => {
const r = await api('DELETE','/labels/'+labelId);
if (r?.ok) {
toast('Label deleted','success');
if (S.currentFolder === 'label:'+labelId) selectFolder('unified','Unified Inbox');
await loadLabels();
await loadMessages();
} else toast('Delete failed','error');
});
}
// Assigns/unassigns a label on a message and updates local state (list row + open detail)
// without a full reload.
async function toggleMessageLabel(msgId, labelId) {
const msg = S.messages.find(m=>m.id===msgId) || (S.currentMessage?.id===msgId ? S.currentMessage : null);
const has = msg?.labels?.some(l=>l.id===labelId);
const r = has
? await api('DELETE','/messages/'+msgId+'/labels/'+labelId)
: await api('POST','/messages/'+msgId+'/labels/'+labelId);
if (!r?.ok) { toast('Failed to update label','error'); return; }
const label = S.labels.find(l=>l.id===labelId);
[S.messages.find(m=>m.id===msgId), S.currentMessage?.id===msgId?S.currentMessage:null].forEach(m => {
if (!m) return;
m.labels = m.labels || [];
if (has) m.labels = m.labels.filter(l=>l.id!==labelId);
else if (label) m.labels.push(label);
});
renderMessageList();
if (S.currentMessage?.id===msgId) renderMessageDetail(S.currentMessage,false);
}
function toggleLabelPicker(e, msgId) {
e.stopPropagation();
const msg = S.currentMessage?.id===msgId ? S.currentMessage : S.messages.find(m=>m.id===msgId);
const items = S.labels.map(l => {
const on = msg?.labels?.some(x=>x.id===l.id);
return `<div class="ctx-item label-picker-item" onclick="toggleMessageLabel(${msgId},${l.id});closeMenu()">
<span class="nav-label-dot" style="background:${l.color}"></span>${on?'✓ ':'○ '}${esc(l.name)}
</div>`;
}).join('') || '<div class="ctx-item" style="color:var(--muted)">No labels yet</div>';
showCtxMenu(e, items);
}
// ── Messages ───────────────────────────────────────────────────────────────
function selectFolder(folderId, folderName) {
S.currentFolder=folderId; S.currentFolderName=folderName||S.currentFolderName;
S.currentPage=1; S.messages=[]; S.searchQuery='';
document.getElementById('search-input').value='';
if (hasActiveSearchFilters()) clearSearchFiltersQuiet();
const sfp=document.getElementById('search-filters-panel'); if (sfp) sfp.style.display='none';
document.getElementById('panel-title').textContent=folderName||S.currentFolderName;
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
const navEl=folderId==='unified'?document.getElementById('nav-unified')
:folderId==='starred'?document.getElementById('nav-starred')
:document.getElementById('nav-f'+folderId);
if (navEl) navEl.classList.add('active');
mobCloseNav();
mobSetView('list');
loadMessages();
}
const handleSearch=debounce(q=>{
S.searchQuery=q.trim(); S.currentPage=1;
document.getElementById('panel-title').textContent=q.trim()?'Search: '+q.trim():S.currentFolderName;
loadMessages();
},350);
// ── Advanced search filters (scope, attachment, date range, size) ───────────
// Lives at the end of <body> (position:fixed), same reasoning as #compose-dropdown —
// its old spot inside .message-list-panel got clipped/overflowed in reading-pane
// "bottom" mode (fixed-height list panel). Positioned via JS under the search bar.
function toggleSearchFilters(e) {
if (e) e.stopPropagation();
const panel = document.getElementById('search-filters-panel');
const bar = document.querySelector('.search-bar');
if (!panel || !bar) return;
const isOpen = panel.style.display !== 'none';
document.removeEventListener('click', closeSearchFiltersOutside);
if (isOpen) { panel.style.display = 'none'; return; }
populateMailboxScopeOptions();
const r = bar.getBoundingClientRect();
panel.style.display = 'block';
panel.style.width = r.width + 'px';
panel.style.left = r.left + 'px';
panel.style.top = Math.min(r.bottom + 4, window.innerHeight - 60) + 'px';
setTimeout(() => document.addEventListener('click', closeSearchFiltersOutside), 0);
}
function closeSearchFiltersOutside(e) {
const panel = document.getElementById('search-filters-panel');
const btn = document.getElementById('search-filters-btn');
if (!panel || panel.contains(e.target) || (btn && btn.contains(e.target))) return;
panel.style.display = 'none';
document.removeEventListener('click', closeSearchFiltersOutside);
}
// Offers "this account" / "this folder" only when a real folder is currently selected
// (not Unified Inbox / Starred, which don't map to a single account or folder).
function populateMailboxScopeOptions() {
const sel = document.getElementById('sf-mailbox-scope');
if (!sel) return;
const prev = sel.value;
sel.innerHTML = '<option value="">All mailboxes</option>';
const cur = S.folders.find(f => String(f.id) === String(S.currentFolder));
if (cur) {
const acc = S.accounts.find(a => a.id === cur.account_id);
const accLabel = acc ? (acc.display_name || acc.email_address) : 'this account';
sel.innerHTML += `<option value="account:${cur.account_id}">This account: ${esc(accLabel)}</option>`;
sel.innerHTML += `<option value="folder:${cur.id}">This folder: ${esc(cur.name)}</option>`;
}
if ([...sel.options].some(o => o.value === prev)) sel.value = prev;
}
function applySearchFilters() {
const f = S.searchFilters;
f.scope = document.getElementById('sf-scope').value;
f.hasAttachment = document.getElementById('sf-attachment').value;
f.dateFrom = document.getElementById('sf-date-from').value;
f.dateTo = document.getElementById('sf-date-to').value;
const olderDays = document.getElementById('sf-older-days').value;
if (olderDays) {
const cutoff = new Date(Date.now() - olderDays * 86400000);
f.dateTo = cutoff.toISOString().slice(0, 10);
document.getElementById('sf-date-to').value = f.dateTo;
}
f.minSizeKB = document.getElementById('sf-min-size').value;
f.maxSizeKB = document.getElementById('sf-max-size').value;
const mb = document.getElementById('sf-mailbox-scope').value; // "" | "account:<id>" | "folder:<id>"
f.accountId = mb.startsWith('account:') ? mb.slice(8) : '';
f.folderId = mb.startsWith('folder:') ? mb.slice(7) : '';
document.getElementById('search-filters-btn').classList.toggle('active', hasActiveSearchFilters());
S.currentPage = 1;
loadMessages();
}
// Resets filter state/UI without triggering a reload — for callers (like selectFolder)
// that are about to call loadMessages() themselves right after.
function clearSearchFiltersQuiet() {
S.searchFilters = { scope:'', hasAttachment:'', dateFrom:'', dateTo:'', minSizeKB:'', maxSizeKB:'', accountId:'', folderId:'' };
['sf-attachment','sf-date-from','sf-date-to','sf-older-days','sf-min-size','sf-max-size','sf-mailbox-scope']
.forEach(id => { const el=document.getElementById(id); if (el) el.value=''; });
const scopeEl=document.getElementById('sf-scope'); if (scopeEl) scopeEl.value='all'; // "" isn't a valid <option> here
const btn=document.getElementById('search-filters-btn'); if (btn) btn.classList.remove('active');
}
function clearSearchFilters() {
clearSearchFiltersQuiet();
S.currentPage = 1;
loadMessages();
}
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 || hasActiveSearchFilters()) {
const f = S.searchFilters;
let url = `/search?q=${encodeURIComponent(S.searchQuery)}&page=${S.currentPage}&page_size=50`;
if (f.scope) url += `&scope=${f.scope}`;
if (f.hasAttachment !== '') url += `&has_attachment=${f.hasAttachment}`;
if (f.dateFrom) url += `&date_from=${f.dateFrom}`;
if (f.dateTo) url += `&date_to=${f.dateTo}`;
if (f.minSizeKB) url += `&min_size_kb=${f.minSizeKB}`;
if (f.maxSizeKB) url += `&max_size_kb=${f.maxSizeKB}`;
if (f.accountId) url += `&account_id=${f.accountId}`;
if (f.folderId) url += `&folder_id=${f.folderId}`;
result=await api('GET',url);
}
else if (S.currentFolder==='unified') result=await api('GET',`/messages/unified?page=${S.currentPage}&page_size=50`);
else if (S.currentFolder==='starred') result=await api('GET',`/messages/starred?page=${S.currentPage}&page_size=50`);
else if (String(S.currentFolder).startsWith('label:')) result=await api('GET',`/messages/by-label/${String(S.currentFolder).slice(6)}?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='<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':'';
}
function setFilter(mode) {
S.filterUnread = (mode === 'unread');
S.filterAttachment = (mode === 'attachment');
S.sortOrder = (mode === 'unread' || mode === 'default' || mode === 'attachment') ? 'date-desc' : mode;
// Update checkmarks
['default','unread','attachment','date-desc','date-asc','size-desc'].forEach(k => {
const el = document.getElementById('fopt-'+k);
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
});
// Update button label
const labels = {
'default':'Filter', 'unread':'Unread', 'attachment':'📎 Has Attachment',
'date-desc':'↓ Date', 'date-asc':'↑ Date', 'size-desc':'↓ Size'
};
const labelEl = document.getElementById('filter-label');
if (labelEl) {
labelEl.textContent = labels[mode] || 'Filter';
labelEl.style.color = mode !== 'default' ? 'var(--accent)' : '';
}
const menuEl = document.getElementById('filter-dropdown-menu');
if (menuEl) menuEl.style.display = 'none';
renderMessageList();
}
// Keep old names as aliases so nothing else breaks
function toggleFilterUnread() { setFilter(S.filterUnread ? 'default' : 'unread'); }
function setSortOrder(order) { setFilter(order); }
// ── Multi-select state ────────────────────────────────────────
if (!window.SEL) window.SEL = { ids: new Set(), lastIdx: -1 };
function renderMessageList() {
const list=document.getElementById('message-list');
let msgs = [...S.messages];
// Filter
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment);
// Sort
if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
else if (S.sortOrder === 'size-desc') msgs.sort((a,b) => (b.size||0)-(a.size||0));
else msgs.sort((a,b) => new Date(b.date)-new Date(a.date));
if (!msgs.length){
const emptyMsg = S.filterUnread ? 'No unread messages' : S.filterAttachment ? 'No messages with attachments' : 'No messages';
list.innerHTML=`<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>`;
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})">
<div class="msg-top">
<span class="msg-unread-dot" title="${m.is_read?'':'Unread'}"></span>
<span class="msg-dot" style="background:${m.account_color}" title="${esc(m.account_email||'')}"></span>
<span class="msg-from">${esc(m.from_name||m.from_email)}</span>
<span class="msg-date">${formatDate(m.date)}</span>
</div>
<div class="msg-line2">
<span class="msg-text" title="${esc(m.subject||'(no subject)')}">
<span class="msg-subject">${esc(m.subject||'(no subject)')}</span>${m.preview?` — <span class="msg-preview">${esc(m.preview)}</span>`:''}
</span>
<span class="msg-icons">
${m.size?`<span class="msg-size">${formatSize(m.size)}</span>`:''}
${m.has_attachment?'<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--muted)"><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>':''}
${m.labels?.length?`<span class="msg-label-dots">${m.labels.map(l=>`<span class="msg-label-dot" style="background:${l.color}" title="${esc(l.name)}"></span>`).join('')}</span>`:''}
<span class="msg-star ${m.is_starred?'on':''}" onclick="toggleStar(${m.id},event)">${m.is_starred?'★':'☆'}</span>
</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);
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();
}
);
}
function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
async function openMessage(id) {
S.selectedMessageId=id; renderMessageList();
mobSetView('detail');
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});
}
}
// ── External link navigation whitelist ───────────────────────────────────────
// Persisted in sessionStorage so it resets on tab close (safety default).
const _extNavOk = new Set(JSON.parse(sessionStorage.getItem('extNavOk')||'[]'));
function _saveExtNavOk(){ sessionStorage.setItem('extNavOk', JSON.stringify([..._extNavOk])); }
function confirmExternalNav(url) {
const origin = (() => { try { return new URL(url).origin; } catch(e){ return url; } })();
if (_extNavOk.has(origin)) { window.open(url,'_blank','noopener,noreferrer'); return; }
const overlay = document.createElement('div');
overlay.className = 'modal-overlay open';
overlay.innerHTML = `<div class="modal" style="max-width:480px">
<h2 style="margin:0 0 12px">Open external link?</h2>
<div style="word-break:break-all;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;font-family:monospace;margin-bottom:16px;color:var(--text2)">${esc(url)}</div>
<p style="margin:0 0 20px;font-size:13px;color:var(--text2)">This link was in a received email. Opening it will take you to an external website.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-primary" id="enav-once">Open once</button>
<button class="btn-primary" id="enav-always" style="background:var(--accent2,#2a7)">Always allow ${esc(origin)}</button>
<button class="action-btn" id="enav-cancel">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#enav-once').onclick = () => { overlay.remove(); window.open(url,'_blank','noopener,noreferrer'); };
overlay.querySelector('#enav-always').onclick = () => { _extNavOk.add(origin); _saveExtNavOk(); overlay.remove(); window.open(url,'_blank','noopener,noreferrer'); };
overlay.querySelector('#enav-cancel').onclick = () => overlay.remove();
overlay.onclick = e => { if(e.target===overlay) overlay.remove(); };
}
function renderMessageDetail(msg, showRemoteContent) {
const detail=document.getElementById('message-detail');
const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email);
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}iframe{display:none!important}</style>`;
// Injected into srcdoc: reports height + intercepts all link clicks → postMessage to 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});
document.addEventListener('click',function(e){
var el=e.target; while(el&&el.tagName!=='A') el=el.parentElement;
if(!el) return;
var href=el.getAttribute('href');
if(!href||href.startsWith('#')||href.startsWith('mailto:')) return;
e.preventDefault(); e.stopPropagation();
parent.postMessage({type:'gomail-open-url',url:href},'*');
},true);
<\/script>`;
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
function stripEmbeddedFrames(h){ return h.replace(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.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,'');
}
let bodyHtml='';
if (msg.body_html) {
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (allowed) {
const srcdoc = cssReset + heightScript + html;
bodyHtml=`<iframe id="msg-frame" sandbox="${sandboxAttr}"
style="width:100%;border:none;min-height:200px;display:block"
srcdoc="${srcdoc.replace(/"/g,'&quot;')}"></iframe>`;
} else {
const stripped = stripRemoteImages(html);
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 2h16c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
Remote images blocked.
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load images</button>
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
</div>
<iframe id="msg-frame" sandbox="${sandboxAttr}"
style="width:100%;border:none;min-height:200px;display:block"
srcdoc="${(cssReset + heightScript + stripped).replace(/"/g,'&quot;')}"></iframe>`;
}
} else {
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
}
let attachHtml='';
if (msg.attachments?.length) {
const chips = msg.attachments.map(a=>{
const url=`/api/messages/${msg.id}/attachments/${a.id}`;
const ct=a.content_type||'';
const viewable=/^(image\/|text\/|application\/pdf$|video\/|audio\/)/.test(ct);
const icon=ct.startsWith('image/')?'🖼':ct==='application/pdf'?'📄':ct.startsWith('video/')?'🎬':ct.startsWith('audio/')?'🎵':'📎';
if(viewable){
return `<a class="attachment-chip" href="${url}" target="_blank" rel="noopener" title="Open ${esc(a.filename)}">${icon} <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)}">${icon} <span>${esc(a.filename)}</span><span style="color:var(--muted);font-size:10px"> ${formatSize(a.size)}</span></a>`;
}).join('');
const dlAll=`<button class="attachment-chip" onclick="downloadAllAttachments(${msg.id})" style="cursor:pointer;border:1px solid var(--border)">⬇ <span>Download all</span></button>`;
attachHtml=`<div class="attachments-bar">${dlAll}${chips}</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 class="detail-labels">
${(msg.labels||[]).map(l=>`<span class="label-chip" style="background:${l.color}22;color:${l.color};border-color:${l.color}55">
<span class="label-chip-dot" style="background:${l.color}"></span>${esc(l.name)}
<button onclick="toggleMessageLabel(${msg.id},${l.id})" title="Remove label">×</button>
</span>`).join('')}
<button class="label-add-btn" onclick="toggleLabelPicker(event,${msg.id})">+ Label</button>
</div>
</div>
<div class="detail-actions">
<button class="action-btn" onclick="openReply()">↩ Reply</button>
<button class="action-btn" onclick="openForward()">↪ Forward</button>
<button class="action-btn" onclick="openForwardAsAttachment()" title="Forward the original message as an .eml file attachment">↪ Fwd as Attachment</button>
<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>
<button class="action-btn danger" onclick="deleteMessage(${msg.id})">🗑 Delete</button>
</div>
${attachHtml}
<div class="detail-body">${bodyHtml}</div>`;
// 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) {
const frame = document.getElementById('msg-frame');
if (frame) {
// 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) {
lastH = h;
frame.style.height = h + 'px';
}
} else if (e.data?.type === 'gomail-open-url' && e.data.url) {
confirmExternalNav(e.data.url);
}
};
window.addEventListener('message', window._frameMsgHandler);
}
}
}
// Download all attachments for a message sequentially
async function downloadAllAttachments(msgId) {
const msg = S.currentMessage;
if (!msg?.attachments?.length) return;
for (const a of msg.attachments) {
const url = `/api/messages/${msgId}/attachments/${a.id}`;
try {
const resp = await fetch(url);
const blob = await resp.blob();
const tmp = document.createElement('a');
tmp.href = URL.createObjectURL(blob);
tmp.download = a.filename || 'attachment';
tmp.click();
URL.revokeObjectURL(tmp.href);
// Small delay to avoid browser throttling sequential downloads
await new Promise(r => setTimeout(r, 400));
} catch(e) { toast('Failed to download '+esc(a.filename),'error'); }
}
}
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||'';
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">
<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>
</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');
}
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>` : '';
const labelItems = S.labels.map(l => {
const on = msg?.labels?.some(x=>x.id===l.id);
return `<div class="ctx-item ctx-sub-item label-picker-item" onclick="toggleMessageLabel(${id},${l.id});closeMenu()">
<span class="nav-label-dot" style="background:${l.color}"></span>${on?'✓ ':'○ '}${esc(l.name)}
</div>`;
}).join('');
const labelSub = S.labels.length ? `
<div class="ctx-item ctx-has-sub">🏷 Label as
<span class="ctx-sub-arrow"></span>
<div class="ctx-submenu">${labelItems}</div>
</div>` : '';
showCtxMenu(e,`
<div class="ctx-item" onclick="window.open('/message/${id}','_blank');closeMenu()">↗ Open in new tab</div>
<div class="ctx-sep"></div>
<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}
${labelSub}
<div class="ctx-item" onclick="showMessageHeaders(${id});closeMenu()">⋮ View headers</div>
<div class="ctx-item" onclick="downloadEML(${id});closeMenu()">⬇ Download .eml</div>
<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) {
const folder = S.folders.find(f=>f.id===folderId);
const doMove = async () => {
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);
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);
}
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');
});
}
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(preferAccountId) {
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('');
// Default to the account of the currently viewed folder, or explicitly passed account
if (preferAccountId) {
sel.value = String(preferAccountId);
} else if (S.currentFolder && S.currentFolder !== 'unified' && S.currentFolder !== 'starred') {
const folder = S.folders.find(f => f.id === S.currentFolder);
if (folder) sel.value = String(folder.account_id);
}
}
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); }
});
document.getElementById('compose-subject').value=opts.subject||'';
document.getElementById('cc-row').style.display='none';
document.getElementById('bcc-row').style.display='none';
populateComposeFrom(opts.accountId||null);
const editor=document.getElementById('compose-editor');
const fromAccountId=parseInt(document.getElementById('compose-from')?.value||0);
const forReply=S.composeMode!=='new';
editor.innerHTML=(opts.body||'') + (opts.skipSignature?'':signatureBlockHTML(fromAccountId, forReply));
S.draftDirty=false;
S.draftUID=0; // IMAP UID of this compose session's autosaved draft, once one exists
updateAttachList();
showCompose();
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80);
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;
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) {
// Only interrupt closing when there's something to decide about: unsaved edits, or a
// draft that autosave already wrote to the server (which "keep editing" should leave
// in place, but a bare close-without-asking would silently orphan).
if (!skipCheck && (S.draftDirty || S.draftUID)) {
const el = document.getElementById('draft-close-confirm');
const cancelBtn = document.getElementById('draft-close-cancel');
const deleteBtn = document.getElementById('draft-close-delete');
const saveBtn = document.getElementById('draft-close-save');
el.classList.add('open');
const cleanup = () => { el.classList.remove('open'); cancelBtn.onclick=null; deleteBtn.onclick=null; saveBtn.onclick=null; };
cancelBtn.onclick = () => { cleanup(); }; // keep editing — compose panel stays open
deleteBtn.onclick = async () => { cleanup(); await discardDraft(); _closeCompose(); };
saveBtn.onclick = async () => { cleanup(); await saveDraft(); _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'; }
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',
accountId: msg.account_id||null,
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;
S.composeForwardFromId=msg.id;
openCompose({
mode:'forward', forwardId:msg.id, title:'Forward',
accountId: msg.account_id||null,
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>`,
});
}
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',
accountId: msg.account_id||null,
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';}
// ── 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();
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');
container.appendChild(inp);
const commit = () => {
const v=inp.value.trim().replace(/[,;\s]+$/,'');
if(v){ addTag(containerId,v); inp.value=''; }
};
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) {
const tags=container.querySelectorAll('.email-tag');
if(tags.length) tags[tags.length-1].remove();
}
S.draftDirty=true;
});
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(); });
}
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;
const remove=document.createElement('button');
remove.innerHTML='×'; remove.className='tag-remove'; remove.type='button';
remove.onclick=e=>{e.stopPropagation();tag.remove();S.draftDirty=true;};
tag.appendChild(label); tag.appendChild(remove);
const inp=container.querySelector('.tag-input');
container.insertBefore(tag, inp||null);
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);
}
// ── Draft autosave ─────────────────────────────────────────────────────────
function startDraftAutosave() {
clearDraftAutosave();
S.draftTimer=setInterval(()=>{ if(S.draftDirty) saveDraft(true); }, 60000);
const editor=document.getElementById('compose-editor');
if(editor) editor.oninput=()=>S.draftDirty=true;
}
function clearDraftAutosave() {
if(S.draftTimer){ clearInterval(S.draftTimer); S.draftTimer=null; }
}
async function saveDraft(silent) {
S.draftDirty=false;
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(),
draft_uid:S.draftUID||0,
};
const r=await api('POST','/draft',meta);
if(r?.ok){ S.draftUID=r.draft_uid||S.draftUID; if(!silent) toast('Draft saved','success'); else toast('Draft auto-saved to server','success'); }
else if(!silent) toast(r?.error||'Draft save failed','error');
}
// Deletes the draft that autosave already wrote to the server for this compose session
// (identified by its IMAP UID). No-op if nothing was ever saved.
async function discardDraft() {
if (!S.draftUID) return;
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
if (!accountId) return;
await api('POST','/draft/discard',{account_id:accountId, draft_uid:S.draftUID});
S.draftUID=0;
}
// ── 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) {
// 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();
}
function updateAttachList() {
const el=document.getElementById('compose-attach-list');
if(!composeAttachments.length){el.innerHTML='';return;}
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
📎 <span>${esc(a.name)}</span>
<span style="color:var(--muted);font-size:10px">${a.size?formatSize(a.size):''}</span>
<button onclick="removeAttachment(${i})" class="tag-remove" type="button">×</button>
</div>`).join('');
}
// ── 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');
}
});
}
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(), 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'
:S.composeMode==='forward-attachment'?'/forward-attachment'
:'/send';
const meta={
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,
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();
// Refresh after a short delay so the syncer has time to pick up the sent message
setTimeout(async () => { await loadFolders(); await loadMessages(); }, 2500);
}
else toast(r?.error||'Send failed','error');
}
// ── Compose drag + all-edge resize ─────────────────────────────────────────
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;
// 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';
};
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); saveComposeGeometry(dlg); };
document.addEventListener('mousemove',mm);
document.addEventListener('mouseup',mu);
e.preventDefault();
});
}
// 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';
};
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); saveComposeGeometry(dlg); };
document.addEventListener('mousemove',mm);
document.addEventListener('mouseup',mu);
e.preventDefault();
});
});
}
// ── Settings ───────────────────────────────────────────────────────────────
async function openSettings() {
openModal('settings-modal');
showSettingsTab('accounts');
loadSyncInterval();
renderMFAPanel();
loadIPRules();
populateSettingsAccountSelects();
// Pre-fill profile fields with current values
const me = await api('GET', '/me');
if (me) {
document.getElementById('profile-username').placeholder = me.username || 'New username';
document.getElementById('profile-email').placeholder = me.email || 'New email';
}
}
// ── Settings: tabs ───────────────────────────────────────────────────────
let _settingsTabsLoaded = {};
function showSettingsTab(tab) {
document.querySelectorAll('.settings-nav button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.toggle('active', p.dataset.tab === tab));
if (_settingsTabsLoaded[tab]) return;
_settingsTabsLoaded[tab] = true;
if (tab === 'accounts') { renderAccountsSettingsList(); }
else if (tab === 'rules') { addRuleConditionRow(); loadRules(); }
else if (tab === 'signatures') { renderSignaturesList(); renderSignatureDefaultsForm(); }
else if (tab === 'certs') { loadCerts(); }
}
function accountOptionsHTML() {
return S.accounts.map(a => `<option value="${a.id}">${esc(a.display_name||a.email_address)} &lt;${esc(a.email_address)}&gt;</option>`).join('');
}
function populateSettingsAccountSelects() {
['rules-account-select', 'certs-account-select', 'sig-defaults-account-select'].forEach(id => {
const sel = document.getElementById(id);
if (sel && !sel.options.length) sel.innerHTML = accountOptionsHTML();
});
}
// ── Settings: Rules ──────────────────────────────────────────────────────
function addRuleConditionRow() {
const wrap = document.getElementById('rule-conditions');
const row = document.createElement('div');
row.className = 'rule-condition-row';
row.style.cssText = 'display:flex;gap:6px;margin-bottom:8px;align-items:center';
row.innerHTML = `
<select class="rc-field" style="flex:1">
<option value="from">From</option><option value="to">To</option><option value="subject">Subject</option>
<option value="body">Body</option><option value="has_attachment">Has attachment</option><option value="recipient_type">Recipient type</option>
</select>
<select class="rc-op" style="flex:1"><option value="contains">contains</option><option value="equals">equals</option><option value="starts_with">starts with</option></select>
<input class="rc-value" type="text" placeholder="value" style="flex:1">
<button class="icon-btn" onclick="this.closest('.rule-condition-row').remove()" title="Remove">&times;</button>`;
wrap.appendChild(row);
}
function updateRuleActionFields() {
const action = document.getElementById('rule-action').value;
const valField = document.getElementById('rule-action-value-field');
const valLabel = valField.querySelector('label');
const valInput = document.getElementById('rule-action-value');
const bodyField = document.getElementById('rule-autoreply-body-field');
bodyField.style.display = action === 'auto_reply' ? 'block' : 'none';
if (action === 'move_to_folder') { valField.style.display = ''; valLabel.textContent = 'Folder name'; valInput.placeholder = 'folder name'; }
else if (action === 'forward') { valField.style.display = ''; valLabel.textContent = 'Forward to'; valInput.placeholder = 'someone@example.com'; }
else if (action === 'auto_reply') { valField.style.display = ''; valLabel.textContent = 'Reply subject'; valInput.placeholder = 'Out of office'; }
else { valField.style.display = 'none'; }
}
let _rulesCache = [];
async function loadRules() {
const accountId = parseInt(document.getElementById('rules-account-select')?.value || 0);
const el = document.getElementById('rules-list');
if (!accountId) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">Connect an account first.</p>'; return; }
const rules = await api('GET', '/rules?account_id=' + accountId);
_rulesCache = rules || [];
if (!rules || !rules.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No rules yet.</p>'; return; }
el.innerHTML = `<table class="data-table">
<thead><tr><th>Name</th><th>Priority</th><th>Action</th><th>Active</th><th></th></tr></thead>
<tbody>${rules.map(r => `
<tr>
<td style="font-weight:500">${esc(r.name)}</td>
<td style="color:var(--muted)">${r.priority}</td>
<td style="color:var(--muted)">${esc(r.action)}${r.action_value?' → '+esc(r.action_value):''}</td>
<td><input type="checkbox" ${r.is_active?'checked':''} onchange="toggleRuleActive(${r.id},${accountId},this.checked)"></td>
<td><button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteRule(${r.id},${accountId})">Delete</button></td>
</tr>`).join('')}
</tbody></table>`;
}
async function saveRule() {
const accountId = parseInt(document.getElementById('rules-account-select')?.value || 0);
if (!accountId) { toast('Connect an account first', 'err'); return; }
const conditions = [...document.querySelectorAll('#rule-conditions .rule-condition-row')].map(row => ({
field: row.querySelector('.rc-field').value, op: row.querySelector('.rc-op').value, value: row.querySelector('.rc-value').value.trim(),
})).filter(c => c.value);
if (!conditions.length) { toast('Add at least one condition', 'err'); return; }
const action = document.getElementById('rule-action').value;
const body = {
account_id: accountId,
name: document.getElementById('rule-name').value.trim() || 'Untitled rule',
priority: parseInt(document.getElementById('rule-priority').value) || 0,
match_type: document.getElementById('rule-match-type').value,
conditions, action,
action_value: document.getElementById('rule-action-value').value.trim(),
action_options: action === 'auto_reply' ? { body: document.getElementById('rule-autoreply-body').value } : {},
is_active: true,
};
const r = await api('POST', '/rules', body);
if (r && r.ok) { toast('Rule added'); document.getElementById('rule-name').value=''; document.getElementById('rule-conditions').innerHTML=''; addRuleConditionRow(); loadRules(); }
else toast(r?.error || 'Failed to save rule', 'err');
}
async function toggleRuleActive(id, accountId, checked) {
const rule = _rulesCache.find(r => r.id === id);
if (!rule) return;
rule.is_active = checked;
await api('PUT', '/rules/' + id, rule);
}
async function deleteRule(id, accountId) {
await api('DELETE', '/rules/' + id + '?account_id=' + accountId);
loadRules();
}
// ── Settings: Signatures ─────────────────────────────────────────────────
function renderSignaturesList() {
const el = document.getElementById('signatures-list');
if (!S.signatures.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No signatures yet.</p>'; return; }
el.innerHTML = S.signatures.map(s => `
<div class="settings-group" style="padding-bottom:12px;margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<b>${esc(s.name)}</b>
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteSignature(${s.id})">Delete</button>
</div>
<div style="font-size:13px;color:var(--text2);white-space:pre-wrap;background:var(--surface2);padding:8px;border-radius:6px">${s.content_html}</div>
</div>`).join('');
}
async function saveSignature() {
const name = document.getElementById('sig-name').value.trim();
if (!name) { toast('Name required', 'err'); return; }
const content_html = document.getElementById('sig-content').value;
const r = await api('POST', '/signatures', { name, content_html });
if (r && r.ok) {
toast('Signature added');
document.getElementById('sig-name').value = ''; document.getElementById('sig-content').value = '';
await loadSignatures(); renderSignaturesList(); renderSignatureDefaultsForm();
} else toast(r?.error || 'Failed to save', 'err');
}
async function deleteSignature(id) {
await api('DELETE', '/signatures/' + id);
await loadSignatures(); renderSignaturesList(); renderSignatureDefaultsForm();
}
function renderSignatureDefaultsForm() {
const accountId = parseInt(document.getElementById('sig-defaults-account-select')?.value || 0);
const el = document.getElementById('sig-defaults-form');
if (!el) return;
if (!accountId) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">Connect an account first.</p>'; return; }
const acc = S.accounts.find(a => a.id === accountId) || {};
const opts = '<option value="0">None</option>' + S.signatures.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
el.innerHTML = `
<div class="modal-field"><label>Default for new messages</label>
<select id="sig-default-new">${opts}</select></div>
<div class="modal-field"><label>Default for replies/forwards</label>
<select id="sig-default-reply">${opts}</select></div>
<button class="btn-primary" onclick="saveSignatureDefaults()">Save</button>`;
document.getElementById('sig-default-new').value = acc.default_signature_new_id || 0;
document.getElementById('sig-default-reply').value = acc.default_signature_reply_id || 0;
}
async function saveSignatureDefaults() {
const accountId = parseInt(document.getElementById('sig-defaults-account-select')?.value || 0);
if (!accountId) return;
const body = {
default_new_id: parseInt(document.getElementById('sig-default-new').value) || 0,
default_reply_id: parseInt(document.getElementById('sig-default-reply').value) || 0,
};
const r = await api('PUT', '/accounts/' + accountId + '/signature-defaults', body);
if (r && r.ok) { toast('Defaults saved'); await loadAccounts(); }
else toast(r?.error || 'Failed to save', 'err');
}
// ── Settings: Certificates (S/MIME + PGP) ───────────────────────────────
async function loadCerts() {
const accountId = parseInt(document.getElementById('certs-account-select')?.value || 0);
if (!accountId) return;
const [smimeIds, smimeContacts, pgpIds, pgpContacts] = await Promise.all([
api('GET', '/smime/identity?account_id=' + accountId),
api('GET', '/smime/contacts'),
api('GET', '/pgp/identity?account_id=' + accountId),
api('GET', '/pgp/contacts'),
]);
renderSMIMEIdentities(smimeIds || [], accountId);
renderSMIMEContacts(smimeContacts || []);
renderPGPIdentities(pgpIds || [], accountId);
renderPGPContacts(pgpContacts || []);
}
function renderSMIMEIdentities(ids, accountId) {
const el = document.getElementById('smime-identity-list');
if (!ids.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No S/MIME certificates yet. Generate a free self-signed certificate, or import one you already have.</p>'; return; }
el.innerHTML = ids.map(i => `
<div class="setting-row">
<div><div class="setting-label">Certificate</div><div style="font-size:12px;color:var(--muted)">Expires ${new Date(i.not_after).toLocaleDateString()}</div></div>
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="smimeRemoveIdentity(${i.id},${accountId})">Delete</button>
</div>`).join('');
}
async function smimeGenerate() {
const accountId = parseInt(document.getElementById('certs-account-select')?.value || 0);
if (!accountId) { toast('Connect an account first', 'err'); return; }
const r = await api('POST', '/smime/identity', { account_id: accountId });
if (r && r.ok) { toast('Certificate generated'); loadCerts(); }
else toast(r?.error || 'Failed to generate', 'err');
}
async function smimeImport() {
const accountId = parseInt(document.getElementById('certs-account-select')?.value || 0);
const fileInput = document.getElementById('smime-import-file');
if (!accountId || !fileInput.files.length) { toast('Choose a .p12 file first', 'err'); return; }
const fd = new FormData();
fd.append('account_id', accountId);
fd.append('p12_file', fileInput.files[0]);
fd.append('p12_password', document.getElementById('smime-import-password').value);
const resp = await fetch('/api/smime/identity/import?account_id=' + accountId, { method: 'POST', body: fd });
const r = await resp.json().catch(() => null);
if (r && r.ok) { toast('Certificate imported'); fileInput.value = ''; loadCerts(); }
else toast(r?.error || 'Import failed', 'err');
}
async function smimeRemoveIdentity(id, accountId) {
await api('DELETE', '/smime/identity/' + id + '?account_id=' + accountId);
loadCerts();
}
function renderSMIMEContacts(contacts) {
const el = document.getElementById('smime-contacts-list');
if (!contacts.length) { el.innerHTML = ''; return; }
el.innerHTML = `<table class="data-table"><thead><tr><th>Email</th><th></th></tr></thead><tbody>${contacts.map(c => `
<tr><td>${esc(c.email)}</td><td><button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="smimeRemoveContact(${c.id})">Delete</button></td></tr>`).join('')}</tbody></table>`;
}
async function smimeAddContact() {
const email = document.getElementById('smime-contact-email').value.trim();
const fileInput = document.getElementById('smime-contact-file');
if (!email || !fileInput.files.length) { toast('Email and certificate file required', 'err'); return; }
const fd = new FormData();
fd.append('email', email);
fd.append('cert_file', fileInput.files[0]);
const resp = await fetch('/api/smime/contacts', { method: 'POST', body: fd });
const r = await resp.json().catch(() => null);
if (r && r.ok) { toast('Contact added'); document.getElementById('smime-contact-email').value=''; fileInput.value=''; loadCerts(); }
else toast(r?.error || 'Failed to add contact', 'err');
}
async function smimeRemoveContact(id) {
await api('DELETE', '/smime/contacts/' + id);
loadCerts();
}
function renderPGPIdentities(ids, accountId) {
const el = document.getElementById('pgp-identity-list');
if (!ids.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No PGP keys yet. Generate a new keypair, or import one you already have.</p>'; return; }
el.innerHTML = ids.map(i => `
<div class="setting-row">
<div><div class="setting-label">${esc(i.label || i.email)}</div><div style="font-size:11px;color:var(--muted);font-family:monospace">${esc(i.fingerprint)}</div></div>
<div style="display:flex;gap:6px;align-items:center">
<input type="password" id="pgp-unlock-pass-${i.id}" placeholder="passphrase" style="width:120px;display:none;padding:4px 8px;font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text)">
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" id="pgp-unlock-btn-${i.id}" onclick="pgpUnlockToggle(${i.id},${accountId})">Unlock</button>
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="pgpRemoveIdentity(${i.id},${accountId})">Delete</button>
</div>
</div>`).join('');
}
async function pgpGenerate() {
const accountId = parseInt(document.getElementById('certs-account-select')?.value || 0);
if (!accountId) { toast('Connect an account first', 'err'); return; }
const pass = document.getElementById('pgp-gen-pass').value;
const pass2 = document.getElementById('pgp-gen-pass2').value;
if (pass.length < 8) { toast('Passphrase must be at least 8 characters', 'err'); return; }
if (pass !== pass2) { toast('Passphrases do not match', 'err'); return; }
const body = { account_id: accountId, label: document.getElementById('pgp-gen-label').value.trim(), passphrase: pass, passphrase_confirm: pass2 };
const r = await api('POST', '/pgp/identity', body);
if (r && r.ok) { toast('PGP key generated'); document.getElementById('pgp-gen-pass').value=''; document.getElementById('pgp-gen-pass2').value=''; loadCerts(); }
else toast(r?.error || 'Failed to generate', 'err');
}
async function pgpImport() {
const accountId = parseInt(document.getElementById('certs-account-select')?.value || 0);
const fileInput = document.getElementById('pgp-import-file');
if (!accountId || !fileInput.files.length) { toast('Choose a key file first', 'err'); return; }
const fd = new FormData();
fd.append('account_id', accountId);
fd.append('key_file', fileInput.files[0]);
fd.append('passphrase', document.getElementById('pgp-import-pass').value);
const resp = await fetch('/api/pgp/identity/import?account_id=' + accountId, { method: 'POST', body: fd });
const r = await resp.json().catch(() => null);
if (r && r.ok) { toast('PGP key imported'); fileInput.value = ''; loadCerts(); }
else toast(r?.error || 'Import failed', 'err');
}
async function pgpRemoveIdentity(id, accountId) {
await api('DELETE', '/pgp/identity/' + id + '?account_id=' + accountId);
loadCerts();
}
function pgpUnlockToggle(id, accountId) {
const input = document.getElementById('pgp-unlock-pass-' + id);
const btn = document.getElementById('pgp-unlock-btn-' + id);
if (!input) return;
if (input.style.display === 'none') {
input.style.display = '';
input.focus();
btn.textContent = 'Confirm';
return;
}
pgpUnlock(id, accountId, input.value);
}
async function pgpUnlock(id, accountId, passphrase) {
if (!passphrase) { toast('Enter a passphrase', 'err'); return; }
const r = await api('POST', '/pgp/unlock?account_id=' + accountId, { identity_id: id, passphrase });
toast(r && r.ok ? 'Key unlocked for this session' : (r?.error || 'Incorrect passphrase'), r && r.ok ? '' : 'err');
if (r && r.ok) loadCerts();
}
function renderPGPContacts(contacts) {
const el = document.getElementById('pgp-contacts-list');
if (!contacts.length) { el.innerHTML = ''; return; }
el.innerHTML = `<table class="data-table"><thead><tr><th>Email</th><th>Fingerprint</th><th></th></tr></thead><tbody>${contacts.map(c => `
<tr><td>${esc(c.email)}</td><td style="font-family:monospace;font-size:11px">${esc(c.fingerprint)}</td>
<td><button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="pgpRemoveContact(${c.id})">Delete</button></td></tr>`).join('')}</tbody></table>`;
}
async function pgpAddContact() {
const email = document.getElementById('pgp-contact-email').value.trim();
const fileInput = document.getElementById('pgp-contact-file');
if (!email || !fileInput.files.length) { toast('Email and public key file required', 'err'); return; }
const fd = new FormData();
fd.append('email', email);
fd.append('label', document.getElementById('pgp-contact-label').value.trim());
fd.append('key_file', fileInput.files[0]);
const resp = await fetch('/api/pgp/contacts', { method: 'POST', body: fd });
const r = await resp.json().catch(() => null);
if (r && r.ok) { toast('Contact added'); document.getElementById('pgp-contact-email').value=''; document.getElementById('pgp-contact-label').value=''; fileInput.value=''; loadCerts(); }
else toast(r?.error || 'Failed to add contact', 'err');
}
async function pgpRemoveContact(id) {
await api('DELETE', '/pgp/contacts/' + id);
loadCerts();
}
async function updateProfile(field) {
const value = document.getElementById('profile-' + field).value.trim();
const password = document.getElementById('profile-confirm-pw').value;
if (!value) { toast('Please enter a new ' + field, 'error'); return; }
if (!password) { toast('Current password required to confirm changes', 'error'); return; }
const r = await api('PUT', '/profile', { field, value, password });
if (r?.ok) {
toast(field.charAt(0).toUpperCase() + field.slice(1) + ' updated', 'success');
document.getElementById('profile-' + field).value = '';
document.getElementById('profile-confirm-pw').value = '';
} else {
toast(r?.error || 'Update failed', 'error');
}
}
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('Sync interval saved','success'); else toast('Failed','error');
}
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='<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;
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');
}
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 loadIPRules() {
const r = await api('GET', '/ip-rules');
if (!r) return;
document.getElementById('ip-rule-mode').value = r.mode || 'disabled';
document.getElementById('ip-rule-list').value = r.ip_list || '';
toggleIPRuleHelp();
}
function toggleIPRuleHelp() {
const mode = document.getElementById('ip-rule-mode').value;
const helpEl = document.getElementById('ip-rule-help');
const listField = document.getElementById('ip-rule-list-field');
const helps = {
disabled: '',
brute_skip: 'IPs in the list below will never be locked out of your account, even after many failed attempts. All other IPs are subject to global brute-force protection.',
allow_only: '⚠ Only IPs in the list below will be able to log into your account. All other IPs will see an "Access not authorized" error. Make sure to include your current IP before saving.',
};
helpEl.textContent = helps[mode] || '';
helpEl.style.display = mode !== 'disabled' ? 'block' : 'none';
listField.style.display = mode !== 'disabled' ? 'block' : 'none';
}
async function saveIPRules() {
const mode = document.getElementById('ip-rule-mode').value;
const ip_list = document.getElementById('ip-rule-list').value.trim();
if (mode !== 'disabled' && !ip_list) {
toast('Please enter at least one IP address', 'error'); return;
}
const r = await api('PUT', '/ip-rules', { mode, ip_list });
if (r?.ok) toast('IP rules saved', 'success');
else toast(r?.error || 'Save failed', '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';
// Submenus ("Move to", "Label as") default to opening right/below their parent item.
// Near a screen edge that pushes them off-view, so measure each one against the
// viewport and flip it to the opposite side when it wouldn't fit.
menu.querySelectorAll('.ctx-has-sub').forEach(parent=>{
const sub=parent.querySelector('.ctx-submenu');
if (!sub) return;
sub.style.left=''; sub.style.right=''; sub.style.top=''; sub.style.bottom='';
sub.style.visibility='hidden'; sub.style.display='block';
const pr=parent.getBoundingClientRect(), sw=sub.offsetWidth, sh=sub.offsetHeight;
sub.style.display=''; sub.style.visibility='';
if (pr.right+sw>window.innerWidth) { sub.style.left='auto'; sub.style.right='100%'; }
if (pr.top+sh>window.innerHeight) { sub.style.top='auto'; sub.style.bottom='-4px'; }
});
});
}
// ── 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() {
initTagField('compose-to');
initTagField('compose-cc-tags');
initTagField('compose-bcc-tags');
// 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();
}
// 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, 10000); // 10 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);
}
// Always refresh the message list and folder counts when new mail arrives
await loadFolders();
await loadMessages();
}
} 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
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')
: `${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
}
}
// ── Mobile navigation ────────────────────────────────────────────────────────
function isMobile() { return window.innerWidth <= 700; }
function mobSetView(view) {
if (!isMobile()) return;
const app = document.getElementById('app-root');
if (!app) return;
app.dataset.mobView = view;
const navBtn = document.getElementById('mob-nav-btn');
const backBtn = document.getElementById('mob-back-btn');
const titleEl = document.getElementById('mob-title');
if (view === 'detail') {
if (navBtn) navBtn.style.display = 'none';
if (backBtn) backBtn.style.display = 'flex';
if (titleEl) titleEl.textContent = S.currentMessage?.subject || 'Message';
} else {
if (navBtn) navBtn.style.display = 'flex';
if (backBtn) backBtn.style.display = 'none';
if (titleEl) titleEl.textContent = S.currentFolderName || 'GoWebMail';
}
}
function mobBack() {
if (!isMobile()) return;
const app = document.getElementById('app-root');
if (!app) return;
if (app.dataset.mobView === 'detail') {
mobSetView('list');
}
}
function mobShowNav() {
document.querySelector('.sidebar')?.classList.add('mob-open');
document.getElementById('mob-sidebar-backdrop')?.classList.add('mob-open');
}
function mobCloseNav() {
document.querySelector('.sidebar')?.classList.remove('mob-open');
document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open');
}
// Update mob title when folder changes
const _origSelectFolder = selectFolder;
// (selectFolder already calls mobSetView/mobCloseNav inline)
// On resize between mobile/desktop, reset any leftover mobile state
window.addEventListener('resize', () => {
if (!isMobile()) {
const app = document.getElementById('app-root');
if (app) app.dataset.mobView = 'list';
document.querySelector('.sidebar')?.classList.remove('mob-open');
document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open');
}
});
// ── Compose dropdown ────────────────────────────────────────────────────────
// Lives at the end of <body> (position:fixed) rather than inside .sidebar-header,
// because .sidebar has overflow:hidden — an absolutely-positioned child there gets
// clipped by that ancestor regardless of z-index. Position it via JS instead,
// same idea as positionMenu() for the context menu.
function toggleComposeDropdown(e) {
e.stopPropagation();
const dd = document.getElementById('compose-dropdown');
if (!dd) return;
const isOpen = dd.style.display !== 'none';
if (isOpen) { dd.style.display = 'none'; return; }
const r = e.currentTarget.getBoundingClientRect();
dd.style.display = 'block';
dd.style.left = Math.min(r.left, window.innerWidth - dd.offsetWidth - 8) + 'px';
dd.style.top = (r.bottom + 4) + 'px';
setTimeout(() => document.addEventListener('click', closeComposeDropdown, { once: true }), 0);
}
function closeComposeDropdown() {
const dd = document.getElementById('compose-dropdown');
if (dd) dd.style.display = 'none';
}