Mirrors the same fix in fumi-core. sendMail attached the token to every message addressed to an accepted contact, so a secret rode in the body of ordinary mail indefinitely. §5.8 has the owner deliver each token to its correspondent; it doesn't say every message carries one. Harmless between people, and fatal for any recipient that republishes what it receives: a published record is checked by rebuilding the exact bytes the author signed, and those can't include a token without disclosing it. Someone who accepted such a service could never post to it again, and accepting a correspondent is what the rest of the system teaches. Contacts stored before this field existed count as still owing, so the next message to each carries the token once more. That is what every message did before, so nothing that works today can break. Marked only once the recipient's server has taken the message, so a send that threw leaves the token still owing. Not cleared by block: the token is derived from the master and the identity frozen at acceptance, so they keep the value either way.
1670 lines
68 KiB
JavaScript
1670 lines
68 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, once,
|
|
// so a reply from them reaches our main tier. See store.tokenToDeliver for
|
|
// why this isn't on every message.
|
|
const owed = store.tokenToDeliver(addr.short);
|
|
if (owed) fields.push(["Accept", b32encode(tokenFor(master, owed))]);
|
|
// Trimmed before the payload is signed, so the signature covers exactly the
|
|
// text the author meant. A trailing newline is not content — but it becomes
|
|
// content the moment it is signed, and nothing downstream can strip it
|
|
// without invalidating the signature. smolboid, for one, refuses a post
|
|
// whose text carries a line break, because a published record is one line
|
|
// of a feed.
|
|
const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v),
|
|
body.trim()));
|
|
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();
|
|
}
|
|
// Only now, with the message taken by their server: a send that threw
|
|
// earlier leaves the token still owing, so the next attempt carries it.
|
|
if (owed) store.markDelivered(addr.short);
|
|
// §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();
|
|
}
|