const API = '/api'; let token = localStorage.getItem('gomail_token') || ''; let me = null; let accounts = []; // [{id:'', label, provider:'local'}, ...linked] let currentAccountId = 'UNIFIED'; let currentFolder = 'INBOX'; let folders = []; let loadedMessages = []; // last-fetched folder/unified-inbox contents let searchQuery = ''; let searchResults = null; // null = not searching; array = server search results let searchTruncated = false; let searchDebounceTimer = null; let selectedKey = ''; let pendingMFAToken = ''; // ── fetch helper ────────────────────────────────────────────────────────── async function api(path, opts = {}) { const r = await fetch(API + path, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, ...(opts.headers || {}) } }); if (r.status === 401) { showLogin(); return null; } return r.ok ? r.json() : Promise.reject(await r.json()); } function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); } function bodyOf(raw) { if (!raw) return ''; const decoded = atob(raw); const idx = decoded.indexOf('\r\n\r\n'); return idx >= 0 ? decoded.slice(idx + 4) : decoded; } function formatDate(s) { if (!s) return ''; const d = new Date(s); return isNaN(d) ? s : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } function initial(label) { return (label || '?').trim().charAt(0).toUpperCase() || '?'; } // account-scoping: '' (local) omits the query param, matching provider()'s // own default-to-local convention server-side. function acctQuery(id) { return id ? '?account=' + encodeURIComponent(id) : ''; } // ── auth ────────────────────────────────────────────────────────────────── async function login() { const email = document.getElementById('le').value, pwd = document.getElementById('lp').value; try { const d = await fetch(API + '/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Email: email, Password: pwd }) }).then(r => r.json()); if (d.error) throw new Error(d.error); if (d.mfa_required) { pendingMFAToken = d.mfa_token; showMFALogin(); return; } token = d.token; localStorage.setItem('gomail_token', token); showApp(); } catch (e) { const el = document.getElementById('lerr'); el.textContent = e.message || 'Login failed'; el.style.display = ''; } } async function mfaVerifyLogin() { const code = document.getElementById('mfa-code').value; try { const d = await fetch(API + '/auth/mfa-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ MFAToken: pendingMFAToken, Code: code }) }).then(r => r.json()); if (d.error) throw new Error(d.error); token = d.token; localStorage.setItem('gomail_token', token); showApp(); } catch (e) { const el = document.getElementById('mfaerr'); el.textContent = e.message || 'Invalid code'; el.style.display = ''; } } function logout() { localStorage.removeItem('gomail_token'); token = ''; showLogin(); } function showLogin() { document.getElementById('login').style.display = 'flex'; document.getElementById('mfa-login').style.display = 'none'; document.getElementById('app').style.display = 'none'; } function showMFALogin() { document.getElementById('login').style.display = 'none'; document.getElementById('mfa-login').style.display = 'flex'; } async function showApp() { document.getElementById('login').style.display = 'none'; document.getElementById('mfa-login').style.display = 'none'; document.getElementById('app').style.display = 'flex'; me = await api('/me'); if (!me) return; document.getElementById('me-email').textContent = me.email; await loadAccounts(); } async function boot() { if (!token) { showLogin(); return; } try { const m = await api('/me'); if (m) { me = m; document.getElementById('app').style.display = 'flex'; document.getElementById('me-email').textContent = me.email; await loadAccounts(); } else showLogin(); } catch { showLogin(); } } // ── accounts ────────────────────────────────────────────────────────────── async function loadAccounts() { const linked = await api('/accounts') || []; accounts = [{ id: '', label: me.email, provider: 'local' }, ...linked.map(a => ({ id: a.id, label: a.display_name || a.email_address, provider: a.provider }))]; renderAccountSwitcher(); await selectAccount('UNIFIED'); } function renderAccountSwitcher() { const rows = [{ id: 'UNIFIED', label: 'Unified Inbox', icon: '✦' }, ...accounts]; document.getElementById('account-switcher').innerHTML = rows.map(a => ` `).join(''); } function clearSearch() { searchQuery = ''; searchResults = null; searchTruncated = false; const box = document.getElementById('search-box'); if (box) box.value = ''; const toggle = document.getElementById('search-body-toggle'); if (toggle) toggle.style.display = 'none'; } async function selectAccount(id) { clearSearch(); currentAccountId = id; renderAccountSwitcher(); document.getElementById('view-mail').style.display = 'flex'; document.getElementById('view-quarantine').style.display = 'none'; document.getElementById('view-settings').style.display = 'none'; const existingWarning = document.getElementById('unified-warning'); if (existingWarning) existingWarning.remove(); if (id === 'UNIFIED') { document.getElementById('folder-section').style.display = 'none'; document.getElementById('list-title').textContent = 'Unified Inbox'; await loadUnifiedInbox(); } else { document.getElementById('folder-section').style.display = ''; await loadFolders(); } } // ── folders (per-account view) ─────────────────────────────────────────── async function loadFolders() { folders = await api('/folders' + acctQuery(currentAccountId)) || []; if (!folders.find(f => f.id === currentFolder)) { const inbox = folders.find(f => f.type === 'inbox'); currentFolder = inbox ? inbox.id : (folders[0] ? folders[0].id : 'INBOX'); } renderFolderList(); await loadMessages(currentFolder); } function renderFolderList() { document.getElementById('folder-list').innerHTML = folders.map(f => ` `).join(''); } async function selectFolder(id) { clearSearch(); currentFolder = id; renderFolderList(); const f = folders.find(x => x.id === id); document.getElementById('list-title').textContent = f ? f.display_name : id; await loadMessages(id); } // ── messages ────────────────────────────────────────────────────────────── async function loadMessages(folderID) { loadedMessages = await api('/folders/' + folderID + '/messages' + acctQuery(currentAccountId)) || []; renderMessageList(); } async function loadUnifiedInbox() { const res = await api('/inbox/unified'); if (!res) return; loadedMessages = res.messages || []; renderMessageList(); const existing = document.getElementById('unified-warning'); if (existing) existing.remove(); if (res.warnings && res.warnings.length) { const notice = document.createElement('div'); notice.id = 'unified-warning'; notice.className = 'notice'; notice.style.margin = '0 12px 8px'; notice.textContent = 'Some accounts could not be reached: ' + res.warnings.join('; '); document.getElementById('list-title').insertAdjacentElement('afterend', notice); } } function onSearchInput(v) { searchQuery = v; clearTimeout(searchDebounceTimer); document.getElementById('search-body-toggle').style.display = v ? '' : 'none'; if (!v) { searchResults = null; renderMessageList(); return; } searchDebounceTimer = setTimeout(() => runSearch(false), 300); } // runSearch calls the real server-side search (internal/webmail/api.go's // search handler). Unified view sends no ?account=, so the server fans the // search out across the local mailbox and every linked account (each // result tagged with account_id/account_label, like the unified inbox); // otherwise it's scoped to the one selected account. async function runSearch(withBody) { const q = searchQuery; if (!q) return; const realAccountId = currentAccountId === 'UNIFIED' ? '' : currentAccountId; let url = '/search?q=' + encodeURIComponent(q); if (withBody) url += '&body=1'; if (realAccountId) url += '&account=' + encodeURIComponent(realAccountId); try { const res = await api(url); if (!res) return; searchResults = res.messages || []; searchTruncated = !!res.truncated; renderMessageList(); } catch (e) { /* transient — leave prior results/state as-is */ } } function renderMessageList() { const list = document.getElementById('msg-list'); if (searchResults !== null) { const notice = searchTruncated ? '
Showing partial results — narrow your search for a complete list.
' : ''; if (!searchResults.length) { list.innerHTML = notice + '
No matches
'; return; } list.innerHTML = notice + searchResults.map(m => { const unread = !(m.flags || []).includes('\\Seen'); const acct = currentAccountId === 'UNIFIED' ? (m.account_id || '') : currentAccountId; const key = acct + '|' + m.folder_id + '|' + m.id; return `
${unread ? '' : ''}${esc(m.from || '(unknown)')}
${esc(m.subject || '(no subject)')}
${currentAccountId === 'UNIFIED' ? `${esc(m.account_label || '')}` : ''}${esc(m.folder_name || '')}${esc(formatDate(m.date))}
`; }).join(''); return; } if (!loadedMessages.length) { list.innerHTML = '
No messages
'; return; } list.innerHTML = loadedMessages.map(m => { const unread = !(m.flags || []).includes('\\Seen'); const acct = currentAccountId === 'UNIFIED' ? (m.account_id || '') : currentAccountId; const folderId = currentAccountId === 'UNIFIED' ? m.folder_id : currentFolder; const key = acct + '|' + folderId + '|' + m.id; return `
${unread ? '' : ''}${esc(m.from || '(unknown)')}
${esc(m.subject || '(no subject)')}
${currentAccountId === 'UNIFIED' ? `${esc(m.account_label || '')}` : ''}${esc(formatDate(m.date))}
`; }).join(''); } async function viewMessage(acct, folderId, id) { selectedKey = acct + '|' + folderId + '|' + id; renderMessageList(); const msg = await api('/messages/' + folderId + '/' + id + acctQuery(acct)); if (!msg) return; document.getElementById('msg-view').innerHTML = `
${esc(msg.subject || '(no subject)')}
From: ${esc(msg.from)}
To: ${esc(msg.to)}
${esc(formatDate(msg.date))}
${esc(bodyOf(msg.raw))}
`; api('/messages/' + folderId + '/' + id + '/flags' + acctQuery(acct), { method: 'PUT', body: JSON.stringify({ Flags: ['\\Seen'] }) }); } async function deleteMessage(acct, folderId, id) { await api('/messages/' + folderId + '/' + id + acctQuery(acct), { method: 'DELETE' }); document.getElementById('msg-view').innerHTML = '
Select a message
'; if (currentAccountId === 'UNIFIED') await loadUnifiedInbox(); else await loadMessages(currentFolder); } // ── compose ─────────────────────────────────────────────────────────────── function openCompose() { const sel = document.getElementById('c-from'); sel.innerHTML = accounts.map(a => ``).join(''); sel.value = currentAccountId === 'UNIFIED' ? '' : currentAccountId; document.getElementById('compose-modal').style.display = 'flex'; } function closeCompose() { document.getElementById('compose-modal').style.display = 'none'; } async function sendMessage() { const from = document.getElementById('c-from').value; const to = document.getElementById('c-to').value.split(',').map(s => s.trim()).filter(Boolean); const subject = document.getElementById('c-subject').value; const body = document.getElementById('c-body').value; try { await api('/messages' + acctQuery(from), { method: 'POST', body: JSON.stringify({ to, subject, body }) }); closeCompose(); document.getElementById('c-to').value = ''; document.getElementById('c-subject').value = ''; document.getElementById('c-body').value = ''; } catch (e) { alert('Send failed: ' + (e.error || e.message)); } } // ── quarantine ──────────────────────────────────────────────────────────── async function showQuarantine() { document.getElementById('view-mail').style.display = 'none'; document.getElementById('view-settings').style.display = 'none'; document.getElementById('view-quarantine').style.display = 'block'; const entries = await api('/quarantine'); if (!entries) return; document.getElementById('quarantine-list').innerHTML = entries.length ? entries.map(e => `
Reason: ${esc(e.Reason || '—')}
Held: ${esc(e.CreatedAt)}
`).join('') : '
Nothing held
'; } async function releaseQ(id) { try { await api('/quarantine/' + id + '/release', { method: 'POST' }); showQuarantine(); } catch (e) { alert('Release failed: ' + (e.error || e.message)); } } // ── settings ────────────────────────────────────────────────────────────── async function showSettings() { document.getElementById('view-mail').style.display = 'none'; document.getElementById('view-quarantine').style.display = 'none'; document.getElementById('view-settings').style.display = 'block'; await renderSettings(); } async function renderSettings() { me = await api('/me') || me; const body = document.getElementById('settings-body'); body.innerHTML = `

