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 ? '${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.'}
A device, security key, or platform authenticator (Touch ID, Windows Hello) you can sign in with instead of typing a code.
Used for password reset — not your own mailbox, so you can't get locked out of it.
For mail clients that need a password instead of your real one — IMAP/SMTP/POP3 login.
Other mailboxes shown in Unified Inbox and the account switcher.
Scan isn't available here — enter this manually in your authenticator app (Google Authenticator, 1Password, etc.):
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 => `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', `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();