gsmol/web/js/app.js
2026-10-09 13:47:50 +03:00

1660 lines
67 KiB
JavaScript

// 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 <dialog> shown with showModal() renders in the browser's top
// layer, above the rest of the page — a toast anchored to <body> 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. <dialog>'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 <address>" 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 <input type=file>
// 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();
}