// UI wiring for the smol mail client: identity setup, folders, reading, // composing, fetching with verify-before-ack, and the §4/§7 trust surfaces. import { hex, timingSafeEqual, unhex, utf8Bytes } from "./crypto.js"; import { DEFAULT_PORT, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, b32decode, b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, identitySeed, identityFromSeed, makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, registerOp, resolveOp, seal, sendOp, tokenFor, unseal, walkChain, } from "./proto.js"; import * as store from "./store.js"; import { connectStream } from "./transport.js"; import { PRESET_SERVER } from "./config.js"; const $ = (id) => document.getElementById(id); // Everything is built as nodes rather than markup, so no message content can // ever reach innerHTML. Null children are dropped, which keeps conditional // pieces inline at the call site. function el(tag, props = {}, ...children) { const node = Object.assign(document.createElement(tag), props); node.append(...children.filter(child => child != null)); return node; } // SVG lives in its own namespace, so el()'s createElement cannot build these; // each is a reference into the sprite in index.html. function icon(name) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("class", "icon"); svg.setAttribute("aria-hidden", "true"); const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); use.setAttribute("href", `#i-${name}`); svg.append(use); return svg; } const placeholder = (text, warn = false) => el("p", { className: warn ? "placeholder warn" : "placeholder", textContent: text }); // A native shown with showModal() renders in the browser's top // layer, above the rest of the page — a toast anchored to sits // visually behind an open dialog's backdrop no matter its z-index. Keeping // the toast inside whichever dialog is open puts it in that same top-layer // subtree, so a status raised while settings or compose is open (e.g. after // "register") actually gets seen instead of landing, barely visible, behind it. function reparentStatus() { const toast = $("status"); const target = document.querySelector("dialog[open]") ?? document.body; if (toast.parentElement !== target) target.append(toast); } for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("close", reparentStatus); // The status line is a toast: click to dismiss, empty means hidden. Plain // notices fade; warnings stay until dismissed, since those carry the §4/§8 // trust messages the user must actually read. let toastTimer = null; function status(text, warn = false) { reparentStatus(); const toast = $("status"); toast.textContent = text; toast.hidden = !text; toast.className = warn ? "toast warn" : "toast"; clearTimeout(toastTimer); if (text && !warn) toastTimer = setTimeout(() => status(""), 6000); } $("status").onclick = () => status(""); const fmtTime = (seconds) => new Date(seconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); // Gmail-style row stamp: time for today, date within this year, full date older. const fmtRowTime = (seconds) => { const date = new Date(seconds * 1000), today = new Date(); if (date.toDateString() === today.toDateString()) return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); if (date.getFullYear() === today.getFullYear()) return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }; // A copy button beats asking the user to select 64 hex characters by hand. // Flashes its own label rather than a toast — settings' rows need feedback // that doesn't float away from the row that caused it. function copyButton(text, what) { return el("button", { type: "button", className: "copy", title: `copy the ${what}`, textContent: "copy", onclick: async (event) => { const button = event.currentTarget; const resting = button.textContent; try { await navigator.clipboard.writeText(text); button.textContent = "copied"; } catch { button.textContent = "failed"; } setTimeout(() => { button.textContent = resting; }, 1500); } }); } // The stored account as an address object, default port included when custom. function accountAddress() { const account = store.account(); if (!account) return null; const suffix = account.port === DEFAULT_PORT ? "" : `:${account.port}`; return parseAddress(`${account.user}@${account.host}${suffix}`); } // --- appearance ---------------------------------------------------------------- // Three states, because "follow the system" is a real preference and not the // absence of one. The CSS does the rest: color-scheme decides which half of // every light-dark() token applies, so nothing here touches a colour. const THEMES = [ { value: null, icon: "auto", label: "theme: following the system" }, { value: "light", icon: "sun", label: "theme: light" }, { value: "dark", icon: "moon", label: "theme: dark" }, ]; function applyTheme() { const mode = THEMES.find(t => t.value === store.theme()) ?? THEMES[0]; if (mode.value) document.documentElement.dataset.theme = mode.value; else delete document.documentElement.dataset.theme; const button = $("theme"); button.querySelector("use").setAttribute("href", `#i-${mode.icon}`); button.title = mode.label; button.setAttribute("aria-label", mode.label); } $("theme").onclick = () => { const current = THEMES.findIndex(t => t.value === store.theme()); store.setTheme(THEMES[(current + 1) % THEMES.length].value); applyTheme(); status(THEMES.find(t => t.value === store.theme()).label); }; // --- connection -------------------------------------------------------------- // §4: registration and fetching demand a pinned key; sending to a recipient // whose key we already hold tolerates an unpinned server. async function connect(addr, { requirePin }) { const pinned = store.serverPin(addr.host); if (requirePin && !pinned) throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` + `trusted channel, then pin it under settings → server.`); const timeoutMs = store.timeoutSeconds() * 1000; const stream = await connectStream(addr.host, addr.port, timeoutMs); const opened = await openSession(stream, addr.host, pinned, timeoutMs); if (!pinned) status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` + `Lookups from this session are UNVERIFIED.`, true); return opened; } // --- identity setup ------------------------------------------------------------ // Nothing in the top bar works before an identity exists, so it stays hidden // rather than offering buttons that can only answer "no identity yet". function showSetup() { $("setup").hidden = false; $("app").hidden = true; $("search").hidden = true; $("top-actions").hidden = true; $("account").textContent = ""; } function showApp() { $("setup").hidden = true; $("app").hidden = false; $("search").hidden = false; $("top-actions").hidden = false; renderAccount(); renderFolder(); } // Generate-or-recover an identity, establish trust in a server, then bind a // name to it — three concerns, three screens, no bleed-through between them. const SETUP_SECTIONS = { intro: "setup-intro", "seed-shown": "setup-seed", "seed-restore": "setup-restore", "server-setup": "setup-server", "account-setup": "setup-account", }; function showSetupStage(stage) { for (const [key, id] of Object.entries(SETUP_SECTIONS)) $(id).hidden = key !== stage; if (stage === "server-setup") renderServerScreen(); } // Discards the identity screens 2 and 4 can still walk away from — nothing is // bound to an account yet, so nothing is lost: the same seed typed again on // screen 3 regenerates the identical identity. function discardSetupIdentity() { store.discardIdentity(); serverFetchedKey = null; $("server-host").value = ""; showSetupStage("intro"); } // A deployment that baked in a server key already knows its own host, so // screen 4 starts from it rather than asking again. function enterServerSetup() { if (!$("server-host").value && PRESET_SERVER) $("server-host").value = PRESET_SERVER.host; showSetupStage("server-setup"); } $("create-identity").onclick = () => { const seed = newMaster(); store.setIdentity(seed); $("setup-seed-value").textContent = hex(seed); showSetupStage("seed-shown"); }; $("show-restore").onclick = () => showSetupStage("seed-restore"); $("seed-saved").onclick = () => { store.setMasterSaved(true); enterServerSetup(); }; $("seed-back").onclick = () => discardSetupIdentity(); // A master secret in either of its two interchangeable encodings: 64 hex // characters, or the base32 form used everywhere else a key is shown. function parseSeedText(text) { text = text.trim(); if (/^[0-9a-fA-F]{64}$/.test(text)) return unhex(text); try { const bytes = b32decode(text); if (bytes.length === 32) return bytes; } catch { /* fall through to the error below */ } throw new SmolError("not a valid secret — paste the 64 hex characters or the base32 form"); } $("restore-form").onsubmit = (event) => { event.preventDefault(); $("restore-error").textContent = ""; let seed; try { seed = parseSeedText($("restore-master").value); } catch (err) { $("restore-error").textContent = err.message; return; } // §2: a master alone does not say which rotation index a server has bound; // account-setup's "existing address" mode discovers it once a server can be // asked. Either way the accepted-correspondent set is unknown, so it must // not overwrite the server's (syncOk: false). store.restoreMaster(seed, 0); enterServerSetup(); }; $("restore-back").onclick = () => { $("restore-master").value = ""; $("restore-error").textContent = ""; showSetupStage("intro"); }; // Holds a key fetched this screen visit but not yet pinned, tied to the host // it came from — editing the host field makes renderServerScreen() stop // showing it, since it only ever matches the host currently typed. let serverFetchedKey = null; // The host screen 4 ends on, carried into screen 5 to build the full address. let setupHost = null; function renderServerScreen() { const host = $("server-host").value.trim().toLowerCase(); const pinned = host ? store.serverPin(host) : null; const fetched = host && serverFetchedKey?.host === host ? serverFetchedKey.key : null; const key = pinned ?? fetched; const area = $("server-key-area"); area.hidden = !key; area.replaceChildren(); if (key) { area.append( field("fingerprint", fingerprint(key)), field("key", b32encode(key)), el("p", { className: "hint", textContent: "If the server operator published a key or " + "fingerprint, make sure it matches before trusting it." })); } $("server-action").textContent = key ? "continue" : "fetch the server's key"; $("server-error").textContent = ""; } $("server-host").oninput = renderServerScreen; function setServerBusy(busy) { for (const node of [$("server-host"), $("server-action"), $("server-back")]) node.disabled = busy; } $("server-form").onsubmit = async (event) => { event.preventDefault(); $("server-error").textContent = ""; let host; try { host = cleanHost($("server-host").value); } catch (err) { $("server-error").textContent = err.message; return; } const pinned = store.serverPin(host); const fetched = serverFetchedKey?.host === host ? serverFetchedKey.key : null; if (pinned || fetched) { if (!pinned) store.pinServer(host, fetched); serverFetchedKey = null; setupHost = host; showSetupStage("account-setup"); return; } setServerBusy(true); try { // Bypassed connect(): its "UNVERIFIED" status toast is right everywhere // else a session proceeds unpinned, but here the whole point of the // fetch is that nothing is pinned yet — the toast would just sit over // the button that is about to pin it. const timeoutMs = store.timeoutSeconds() * 1000; const stream = await connectStream(host, DEFAULT_PORT, timeoutMs); const opened = await openSession(stream, host, null, timeoutMs); opened.session.stream.close(); serverFetchedKey = { host, key: opened.serverStatic }; renderServerScreen(); } catch (err) { $("server-error").textContent = err.message; } finally { setServerBusy(false); } }; $("server-back").onclick = () => discardSetupIdentity(); let accountMode = "new"; function setAccountMode(mode) { accountMode = mode; for (const button of document.querySelectorAll("#account-mode button")) button.classList.toggle("active", button.dataset.mode === mode); $("account-token-label").hidden = mode !== "new"; $("account-action").textContent = mode === "new" ? "register" : "restore access"; $("account-error").textContent = ""; } for (const button of document.querySelectorAll("#account-mode button")) button.onclick = () => setAccountMode(button.dataset.mode); function setAccountBusy(busy) { for (const node of [$("account-user"), $("account-token"), $("account-action"), $("account-back"), ...document.querySelectorAll("#account-mode button")]) node.disabled = busy; } $("account-form").onsubmit = async (event) => { event.preventDefault(); $("account-error").textContent = ""; const me = store.identity(); const button = $("account-action"); const resting = button.textContent; let addr; try { addr = parseAddress(`${$("account-user").value.trim()}@${setupHost}`); } catch (err) { $("account-error").textContent = err.message; return; } setAccountBusy(true); button.textContent = accountMode === "new" ? "registering…" : "restoring…"; try { if (accountMode === "new") { const opened = await connect(addr, { requirePin: true }); try { await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: me, token: $("account-token").value.trim() }); } finally { opened.session.stream.close(); } store.setAccount(addr); } else { await recallAndDiscoverRotation(store.master(), addr.short); } showApp(); status(`welcome, ${addr.short}`); } catch (err) { $("account-error").textContent = err.message; } finally { setAccountBusy(false); button.textContent = resting; } }; $("account-back").onclick = () => showSetupStage("server-setup"); // --- header --------------------------------------------------------------------- function renderAccount() { const me = store.identity(); const account = store.account(); $("account").textContent = me ? (account ? `${account.user}@${account.host}` : "not registered") : ""; $("avatar").textContent = account ? account.user[0].toUpperCase() : "·"; } // --- folders and list --------------------------------------------------------- let folder = "inbox"; let selectedId = null; let query = ""; // Bumped per render, so a superseded pass stops appending rows mid-flight. let renderToken = 0; // Rows opened per turn: a cold folder yields between batches rather than // blocking the tab for an X25519 and an Ed25519 verification per message. const BATCH = 20; for (const button of $("folders").querySelectorAll("button")) button.onclick = () => { folder = button.dataset.folder; for (const b of $("folders").querySelectorAll("button")) { const active = b === button; b.classList.toggle("active", active); if (active) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current"); } selectedId = null; resetView(); renderFolder(); }; // The reader empties when a folder changes, so a stale message cannot linger // under a different list. function resetView() { $("app").classList.remove("reading"); $("view").replaceChildren(placeholder(folder === "contacts" ? "contacts and their keys are listed alongside" : "select a message")); } // Counted from ids and read markers alone, independent of the visible folder — // leaving the inbox used to blank the badge. async function updateUnreadBadge() { $("count-inbox").textContent = (await store.unreadCount()) || ""; $("count-requests").textContent = (await store.requestsUnreadCount()) || ""; } async function renderFolder() { const list = $("list"); updateUnreadBadge(); // The toolbar carries the current folder's action, not a fixed one. const contacts = folder === "contacts"; $("mail-tools").hidden = contacts; $("import-form").hidden = !contacts; // The filter never reaches past the open folder, so the field says which one. $("search").placeholder = `search ${folder}`; $("search").setAttribute("aria-label", `search ${folder}`); if (contacts) return renderContacts(list); const token = ++renderToken; const rows = await store.listMessages(folder); // newest first if (token !== renderToken) return; list.replaceChildren(); if (!rows.length) { list.append(placeholder(folder === "inbox" ? "no mail — fetch" : folder === "requests" ? "no unsolicited mail" : "nothing sent")); return void updateListStatus(); } for (let offset = 0; offset < rows.length; offset += BATCH) { if (offset) await new Promise(resolve => setTimeout(resolve)); if (token !== renderToken) return; list.append(...rows.slice(offset, offset + BATCH).map(messageRow)); applyQuery(); } } // A real button, so every row is reachable and openable from the keyboard. function messageRow(row) { const opened = describe(row); // Both mail tiers have a read state; sent copies were never "unread". const inbound = folder === "inbox" || folder === "requests"; const unread = inbound && !store.isRead(row.id); const item = el("button", { type: "button", className: `item${row.id === selectedId ? " selected" : ""}${unread ? " unread" : ""}`, onclick: () => openMessage(row.id), }); if (row.id === selectedId) item.setAttribute("aria-current", "true"); const who = el("div", { className: "who", textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") }); if (inbound && !opened.error && !opened.knownSender) who.append(el("span", { className: "dot", title: "sender key not bound to an address" })); item.append(who, el("div", { className: "mid" }, el("span", { className: "subject", textContent: opened.error ? `` : (opened.subject || "(no subject)") }), el("span", { className: "snippet", textContent: opened.error ? "" : ` — ${opened.snippet}` })), el("div", { className: "when", textContent: fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt)) })); // Sender or recipient, their key, the subject and the whole body — not the // snippet, and not the timestamp. item.dataset.search = (opened.error ? `${row.recipient ?? ""} ${opened.error}` : [row.recipient ?? "", opened.from, b32encode(opened.sender), opened.subject, opened.body].join(" ") ).toLowerCase(); return item; } // Filters the current folder client-side; nothing leaves the page. Each row // carries its own haystack, built from the fields worth matching rather than // from whatever the row happens to render. Re-applied after every render, so a // redraw cannot silently drop it. function applyQuery() { for (const item of $("list").querySelectorAll(".item")) item.hidden = Boolean(query) && !(item.dataset.search ?? "").includes(query); updateListStatus(); } // The toolbar's right side, which also makes a narrowed search visible. function updateListStatus() { const items = [...$("list").querySelectorAll(".item")]; const shown = items.filter(item => !item.hidden).length; $("list-status").textContent = !items.length ? "" : shown === items.length ? `${items.length} message${items.length === 1 ? "" : "s"}` : `${shown} of ${items.length}`; } $("search").oninput = () => { query = $("search").value.trim().toLowerCase(); applyQuery(); }; // Opening an envelope costs an X25519 agreement and an Ed25519 verification, and // an envelope's plaintext never changes — so the result is kept. Failures are // cached too, so one bad message is not retried on every render. const openedCache = new Map(); function openEnvelope(row) { let entry = openedCache.get(row.id); if (!entry) { try { const opened = unseal(store.identities(), row.envelope); const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body)); entry = { ...opened, fields, body, subject: fields.subject || "", snippet: body.replace(/\s+/g, " ").trim().slice(0, 140) }; } catch (err) { entry = { error: err.message }; } openedCache.set(row.id, entry); } return entry; } // What is known about a sender changes as the user binds addresses to keys, so // this layer sits over the cached envelope and is recomputed per render — it is // a map lookup, not crypto. The wire carries no sender name, so an unknown key // can only be shown as its fingerprint until bound, which a signed Reply-To // field lets the sender do in-band. function describe(row) { const opened = openEnvelope(row); if (opened.error) return opened; const known = store.addressForKey(opened.sender); let replyAddress = null; if (opened.fields["reply-to"]) { try { const claimed = parseAddress(opened.fields["reply-to"]); // Only a full smol:// address whose key matches the signer counts; // anything else is ordinary text. if (claimed.identity && timingSafeEqual(claimed.identity, opened.sender)) replyAddress = claimed; } catch { /* malformed claim: display, never bind */ } } return { ...opened, from: known ?? "unknown sender", // rows stay narrow: the full fingerprint belongs to the reader fromShort: known ?? `unknown · ${b32encode(opened.sender).slice(0, 4)}`, knownSender: Boolean(known), verifiedSender: Boolean(known && store.contact(known)?.verified), replyAddress, }; } // A label/value row; `empty` marks a value that is guidance rather than data. const field = (label, value, empty = false, extra = null) => el("div", { className: "field" }, el("span", { className: "field-label", textContent: label }), el("span", { className: empty ? "field-value empty" : "field-value", textContent: value }), extra); // --- reading ------------------------------------------------------------------ async function openMessage(id) { const row = await store.getMessage(folder, id); if (!row) return; selectedId = id; store.markRead(id); renderFolder(); const opened = describe(row); const view = $("view"); // Under the narrow breakpoint the reader covers the list, so it needs a way back. $("app").classList.add("reading"); const back = el("button", { type: "button", className: "back", onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages"); if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true)); // "accept" and "in-reply-to" are protocol machinery (§5.5, §5.8), not content. const fields = Object.entries(opened.fields).filter(([k]) => k !== "subject" && k !== "accept"); const actions = el("div", { className: "actions" }); view.replaceChildren(...[ back, el("h2", { textContent: opened.subject || "(no subject)" }), el("div", { className: "meta", textContent: folder === "sent" ? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}` : `from: ${opened.from}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}` }), trustRow(opened), fields.length ? el("div", { className: "fields", textContent: fields.map(([k, v]) => `${k}: ${v}`).join("\n") }) : null, el("pre", { textContent: opened.body }), actions, ].filter(node => node != null)); // Deleting removes the local copy, and the server's too if "leave mail on // server" left one there to remove (settings; SPEC.md §10). actions.append(el("button", { type: "button", onclick: async () => { try { await deleteMessage(folder, row); status("message deleted"); selectedId = null; resetView(); renderFolder(); } catch (err) { status(err.message, true); // the local copy is kept if a needed server delete failed } } }, "delete")); if (folder !== "inbox" && folder !== "requests") return; if (folder === "requests" && opened.knownSender) { const known = store.addressForKey(opened.sender); if (!store.accepted(known)?.active) { actions.before(placeholder( `unsolicited mail from ${known}, not yet accepted into your main tier`)); actions.append(el("button", { type: "button", onclick: () => acceptContact(known) }, "accept — future mail lands in your main tier")); } } if (opened.knownSender) { actions.append(el("button", { type: "button", onclick: () => startReply(row) }, icon("reply"), "reply")); } else if (opened.replyAddress) { // The sender signed their own smol:// address into the payload, so binding // it is one click. A pinned conflicting key is refused (§8). actions.before(placeholder( `this message carries the sender's signed address: ${opened.replyAddress.short}`)); actions.append(el("button", { type: "button", textContent: "save sender & reply", onclick: () => { try { bindReplyAddress(opened.replyAddress, opened.sender); status(`${opened.replyAddress.short} saved for this sender (verified key)`); renderFolder(); openMessage(row.id); startReply(row); } catch (err) { status(err.message, true); } } })); } else { // The wire gives only a signed key; a reply needs the sender's address, // which the user supplies once and we bind to that exact key. const input = el("input", { placeholder: "alice@example.org or smol://…", spellcheck: false }); const submit = el("button", { textContent: "name sender & reply" }); const form = el("form", { className: "name-sender" }, input, submit); form.onsubmit = async (event) => { event.preventDefault(); submit.disabled = true; try { await nameSender(input.value, opened.sender); openMessage(row.id); // the sender is named now; the reply button appears startReply(row); } catch (err) { status(err.message, true); } finally { submit.disabled = false; } }; actions.before(placeholder("The wire carries no sender name, only this signed key. " + "To reply, give the sender's address — it is checked against this key:")); actions.append(form); } } // Deletes a message locally, and from the server too if it might still be // sitting there (only possible when "leave mail on server" was on when it was // fetched — SPEC.md §10). Sent copies are local-only; there is nothing // server-side to remove for them (§5.6). Throws, leaving the local copy in // place, if a needed server-side delete fails — otherwise a message could // look gone locally while silently persisting on the server. async function deleteMessage(folder, row) { if (folder !== "sent" && row.keptOnServer) { const me = store.identity(); const addr = accountAddress(); if (!me || !addr) throw new SmolError("not registered; cannot reach the server to delete this message"); const opened = await connect(addr, { requirePin: true }); try { await authenticate(opened.session, opened.handshakeHash, addr.user, me); await deleteOp(opened.session, [unhex(row.id)]); } finally { opened.session.stream.close(); } } await store.removeMessage(folder, row.id); } // §4/§8: a signature always verifies here, because unseal() throws otherwise. // What can be weak is the binding between that key and an address, so that is // what the chip reports. function trustRow(opened) { if (folder === "sent") return el("div", { className: "trust" }, el("span", { className: "badge", textContent: "your own sealed copy" })); const chip = opened.verifiedSender ? ["badge ok", "verified sender key"] : opened.knownSender ? ["badge", "key pinned on first use"] : ["badge alert", "key bound to no address"]; return el("div", { className: "trust" }, el("span", { className: chip[0], textContent: chip[1] }), el("div", { className: "keyblock" }, el("div", { textContent: fingerprint(opened.sender) }), el("div", { className: "full", textContent: b32encode(opened.sender) }))); } const replySubject = (subject) => !subject ? "" : subject.startsWith("Re:") ? subject : `Re: ${subject}`; // One path for all three ways a reply can start, so the Re: rule lives once. function startReply(row) { const opened = describe(row); openCompose({ to: store.addressForKey(opened.sender) ?? "", subject: replySubject(opened.subject), replyTo: row.id, }); } // Bind a user-supplied address to the key that signed a message. A smol:// // address carries its own key (verified); a short address is resolved and the // result kept on first use. Anything that binds a different key is refused. async function nameSender(text, senderKey) { const addr = parseAddress(text.trim()); let key = addr.identity, verified = true; if (!key) { const opened = await connect(addr, { requirePin: false }); try { ({ identity: key } = await resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } verified = false; // trust on first use, as with any RESOLVE } if (!timingSafeEqual(key, senderKey)) throw new SmolError("that address carries a different key than this message's sender"); store.saveContact(addr.short, senderKey, verified); status(`${addr.short} saved for this sender${verified ? " (verified key)" : ""}`); renderFolder(); } // A signed Reply-To is the sender's own claim, verified the moment unseal() // verified the message's signature — no further trust step to gate behind a // click. Never overwrites an address already pinned to a different key (§8: // a key change without a rotation chain needs out-of-band confirmation). No // UI side effects: callers (the reader's button, fetch's auto-bind) decide // how to report the result and whether many need batching into one summary. function bindReplyAddress(addr, senderKey) { const existing = store.contact(addr.short); if (existing && !timingSafeEqual(existing.key, senderKey)) throw new SmolError(`${addr.short} is already known with a different key — ` + `verify out of band before replying`); store.saveContact(addr.short, senderKey, true); } // --- compose and send ----------------------------------------------------------- const compose = $("compose"); function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) { const form = compose.querySelector("form"); form.to.value = to; form.subject.value = subject; form.body.value = body; form.dataset.replyTo = replyTo ?? ""; $("compose-error").textContent = ""; compose.showModal(); } $("write").onclick = () => openCompose(); $("compose-cancel").onclick = () => compose.close(); // Every submission is a send: cancel is a plain button, and Enter in a field // submits with no submitter — which the old check read as cancel, discarding // the draft. compose.querySelector("form").onsubmit = async (event) => { event.preventDefault(); const form = event.target; const send = $("send"); $("compose-error").textContent = ""; send.disabled = true; // one click, one envelope: a second seals a second message try { await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo, form.elements.anonymous.checked); compose.close(); } catch (err) { $("compose-error").textContent = err.message; } finally { send.disabled = false; } }; async function sendMail(toText, subject, body, replyTo, anonymous) { const me = store.identity(); const master = store.master(); if (!me || !master) throw new SmolError("no identity yet"); const addr = parseAddress(toText); const recipient = await resolveRecipient(addr); const account = accountAddress(); const fields = [["Subject", subject], ["In-Reply-To", replyTo]]; // A signed Reply-To lets a first-time recipient name and answer us (§5.5 // allows unknown keys); "anonymous" omits it. if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]); // §5.8: hand an accepted correspondent the token for our own mailbox, so a // first reply from them reaches our main tier. const accepted = store.accepted(addr.short); if (accepted?.active) fields.push(["Accept", b32encode(tokenFor(master, accepted.identity))]); const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v), body.replace(/\s+$/, "") + "\n")); const envelope = seal(me, recipient, bodyBytes); // §5.8: our token for their mailbox, if they have given us one. const held = store.tokenFrom(addr.short); const mac = held ? acceptMac(held, messageId(envelope)) : null; const opened = await connect(addr, { requirePin: false }); try { await sendOp(opened.session, envelope, mac); } finally { opened.session.stream.close(); } // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves. await store.storeMessage("sent", { id: hex(messageId(envelope)), recipient: addr.short, envelope: seal(me, me.publicKey, bodyBytes), sentAt: Math.floor(Date.now() / 1000), }); status(`sent to ${addr.short}${mac ? " (accepted)" : ""}`); if (folder === "sent") renderFolder(); } // Prefer a key we already trust; fall back to RESOLVE with trust on first use. async function resolveRecipient(addr) { if (addr.identity) { store.saveContact(addr.short, addr.identity, true); return addr.identity; } const known = store.contact(addr.short); if (known) return known.key; const opened = await connect(addr, { requirePin: false }); let current; try { ({ identity: current } = await resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } store.saveContact(addr.short, current, false); status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`); return current; } // --- fetch ---------------------------------------------------------------------- // The address we know a signer by: a contact, or the Reply-To it signed for // itself. Naming a mailbox is not trusting a key, so nothing is pinned here // (§5.7, §8). function addressOfSigner(sender, replyToField) { const known = store.addressForKey(sender); if (known) return known; if (!replyToField) return null; try { const parsed = parseAddress(replyToField); if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short; } catch { /* malformed claim: no address to learn a token under */ } return null; } // §5.8: an Accept field is bound to the signer of the message that carried // it, which unseal() has already verified. function learnTokenFrom(unsealed, opened) { const raw = opened.fields["accept"]; if (!raw) return; let token; try { token = b32decode(raw); } catch { return; } if (token.length !== TOKEN_LEN) return; const address = addressOfSigner(unsealed.sender, opened.fields["reply-to"]); if (address) store.learnToken(address, token); } $("fetch").onclick = async () => { const me = store.identity(); const master = store.master(); const addr = accountAddress(); if (!me || !master) return status("no identity yet", true); if (!addr) return status("not registered yet — claim an address in settings", true); const button = $("fetch"), label = button.querySelector(".label"); const resting = label.textContent; button.disabled = true; // one session at a time; the icon spins while it is label.textContent = "fetching…"; status("fetching…"); let stored = 0, verified = 0; const rejected = []; // §10: acknowledging (deleting) is the default; "leave mail on server" pages // forward by cursor instead, so already-fetched mail is never re-downloaded // even though it isn't deleted (storeIfNew also dedupes, as a second line // of defense). const leaveOnServer = store.leaveOnServer(); let [afterTime, afterId] = store.cursor(); try { const opened = await connect(addr, { requirePin: true }); try { const { sync, tokens } = store.tokenSet(master); await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens }); for (;;) { const records = await fetchOp(opened.session, afterTime, afterId); if (!records.length) break; const acked = []; for (const record of records) { afterTime = record.receivedAt; afterId = record.id; let unsealed; try { if (!timingSafeEqual(messageId(record.envelope), record.id)) throw new SmolError("id does not match the envelope"); unsealed = unseal(store.identities(), record.envelope); } catch (err) { // Left on the server rather than destroyed, so a client-side bug // cannot lose mail. Collected rather than announced, because the // closing summary would overwrite each one in turn. rejected.push(`${hex(record.id)}: ${err.message}`); continue; } const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt, tier: record.isRequest ? store.TIER_REQUESTS : store.TIER_MAIN, keptOnServer: leaveOnServer }; if (await store.storeIfNew("inbox", row)) { stored++; // A signed Reply-To is exactly as verified as the signature // unseal() just checked — see bindReplyAddress(). describe() // both parses the same claim the reader would show and warms its // cache, so opening the message right after costs nothing extra. const described = describe(row); if (described.replyAddress) { try { bindReplyAddress(described.replyAddress, unsealed.sender); verified++; } catch { /* already known under a different key: leave it be */ } } learnTokenFrom(unsealed, described); } acked.push(record.id); } if (leaveOnServer) { // Persisted per batch, so an interrupted fetch resumes here rather // than re-paging from the start next time. store.setCursor(afterTime, afterId); } else if (acked.length) { await deleteOp(opened.session, acked); } } } finally { opened.session.stream.close(); } // Everything acknowledged is deleted, so the next fetch starts fresh; a // record left on the server (rejected above) simply resurfaces then. if (!leaveOnServer) store.setCursor(0, new Uint8Array(ID_LEN)); status([`${stored} new, ${rejected.length} rejected`, verified ? `${verified} sender${verified === 1 ? "" : "s"} verified from a signed Reply-To` : null, ...rejected.map(line => ` ${line}`), rejected.length ? "left on the server" : null].filter(Boolean).join("\n"), rejected.length > 0); } catch (err) { status(err.message, true); } finally { button.disabled = false; label.textContent = resting; } renderFolder(); }; // --- contacts ------------------------------------------------------------------- function renderContacts(list) { const contacts = store.allContacts(); list.replaceChildren(...contacts.map(({ address, key, verified, history }) => { const accepted = store.accepted(address); const item = el("button", { type: "button", className: `item contact${address === selectedId ? " selected" : ""}`, onclick: () => openContact(address), }); if (address === selectedId) item.setAttribute("aria-current", "true"); item.append( el("div", { className: "who", textContent: address }), el("div", { className: "mid" }, el("div", { textContent: fingerprint(b32decode(key)) }), el("span", { className: verified ? "badge ok" : "badge", textContent: verified ? "verified key" : "tofu" }), accepted?.active ? el("span", { className: "badge ok", textContent: "accepted" }) : null, history.length ? el("span", { className: "badge", textContent: `${history.length} previous` }) : null)); item.dataset.search = `${address} ${key} ${fingerprint(b32decode(key))} ${history.map(h => h.key).join(" ")}`.toLowerCase(); return item; })); if (!contacts.length) list.append(placeholder("no contacts yet — import a smol:// address above, or write to someone")); } // The full key, the address to hand out, and any key this one replaced — the // row can only afford a fingerprint, and §8 turns on the user being able to // inspect exactly what changed. function openContact(address) { const known = store.contact(address); if (!known) return; selectedId = address; renderFolder(); const addr = parseAddress(address); const view = $("view"); $("app").classList.add("reading"); const refresh = el("button", { type: "button", onclick: async (event) => { event.target.disabled = true; try { await refreshContact(address); } finally { event.target.disabled = false; } } }, icon("refresh"), "re-resolve"); const isAccepted = Boolean(store.accepted(address)?.active); const tokenButton = el("button", { type: "button", textContent: isAccepted ? "block" : "accept", onclick: async (event) => { event.target.disabled = true; try { if (isAccepted) await blockContact(address); else await acceptContact(address); } finally { event.target.disabled = false; } } }); view.replaceChildren(...[ el("button", { type: "button", className: "back", onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "contacts"), el("h2", { textContent: address }), el("div", { className: "trust" }, el("span", { className: known.verified ? "badge ok" : "badge", textContent: known.verified ? "verified key" : "key pinned on first use" })), field("fingerprint", fingerprint(known.key)), field("public key", b32encode(known.key)), field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")), el("div", { className: "seed-card" }, el("h4", { textContent: "accept token" }), el("p", { className: "hint", textContent: isAccepted ? "This contact's mail lands in your main tier." : "This contact's mail lands in requests until accepted." }), el("div", { className: "actions" }, tokenButton)), known.history.length ? el("section", { className: "history" }, el("h3", { textContent: `previous keys (${known.history.length})` }), el("p", { className: "hint", textContent: "Replaced by a signed rotation chain. Mail signed with these was sent " + "before the change; rotation is not revocation, so a stolen key can still rotate onward." }), ...known.history.map(({ key, until }) => el("div", { className: "keyblock" }, el("div", { textContent: fingerprint(b32decode(key)) }), el("div", { className: "full", textContent: key }), el("div", { className: "replaced", textContent: `replaced ${fmtTime(Math.floor(until / 1000))}` })))) : null, el("div", { className: "actions" }, refresh), ].filter(node => node != null)); } // Re-resolve a contact and apply §8: a valid rotation chain is accepted and // surfaced; anything else requires out-of-band verification. async function refreshContact(address) { try { const addr = parseAddress(address); if (addr.identity) throw new SmolError("that address already carries a key; use import instead"); const known = store.contact(addr.short); const opened = await connect(addr, { requirePin: false }); let current, chain; try { ({ identity: current, chain } = await resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } if (!known) { store.saveContact(addr.short, current, false); status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`); } else if (timingSafeEqual(known.key, current)) { status(`${addr.short}: key unchanged`); } else if (walkChain(addr.user, known.key, current, chain)) { store.saveContact(addr.short, current, known.verified); status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true); } else { status(`${addr.short} presents a different key with no valid rotation chain.\n` + `Verify out of band, then import the new smol:// address.`, true); } } catch (err) { status(err.message, true); } if (folder === "contacts" && selectedId === address) openContact(address); else renderFolder(); } // --- accept tokens (§5.8) -------------------------------------------------------- // An accept or a block only takes effect once the server holds the changed // set, so it is pushed right away rather than at the next fetch. async function pushTokens() { const me = store.identity(); const master = store.master(); const addr = accountAddress(); if (!me || !master) throw new SmolError("no identity yet"); if (!addr) { status("not registered; the set will be pushed with your first fetch", true); return 0; } const opened = await connect(addr, { requirePin: true }); try { const { sync, tokens } = store.tokenSet(master); return await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens }); } finally { opened.session.stream.close(); } } // Admit a contact to the main tier; their token travels in our next message // to them. async function acceptContact(address) { try { const key = store.contact(address)?.key; if (!key) throw new SmolError(`no key for ${address} yet`); store.accept(address, key); store.setSyncOk(true); const held = await pushTokens(); status(`${address} accepted; server now holds ${held} accept token(s)`); renderFolder(); if (folder === "contacts" && selectedId === address) openContact(address); } catch (err) { status(err.message, true); } } // Withdraw a contact's accept token; their mail lands in requests from their // next message on. async function blockContact(address) { try { store.block(address); const held = await pushTokens(); status(`${address} blocked; server now holds ${held} accept token(s)`); renderFolder(); if (folder === "contacts" && selectedId === address) openContact(address); } catch (err) { status(err.message, true); } } // --- settings --------------------------------------------------------------------- const settings = $("settings"); const settingsTabs = [...$("settings-tabs").querySelectorAll("button")]; const settingsPanels = [...$("settings-panels").querySelectorAll(".panel")]; function showTab(name) { for (const button of settingsTabs) { const active = button.dataset.tab === name; button.classList.toggle("active", active); if (active) button.setAttribute("aria-current", "true"); else button.removeAttribute("aria-current"); } for (const panel of settingsPanels) panel.hidden = panel.dataset.panel !== name; $("settings-panels").scrollTop = 0; } for (const button of settingsTabs) button.onclick = () => showTab(button.dataset.tab); function openSettings() { renderSettings(); showTab("identity"); if (!settings.open) settings.showModal(); // showModal() focuses the first focusable node, which is the close button; // the current tab says where you are instead. $("settings-tabs").querySelector("button.active").focus(); } $("settings-open").onclick = () => openSettings(); $("settings-close").onclick = () => settings.close(); $("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked); // --- settings panels -------------------------------------------------------------- // One renderer per panel, all of them cheap, so every mutation below can just // call this rather than reason about which panel it touched. function renderSettings() { $("leave-on-server").checked = store.leaveOnServer(); $("backup-error").textContent = ""; renderIdentityPanel(); renderServerPanel(); renderAdvancedPanel(); } // A reusable Cancel/confirm modal for the advanced panel's two irreversible // actions. 's own cancel path (Escape, backdrop click) counts as // Cancel, same as clicking the button. function confirmDialog({ title, body, confirmLabel, danger = false }) { return new Promise((resolve) => { $("confirm-title").textContent = title; $("confirm-text").textContent = body; const ok = $("confirm-ok"); ok.textContent = confirmLabel; ok.className = danger ? "danger" : "filled"; const dialog = $("confirm"); let decided = false; const finish = (result) => { decided = true; resolve(result); dialog.close(); }; $("confirm-cancel").onclick = () => finish(false); ok.onclick = () => finish(true); dialog.addEventListener("close", () => { if (!decided) resolve(false); }, { once: true }); dialog.showModal(); }); } function renderServerPanel() { const account = store.account(); const host = account?.host ?? null; const key = host ? store.serverPin(host) : null; $("server-connection").replaceChildren( field("Host", host ?? "not registered yet", !host, host ? copyButton(host, "host") : null), field("Public Key", key ? b32encode(key) : "not pinned yet", !key, key ? copyButton(b32encode(key), "public key") : null), el("div", { className: "spin-row" }, el("div", { className: "spin-text" }, el("span", { className: "check-title", textContent: "Timeout" }), el("span", { className: "hint", textContent: "Seconds before a network call gives up" })), el("input", { type: "number", min: 1, max: 600, step: 1, value: store.timeoutSeconds(), onchange: (event) => { store.setTimeoutSeconds(event.target.value); event.target.value = store.timeoutSeconds(); // reflects clamping or an ignored non-numeric edit } }))); } function renderIdentityPanel() { const me = store.identity(); const account = store.account(); const fields = $("identity-fields"); fields.replaceChildren(); const secret = $("identity-secret"); secret.replaceChildren(); if (!me) return; const address = account ? `${account.user}@${account.host}` : "not registered yet"; const key = b32encode(me.publicKey); const uri = account ? accountAddress().uri(me.publicKey) : "claim an address to get one"; fields.append( field("Address", address, !account, account ? copyButton(address, "address") : null), field("Key", key, false, copyButton(key, "key")), field("Fingerprint", fingerprint(me.publicKey), false, copyButton(fingerprint(me.publicKey), "fingerprint")), field("URI", uri, !account, account ? copyButton(uri, "uri") : null)); const masterBytes = store.master(); if (!masterBytes) return; // Revealed on request rather than on open: the panel a new user is sent to // first is a poor place to paint the only secret there is across the screen. const masked = "\u2022".repeat(64); const seedText = hex(masterBytes); const seed = el("div", { className: "seed", textContent: masked }); const reveal = el("button", { type: "button", textContent: "reveal", title: "reveal the recovery secret", onclick: (event) => { const showing = seed.textContent !== masked; seed.textContent = showing ? masked : seedText; event.currentTarget.textContent = showing ? "reveal" : "hide"; event.currentTarget.title = showing ? "reveal the recovery secret" : "hide the recovery secret"; } }); secret.append(el("div", { className: "seed-card" }, el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")), el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " + "it loses the address with it — there is no reset. Keep it in a password manager." }))); } function renderAdvancedPanel() { $("rotations-count").replaceChildren(field("Completed", String(store.rotations()))); $("rotate-error").textContent = ""; $("logout-error").textContent = ""; } // A pin is keyed by bare host — connect() strips the port before looking one // up (parseAddress splits user@host:port into three), so "user@host" or // "host:port" here would silently pin a string nothing ever looks up again, // exactly the mistake that produced "pinned
" followed by a // confusing "no pinned key" from recall right after. Shared by the settings // pin form and the onboarding server screen. function cleanHost(hostText) { const host = hostText.trim().toLowerCase(); if (host.includes("@")) throw new SmolError(`"${host}" looks like an address, not a server — the part after the @ goes here`); if (host.includes(":")) throw new SmolError(`"${host}": a pin is by host only, no port — just the part before the colon`); return host; } $("import-form").onsubmit = (event) => { event.preventDefault(); const form = event.target; try { const addr = parseAddress(form.uri.value.trim()); if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key"); store.saveContact(addr.short, addr.identity, true); status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` + `fingerprint: ${fingerprint(addr.identity)}`); renderFolder(); form.reset(); } catch (err) { status(err.message, true); } }; // §2: a master alone does not say which rotation index a server has bound, // so restoring resolves the address and walks indices 0..MAX_CHAIN until one // derives the key RESOLVE returned, correcting restore-identity's default // guess of index 0. Also binds "account" — onboarding's only path to it now. async function recallAndDiscoverRotation(masterBytes, text) { const addr = parseAddress(text.trim()); const opened = await connect(addr, { requirePin: true }); let current; try { ({ identity: current } = await resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } let found = null; for (let n = 0; n <= MAX_CHAIN; n++) { if (timingSafeEqual(identityFromSeed(identitySeed(masterBytes, n)).publicKey, current)) { found = n; break; } } if (found === null) throw new SmolError(`the key bound to ${addr.short} is not derived from this master ` + `within ${MAX_CHAIN} rotations`); store.setRotationIndex(found); store.setAccount(addr); return addr; } // The File System Access API gives a real save dialog with a suggested name; // where it is unavailable (Firefox, Safari), a plain download is the // equivalent a browser can offer. Either way nothing is shown on success — // the file landing where the user put it is the confirmation. $("export-data").onclick = async () => { $("backup-error").textContent = ""; try { const data = await store.exportData(); const stamp = new Date(data.exportedAt).toISOString().slice(0, 10); const filename = `gsmol-export-${stamp}.json`; const text = JSON.stringify(data); if (window.showSaveFilePicker) { const handle = await window.showSaveFilePicker({ suggestedName: filename, types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] }); const writable = await handle.createWritable(); await writable.write(text); await writable.close(); } else { const blob = new Blob([text], { type: "application/json" }); const url = URL.createObjectURL(blob); Object.assign(document.createElement("a"), { href: url, download: filename }).click(); URL.revokeObjectURL(url); } } catch (err) { if (err.name !== "AbortError") $("backup-error").textContent = err.message; // AbortError: picker cancelled } }; // Shared by both the File System Access path and the // fallback it reduces to where that API is unavailable. async function importBackupText(text) { if (!text.trim()) return; // empty or whitespace-only content does nothing await store.importData(JSON.parse(text)); renderSettings(); renderFolder(); } $("import-data-trigger").onclick = async () => { $("backup-error").textContent = ""; if (!window.showOpenFilePicker) return $("import-data").click(); try { const [handle] = await window.showOpenFilePicker({ types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] }); await importBackupText(await (await handle.getFile()).text()); } catch (err) { if (err.name !== "AbortError") $("backup-error").textContent = err.message; } }; $("import-data").onchange = async (event) => { const file = event.target.files[0]; event.target.value = ""; // same file re-selected twice must still fire change if (!file) return; try { await importBackupText(await file.text()); } catch (err) { $("backup-error").textContent = err.message; } }; // A confirm dialog's body repeats its group's description verbatim, read // straight from the panel rather than duplicated here so the two can never // drift apart. textContent carries the HTML source's line-wrap whitespace, // which this collapses back to plain prose. const panelText = (id) => $(id).textContent.replace(/\s+/g, " ").trim(); // §7: rotate to the next index's derived key and rebind the account with a // signed certificate. The superseded key stays derivable from the master, // since mail sealed to it stays readable with nothing else. async function rotateIdentity() { const me = store.identity(); const master = store.master(); const addr = accountAddress(); $("rotate-error").textContent = ""; if (!me || !master || !addr) { $("rotate-error").textContent = "rotate needs a registered account"; return; } const confirmed = await confirmDialog({ title: "Rotate the identity?", body: panelText("rotations-description"), confirmLabel: "Rotate" }); if (!confirmed) return; const button = $("rotate-identity"); const resting = button.textContent; button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert button.textContent = "rotating…"; try { const freshSeed = identitySeed(master, store.rotations() + 1); const fresh = identityFromSeed(freshSeed); const cert = makeCert(addr.user, me, freshSeed); const opened = await connect(addr, { requirePin: true }); try { await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert }); } finally { opened.session.stream.close(); } store.advanceRotation(); // The key set grew, so a cached "not one of our keys" failure may be stale. openedCache.clear(); renderAccount(); renderSettings(); renderFolder(); } catch (err) { $("rotate-error").textContent = err.message; } finally { button.disabled = false; button.textContent = resting; } } $("rotate-identity").onclick = rotateIdentity; // Unlike rotate, nothing is kept: this is the master's only local copy, gone. async function logout() { $("logout-error").textContent = ""; const confirmed = await confirmDialog({ title: "Log out and delete all data?", body: panelText("logout-description"), confirmLabel: "Log Out", danger: true }); if (!confirmed) return; try { await store.clearAll(); location.reload(); } catch (err) { $("logout-error").textContent = err.message; } } $("logout").onclick = logout; // --- boot -------------------------------------------------------------------------- // A deployment MAY bake in a server key (the NixOS module's `presetServer`), // which only makes sense when whoever serves this page and whoever runs that // smolmaild are the same trusted party: the value arrives over this // deployment's own TLS, which is the trusted channel SPEC.md §4 asks a pin to // come from. This only seeds the first run — once written it is an ordinary // pin, editable and removable in settings like any other, and never // overwrites a host the user (or a previous boot) already pinned. function applyPresetServer() { if (!PRESET_SERVER || store.serverPin(PRESET_SERVER.host)) return; try { store.pinServer(PRESET_SERVER.host, b32decode(PRESET_SERVER.publicKey)); } catch { /* malformed preset: leave unpinned rather than block boot */ } } applyPresetServer(); applyTheme(); // An identity not yet bound to an account always resumes at the server // screen, however it was reached — there is nothing a banner needs to remind // the user of afterward, since showApp() is unreachable until both are done. if (!store.identity()) { showSetupStage("intro"); showSetup(); } else if (!store.account()) { enterServerSetup(); showSetup(); } else { showApp(); }