// 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"; import { applyStaticLocale, loadLocale, t } from "./i18n.js"; const $ = (id) => document.getElementById(id); // Blocks the rest of this module's top-level code (including every eager // render below) until the dictionary is in, so nothing races t() before it // has anything to look up. await loadLocale(); applyStaticLocale(); // 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: t("property-row-copy-tooltip", { field: what }), textContent: t("copy-button-idle"), onclick: async (event) => { const button = event.currentTarget; const resting = button.textContent; try { await navigator.clipboard.writeText(text); button.textContent = t("copy-button-success"); } catch { button.textContent = t("copy-button-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", labelId: "theme-auto-label" }, { value: "light", icon: "sun", labelId: "theme-light-label" }, { value: "dark", icon: "moon", labelId: "theme-dark-label" }, ]; function applyTheme() { const mode = THEMES.find(entry => entry.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}`); const label = t(mode.labelId); button.title = label; button.setAttribute("aria-label", label); } $("theme").onclick = () => { const current = THEMES.findIndex(entry => entry.value === store.theme()); store.setTheme(THEMES[(current + 1) % THEMES.length].value); applyTheme(); status(t(THEMES.find(entry => entry.value === store.theme()).labelId)); }; // --- connection -------------------------------------------------------------- // Every caller that needs its own account's server reachable here already // checked for one before calling (see e.g. fetch's and rotateIdentity's own // `!addr` guards) — and that host is always pinned by the time it exists, // since onboarding pins before it ever binds an account. A correspondent's // server, by contrast, legitimately proceeds unpinned (the UNVERIFIED // warning below is the only consequence); there is no live case left where // this needs to refuse a connection outright, so it no longer tries to. async function connect(addr) { const pinned = store.serverPin(addr.host); 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(t("connect-unpinned-warning", { host: addr.host, key: b32encode(opened.serverStatic) }), 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 = () => { 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(t("seed-wrong-length")); } $("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(t("detail-fingerprint-label"), fingerprint(key)), field(t("detail-key-label"), b32encode(key)), el("p", { className: "hint", textContent: t("trust-fingerprint-hint") })); } $("server-action").textContent = key ? t("signup-continue-button") : t("signup-fetch-key-button"); $("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" ? t("signup-register-button") : t("signup-recall-button"); $("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" ? t("status-busy-registering") : t("status-busy-restoring"); try { if (accountMode === "new") { const opened = await connect(addr); 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); showApp(); status(t("status-registered-note")); } else { await recallAndDiscoverRotation(store.master(), addr.short); showApp(); status(t("status-restored-note", { index: store.rotations() })); } } 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}` : t("account-not-registered-value")) : ""; $("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" ? t("mail-contacts-reader-hint") : t("mail-empty-title"))); } // 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. const searchLabel = t("mail-search-placeholder", { folder }); $("search").placeholder = searchLabel; $("search").setAttribute("aria-label", searchLabel); 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" ? t("mail-inbox-empty-title") : folder === "requests" ? t("mail-requests-empty-title") : t("mail-sent-empty-title"))); 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: t("mail-sender-unbound-tooltip") })); item.append(who, el("div", { className: "mid" }, el("span", { className: "subject", textContent: opened.error ? t("mail-row-unreadable-error", { error: opened.error }) : (opened.subject || t("mail-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 ? t(items.length === 1 ? "mail-list-count-one" : "mail-list-count-other", { count: items.length }) : t("mail-list-shown-of-total", { shown, total: 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"), t("mail-back-to-list-button")); 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 || t("mail-no-subject") }), el("div", { className: "meta", textContent: folder === "sent" ? t("mail-meta-sent", { recipient: row.recipient, id: row.id, time: fmtTime(row.sentAt) }) : t("mail-meta-received", { from: opened.from, id: row.id, time: 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(t("status-deleted-note")); selectedId = null; resetView(); renderFolder(); } catch (err) { status(err.message, true); // the local copy is kept if a needed server delete failed } } }, t("action-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(t("mail-request-unaccepted-hint", { address: known }))); actions.append(el("button", { type: "button", onclick: () => acceptContact(known) }, t("mail-accept-button"))); } } if (opened.knownSender) { actions.append(el("button", { type: "button", onclick: () => startReply(row) }, icon("reply"), t("action-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(t("mail-signed-reply-hint", { address: opened.replyAddress.short }))); actions.append(el("button", { type: "button", textContent: t("mail-save-sender-reply-button"), onclick: () => { try { bindReplyAddress(opened.replyAddress, opened.sender); status(t("contacts-sender-saved-note-verified", { address: opened.replyAddress.short })); 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: t("mail-name-sender-button") }); 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(t("mail-unknown-sender-hint"))); 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(t("mail-delete-not-registered-error")); const opened = await connect(addr); 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: t("mail-trust-own-copy") })); const chip = opened.verifiedSender ? ["badge ok", t("mail-trust-verified-sender")] : opened.knownSender ? ["badge", t("mail-trust-tofu-sender")] : ["badge alert", t("mail-trust-unbound-sender")]; 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 : t("reply-subject-prefix", { 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, }); } // Never silently overwrites a different key already held for the same // address — a self-certifying key is still just a claim until compared // against what's already trusted. Re-saving an identical key proceeds // unasked, matching saveContact()'s own "not a rotation" rule. Throws on // decline, so every call site's existing catch already reports it correctly // with no further changes needed there. async function saveContactChecked(address, keyBytes, verified) { const existing = store.contact(address); if (existing && !timingSafeEqual(existing.key, keyBytes)) { const ok = await confirmDialog({ title: t("key-change-dialog-title"), body: t("key-change-dialog-body", { address, known_fingerprint: fingerprint(existing.key), offered_fingerprint: fingerprint(keyBytes) }), confirmLabel: t("key-change-dialog-replace-button"), danger: true, }); if (!ok) throw new SmolError(t("contacts-key-kept-note", { address })); } store.saveContact(address, keyBytes, verified); } // 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. A key that disagrees with this message's sender // is refused outright; one that disagrees with an address already saved // under a different key is asked about instead (saveContactChecked). async function nameSender(text, senderKey) { const addr = parseAddress(text.trim()); let key = addr.identity, verified = true; if (!key) { const opened = await connect(addr); 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(t("contacts-sender-key-mismatch-error")); await saveContactChecked(addr.short, senderKey, verified); status(t(verified ? "contacts-sender-saved-note-verified" : "contacts-sender-saved-note-unverified", { address: addr.short })); 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(t("contacts-reply-key-conflict-error", { address: addr.short })); 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 = ""; resetUnpinnedPrompt(); compose.showModal(); } $("write").onclick = () => openCompose(); $("compose-cancel").onclick = () => compose.close(); // Holds the address a refused send is waiting on — "fetch their key, pin it, // resend" without losing the draft. Tied to one address at a time: editing // the recipient away from it retires the card (the oninput handler below). let unpinnedPrompt = null; // { addressShort, host, port, fetchedKey } function resetUnpinnedPrompt() { unpinnedPrompt = null; $("compose-unpinned").hidden = true; $("compose-unpinned-key-area").hidden = true; $("compose-unpinned-key-area").replaceChildren(); $("compose-unpinned-fetch").hidden = false; $("compose-unpinned-pin").hidden = true; $("compose-unpinned-error").textContent = ""; } function showUnpinnedPrompt(addr) { // Only #send is disabled mid-flight, not the inputs — if the field no // longer names this address by the time the refusal comes back, there's // nothing to recover for. let live; try { live = parseAddress(compose.querySelector("form").to.value).short; } catch { live = null; } if (live !== addr.short) return; unpinnedPrompt = { addressShort: addr.short, host: addr.host, port: addr.port, fetchedKey: null }; $("compose-unpinned-title").textContent = t("compose-pin-title", { host: addr.host }); $("compose-unpinned-body").textContent = t("compose-pin-body", { address: addr.short }); $("compose-unpinned").hidden = false; } // 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. Factored out from the submit handler so "Pin and send" can // retry the exact same attempt inline, rather than synthesizing a submit. async function attemptSend(form) { const toText = form.to.value; const send = $("send"); $("compose-error").textContent = ""; send.disabled = true; // one click, one envelope: a second seals a second message try { await sendMail(toText, form.subject.value, form.body.value, form.dataset.replyTo, form.elements.anonymous.checked); compose.close(); } catch (err) { if (err instanceof UnpinnedServerError) { let addr; try { addr = parseAddress(toText); } catch { addr = null; } if (addr) { showUnpinnedPrompt(addr); return; } } $("compose-error").textContent = err.message; resetUnpinnedPrompt(); } finally { send.disabled = false; } } compose.querySelector("form").onsubmit = (event) => { event.preventDefault(); attemptSend(event.target); }; compose.querySelector("form").elements.to.oninput = () => { if (!unpinnedPrompt) return; let short; try { short = parseAddress(compose.querySelector("form").to.value).short; } catch { short = null; } if (short !== unpinnedPrompt.addressShort) resetUnpinnedPrompt(); }; $("compose-unpinned-fetch").onclick = async () => { if (!unpinnedPrompt) return; const { host, port, addressShort } = unpinnedPrompt; const button = $("compose-unpinned-fetch"); button.disabled = true; $("compose-unpinned-error").textContent = ""; try { // Bypasses connect() deliberately, same reasoning as onboarding's server // screen: its "UNVERIFIED" toast would sit over the button that's about // to pin the result. const timeoutMs = store.timeoutSeconds() * 1000; const stream = await connectStream(host, port, timeoutMs); const opened = await openSession(stream, host, null, timeoutMs); opened.session.stream.close(); if (!unpinnedPrompt || unpinnedPrompt.addressShort !== addressShort) return; // retired mid-fetch unpinnedPrompt.fetchedKey = opened.serverStatic; $("compose-unpinned-key-area").hidden = false; $("compose-unpinned-key-area").replaceChildren( field(t("detail-fingerprint-label"), fingerprint(opened.serverStatic)), field(t("detail-key-label"), b32encode(opened.serverStatic)), el("p", { className: "hint", textContent: t("trust-fingerprint-hint") })); $("compose-unpinned-fetch").hidden = true; $("compose-unpinned-pin").hidden = false; } catch (err) { $("compose-unpinned-error").textContent = err.message; } finally { button.disabled = false; } }; $("compose-unpinned-pin").onclick = async () => { if (!unpinnedPrompt?.fetchedKey) return; const { host, fetchedKey } = unpinnedPrompt; const button = $("compose-unpinned-pin"); button.disabled = true; try { store.pinServer(host, fetchedKey); // a local write, done inline before the retry resetUnpinnedPrompt(); await attemptSend(compose.querySelector("form")); } catch (err) { $("compose-unpinned-error").textContent = err.message; } finally { button.disabled = false; } }; async function sendMail(toText, subject, body, replyTo, anonymous) { const me = store.identity(); const master = store.master(); if (!me || !master) throw new SmolError(t("identity-missing-error")); 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); 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(t(mac ? "mail-sent-note-accepted" : "mail-sent-note", { address: addr.short })); if (folder === "sent") renderFolder(); } // Thrown only when the key would be learned from a server the user hasn't // pinned: a bare address, not yet a contact, host unpinned. A distinct class, // not a SmolError with a particular message, so compose's catch can show the // recovery card instead of plain text. class UnpinnedServerError extends SmolError { constructor(host) { super(`${host} is not pinned`); this.host = host; } } // Prefer a key we already trust; fall back to RESOLVE with trust on first use. // A smol:// address and an already-known contact are exempt from "require a // pinned server" below by construction — both return before that check is // ever reached, which is what keeps the gate from blocking sends it was // never meant to touch. async function resolveRecipient(addr) { if (addr.identity) { await saveContactChecked(addr.short, addr.identity, true); return addr.identity; } const known = store.contact(addr.short); if (known) return known.key; if (store.requirePinnedServer() && !store.serverPin(addr.host)) throw new UnpinnedServerError(addr.host); const opened = await connect(addr); let current; try { ({ identity: current } = await resolveOp(opened.session, addr.user)); } finally { opened.session.stream.close(); } store.saveContact(addr.short, current, false); status(t(opened.pinned ? "contacts-resolved-note" : "contacts-resolved-note-unverified", { address: addr.short })); 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(t("identity-missing-error"), true); if (!addr) return status(t("account-not-registered-fetch-warning"), 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 = t("status-busy-fetching"); status(t("status-busy-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); 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(t("mail-envelope-id-mismatch-error")); 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([t("mail-fetch-summary-counts", { stored, rejected: rejected.length }), verified ? t(verified === 1 ? "mail-fetch-summary-verified-one" : "mail-fetch-summary-verified-other", { verified }) : null, ...rejected.map(line => ` ${line}`), rejected.length ? t("mail-fetch-summary-left-on-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: t(verified ? "contact-state-verified" : "contact-state-tofu") }), accepted?.active ? el("span", { className: "badge ok", textContent: t("contact-state-accepted") }) : null, history.length ? el("span", { className: "badge", textContent: t("contacts-history-badge", { count: history.length }) }) : null)); item.dataset.search = `${address} ${key} ${fingerprint(b32decode(key))} ${history.map(h => h.key).join(" ")}`.toLowerCase(); return item; })); if (!contacts.length) list.append(placeholder(t("contacts-import-empty-hint"))); } // 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"), t("contacts-refresh-button")); const isAccepted = Boolean(store.accepted(address)?.active); const tokenButton = el("button", { type: "button", textContent: t(isAccepted ? "action-block" : "action-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"), t("contacts-caption")), el("h2", { textContent: address }), el("div", { className: "trust" }, el("span", { className: known.verified ? "badge ok" : "badge", textContent: t(known.verified ? "contact-state-verified" : "contact-state-tofu") })), field(t("detail-fingerprint-label"), fingerprint(known.key)), field(t("detail-key-label"), b32encode(known.key)), field(t("detail-address-label"), addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")), field(t("detail-server-label"), addr.host, false, el("span", { className: store.serverPin(addr.host) ? "badge ok" : "badge", textContent: t(store.serverPin(addr.host) ? "contacts-server-pinned-badge" : "contacts-server-not-pinned-badge") })), el("div", { className: "seed-card" }, el("h4", { textContent: t("contacts-accept-token-title") }), el("p", { className: "hint", textContent: t(isAccepted ? "contacts-accepted-hint" : "contacts-unaccepted-hint") }), el("div", { className: "actions" }, tokenButton)), known.history.length ? el("section", { className: "history" }, el("h3", { textContent: t("contacts-history-title", { count: known.history.length }) }), el("p", { className: "hint", textContent: t("contacts-history-hint") }), ...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: t("contacts-history-replaced", { time: 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(t("contacts-refresh-embedded-key-error")); const known = store.contact(addr.short); const opened = await connect(addr); 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(t(opened.pinned ? "contacts-resolved-note" : "contacts-resolved-note-unverified", { address: addr.short })); } else if (timingSafeEqual(known.key, current)) { status(t("contacts-refresh-unchanged-note", { address: addr.short })); } else if (walkChain(addr.user, known.key, current, chain)) { store.saveContact(addr.short, current, known.verified); status(t("contacts-refresh-rotated-note", { address: addr.short, key: b32encode(current) }), true); } else { status(t("contacts-refresh-no-chain-warning", { address: addr.short }), 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(t("identity-missing-error")); if (!addr) { status(t("account-not-registered-tokens-note"), true); return 0; } const opened = await connect(addr); 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(t("contacts-no-key-error", { address })); store.accept(address, key); store.setSyncOk(true); const held = await pushTokens(); status(t(held === 1 ? "contacts-accepted-note-one" : "contacts-accepted-note-other", { address, held })); 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(t(held === 1 ? "contacts-blocked-note-one" : "contacts-blocked-note-other", { address, held })); 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); $("require-pinned-server").onchange = (event) => store.setRequirePinnedServer(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(); $("require-pinned-server").checked = store.requirePinnedServer(); $("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(t("settings-field-host"), host ?? t("settings-not-registered-value"), !host, host ? copyButton(host, "host") : null), field(t("settings-field-public-key"), key ? b32encode(key) : t("settings-public-key-not-pinned"), !key, key ? copyButton(b32encode(key), "public key") : null), el("div", { className: "spin-row" }, el("div", { className: "spin-text" }, el("span", { className: "check-title", textContent: t("server-timeout-title") }), el("span", { className: "hint", textContent: t("server-timeout-subtitle") })), 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}` : t("settings-not-registered-value"); const key = b32encode(me.publicKey); const uri = account ? accountAddress().uri(me.publicKey) : t("settings-uri-claim-hint"); fields.append( field(t("settings-field-address"), address, !account, account ? copyButton(address, "address") : null), field(t("settings-field-key"), key, false, copyButton(key, "key")), field(t("settings-field-fingerprint"), fingerprint(me.publicKey), false, copyButton(fingerprint(me.publicKey), "fingerprint")), field(t("settings-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: t("settings-secret-reveal-button"), title: t("settings-secret-reveal-tooltip"), onclick: (event) => { const showing = seed.textContent !== masked; seed.textContent = showing ? masked : seedText; event.currentTarget.textContent = t(showing ? "settings-secret-reveal-button" : "settings-secret-hide-button"); event.currentTarget.title = t(showing ? "settings-secret-reveal-tooltip" : "settings-secret-hide-tooltip"); } }); secret.append(el("div", { className: "seed-card" }, el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")), el("p", { textContent: t("settings-secret-warning") }))); } function renderAdvancedPanel() { $("rotations-count").replaceChildren(field(t("advanced-rotations-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(t("signup-host-looks-like-address-error", { host })); if (host.includes(":")) throw new SmolError(t("signup-host-has-port-error", { host })); return host; } $("import-form").onsubmit = async (event) => { event.preventDefault(); const form = event.target; try { const addr = parseAddress(form.uri.value.trim()); if (!addr.identity) throw new SmolError(t("contacts-import-needs-key")); await saveContactChecked(addr.short, addr.identity, true); status(t("contacts-import-detail-note", { address: addr.short, key: b32encode(addr.identity), 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); 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(t("recall-rotation-not-found-error", { address: addr.short, max: MAX_CHAIN })); 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: t("backup-export-dialog-title"), 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: t("backup-import-dialog-title"), 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 = t("advanced-rotate-needs-account-error"); return; } const confirmed = await confirmDialog({ title: t("rotate-dialog-title"), body: panelText("rotations-description"), confirmLabel: t("rotate-dialog-confirm-button") }); 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 = t("status-busy-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); 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: t("reset-dialog-title"), body: panelText("logout-description"), confirmLabel: t("reset-dialog-confirm-button"), 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(); }