// 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, utf8Bytes } from "./crypto.js"; import { DEFAULT_PORT, SmolError, authenticate, b32decode, b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, makeCert, messageId, newIdentity, openSession, parseAddress, parseFrontmatter, registerOp, resolveOp, seal, sendOp, unseal, walkChain, } from "./proto.js"; import * as store from "./store.js"; import { connectStream } from "./transport.js"; const $ = (id) => document.getElementById(id); // The status line is a toast: click to dismiss, empty means hidden. function status(text, warn = false) { const toast = $("status"); toast.textContent = text; toast.hidden = !text; toast.className = "toast" + (warn ? " warn" : ""); } $("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" }); }; // 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}`); } // --- 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 in settings.`); const stream = await connectStream(addr.host, addr.port); const opened = await openSession(stream, addr.host, pinned); if (!pinned) status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` + `RESOLVE results from this session are UNVERIFIED (SPEC.md §8).`, true); return opened; } // --- identity setup ------------------------------------------------------------ function showSetup() { $("setup").hidden = false; $("app").hidden = true; $("account").textContent = ""; } function showApp() { $("setup").hidden = true; $("app").hidden = false; renderAccount(); renderFolder(); } $("create-identity").onclick = () => { store.setIdentity(newIdentity().seed); showApp(); status("identity created. Back up the seed in settings now; it is the only secret."); openSettings(); }; $("restore-identity").onclick = () => { const text = $("restore-seed").value.trim(); if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true); const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16)); try { store.setIdentity(seedBytes); } catch (err) { return status(err.message, true); } showApp(); status("identity restored."); }; // --- 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; for (const button of $("folders").querySelectorAll("button")) button.onclick = () => { folder = button.dataset.folder; for (const b of $("folders").querySelectorAll("button")) b.classList.toggle("active", b === button); selectedId = null; resetView(); renderFolder(); }; // The reader empties when a folder changes, so a stale message cannot linger // under a different list. function resetView() { $("view").replaceChildren(Object.assign(document.createElement("p"), { className: "placeholder", textContent: "select a message" })); } async function renderFolder() { const list = $("list"); if (folder === "contacts") return renderContacts(list); const rows = [...await store.listMessages(folder)].reverse(); // newest first list.replaceChildren(); let unread = 0; for (const row of rows) { const opened = describe(row); const isUnread = !store.isRead(row.id); if (folder === "inbox" && isUnread) unread++; const item = document.createElement("div"); item.className = `item${row.id === selectedId ? " selected" : ""}${isUnread ? " unread" : ""}`; const who = document.createElement("div"); who.className = "who"; who.textContent = folder === "sent" ? row.recipient : (opened.fromShort ?? "?"); const mid = document.createElement("div"); mid.className = "mid"; const subject = document.createElement("span"); subject.className = "subject"; subject.textContent = opened.error ? `` : (opened.subject || "(no subject)"); const snippet = document.createElement("span"); snippet.className = "snippet"; snippet.textContent = opened.error ? "" : ` — ${opened.snippet}`; mid.append(subject, snippet); const when = document.createElement("div"); when.className = "when"; when.textContent = fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt)); item.append(who, mid, when); item.onclick = () => openMessage(row.id); list.append(item); } $("count-inbox").textContent = folder === "inbox" && unread ? String(unread) : ""; if (!rows.length) list.append(Object.assign(document.createElement("p"), { className: "placeholder", textContent: folder === "inbox" ? "no mail — fetch" : "nothing sent" })); } // The header search filters the rendered list client-side; nothing leaves the page. $("search").oninput = () => { const query = $("search").value.trim().toLowerCase(); for (const item of $("list").querySelectorAll(".item")) item.hidden = Boolean(query) && !item.textContent.toLowerCase().includes(query); }; // Open an envelope and split its body; anything unreadable shows as such // rather than throwing, so one bad message cannot blank the list. The wire // carries no sender name, so an unknown key can only be shown as its // fingerprint until the user binds it to an address — which a signed Reply-To // field lets the sender do in-band. function describe(row) { try { const opened = unseal(store.identities(), row.envelope); const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body)); const known = store.addressForKey(opened.sender); let replyAddress = null; if (fields["Reply-To"]) { try { const claimed = parseAddress(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, fields, body, subject: fields.Subject || "", snippet: body.replace(/\s+/g, " ").trim().slice(0, 140), from: known ?? `unknown · ${fingerprint(opened.sender)}`, // rows stay narrow: the full fingerprint belongs to the reader fromShort: known ?? `unknown · ${b32encode(opened.sender).slice(0, 4)}`, knownSender: Boolean(known), replyAddress, }; } catch (err) { return { error: err.message }; } } // --- 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"); if (opened.error) { view.replaceChildren(); const note = document.createElement("p"); note.className = "placeholder warn"; note.textContent = opened.error; view.append(note); return; } const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject"); view.innerHTML = `

