602 lines
29 KiB
JavaScript
602 lines
29 KiB
JavaScript
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, '<').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 => `
|
||
|
|
<div class="nav-row ${a.id === currentAccountId ? 'active' : ''}" onclick="selectAccount('${esc(a.id)}')">
|
||
|
|
<div class="seal">${a.icon || esc(initial(a.label))}</div>
|
||
|
|
<div class="nav-row-label">${esc(a.label)}</div>
|
||
|
|
</div>`).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 => `
|
||
|
|
<div class="nav-row ${f.id === currentFolder ? 'active' : ''}" onclick="selectFolder('${esc(f.id)}')">
|
||
|
|
<div class="seal">${f.unread_count > 0 ? '<span class="dot"></span>' : ''}</div>
|
||
|
|
<div class="nav-row-label">${esc(f.display_name)}</div>
|
||
|
|
<div class="count-badge">${f.unread_count > 0 ? f.unread_count : ''}</div>
|
||
|
|
</div>`).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 ? '<div class="notice" style="margin:0 12px 8px">Showing partial results — narrow your search for a complete list.</div>' : '';
|
||
|
|
if (!searchResults.length) { list.innerHTML = notice + '<div style="padding:20px;color:var(--text-faint);text-align:center">No matches</div>'; 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 `<div class="msg-row ${unread ? 'unread' : ''} ${key === selectedKey ? 'selected' : ''}" onclick="viewMessage('${esc(acct)}','${esc(m.folder_id)}','${esc(m.id)}')">
|
||
|
|
<div class="msg-from">${unread ? '<span class="dot"></span>' : ''}<span>${esc(m.from || '(unknown)')}</span></div>
|
||
|
|
<div class="msg-subject">${esc(m.subject || '(no subject)')}</div>
|
||
|
|
<div class="msg-meta">${currentAccountId === 'UNIFIED' ? `<span class="chip">${esc(m.account_label || '')}</span>` : ''}<span class="chip">${esc(m.folder_name || '')}</span><span>${esc(formatDate(m.date))}</span></div>
|
||
|
|
</div>`;
|
||
|
|
}).join('');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!loadedMessages.length) { list.innerHTML = '<div style="padding:20px;color:var(--text-faint);text-align:center">No messages</div>'; 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 `<div class="msg-row ${unread ? 'unread' : ''} ${key === selectedKey ? 'selected' : ''}" onclick="viewMessage('${esc(acct)}','${esc(folderId)}','${esc(m.id)}')">
|
||
|
|
<div class="msg-from">${unread ? '<span class="dot"></span>' : ''}<span>${esc(m.from || '(unknown)')}</span></div>
|
||
|
|
<div class="msg-subject">${esc(m.subject || '(no subject)')}</div>
|
||
|
|
<div class="msg-meta">${currentAccountId === 'UNIFIED' ? `<span class="chip">${esc(m.account_label || '')}</span>` : ''}<span>${esc(formatDate(m.date))}</span></div>
|
||
|
|
</div>`;
|
||
|
|
}).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 = `
|
||
|
|
<div class="reading-subject">${esc(msg.subject || '(no subject)')}</div>
|
||
|
|
<div class="reading-meta">
|
||
|
|
<div>From: ${esc(msg.from)}</div>
|
||
|
|
<div>To: ${esc(msg.to)}</div>
|
||
|
|
<div>${esc(formatDate(msg.date))}</div>
|
||
|
|
</div>
|
||
|
|
<div class="reading-body">${esc(bodyOf(msg.raw))}</div>
|
||
|
|
<div style="margin-top:20px;display:flex;gap:8px">
|
||
|
|
<button onclick="deleteMessage('${esc(acct)}','${esc(folderId)}','${esc(id)}')" class="btn btn-ghost">Delete</button>
|
||
|
|
</div>`;
|
||
|
|
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 = '<div class="reading-empty">Select a message</div>';
|
||
|
|
if (currentAccountId === 'UNIFIED') await loadUnifiedInbox(); else await loadMessages(currentFolder);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── compose ───────────────────────────────────────────────────────────────
|
||
|
|
function openCompose() {
|
||
|
|
const sel = document.getElementById('c-from');
|
||
|
|
sel.innerHTML = accounts.map(a => `<option value="${esc(a.id)}">${esc(a.label)}</option>`).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 => `
|
||
|
|
<div class="card" style="margin-bottom:10px;display:flex;justify-content:space-between;align-items:center">
|
||
|
|
<div><div style="font-size:13px">Reason: ${esc(e.Reason || '—')}</div>
|
||
|
|
<div style="color:var(--text-faint);font-size:12px">Held: ${esc(e.CreatedAt)}</div></div>
|
||
|
|
<button onclick="releaseQ('${esc(e.ID)}')" class="btn btn-primary">Release</button>
|
||
|
|
</div>`).join('') : '<div style="color:var(--text-faint);text-align:center;padding:40px">Nothing held</div>';
|
||
|
|
}
|
||
|
|
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 = `
|
||
|
|
<div class="settings-section card">
|
||
|
|
<h3>Two-factor authentication</h3>
|
||
|
|
<p class="hint">${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.'}</p>
|
||
|
|
<div id="mfa-area"></div>
|
||
|
|
</div>
|
||
|
|
<div class="settings-section card">
|
||
|
|
<h3>Passkeys</h3>
|
||
|
|
<p class="hint">A device, security key, or platform authenticator (Touch ID, Windows Hello) you can sign in with instead of typing a code.</p>
|
||
|
|
<div id="passkeys-area"></div>
|
||
|
|
<button onclick="addPasskey()" class="btn btn-primary" style="margin-top:10px">Add a passkey</button>
|
||
|
|
</div>
|
||
|
|
<div class="settings-section card">
|
||
|
|
<h3>Recovery email</h3>
|
||
|
|
<p class="hint">Used for password reset — not your own mailbox, so you can't get locked out of it.</p>
|
||
|
|
<div style="display:flex;gap:8px">
|
||
|
|
<input id="recovery-email-input" class="inp" placeholder="you@elsewhere.example" value="${esc(me.recovery_email || '')}">
|
||
|
|
<button onclick="saveRecoveryEmail()" class="btn btn-primary" style="flex:none">Save</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="settings-section card">
|
||
|
|
<h3>App passwords</h3>
|
||
|
|
<p class="hint">For mail clients that need a password instead of your real one — IMAP/SMTP/POP3 login.</p>
|
||
|
|
<div id="app-passwords-area"></div>
|
||
|
|
<div style="display:flex;gap:8px;margin-top:10px">
|
||
|
|
<input id="app-pw-label" class="inp" placeholder="Label, e.g. \"Phone Mail app\"">
|
||
|
|
<button onclick="createAppPassword()" class="btn btn-primary" style="flex:none">Create</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="settings-section card">
|
||
|
|
<h3>Linked accounts</h3>
|
||
|
|
<p class="hint">Other mailboxes shown in Unified Inbox and the account switcher.</p>
|
||
|
|
<div id="linked-accounts-area"></div>
|
||
|
|
<div style="display:flex;gap:8px;margin-top:14px">
|
||
|
|
<button onclick="startOAuth('google')" class="btn btn-ghost">Link Google account</button>
|
||
|
|
<button onclick="startOAuth('microsoft')" class="btn btn-ghost">Link Microsoft account</button>
|
||
|
|
<button onclick="toggleImapForm()" class="btn btn-ghost">Add IMAP account</button>
|
||
|
|
</div>
|
||
|
|
<div id="imap-form" style="display:none;margin-top:14px;padding-top:14px;border-top:1px solid var(--border)">
|
||
|
|
<div class="field"><input id="imap-email" class="inp" placeholder="Email address"></div>
|
||
|
|
<div class="field"><input id="imap-password" type="password" class="inp" placeholder="Password"></div>
|
||
|
|
<div class="field" style="display:flex;gap:8px">
|
||
|
|
<input id="imap-host" class="inp" placeholder="IMAP host">
|
||
|
|
<input id="imap-port" class="inp" placeholder="993" style="width:90px">
|
||
|
|
</div>
|
||
|
|
<div class="field" style="display:flex;gap:8px">
|
||
|
|
<input id="smtp-host" class="inp" placeholder="SMTP host">
|
||
|
|
<input id="smtp-port" class="inp" placeholder="465" style="width:90px">
|
||
|
|
</div>
|
||
|
|
<button onclick="submitImapAccount()" class="btn btn-primary">Add account</button>
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
renderMFAArea();
|
||
|
|
renderPasskeys();
|
||
|
|
renderAppPasswords();
|
||
|
|
renderLinkedAccountsSettings();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderMFAArea() {
|
||
|
|
const area = document.getElementById('mfa-area');
|
||
|
|
if (me.mfa_enabled) {
|
||
|
|
area.innerHTML = `
|
||
|
|
<div class="field"><input id="mfa-disable-pw" type="password" class="inp" placeholder="Current password" style="max-width:260px"></div>
|
||
|
|
<button onclick="mfaDisableSubmit()" class="btn btn-danger">Disable two-factor</button>`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
area.innerHTML = `<button onclick="mfaSetupStart()" class="btn btn-primary">Set up two-factor</button>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function mfaSetupStart() {
|
||
|
|
try {
|
||
|
|
const d = await api('/me/mfa/setup', { method: 'POST' });
|
||
|
|
document.getElementById('mfa-area').innerHTML = `
|
||
|
|
<p class="hint">Scan isn't available here — enter this manually in your authenticator app (Google Authenticator, 1Password, etc.):</p>
|
||
|
|
<div class="card" style="background:var(--bg);word-break:break-all;font-size:12px;margin-bottom:10px">${esc(d.provisioning_uri)}</div>
|
||
|
|
<div class="field"><input id="mfa-confirm-code" class="inp" placeholder="Enter the 6-digit code" style="max-width:200px"></div>
|
||
|
|
<button onclick="mfaConfirmSubmit()" class="btn btn-primary">Confirm</button>`;
|
||
|
|
} 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 = `
|
||
|
|
<div class="notice">Two-factor enabled. Save these backup codes somewhere safe — each works once if you lose access to your authenticator app.</div>
|
||
|
|
<div class="card" style="background:var(--bg);font-family:monospace;font-size:13px;line-height:1.8">${d.backup_codes.map(esc).join('<br>')}</div>`;
|
||
|
|
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 => `
|
||
|
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||
|
|
<div><div style="font-size:13px">${esc(p.name)}</div><div style="color:var(--text-faint);font-size:11px">Added ${esc(formatDate(p.created_at))}</div></div>
|
||
|
|
<button onclick="deletePasskey('${esc(p.id)}')" class="btn btn-ghost">Remove</button>
|
||
|
|
</div>`).join('') : '<p class="hint">No passkeys registered yet.</p>';
|
||
|
|
}
|
||
|
|
|
||
|
|
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 => `
|
||
|
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||
|
|
<div><div style="font-size:13px">${esc(e.Label)}</div><div style="color:var(--text-faint);font-size:11px">Scopes: ${esc(e.Scopes)}</div></div>
|
||
|
|
<button onclick="deleteAppPassword('${esc(e.ID)}')" class="btn btn-ghost">Revoke</button>
|
||
|
|
</div>`).join('') : '<p class="hint">No app passwords yet.</p>';
|
||
|
|
}
|
||
|
|
|
||
|
|
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',
|
||
|
|
`<div class="notice">Copy this now, it won't be shown again: <strong>${esc(d.token)}</strong></div>`);
|
||
|
|
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 => `
|
||
|
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)">
|
||
|
|
<div><div style="font-size:13px">${esc(a.label)}</div><div style="color:var(--text-faint);font-size:11px">${esc(a.provider)}</div></div>
|
||
|
|
<button onclick="unlinkAccount('${esc(a.id)}')" class="btn btn-ghost">Unlink</button>
|
||
|
|
</div>`).join('') : '<p class="hint">No linked accounts yet.</p>';
|
||
|
|
}
|
||
|
|
|
||
|
|
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();
|