Two-factor authentication

${me.mfa_enabled ? 'Enabled — a code or passkey is required at every sign-in.' : 'Not enabled. Add a code from an authenticator app or a passkey for a second sign-in step.'}

Passkeys

A device, security key, or platform authenticator (Touch ID, Windows Hello) you can sign in with instead of typing a code.

Recovery email

Used for password reset — not your own mailbox, so you can't get locked out of it.

App passwords

For mail clients that need a password instead of your real one — IMAP/SMTP/POP3 login.

Linked accounts

Other mailboxes shown in Unified Inbox and the account switcher.

`; renderMFAArea(); renderPasskeys(); renderAppPasswords(); renderLinkedAccountsSettings(); } function renderMFAArea() { const area = document.getElementById('mfa-area'); if (me.mfa_enabled) { area.innerHTML = `
`; return; } area.innerHTML = ``; } async function mfaSetupStart() { try { const d = await api('/me/mfa/setup', { method: 'POST' }); document.getElementById('mfa-area').innerHTML = `

Scan isn't available here — enter this manually in your authenticator app (Google Authenticator, 1Password, etc.):

${esc(d.provisioning_uri)}
`; } catch (e) { alert('Setup failed: ' + (e.error || e.message)); } } async function mfaConfirmSubmit() { const code = document.getElementById('mfa-confirm-code').value; try { const d = await api('/me/mfa/confirm', { method: 'POST', body: JSON.stringify({ Code: code }) }); document.getElementById('mfa-area').innerHTML = `
Two-factor enabled. Save these backup codes somewhere safe — each works once if you lose access to your authenticator app.
${d.backup_codes.map(esc).join('
')}
`; me.mfa_enabled = true; } catch (e) { alert('Invalid code: ' + (e.error || e.message)); } } async function mfaDisableSubmit() { const pw = document.getElementById('mfa-disable-pw').value; try { await api('/me/mfa/disable', { method: 'POST', body: JSON.stringify({ Password: pw }) }); me.mfa_enabled = false; renderMFAArea(); } catch (e) { alert('Failed: ' + (e.error || e.message)); } } // ── passkeys (WebAuthn) ───────────────────────────────────────────────── function b64urlToBuf(b64url) { const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : ''; const raw = atob(b64 + pad); const buf = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i); return buf.buffer; } function bufToB64url(buf) { const bytes = new Uint8Array(buf); let str = ''; for (const b of bytes) str += String.fromCharCode(b); return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } async function renderPasskeys() { const area = document.getElementById('passkeys-area'); const list = await api('/me/passkeys') || []; area.innerHTML = list.length ? list.map(p => `
${esc(p.name)}
Added ${esc(formatDate(p.created_at))}
`).join('') : '

No passkeys registered yet.

'; } async function addPasskey() { if (!window.PublicKeyCredential) { alert('This browser does not support passkeys.'); return; } const name = prompt('Name this passkey (e.g. "YubiKey", "MacBook Touch ID"):', 'Passkey'); if (name === null) return; try { const options = await api('/me/passkeys/register/start', { method: 'POST' }); const credential = await navigator.credentials.create({ publicKey: { rp: options.rp, user: { id: b64urlToBuf(options.user.id), name: options.user.name, displayName: options.user.displayName }, challenge: b64urlToBuf(options.challenge), pubKeyCredParams: options.pubKeyCredParams, timeout: options.timeout, attestation: options.attestation, authenticatorSelection: options.authenticatorSelection, }, }); await api('/me/passkeys/register/finish', { method: 'POST', body: JSON.stringify({ Challenge: options.challenge, Name: name || 'Passkey', ClientDataJSON: bufToB64url(credential.response.clientDataJSON), AttestationObject: bufToB64url(credential.response.attestationObject), }), }); renderPasskeys(); } catch (e) { alert('Failed to add passkey: ' + (e.error || e.message)); } } async function deletePasskey(id) { try { await api('/me/passkeys/' + encodeURIComponent(id), { method: 'DELETE' }); renderPasskeys(); } catch (e) { alert('Failed: ' + (e.error || e.message)); } } async function usePasskeyLogin() { if (!window.PublicKeyCredential) { alert('This browser does not support passkeys.'); return; } try { const options = await fetch(API + '/auth/passkey/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ MFAToken: pendingMFAToken }), }).then(r => r.json()); if (options.error) throw new Error(options.error); const assertion = await navigator.credentials.get({ publicKey: { rpId: options.rpId, challenge: b64urlToBuf(options.challenge), timeout: options.timeout, userVerification: options.userVerification, allowCredentials: options.allowCredentials.map(c => ({ id: b64urlToBuf(c.id), type: c.type })), }, }); const d = await fetch(API + '/auth/passkey/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ MFAToken: pendingMFAToken, Challenge: options.challenge, CredentialID: bufToB64url(assertion.rawId), ClientDataJSON: bufToB64url(assertion.response.clientDataJSON), AuthenticatorData: bufToB64url(assertion.response.authenticatorData), Signature: bufToB64url(assertion.response.signature), }), }).then(r => r.json()); if (d.error) throw new Error(d.error); token = d.token; localStorage.setItem('gomail_token', token); showApp(); } catch (e) { const el = document.getElementById('mfaerr'); el.textContent = e.message || 'Passkey login failed'; el.style.display = ''; } } async function saveRecoveryEmail() { const v = document.getElementById('recovery-email-input').value; try { await api('/me/recovery-email', { method: 'POST', body: JSON.stringify({ RecoveryEmail: v }) }); } catch (e) { alert('Failed: ' + (e.error || e.message)); } } async function renderAppPasswords() { const area = document.getElementById('app-passwords-area'); const list = await api('/me/app-passwords') || []; area.innerHTML = list.length ? list.map(e => `
${esc(e.Label)}
Scopes: ${esc(e.Scopes)}
`).join('') : '

No app passwords yet.

'; } async function createAppPassword() { const label = document.getElementById('app-pw-label').value; if (!label) return; try { const d = await api('/me/app-passwords', { method: 'POST', body: JSON.stringify({ Label: label }) }); document.getElementById('app-passwords-area').insertAdjacentHTML('afterbegin', `
Copy this now, it won't be shown again: ${esc(d.token)}
`); document.getElementById('app-pw-label').value = ''; renderAppPasswords(); } catch (e) { alert('Failed: ' + (e.error || e.message)); } } async function deleteAppPassword(id) { try { await api('/me/app-passwords/' + id, { method: 'DELETE' }); renderAppPasswords(); } catch (e) { alert('Failed: ' + (e.error || e.message)); } } function renderLinkedAccountsSettings() { const linked = accounts.filter(a => a.id); document.getElementById('linked-accounts-area').innerHTML = linked.length ? linked.map(a => `
${esc(a.label)}
${esc(a.provider)}
`).join('') : '

No linked accounts yet.

'; } async function unlinkAccount(id) { try { await api('/accounts/' + id, { method: 'DELETE' }); await loadAccounts(); renderLinkedAccountsSettings(); } catch (e) { alert('Failed: ' + (e.error || e.message)); } } async function startOAuth(provider) { try { const d = await api('/accounts/oauth/' + provider + '/start'); window.location.href = d.auth_url; } catch (e) { alert(e.error || ('Failed to start ' + provider + ' linking')); } } function toggleImapForm() { const el = document.getElementById('imap-form'); el.style.display = el.style.display === 'none' ? '' : 'none'; } async function submitImapAccount() { const req = { Email: document.getElementById('imap-email').value, Password: document.getElementById('imap-password').value, IMAPHost: document.getElementById('imap-host').value, IMAPPort: parseInt(document.getElementById('imap-port').value, 10) || 993, IMAPTLS: 'implicit', SMTPHost: document.getElementById('smtp-host').value, SMTPPort: parseInt(document.getElementById('smtp-port').value, 10) || 465, SMTPTLS: 'implicit', }; try { await api('/accounts/imap', { method: 'POST', body: JSON.stringify(req) }); toggleImapForm(); await loadAccounts(); renderLinkedAccountsSettings(); } catch (e) { alert('Failed: ' + (e.error || e.message)); } } boot();