` + `
signature verified
`; view.querySelector("h2").textContent = opened.subject || "(no subject)"; view.querySelector(".meta").textContent = folder === "sent" ? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}` : `from: ${opened.from}\nkey: ${b32encode(opened.sender)}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}`; view.querySelector(".fields").textContent = fields.map(([k, v]) => `${k}: ${v}`).join("\n"); view.querySelector("pre").textContent = opened.body; const actions = view.querySelector(".actions"); if (folder === "inbox") { if (opened.knownSender) { actions.append(replyButton(opened, row)); } 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). const note = document.createElement("p"); note.className = "placeholder"; note.textContent = `this message carries the sender's signed address: ${opened.replyAddress.short}`; actions.before(note); const save = document.createElement("button"); save.textContent = "save sender & reply"; save.onclick = () => { try { saveReplyAddress(opened.replyAddress, opened.sender); openMessage(row.id); openCompose({ to: opened.replyAddress.short, subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "", replyTo: row.id, }); } catch (err) { status(err.message, true); } }; actions.append(save); } 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 note = document.createElement("p"); note.className = "placeholder"; note.textContent = "The wire carries no sender name, only this signed key. " + "To reply, give the sender's address — it is checked against this key:"; const form = document.createElement("form"); form.className = "name-sender"; const input = document.createElement("input"); input.placeholder = "alice@example.org or smol://…"; input.spellcheck = false; const name = document.createElement("button"); name.textContent = "name sender & reply"; form.append(input, name); form.onsubmit = async (event) => { event.preventDefault(); name.disabled = true; try { await nameSender(input.value, opened.sender); openMessage(row.id); // the sender is named now; the reply button appears openCompose({ to: store.addressForKey(opened.sender), subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "", replyTo: row.id, }); } catch (err) { status(err.message, true); } finally { name.disabled = false; } }; actions.append(note, form); } } } function replyButton(opened, row) { const reply = document.createElement("button"); reply.textContent = "reply"; reply.onclick = () => openCompose({ to: store.addressForKey(opened.sender) ?? "", subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "", replyTo: row.id, }); return reply; } // 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, so it saves as verified — but // never over an address already pinned to a different key (§8: a key change // without a rotation chain needs out-of-band confirmation). function saveReplyAddress(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); status(`${addr.short} saved for this sender (verified key)`); renderFolder(); } // --- 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.querySelector("form").onsubmit = async (event) => { event.preventDefault(); if (event.submitter?.value !== "send") { compose.close(); return; } const form = event.target; $("compose-error").textContent = ""; 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; } }; async function sendMail(toText, subject, body, replyTo, anonymous) { const me = store.identity(); if (!me) 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)]); const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v), body.replace(/\s+$/, "") + "\n")); const envelope = seal(me, recipient, bodyBytes); const opened = await connect(addr, { requirePin: false }); try { await sendOp(opened.session, envelope); } 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}`); 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 ---------------------------------------------------------------------- $("fetch").onclick = async () => { const me = store.identity(); const addr = accountAddress(); if (!me) return status("no identity yet", true); if (!addr) return status("not registered; register in settings first", true); status("fetching…"); let stored = 0, rejected = 0; try { const opened = await connect(addr, { requirePin: true }); try { await authenticate(opened.session, opened.handshakeHash, addr.user, me); for (;;) { const records = await fetchOp(opened.session); if (!records.length) break; const acked = []; for (const record of records) { try { if (!timingSafeEqual(messageId(record.envelope), record.id)) throw new SmolError("id does not match the envelope"); unseal(store.identities(), record.envelope); } catch (err) { // Left on the server rather than destroyed, so a client-side bug // cannot lose mail. rejected++; status(`${hex(record.id)}: ${err.message}; left on server`, true); continue; } if (await store.storeIfNew("inbox", { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt, })) stored++; acked.push(record.id); } if (!acked.length) break; await deleteOp(opened.session, acked); } } finally { opened.session.stream.close(); } status(`${stored} new, ${rejected} rejected` + (rejected ? " (left on the server)" : "")); } catch (err) { status(err.message, true); } renderFolder(); }; // --- contacts ------------------------------------------------------------------- function renderContacts(list) { list.replaceChildren(); const contacts = store.allContacts(); for (const { address, key, verified } of contacts) { const item = document.createElement("div"); item.className = "item contact"; const who = document.createElement("div"); who.className = "who"; who.textContent = address; const mid = document.createElement("div"); mid.className = "mid"; const fp = document.createElement("div"); fp.textContent = fingerprint(b32decode(key)); const badge = document.createElement("span"); badge.className = verified ? "badge ok" : "badge"; badge.textContent = verified ? "verified key" : "tofu"; mid.append(fp, badge); const refresh = document.createElement("button"); refresh.textContent = "refresh"; refresh.onclick = () => refreshContact(address); item.append(who, mid, refresh); list.append(item); } if (!contacts.length) list.append(Object.assign(document.createElement("p"), { className: "placeholder", textContent: "no contacts; import a smol:// address or write to someone" })); } // 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(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); } renderFolder(); } // --- settings --------------------------------------------------------------------- const settings = $("settings"); function openSettings() { renderSettings(); settings.showModal(); } $("settings-open").onclick = openSettings; $("settings-close").onclick = () => settings.close(); function renderSettings() { const me = store.identity(); const account = store.account(); const card = $("identity-card"); card.replaceChildren(); if (!me) return; const lines = [ `fingerprint: ${fingerprint(me.publicKey)}`, `address: ${account ? account.user + "@" + account.host : "(not registered)"}`, `share: ${account ? accountAddress().uri(me.publicKey) : "(register to get a shareable address)"}`, "", ]; for (const text of lines) { const div = document.createElement("div"); div.textContent = text; card.append(div); } const seed = document.createElement("div"); seed.className = "seed"; seed.textContent = hex(me.seed); const warning = document.createElement("div"); warning.className = "warn"; warning.textContent = "back this seed up; it is the only secret"; const retired = document.createElement("div"); retired.textContent = `retired keys: ${store.identities().length - 1} (kept to read old mail)`; card.append(seed, warning, retired); const pins = $("pins"); pins.replaceChildren(); for (const { host, key } of store.allPins()) { const li = document.createElement("li"); const name = document.createElement("span"); name.textContent = host; const value = document.createElement("span"); value.className = "key"; value.textContent = key; const remove = document.createElement("button"); remove.textContent = "remove"; remove.onclick = () => { store.unpinServer(host); renderSettings(); }; li.append(name, value, remove); pins.append(li); } if (!store.allPins().length) pins.append(Object.assign(document.createElement("li"), { className: "placeholder", textContent: "no pinned servers" })); } $("pin-form").onsubmit = (event) => { event.preventDefault(); const form = event.target; try { const host = form.host.value.trim().toLowerCase(); const key = b32decode(form.key.value.trim()); if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`); store.pinServer(host, key); status(`pinned ${host}`); renderSettings(); form.reset(); } catch (err) { status(err.message, true); } }; $("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)}`); renderSettings(); renderFolder(); form.reset(); } catch (err) { status(err.message, true); } }; $("register-form").onsubmit = async (event) => { event.preventDefault(); const form = event.target; try { const me = store.identity(); if (!me) throw new SmolError("no identity yet"); const addr = parseAddress(form.address.value.trim()); const opened = await connect(addr, { requirePin: true }); try { await registerOp(opened.session, { username: addr.user, identity: me, token: form.token.value.trim() }); } finally { opened.session.stream.close(); } store.setAccount(addr); status(`registered ${addr.short}\nshare: ${addr.uri(me.publicKey)}`); renderAccount(); form.reset(); } catch (err) { status(err.message, true); } }; $("rotate").onclick = async () => { const me = store.identity(); const addr = accountAddress(); if (!me || !addr) return status("rotate needs a registered account", true); if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " + "chain. The old key is kept, since mail sealed to it stays readable with nothing else.")) return; try { const fresh = newIdentity(); const cert = makeCert(me, fresh.seed); const opened = await connect(addr, { requirePin: true }); try { await registerOp(opened.session, { username: addr.user, identity: fresh, cert }); } finally { opened.session.stream.close(); } store.rotateIdentity(fresh.seed); status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`); renderAccount(); renderSettings(); } catch (err) { status(err.message, true); } }; // --- boot -------------------------------------------------------------------------- store.identity() ? showApp() : showSetup(); status(store.identity() ? "" : "create an identity to begin");