feat: harden bridge origin/CSP, cache decryption, and overhaul the web client UI

This commit is contained in:
randogoth 2026-09-26 19:04:36 +03:00
parent f637d5be57
commit 80133da100
10 changed files with 975 additions and 352 deletions

View file

@ -10,7 +10,9 @@ The protocol is raw TCP with a Noise_NX handshake, and browsers cannot open TCP
browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild (TCP :1961)
```
`bridge.py` serves the web app and relays WebSocket bytes to the server untouched. It holds no keys, never parses a byte of traffic, and relays only to allowlisted ports (`--allow-port`, default 1961), so it is not an open proxy. The Noise session, sealing, signing, pinning and trust-on-first-use all run in the browser's JavaScript — the trust boundary is the machine running the browser, as with the CLI client's `identity.key` file.
`bridge.py` serves the web app and relays WebSocket bytes to the server untouched. It holds no keys, never parses a byte of traffic, and relays only to allowlisted ports (`--allow-port`, default 1961), so it is not an open proxy. It also relays only for upgrades whose `Origin` is its own page — WebSockets are exempt from CORS, so without that check any page the user happened to visit could drive the relay. `--allow-origin` admits others, and an upgrade carrying no `Origin` at all, which a browser never sends, is left alone for non-browser clients. The Noise session, sealing, signing, pinning and trust-on-first-use all run in the browser's JavaScript — the trust boundary is the machine running the browser, as with the CLI client's `identity.key` file.
The bridge exists only because the protocol is TCP, and it is the smaller dependency it looks like: `devbox run test` already needs Python and `uv` for the reference vectors and the end-to-end run. What would retire it altogether is a WebSocket transport binding in `smolmaild` itself — Noise messages already carry a `u16` length prefix, so a binary WebSocket stream is a drop-in byte stream — but that is an addition to SPEC.md §4 and to the server, not something this client can decide.
## Run
@ -58,6 +60,6 @@ What is not verified here: clicking through the actual browser UI. `devbox run s
## Notes and limits
- The seed sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. No server push or notifications in v1 (SPEC.md §13); click fetch.
- The seed sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. The bridge serves a strict Content-Security-Policy (`default-src 'self'`, no inline script or style, no foreign origin) to raise that bar, but it is a mitigation and not a fix. No server push or notifications in v1 (SPEC.md §13); click fetch.
- Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The UI surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence.
- The bridge binds to 127.0.0.1. Serving it beyond localhost is possible but unadvised: the relay is plaintext-agnostic (the traffic is already end-to-end encrypted, but the bridge would relay anyone's bytes to allowlisted ports).

View file

@ -27,8 +27,25 @@ from aiohttp import web
log = logging.getLogger("gsmol")
def origin_allowed(request: web.Request) -> bool:
"""Whether this upgrade may open a relay.
WebSockets are exempt from CORS, so without this check any page the user
happens to visit could drive the relay to an allowlisted port on any host.
Browsers always send Origin on an upgrade; non-browser clients (test/client.mjs
on Node) send none, so an absent header is allowed and a foreign one is not.
"""
origin = request.headers.get("Origin")
if origin is None:
return True
own = {f"http://{request.host}", f"https://{request.host}"}
return origin in own or origin in request.app["allow_origins"]
async def relay(request: web.Request) -> web.WebSocketResponse:
"""A WebSocket whose binary frames are piped to host:port untouched."""
if not origin_allowed(request):
raise web.HTTPForbidden(text="cross-origin WebSocket upgrade refused")
port = int(request.match_info["port"])
if port not in request.app["relay_ports"]:
raise web.HTTPForbidden(text=f"port {port} is not allowed")
@ -63,11 +80,36 @@ async def relay(request: web.Request) -> web.WebSocketResponse:
finally:
await ws.close()
await asyncio.gather(pump_up(), pump_down())
# return_exceptions so a failure in one pump cannot leave the other running:
# closing the writer ends the downstream read, closing the socket ends the
# upstream iteration, so each side unblocks the other.
await asyncio.gather(pump_up(), pump_down(), return_exceptions=True)
try:
await writer.wait_closed()
except OSError:
pass
log.info("relay to %s:%d closed", host, port)
return ws
# The seed lives in localStorage, so an XSS in this page is game over (README).
# The client loads no inline script, no inline style and no foreign origin, so a
# strict policy costs nothing. form-action stays 'self' rather than 'none' so the
# compose dialog's method="dialog" form is untouched.
@web.middleware
async def csp(request: web.Request, handler):
response = await handler(request)
response.headers.setdefault("Content-Security-Policy", "; ".join([
"default-src 'self'",
f"connect-src 'self' ws://{request.host} wss://{request.host}",
"base-uri 'none'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
]))
return response
async def index(request: web.Request) -> web.FileResponse:
return web.FileResponse(Path(request.app["web_dir"]) / "index.html")
@ -80,11 +122,15 @@ def main() -> int:
parser.add_argument("--allow-port", type=int, action="append", default=[1961],
metavar="PORT", dest="allow_ports",
help="relayed TCP port; repeatable")
parser.add_argument("--allow-origin", action="append", default=[],
metavar="ORIGIN", dest="allow_origins",
help="extra browser origin permitted to open a relay; repeatable")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
app = web.Application()
app = web.Application(middlewares=[csp])
app["web_dir"], app["relay_ports"] = args.dir, set(args.allow_ports)
app["allow_origins"] = set(args.allow_origins)
app.router.add_get("/", index)
app.router.add_get("/tcp/{host}/{port}", relay)
app.router.add_static("/", args.dir, show_index=False)

View file

@ -184,4 +184,46 @@ for (const v of vectors.ed_to_x25519) {
eq("cert-len", String(chain[0].length), "136");
}
// --- response framing guards (§6.1) -------------------------------------------
// A Session over a scripted server. The cipher states are the identity, so the
// bytes handed in are exactly what call() parses; readNoise wants at least 16,
// and trailing bytes past the frame length are ignored by design.
function scriptedSession(frame) {
const message = crypto.concat(frame, new Uint8Array(Math.max(0, 16 - frame.length)));
const wire = {
queue: crypto.concat(proto.u16BE(message.length), message),
pos: 0,
async readExact(n) {
if (this.pos + n > this.queue.length) throw new Error("script exhausted");
this.pos += n;
return this.queue.slice(this.pos - n, this.pos);
},
};
const passthrough = { encrypt: bytes => bytes, decrypt: bytes => bytes };
return new proto.Session(wire, { send() {}, close() {} }, passthrough, passthrough);
}
async function refuses(name, frame, op) {
let threw = false;
try { await scriptedSession(frame).call(op); } catch { threw = true; }
ok(name, threw, "call resolved instead of throwing");
}
// The shortest legal response is a type byte and a status byte.
await refuses("frame-too-short",
crypto.concat(proto.u32BE(1), Uint8Array.of(proto.OP.FETCH)), proto.OP.FETCH);
// A response reuses the request's type byte; a mismatch means the session
// desynchronised, which must not be read as a status.
await refuses("frame-op-mismatch",
crypto.concat(proto.u32BE(2), Uint8Array.of(proto.OP.RESOLVE, 0)), proto.OP.FETCH);
// The same frame with the right echo still passes, so the guard is not
// simply rejecting everything.
{
const session = scriptedSession(crypto.concat(proto.u32BE(2), Uint8Array.of(proto.OP.FETCH, 0)));
eq("frame-op-echo-ok", (await session.call(proto.OP.FETCH)).status, 0);
}
console.log(`${passed} checks passed`);

6
web/favicon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="smol mail">
<rect width="32" height="32" rx="7" fill="#0b57d0"/>
<rect x="7" y="11" width="18" height="11" rx="2" fill="#fff"/>
<path d="M7.8 12 16 17.6 24.2 12" fill="none" stroke="#0b57d0" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

View file

@ -4,17 +4,33 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>smol mail</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- One sprite, referenced by <use>; same-document so the CSP needs no exception. -->
<svg class="sprite" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<symbol id="i-compose" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></symbol>
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></symbol>
<symbol id="i-sent" viewBox="0 0 24 24"><path d="M22 2 11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></symbol>
<symbol id="i-contacts" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>
<symbol id="i-auto" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></symbol>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></symbol>
<symbol id="i-reply" viewBox="0 0 24 24"><path d="M9 17 4 12l5-5"/><path d="M4 12h11a5 5 0 0 1 5 5v2"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></symbol>
</svg>
<header class="topbar">
<div class="brand">smol mail</div>
<input id="search" class="search" placeholder="search mail" spellcheck="false">
<div class="top-actions">
<button id="fetch">fetch</button>
<button id="settings-open">settings</button>
<input id="search" class="search" placeholder="search mail" aria-label="search mail"
spellcheck="false" hidden>
<div id="top-actions" class="top-actions" hidden>
<button id="theme" class="icon-only" aria-label="theme"><svg class="icon"><use href="#i-auto"/></svg></button>
<button id="settings-open" class="icon-only" aria-label="settings" title="settings"><svg class="icon"><use href="#i-settings"/></svg></button>
<div id="account" class="account"></div>
<div id="avatar" class="avatar"></div>
<div id="avatar" class="avatar" aria-hidden="true"></div>
</div>
</header>
@ -30,16 +46,32 @@
</main>
<main id="app" hidden>
<nav class="rail">
<button id="write" class="compose">compose</button>
<nav class="rail" aria-label="folders">
<button id="write" class="compose"><svg class="icon"><use href="#i-compose"/></svg>compose</button>
<div id="folders">
<button data-folder="inbox" class="active">inbox <span id="count-inbox" class="count"></span></button>
<button data-folder="sent">sent</button>
<button data-folder="contacts">contacts</button>
<button data-folder="inbox" aria-label="inbox" class="active" aria-current="true">
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">inbox</span>
<span id="count-inbox" class="count"></span>
</button>
<button data-folder="sent" aria-label="sent"><svg class="icon"><use href="#i-sent"/></svg><span class="label">sent</span></button>
<button data-folder="contacts" aria-label="contacts"><svg class="icon"><use href="#i-contacts"/></svg><span class="label">contacts</span></button>
</div>
</nav>
<section id="list" class="list"></section>
<article id="view" class="view"><p class="placeholder">select a message</p></article>
<div class="listpane">
<div id="list-toolbar" class="toolbar">
<div id="mail-tools" class="tools">
<button id="fetch" class="primary"><svg class="icon"><use href="#i-refresh"/></svg><span class="label">fetch</span></button>
<span id="list-status" class="list-status"></span>
</div>
<form id="import-form" class="tools" hidden>
<input name="uri" placeholder="smol://alice@example.org/…" aria-label="smol:// address to import"
spellcheck="false" required>
<button class="primary">import</button>
</form>
</div>
<section id="list" class="list" aria-label="messages"></section>
</div>
<article id="view" class="view" aria-label="message"><p class="placeholder">select a message</p></article>
</main>
<dialog id="compose">
@ -50,48 +82,46 @@
<footer>
<label class="anon"><input type="checkbox" name="anonymous"> anonymous — omit my address</label>
<span id="compose-error" class="error"></span>
<button value="cancel">cancel</button>
<button id="send" value="send">send</button>
<button type="button" id="compose-cancel">cancel</button>
<button id="send" class="filled" value="send">send</button>
</footer>
</form>
</dialog>
<dialog id="settings">
<h2>settings</h2>
<section>
<h3>identity</h3>
<div id="identity-card"></div>
<button id="rotate">rotate identity</button>
</section>
<section>
<h3>account</h3>
<form id="register-form">
<input name="address" placeholder="alice@example.org[:1961]" spellcheck="false" required>
<input name="token" placeholder="invite token (if the server requires one)">
<button>register</button>
</form>
<p class="hint">registration requires a pinned server key (SPEC.md §4). Pin one first.</p>
</section>
<section>
<h3>server keys</h3>
<h3><span id="step-server" class="step" data-n="1">1</span> your server</h3>
<p class="hint">Pin the key of the server that will hold your mail. Get it from its operator over a
channel you already trust — registering and fetching both refuse an unpinned server (SPEC.md §4).</p>
<form id="pin-form">
<input name="host" placeholder="example.org" spellcheck="false" required>
<input name="key" placeholder="base32 public key" spellcheck="false" required>
<button>pin</button>
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
<button class="filled">pin</button>
</form>
<ul id="pins" class="kv"></ul>
</section>
<section>
<h3>contacts</h3>
<form id="import-form">
<input name="uri" placeholder="smol://alice@example.org/mfrggzdf…" spellcheck="false" required>
<button>import</button>
<h3><span id="step-address" class="step" data-n="2">2</span> your address</h3>
<p class="hint">Claim a username on a server you have pinned. This is what people write to.</p>
<form id="register-form">
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
<label>invite token<input name="token" placeholder="if required"></label>
<button class="filled">register</button>
</form>
</section>
<section>
<h3>your identity</h3>
<div id="identity-card"></div>
</section>
<footer><button id="settings-close">close</button></footer>
</dialog>
<div id="status" class="toast" hidden></div>
<div id="status" class="toast" role="status" aria-live="polite" hidden></div>
<script type="module" src="js/app.js"></script>
</body>
</html>

View file

@ -12,12 +12,41 @@ import { connectStream } from "./transport.js";
const $ = (id) => document.getElementById(id);
// The status line is a toast: click to dismiss, empty means hidden.
// 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 });
// 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) {
const toast = $("status");
toast.textContent = text;
toast.hidden = !text;
toast.className = "toast" + (warn ? " warn" : "");
toast.className = warn ? "toast warn" : "toast";
clearTimeout(toastTimer);
if (text && !warn) toastTimer = setTimeout(() => status(""), 6000);
}
$("status").onclick = () => status("");
@ -34,6 +63,18 @@ const fmtRowTime = (seconds) => {
return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
};
// A copy button beats asking the user to select 64 hex characters by hand.
function copyButton(text, what) {
return el("button", { type: "button", className: "copy", textContent: "copy", onclick: async () => {
try {
await navigator.clipboard.writeText(text);
status(`${what} copied to the clipboard`);
} catch {
status(`the clipboard is unavailable; select and copy ${what} by hand`, true);
}
} });
}
// The stored account as an address object, default port included when custom.
function accountAddress() {
const account = store.account();
@ -42,6 +83,34 @@ function accountAddress() {
return parseAddress(`${account.user}@${account.host}${suffix}`);
}
// --- appearance ----------------------------------------------------------------
// Three states, because "follow the system" is a real preference and not the
// absence of one. The CSS does the rest: color-scheme decides which half of
// every light-dark() token applies, so nothing here touches a colour.
const THEMES = [
{ value: null, icon: "auto", label: "theme: following the system" },
{ value: "light", icon: "sun", label: "theme: light" },
{ value: "dark", icon: "moon", label: "theme: dark" },
];
function applyTheme() {
const mode = THEMES.find(t => t.value === store.theme()) ?? THEMES[0];
if (mode.value) document.documentElement.dataset.theme = mode.value;
else delete document.documentElement.dataset.theme;
const button = $("theme");
button.querySelector("use").setAttribute("href", `#i-${mode.icon}`);
button.title = mode.label;
button.setAttribute("aria-label", mode.label);
}
$("theme").onclick = () => {
const current = THEMES.findIndex(t => t.value === store.theme());
store.setTheme(THEMES[(current + 1) % THEMES.length].value);
applyTheme();
status(THEMES.find(t => t.value === store.theme()).label);
};
// --- connection --------------------------------------------------------------
// §4: registration and fetching demand a pinned key; sending to a recipient
@ -61,15 +130,21 @@ async function connect(addr, { requirePin }) {
// --- 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();
}
@ -109,11 +184,22 @@ function renderAccount() {
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")) b.classList.toggle("active", b === button);
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();
@ -122,90 +208,159 @@ for (const button of $("folders").querySelectorAll("button"))
// The reader empties when a folder changes, so a stale message cannot linger
// under a different list.
function resetView() {
$("view").replaceChildren(Object.assign(document.createElement("p"),
{ className: "placeholder", textContent: "select a message" }));
$("app").classList.remove("reading");
$("view").replaceChildren(placeholder(folder === "contacts"
? "contacts and their keys are listed alongside"
: "select a message"));
}
// Counted from ids and read markers alone, independent of the visible folder —
// leaving the inbox used to blank the badge.
async function updateUnreadBadge() {
$("count-inbox").textContent = (await store.unreadCount()) || "";
}
async function renderFolder() {
const list = $("list");
if (folder === "contacts") return renderContacts(list);
const rows = [...await store.listMessages(folder)].reverse(); // newest first
updateUnreadBadge();
// The toolbar carries the current folder's action, not a fixed one.
const contacts = folder === "contacts";
$("mail-tools").hidden = contacts;
$("import-form").hidden = !contacts;
// The filter never reaches past the open folder, so the field says which one.
$("search").placeholder = `search ${folder}`;
$("search").setAttribute("aria-label", `search ${folder}`);
if (contacts) return renderContacts(list);
const token = ++renderToken;
const rows = await store.listMessages(folder); // newest first
if (token !== renderToken) return;
list.replaceChildren();
let unread = 0;
for (const row of rows) {
const opened = describe(row);
const isUnread = !store.isRead(row.id);
if (folder === "inbox" && isUnread) unread++;
const item = document.createElement("div");
item.className = `item${row.id === selectedId ? " selected" : ""}${isUnread ? " unread" : ""}`;
const who = document.createElement("div");
who.className = "who";
who.textContent = folder === "sent" ? row.recipient : (opened.fromShort ?? "?");
const mid = document.createElement("div");
mid.className = "mid";
const subject = document.createElement("span");
subject.className = "subject";
subject.textContent = opened.error ? `<unreadable: ${opened.error}>`
: (opened.subject || "(no subject)");
const snippet = document.createElement("span");
snippet.className = "snippet";
snippet.textContent = opened.error ? "" : ` — ${opened.snippet}`;
mid.append(subject, snippet);
const when = document.createElement("div");
when.className = "when";
when.textContent = fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt));
item.append(who, mid, when);
item.onclick = () => openMessage(row.id);
list.append(item);
if (!rows.length) {
list.append(placeholder(folder === "inbox" ? "no mail — fetch" : "nothing sent"));
return void updateListStatus();
}
for (let offset = 0; offset < rows.length; offset += BATCH) {
if (offset) await new Promise(resolve => setTimeout(resolve));
if (token !== renderToken) return;
list.append(...rows.slice(offset, offset + BATCH).map(messageRow));
applyQuery();
}
$("count-inbox").textContent = folder === "inbox" && unread ? String(unread) : "";
if (!rows.length) list.append(Object.assign(document.createElement("p"),
{ className: "placeholder", textContent: folder === "inbox" ? "no mail — fetch" : "nothing sent" }));
}
// The header search filters the rendered list client-side; nothing leaves the page.
$("search").oninput = () => {
const query = $("search").value.trim().toLowerCase();
// A real button, so every row is reachable and openable from the keyboard.
function messageRow(row) {
const opened = describe(row);
// Only the inbox has a read state; sent copies were never "unread".
const unread = folder === "inbox" && !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 (folder === "inbox" && !opened.error && !opened.knownSender)
who.append(el("span", { className: "dot", title: "sender key not bound to an address" }));
item.append(who,
el("div", { className: "mid" },
el("span", { className: "subject", textContent: opened.error
? `<unreadable: ${opened.error}>` : (opened.subject || "(no subject)") }),
el("span", { className: "snippet", textContent: opened.error ? "" : ` — ${opened.snippet}` })),
el("div", { className: "when",
textContent: fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt)) }));
// Sender or recipient, their key, the subject and the whole body — not the
// snippet, and not the timestamp.
item.dataset.search = (opened.error
? `${row.recipient ?? ""} ${opened.error}`
: [row.recipient ?? "", opened.from, b32encode(opened.sender), opened.subject, opened.body].join(" ")
).toLowerCase();
return item;
}
// Filters the current folder client-side; nothing leaves the page. Each row
// carries its own haystack, built from the fields worth matching rather than
// from whatever the row happens to render. Re-applied after every render, so a
// redraw cannot silently drop it.
function applyQuery() {
for (const item of $("list").querySelectorAll(".item"))
item.hidden = Boolean(query) && !item.textContent.toLowerCase().includes(query);
item.hidden = Boolean(query) && !(item.dataset.search ?? "").includes(query);
updateListStatus();
}
// The toolbar's right side, which also makes a narrowed search visible.
function updateListStatus() {
const items = [...$("list").querySelectorAll(".item")];
const shown = items.filter(item => !item.hidden).length;
$("list-status").textContent = !items.length ? ""
: shown === items.length ? `${items.length} message${items.length === 1 ? "" : "s"}`
: `${shown} of ${items.length}`;
}
$("search").oninput = () => {
query = $("search").value.trim().toLowerCase();
applyQuery();
};
// Open an envelope and split its body; anything unreadable shows as such
// rather than throwing, so one bad message cannot blank the list. The wire
// carries no sender name, so an unknown key can only be shown as its
// fingerprint until the user binds it to an address — which a signed Reply-To
// 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) {
try {
const opened = unseal(store.identities(), row.envelope);
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
const known = store.addressForKey(opened.sender);
let replyAddress = null;
if (fields["Reply-To"]) {
try {
const claimed = parseAddress(fields["Reply-To"]);
// Only a full smol:// address whose key matches the signer counts;
// anything else is ordinary text.
if (claimed.identity && timingSafeEqual(claimed.identity, opened.sender))
replyAddress = claimed;
} catch { /* malformed claim: display, never bind */ }
}
return {
...opened,
fields, body,
subject: fields.Subject || "",
snippet: body.replace(/\s+/g, " ").trim().slice(0, 140),
from: known ?? `unknown · ${fingerprint(opened.sender)}`,
// rows stay narrow: the full fingerprint belongs to the reader
fromShort: known ?? `unknown · ${b32encode(opened.sender).slice(0, 4)}`,
knownSender: Boolean(known),
replyAddress,
};
} catch (err) {
return { error: err.message };
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) {
@ -216,97 +371,98 @@ async function openMessage(id) {
renderFolder();
const opened = describe(row);
const view = $("view");
if (opened.error) {
view.replaceChildren();
const note = document.createElement("p");
note.className = "placeholder warn";
note.textContent = opened.error;
view.append(note);
return;
}
// Under the narrow breakpoint the reader covers the list, so it needs a way back.
$("app").classList.add("reading");
const back = el("button", { type: "button", className: "back",
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages");
if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true));
const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject");
view.innerHTML = `<h2></h2><div class="meta"></div><div class="fields"></div>` +
`<div class="verified">signature verified</div><pre></pre><div class="actions"></div>`;
view.querySelector("h2").textContent = opened.subject || "(no subject)";
view.querySelector(".meta").textContent = folder === "sent"
? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}`
: `from: ${opened.from}\nkey: ${b32encode(opened.sender)}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}`;
view.querySelector(".fields").textContent = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
view.querySelector("pre").textContent = opened.body;
const actions = view.querySelector(".actions");
if (folder === "inbox") {
if (opened.knownSender) {
actions.append(replyButton(opened, row));
} else if (opened.replyAddress) {
// The sender signed their own smol:// address into the payload, so
// binding it is one click. A pinned conflicting key is refused (§8).
const note = document.createElement("p");
note.className = "placeholder";
note.textContent =
`this message carries the sender's signed address: ${opened.replyAddress.short}`;
actions.before(note);
const save = document.createElement("button");
save.textContent = "save sender & reply";
save.onclick = () => {
try {
saveReplyAddress(opened.replyAddress, opened.sender);
openMessage(row.id);
openCompose({
to: opened.replyAddress.short,
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
} catch (err) {
status(err.message, true);
}
};
actions.append(save);
} else {
// The wire gives only a signed key; a reply needs the sender's address,
// which the user supplies once and we bind to that exact key.
const note = document.createElement("p");
note.className = "placeholder";
note.textContent = "The wire carries no sender name, only this signed key. " +
"To reply, give the sender's address — it is checked against this key:";
const form = document.createElement("form");
form.className = "name-sender";
const input = document.createElement("input");
input.placeholder = "alice@example.org or smol://…";
input.spellcheck = false;
const name = document.createElement("button");
name.textContent = "name sender & reply";
form.append(input, name);
form.onsubmit = async (event) => {
event.preventDefault();
name.disabled = true;
try {
await nameSender(input.value, opened.sender);
openMessage(row.id); // the sender is named now; the reply button appears
openCompose({
to: store.addressForKey(opened.sender),
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
} catch (err) {
status(err.message, true);
} finally {
name.disabled = false;
}
};
actions.append(note, form);
}
const actions = el("div", { className: "actions" });
view.replaceChildren(...[
back,
el("h2", { textContent: opened.subject || "(no subject)" }),
el("div", { className: "meta", textContent: folder === "sent"
? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}`
: `from: ${opened.from}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}` }),
trustRow(opened),
fields.length ? el("div", { className: "fields",
textContent: fields.map(([k, v]) => `${k}: ${v}`).join("\n") }) : null,
el("pre", { textContent: opened.body }),
actions,
].filter(node => node != null));
if (folder !== "inbox") return;
if (opened.knownSender) {
actions.append(el("button", { type: "button", onclick: () => startReply(row) },
icon("reply"), "reply"));
} else if (opened.replyAddress) {
// The sender signed their own smol:// address into the payload, so binding
// it is one click. A pinned conflicting key is refused (§8).
actions.before(placeholder(
`this message carries the sender's signed address: ${opened.replyAddress.short}`));
actions.append(el("button", { type: "button", textContent: "save sender & reply", onclick: () => {
try {
saveReplyAddress(opened.replyAddress, opened.sender);
openMessage(row.id);
startReply(row);
} catch (err) {
status(err.message, true);
}
} }));
} else {
// The wire gives only a signed key; a reply needs the sender's address,
// which the user supplies once and we bind to that exact key.
const input = el("input", { placeholder: "alice@example.org or smol://…", spellcheck: false });
const submit = el("button", { textContent: "name sender & reply" });
const form = el("form", { className: "name-sender" }, input, submit);
form.onsubmit = async (event) => {
event.preventDefault();
submit.disabled = true;
try {
await nameSender(input.value, opened.sender);
openMessage(row.id); // the sender is named now; the reply button appears
startReply(row);
} catch (err) {
status(err.message, true);
} finally {
submit.disabled = false;
}
};
actions.before(placeholder("The wire carries no sender name, only this signed key. " +
"To reply, give the sender's address — it is checked against this key:"));
actions.append(form);
}
}
function replyButton(opened, row) {
const reply = document.createElement("button");
reply.textContent = "reply";
reply.onclick = () => openCompose({
// §4/§8: a signature always verifies here, because unseal() throws otherwise.
// What can be weak is the binding between that key and an address, so that is
// what the chip reports.
function trustRow(opened) {
if (folder === "sent")
return el("div", { className: "trust" },
el("span", { className: "badge", textContent: "your own sealed copy" }));
const chip = opened.verifiedSender ? ["badge ok", "verified sender key"]
: opened.knownSender ? ["badge", "key pinned on first use"]
: ["badge alert", "key bound to no address"];
return el("div", { className: "trust" },
el("span", { className: chip[0], textContent: chip[1] }),
el("div", { className: "keyblock" },
el("div", { textContent: fingerprint(opened.sender) }),
el("div", { className: "full", textContent: b32encode(opened.sender) })));
}
const replySubject = (subject) =>
!subject ? "" : subject.startsWith("Re:") ? subject : `Re: ${subject}`;
// One path for all three ways a reply can start, so the Re: rule lives once.
function startReply(row) {
const opened = describe(row);
openCompose({
to: store.addressForKey(opened.sender) ?? "",
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
subject: replySubject(opened.subject),
replyTo: row.id,
});
return reply;
}
// Bind a user-supplied address to the key that signed a message. A smol://
@ -360,20 +516,25 @@ function openCompose({ to = "", subject = "", body = "", replyTo = null } = {})
$("write").onclick = () => openCompose();
$("compose-cancel").onclick = () => compose.close();
// Every submission is a send: cancel is a plain button, and Enter in a field
// submits with no submitter — which the old check read as cancel, discarding
// the draft.
compose.querySelector("form").onsubmit = async (event) => {
event.preventDefault();
if (event.submitter?.value !== "send") {
compose.close();
return;
}
const form = event.target;
const send = $("send");
$("compose-error").textContent = "";
send.disabled = true; // one click, one envelope: a second seals a second message
try {
await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo,
form.elements.anonymous.checked);
compose.close();
} catch (err) {
$("compose-error").textContent = err.message;
} finally {
send.disabled = false;
}
};
@ -434,8 +595,13 @@ $("fetch").onclick = async () => {
const addr = accountAddress();
if (!me) return status("no identity yet", true);
if (!addr) return status("not registered; register in settings first", true);
const button = $("fetch"), label = button.querySelector(".label");
const resting = label.textContent;
button.disabled = true; // one session at a time; the icon spins while it is
label.textContent = "fetching…";
status("fetching…");
let stored = 0, rejected = 0;
let stored = 0;
const rejected = [];
try {
const opened = await connect(addr, { requirePin: true });
try {
@ -451,9 +617,9 @@ $("fetch").onclick = async () => {
unseal(store.identities(), record.envelope);
} catch (err) {
// Left on the server rather than destroyed, so a client-side bug
// cannot lose mail.
rejected++;
status(`${hex(record.id)}: ${err.message}; left on server`, true);
// 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;
}
if (await store.storeIfNew("inbox", {
@ -467,9 +633,14 @@ $("fetch").onclick = async () => {
} finally {
opened.session.stream.close();
}
status(`${stored} new, ${rejected} rejected` + (rejected ? " (left on the server)" : ""));
status([`${stored} new, ${rejected.length} rejected`, ...rejected.map(line => ` ${line}`),
rejected.length ? "left on the server" : null].filter(Boolean).join("\n"),
rejected.length > 0);
} catch (err) {
status(err.message, true);
} finally {
button.disabled = false;
label.textContent = resting;
}
renderFolder();
};
@ -477,30 +648,73 @@ $("fetch").onclick = async () => {
// --- contacts -------------------------------------------------------------------
function renderContacts(list) {
list.replaceChildren();
const contacts = store.allContacts();
for (const { address, key, verified } of contacts) {
const item = document.createElement("div");
item.className = "item contact";
const who = document.createElement("div");
who.className = "who";
who.textContent = address;
const mid = document.createElement("div");
mid.className = "mid";
const fp = document.createElement("div");
fp.textContent = fingerprint(b32decode(key));
const badge = document.createElement("span");
badge.className = verified ? "badge ok" : "badge";
badge.textContent = verified ? "verified key" : "tofu";
mid.append(fp, badge);
const refresh = document.createElement("button");
refresh.textContent = "refresh";
refresh.onclick = () => refreshContact(address);
item.append(who, mid, refresh);
list.append(item);
}
if (!contacts.length) list.append(Object.assign(document.createElement("p"),
{ className: "placeholder", textContent: "no contacts; import a smol:// address or write to someone" }));
list.replaceChildren(...contacts.map(({ address, key, verified, history }) => {
const item = el("button", {
type: "button",
className: `item contact${address === selectedId ? " selected" : ""}`,
onclick: () => openContact(address),
});
if (address === selectedId) item.setAttribute("aria-current", "true");
item.append(
el("div", { className: "who", textContent: address }),
el("div", { className: "mid" },
el("div", { textContent: fingerprint(b32decode(key)) }),
el("span", { className: verified ? "badge ok" : "badge",
textContent: verified ? "verified key" : "tofu" }),
history.length ? el("span", { className: "badge",
textContent: `${history.length} previous` }) : null));
item.dataset.search =
`${address} ${key} ${fingerprint(b32decode(key))} ${history.map(h => h.key).join(" ")}`.toLowerCase();
return item;
}));
if (!contacts.length)
list.append(placeholder("no contacts yet — import a smol:// address above, or write to someone"));
}
// The full key, the address to hand out, and any key this one replaced — the
// row can only afford a fingerprint, and §8 turns on the user being able to
// inspect exactly what changed.
function openContact(address) {
const known = store.contact(address);
if (!known) return;
selectedId = address;
renderFolder();
const addr = parseAddress(address);
const view = $("view");
$("app").classList.add("reading");
const refresh = el("button", { type: "button", onclick: async (event) => {
event.target.disabled = true;
try {
await refreshContact(address);
} finally {
event.target.disabled = false;
}
} }, icon("refresh"), "re-resolve");
view.replaceChildren(...[
el("button", { type: "button", className: "back",
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "contacts"),
el("h2", { textContent: address }),
el("div", { className: "trust" },
el("span", { className: known.verified ? "badge ok" : "badge",
textContent: known.verified ? "verified key" : "key pinned on first use" })),
field("fingerprint", fingerprint(known.key)),
field("public key", b32encode(known.key)),
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "the address")),
known.history.length ? el("section", { className: "history" },
el("h3", { textContent: `previous keys (${known.history.length})` }),
el("p", { className: "hint", textContent:
"Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these was sent " +
"before the change; rotation is not revocation, so a stolen key can still rotate onward." }),
...known.history.map(({ key, until }) => el("div", { className: "keyblock" },
el("div", { textContent: fingerprint(b32decode(key)) }),
el("div", { className: "full", textContent: key }),
el("div", { className: "replaced", textContent: `replaced ${fmtTime(Math.floor(until / 1000))}` }))))
: null,
el("div", { className: "actions" }, refresh),
].filter(node => node != null));
}
// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
@ -532,7 +746,8 @@ async function refreshContact(address) {
} catch (err) {
status(err.message, true);
}
renderFolder();
if (folder === "contacts" && selectedId === address) openContact(address);
else renderFolder();
}
// --- settings ---------------------------------------------------------------------
@ -547,50 +762,69 @@ function openSettings() {
$("settings-open").onclick = openSettings;
$("settings-close").onclick = () => settings.close();
// Registering against an unpinned server is refused, so the dialog is ordered
// by what depends on what; the chips say how far along that order you are.
function step(id, done) {
const chip = $(id);
chip.classList.toggle("done", done);
chip.textContent = done ? "\u2713" : chip.dataset.n;
}
function renderSettings() {
const me = store.identity();
const account = store.account();
const pins = store.allPins();
step("step-server", pins.length > 0);
step("step-address", Boolean(account));
$("pins").replaceChildren(...pins.map(({ host, key }) =>
el("li", {},
el("span", { textContent: host }),
el("span", { className: "key", textContent: key }),
el("button", { type: "button", textContent: "remove",
onclick: () => { store.unpinServer(host); renderSettings(); } }))));
if (!pins.length)
$("pins").append(el("li", { className: "placeholder", textContent: "no server pinned yet" }));
const card = $("identity-card");
card.replaceChildren();
if (!me) return;
const lines = [
`fingerprint: ${fingerprint(me.publicKey)}`,
`address: ${account ? account.user + "@" + account.host : "(not registered)"}`,
`share: ${account ? accountAddress().uri(me.publicKey) : "(register to get a shareable address)"}`,
"",
];
for (const text of lines) {
const div = document.createElement("div");
div.textContent = text;
card.append(div);
}
const seed = document.createElement("div");
seed.className = "seed";
seed.textContent = hex(me.seed);
const warning = document.createElement("div");
warning.className = "warn";
warning.textContent = "back this seed up; it is the only secret";
const retired = document.createElement("div");
retired.textContent = `retired keys: ${store.identities().length - 1} (kept to read old mail)`;
card.append(seed, warning, retired);
const share = account ? accountAddress().uri(me.publicKey) : null;
const retired = store.identities().length - 1;
const pins = $("pins");
pins.replaceChildren();
for (const { host, key } of store.allPins()) {
const li = document.createElement("li");
const name = document.createElement("span");
name.textContent = host;
const value = document.createElement("span");
value.className = "key";
value.textContent = key;
const remove = document.createElement("button");
remove.textContent = "remove";
remove.onclick = () => { store.unpinServer(host); renderSettings(); };
li.append(name, value, remove);
pins.append(li);
}
if (!store.allPins().length) pins.append(Object.assign(document.createElement("li"),
{ className: "placeholder", textContent: "no pinned servers" }));
// Settings opens by itself right after an identity is created, which is a poor
// moment to paint the only secret across the screen — so it is asked for.
const masked = "\u2022".repeat(64);
const seed = el("div", { className: "seed", textContent: masked });
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
const showing = seed.textContent !== masked;
seed.textContent = showing ? masked : hex(me.seed);
reveal.textContent = showing ? "reveal" : "hide";
} });
card.append(...[
field("fingerprint", fingerprint(me.publicKey)),
field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account),
field("shareable", share ?? "register above to get one", !share,
share ? copyButton(share, "your address") : null),
el("div", { className: "seed-card" },
el("h4", { textContent: "recovery seed" }),
el("div", { className: "row" }, seed, reveal, copyButton(hex(me.seed), "the seed")),
el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " +
"write as you, and losing it loses the address with it — there is no reset. Copy it " +
"somewhere safe now." })),
field("retired keys", String(retired)),
retired ? el("p", { className: "hint",
textContent: "Kept so mail sealed to a superseded key stays readable." }) : null,
el("details", {},
el("summary", { textContent: "advanced" }),
el("p", { className: "hint", textContent:
"Rotating issues a fresh key with a signed certificate proving it replaces this one. " +
"Contacts accept the change from that chain, and the old seed is kept, since mail sealed " +
"to it is readable with nothing else. Rotation is not revocation (SPEC.md \u00a77)." }),
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity })),
].filter(node => node != null));
}
$("pin-form").onsubmit = (event) => {
@ -618,7 +852,6 @@ $("import-form").onsubmit = (event) => {
store.saveContact(addr.short, addr.identity, true);
status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` +
`fingerprint: ${fingerprint(addr.identity)}`);
renderSettings();
renderFolder();
form.reset();
} catch (err) {
@ -642,13 +875,14 @@ $("register-form").onsubmit = async (event) => {
store.setAccount(addr);
status(`registered ${addr.short}\nshare: ${addr.uri(me.publicKey)}`);
renderAccount();
renderSettings();
form.reset();
} catch (err) {
status(err.message, true);
}
};
$("rotate").onclick = async () => {
async function rotateIdentity() {
const me = store.identity();
const addr = accountAddress();
if (!me || !addr) return status("rotate needs a registered account", true);
@ -665,15 +899,19 @@ $("rotate").onclick = async () => {
opened.session.stream.close();
}
store.rotateIdentity(fresh.seed);
// The key set grew, so a cached "not one of our keys" failure may be stale.
openedCache.clear();
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
renderAccount();
renderSettings();
renderFolder();
} catch (err) {
status(err.message, true);
}
};
}
// --- boot --------------------------------------------------------------------------
applyTheme();
store.identity() ? showApp() : showSetup();
status(store.identity() ? "" : "create an identity to begin");

View file

@ -2,7 +2,7 @@
// The initiator is anonymous; the responder's static key arrives encrypted in
// message two, which is what server pinning checks.
import { aeadDecrypt, aeadEncrypt, concat, hkdfSha256, randomBytes, sha256, timingSafeEqual, utf8Bytes, x25519, x25519Base } from "./crypto.js";
import { aeadDecrypt, aeadEncrypt, concat, hkdfSha256, randomBytes, sha256, utf8Bytes, x25519, x25519Base } from "./crypto.js";
const PROTOCOL = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name
const PROLOGUE = utf8Bytes("smolmail/1");
@ -90,10 +90,3 @@ export class NxInitiator {
return plaintext;
}
}
// §4: a pinned key must match the one the handshake revealed, or everything stops.
export function checkPin(pinned, serverStatic, host) {
if (pinned && !timingSafeEqual(pinned, serverStatic))
throw new Error(`${host} presented a different key than the one pinned`);
return pinned !== null && pinned !== undefined;
}

View file

@ -317,11 +317,16 @@ export class Session {
have = concat(have, await this.readNoise());
if (length < 0 && have.length >= 4) {
length = new DataView(have.buffer).getUint32(0);
if (length < 1 || length > MAX_FRAME)
// §6.1: the shortest response is a type byte and a status byte.
if (length < 2 || length > MAX_FRAME)
throw new SmolError(`server sent a frame of length ${length}`);
}
}
const payload = have.slice(4, 4 + length);
// §6.1: a response reuses the request's type byte. A mismatch means the
// session desynchronised, which must not be mistaken for a status.
if (payload[0] !== op)
throw new SmolError(`server answered op 0x${payload[0].toString(16)}, expected 0x${op.toString(16)}`);
return { status: payload[1], body: payload.slice(2) }; // op echo, status, body (§6.1)
}
}
@ -391,7 +396,7 @@ export async function deleteOp(session, ids) {
const body = concat(u16BE(ids.length), ...ids);
const { status, body: reply } = await session.call(OP.DELETE, body);
expectOk(status, "acknowledging");
return new DataView(reply.buffer).getUint16(0);
return new Reader(reply).u16();
}
export async function registerOp(session, { username, identity, token = "", cert = null }) {

View file

@ -7,18 +7,32 @@ import { b32decode, b32encode, identityFromSeed } from "./proto.js";
const KEY = "gsmol";
// Every getter reads the whole blob and some are called per message row, so the
// parse is cached. `derived` holds the identities that follow from it, which
// cost an Ed25519 scalar multiplication each.
let cached = null, derived = null;
function load() {
if (cached) return cached;
try {
return JSON.parse(localStorage.getItem(KEY) || "{}");
cached = JSON.parse(localStorage.getItem(KEY) || "{}");
} catch {
return {};
cached = {};
}
return cached;
}
function save(state) {
localStorage.setItem(KEY, JSON.stringify(state));
cached = state;
derived = null;
}
// Another tab writing this key leaves our copy stale.
window.addEventListener("storage", (event) => {
if (event.key === KEY || event.key === null) cached = derived = null;
});
function update(fn) {
const state = load();
const next = fn(state);
@ -33,8 +47,7 @@ export function seed() {
}
export function identity() {
const raw = seed();
return raw ? identityFromSeed(raw) : null;
return identities()[0] ?? null;
}
export function setIdentity(newSeed) {
@ -56,10 +69,12 @@ export function rotateIdentity(newSeed) {
// §7: seeds rotated away from are retained, since mail sealed to a superseded
// key is readable with nothing else.
export function identities() {
const me = identity();
if (!me) return [];
if (derived) return derived;
const raw = seed();
if (!raw) return [];
const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s)));
return [me, ...retired];
derived = [identityFromSeed(raw), ...retired];
return derived;
}
// --- account and server pins ---------------------------------------------------
@ -100,15 +115,22 @@ export function allPins() {
export function contact(address) {
const c = (load().contacts || {})[address];
return c ? { key: b32decode(c.key), verified: c.verified } : null;
return c ? { key: b32decode(c.key), verified: c.verified, history: c.history ?? [] } : null;
}
// A key that displaces another is kept: it is the only local record that the
// contact rotated, and §8 turns on the user being able to notice such changes.
// Re-saving the same key is not a rotation and must not add an entry.
export function saveContact(address, keyBytes, verified) {
const key = b32encode(keyBytes);
update(state => {
state.contacts = {
...(state.contacts || {}),
[address]: { key: b32encode(keyBytes), verified, seenAt: Date.now() },
};
const contacts = { ...(state.contacts || {}) };
const previous = contacts[address];
const history = previous && previous.key !== key
? [...(previous.history ?? []), { key: previous.key, until: Date.now() }]
: previous?.history ?? [];
contacts[address] = { key, verified, seenAt: Date.now(), ...(history.length && { history }) };
state.contacts = contacts;
});
}
@ -119,7 +141,7 @@ export function addressForKey(keyBytes) {
export function allContacts() {
return Object.entries(load().contacts || {})
.map(([address, c]) => ({ address, key: c.key, verified: c.verified }));
.map(([address, c]) => ({ address, key: c.key, verified: c.verified, history: c.history ?? [] }));
}
// --- read markers ---------------------------------------------------------------
@ -134,12 +156,30 @@ export function isRead(idHex) {
return Boolean((load().read || {})[idHex]);
}
// --- appearance ------------------------------------------------------------
// "light" or "dark" to pin a side; null to follow the system.
export function theme() {
return load().theme ?? null;
}
export function setTheme(value) {
update(state => {
if (value) state.theme = value;
else delete state.theme;
});
}
// --- sealed mail (IndexedDB) ------------------------------------------------------
const DB_NAME = "gsmol", DB_VERSION = 1;
// Held open: reopening per read cost more than the reads themselves.
let dbPromise = null;
function openDb() {
return new Promise((resolve, reject) => {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
@ -149,7 +189,8 @@ function openDb() {
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}).catch(error => { dbPromise = null; throw error; });
return dbPromise;
}
function tx(db, box, mode, fn) {
@ -163,29 +204,33 @@ function tx(db, box, mode, fn) {
export async function storeMessage(box, record) {
const db = await openDb();
await tx(db, box, "readwrite", store => store.put(record));
db.close();
}
// Returns null when the id already exists, so fetch can leave server state alone.
export async function storeIfNew(box, record) {
const db = await openDb();
const existing = await tx(db, box, "readonly", store => store.get(record.id));
if (existing) { db.close(); return null; }
if (existing) return null;
await tx(db, box, "readwrite", store => store.put(record));
db.close();
return record;
}
export async function listMessages(box) {
const db = await openDb();
const rows = await tx(db, box, "readonly", store => store.getAll());
db.close();
return rows.sort((a, b) => (a.receivedAt ?? a.sentAt) - (b.receivedAt ?? b.sentAt));
return rows.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
}
export async function getMessage(box, id) {
const db = await openDb();
const row = await tx(db, box, "readonly", store => store.get(id));
db.close();
return row ?? null;
return await tx(db, box, "readonly", store => store.get(id)) ?? null;
}
// The unread badge must survive leaving the inbox, so it is counted from ids
// and read markers alone — no envelope is opened.
export async function unreadCount() {
const db = await openDb();
const ids = await tx(db, "inbox", "readonly", store => store.getAllKeys());
const read = load().read || {};
return ids.filter(id => !read[id]).length;
}

View file

@ -1,18 +1,45 @@
/* Every colour is a token, so the dark palette at the foot of this file needs
only to re-point them — no rule is written twice. */
/* One declaration per token: light-dark() picks a side from the resolved
color-scheme, so the theme toggle only has to change `color-scheme` and no
rule below ever needs a dark twin. */
:root {
--bg: #f6f8fc;
--surface: #ffffff;
--fg: #202124;
--muted: #5f6368;
--border: #dadce0;
--accent: #1a73e8;
--accent-soft: #e8f0fe;
--hover: #f1f3f4;
--warn: #d93025;
color-scheme: light dark;
--bg: light-dark(#f6f8fc, #16181c);
--surface: light-dark(#ffffff, #202124);
--fg: light-dark(#202124, #e8eaed);
--muted: light-dark(#5f6368, #9aa0a6);
--border: light-dark(#dadce0, #3c4043);
--divider: light-dark(#ebedf0, #2c2e33);
/* light #1a73e8 fell to 3.93:1 against --accent-soft, which is the compose
pill and the active folder; both sides here clear AA on every surface. */
--accent: light-dark(#0b57d0, #a8c7fa);
--accent-soft: light-dark(#e8f0fe, #22364f);
--on-accent: light-dark(#ffffff, #062e6f);
--hover: light-dark(#f1f3f4, #2a2c31);
--warn: light-dark(#c5221f, #f28b82);
--on-warn: light-dark(#ffffff, #3c0d0a);
--ok-bg: light-dark(#e6f4ea, #1d3826);
--ok-fg: light-dark(#137333, #81c995);
--alert-bg: light-dark(#feefc3, #3d3520);
--alert-fg: light-dark(#7a4f01, #fdd663);
--toast-bg: light-dark(#202124, #e8eaed);
--toast-fg: light-dark(#e8eaed, #202124);
--backdrop: light-dark(rgba(32, 33, 36, 0.3), rgba(0, 0, 0, 0.6));
--shadow-near: light-dark(rgba(60, 64, 67, 0.2), rgba(0, 0, 0, 0.55));
--shadow-far: light-dark(rgba(60, 64, 67, 0.08), rgba(0, 0, 0, 0.35));
--ui: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--radius: 8px;
--shadow: 0 1px 3px rgba(60, 64, 67, 0.2), 0 4px 8px 3px rgba(60, 64, 67, 0.08);
--shadow: 0 1px 3px var(--shadow-near), 0 4px 8px 3px var(--shadow-far);
}
/* The toggle pins a side; without the attribute the system decides. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
@ -21,7 +48,8 @@ body {
display: flex;
flex-direction: column;
height: 100vh;
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
height: 100dvh; /* mobile browser chrome eats the last row of 100vh */
font: 14px/1.5 var(--ui);
color: var(--fg);
background: var(--surface);
}
@ -38,6 +66,22 @@ button {
}
button:hover { background: var(--hover); }
button:disabled { color: var(--muted); cursor: default; }
button:disabled:hover { background: none; }
/* Line icons from the sprite in index.html; they take their colour from the
button, so no icon needs a palette of its own. */
.sprite { display: none; }
.icon {
width: 18px;
height: 18px;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon-only { padding: 8px; display: inline-flex; }
input, textarea {
font: inherit;
@ -47,7 +91,11 @@ input, textarea {
background: var(--surface);
color: var(--fg);
}
input:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
/* Keyboard focus was invisible: buttons carry no border and the input ring used
--accent-soft, which is near-white on white. */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
/* --- top bar --------------------------------------------------------------- */
@ -61,6 +109,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-colo
.brand { font-size: 18px; font-weight: 500; letter-spacing: -0.02em; }
.search {
flex: 1;
min-width: 0; /* an input's intrinsic size is its flex floor otherwise, and it overflows narrow bars */
max-width: 640px;
margin: 0 auto;
background: var(--hover);
@ -68,15 +117,17 @@ input:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-colo
border-radius: var(--radius);
padding: 8px 16px;
}
.search:focus { background: var(--surface); outline: 1px solid var(--border); }
.search:focus { background: var(--surface); outline: 2px solid var(--accent); }
.top-actions { display: flex; align-items: center; gap: 4px; }
.top-actions .icon-only { color: var(--muted); }
.top-actions .icon-only:hover { color: var(--fg); }
.account { font-size: 13px; color: var(--muted); margin-right: 4px; }
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
color: #fff;
color: var(--on-accent);
font-size: 15px;
font-weight: 500;
display: flex;
@ -98,45 +149,85 @@ main { flex: 1; min-height: 0; display: flex; background: var(--bg); }
flex-direction: column;
gap: 4px;
}
/* The one primary action here, so it carries a solid fill rather than the soft
one it shares with selected rows — and its box edge lines up with the folder
buttons below, so the rail reads as a single column. */
.compose {
align-self: flex-start;
margin: 0 8px 16px;
padding: 12px 26px;
align-self: stretch;
display: flex;
align-items: center;
gap: 14px;
margin: 2px 0 18px;
padding: 12px 16px;
border-radius: 20px;
background: var(--accent-soft);
color: var(--accent);
background: var(--accent);
color: var(--on-accent);
font-weight: 500;
box-shadow: var(--shadow);
}
.compose:hover { background: var(--accent); color: #fff; }
.compose:hover { background: var(--accent); box-shadow: var(--shadow); }
#folders button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
width: 100%;
text-align: left;
padding: 8px 16px;
padding: 9px 16px;
color: var(--fg);
font-weight: 500;
}
#folders .label { flex: 1; }
#folders button:hover { background: var(--hover); }
#folders button.active { background: var(--accent-soft); color: var(--accent); }
#folders .count { color: var(--accent); font-weight: 500; margin-left: 8px; }
.list {
.listpane {
display: flex;
flex-direction: column;
min-width: 360px;
max-width: 460px;
overflow-y: auto;
background: var(--surface);
border-right: 1px solid var(--border);
}
.list { flex: 1; min-height: 0; overflow-y: auto; }
/* Fetch acts on the mailbox, so it sits over the mailbox rather than in the
account cluster — and it can stand down in the contacts folder. */
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 6px 12px;
border-bottom: 1px solid var(--border);
}
.toolbar .tools { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.toolbar .primary { display: flex; align-items: center; gap: 8px; color: var(--accent); font-weight: 500; }
#import-form input {
flex: 1;
min-width: 0; /* an input's intrinsic width is its flex floor otherwise */
font-family: var(--mono);
font-size: 12px;
padding: 5px 10px;
}
/* The fetch icon turns while a session is open. */
.toolbar .primary:disabled .icon { animation: spin 1.1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .toolbar .primary:disabled .icon { animation: none; } }
.list-status { margin-left: auto; padding-right: 6px; font-size: 12px; color: var(--muted); }
/* Message rows are <button>, so they are reachable and openable from the
keyboard; the generic button styling above is undone here. */
.item {
display: flex;
gap: 12px;
align-items: baseline;
width: 100%;
padding: 9px 16px;
border-bottom: 1px solid #eee;
text-align: left;
border: 0;
border-bottom: 1px solid var(--divider);
border-radius: 0;
background: var(--surface);
cursor: pointer;
color: var(--muted);
}
@ -153,41 +244,66 @@ main { flex: 1; min-height: 0; display: flex; background: var(--bg); }
.item .when { flex: none; font-size: 12px; color: var(--muted); }
.item button { padding: 2px 12px; font-size: 12px; }
.item.contact .mid { white-space: normal; line-height: 1.45; font-family: var(--mono); font-size: 12px; color: var(--muted); }
.item.contact .badge + .badge { margin-left: 6px; }
/* A sender whose key is bound to no address, visible before opening the row. */
.item .dot {
display: inline-block;
width: 6px;
height: 6px;
margin-left: 6px;
border-radius: 50%;
background: var(--alert-fg);
}
.badge {
display: inline-block;
margin-top: 3px;
font-family: inherit;
font-family: var(--ui);
font-size: 11px;
padding: 1px 9px;
border-radius: 10px;
background: var(--hover);
color: var(--muted);
}
.badge.ok { background: #e6f4ea; color: #137333; }
.badge.ok { background: var(--ok-bg); color: var(--ok-fg); }
.badge.alert { background: var(--alert-bg); color: var(--alert-fg); }
.view { flex: 1; min-width: 0; overflow-y: auto; padding: 24px 28px; }
.view h2 { margin: 0 0 10px; font-size: 20px; font-weight: 400; }
.view .meta { font-size: 13px; color: var(--muted); margin-bottom: 14px; white-space: pre-line; }
.view .meta { font-size: 13px; color: var(--muted); margin-bottom: 14px; white-space: pre-line; word-break: break-word; }
.view .fields { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-bottom: 10px; white-space: pre-line; }
.view pre {
white-space: pre-wrap;
overflow-wrap: anywhere; /* an unbroken URL must not widen the reader */
font-family: inherit;
margin: 14px 0 0;
padding: 14px 0 0;
border-top: 1px solid var(--border);
}
.view .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; }
.view .actions button { border: 1px solid var(--border); box-shadow: var(--shadow); color: var(--fg); }
.view .actions button { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); box-shadow: var(--shadow); color: var(--fg); }
.view .actions button:hover { background: var(--hover); color: var(--fg); }
/* Only meaningful once the reader covers the list, below the breakpoint. */
.back { display: none; align-items: center; gap: 6px; margin: 0 0 10px -8px; }
.verified {
display: inline-block;
/* What is actually known about the signer: a signature always verifies here, so
the chip reports the key-to-address binding, which is the part that can be weak. */
.trust { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.trust .keyblock { font-family: var(--mono); font-size: 12px; line-height: 1.55; color: var(--muted); }
.trust .keyblock .full { word-break: break-all; }
/* Superseded contact keys: the local record that a rotation happened. */
.history { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
.history h3 {
margin: 0 0 4px;
font-size: 12px;
padding: 2px 10px;
border-radius: 12px;
background: #e6f4ea;
color: #137333;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.history .keyblock { margin-top: 12px; font-family: var(--mono); font-size: 12px; color: var(--muted); line-height: 1.55; }
.history .keyblock .full { word-break: break-all; }
.history .replaced { font-family: var(--ui); font-style: italic; }
.warn { color: var(--warn); }
.placeholder { color: var(--muted); font-style: italic; padding: 12px 16px; }
@ -203,6 +319,7 @@ main { flex: 1; min-height: 0; display: flex; background: var(--bg); }
gap: 12px;
text-align: center;
background: var(--bg);
padding: 16px;
}
.setup h2 { margin: 0; font-weight: 400; }
.setup p { color: var(--muted); max-width: 480px; }
@ -215,31 +332,93 @@ dialog {
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px 24px;
min-width: 460px;
max-width: 640px;
color: var(--fg);
background: var(--surface);
min-width: min(460px, calc(100vw - 32px));
max-width: min(640px, calc(100vw - 32px));
max-height: calc(100dvh - 48px);
overflow-y: auto;
}
dialog::backdrop { background: rgba(32, 33, 36, 0.3); }
dialog::backdrop { background: var(--backdrop); }
#compose form { display: flex; flex-direction: column; gap: 10px; }
#compose input, #compose textarea { width: 100%; }
#compose textarea { font-family: inherit; resize: vertical; }
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; }
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
#compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
/* Without this every action read exactly like "cancel" and "close". */
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
.filled:disabled { background: var(--hover); color: var(--muted); box-shadow: none; }
.error { color: var(--warn); font-size: 13px; }
#settings section { margin-bottom: 22px; }
#settings h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
#settings form { display: flex; gap: 8px; margin: 6px 0; }
#settings form input { flex: 1; font-family: var(--mono); font-size: 13px; }
#settings section { margin-bottom: 26px; }
#settings h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
/* The numbers say what order these have to happen in — registering against an
unpinned server is refused, so the server section cannot come second. */
#settings .step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: none;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
font-size: 11px;
font-weight: 600;
}
#settings .step.done { background: var(--ok-bg); color: var(--ok-fg); }
#settings form { display: flex; gap: 10px; margin: 10px 0 6px; flex-wrap: wrap; align-items: flex-end; }
#settings label {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 170px;
font-size: 12px;
color: var(--muted);
}
#settings label input { font-family: var(--mono); font-size: 13px; }
#settings footer { display: flex; justify-content: flex-end; }
.hint { font-size: 12px; color: var(--muted); margin: 4px 0; }
/* label / value rows, in place of the monospace `key: value` dump */
.field { display: flex; align-items: baseline; gap: 12px; padding: 3px 0; }
.field-label { flex: none; width: 96px; font-size: 12px; color: var(--muted); }
.field-value { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
.field-value.empty { font-family: inherit; font-style: italic; color: var(--muted); }
.seed-card { border: 1px solid var(--warn); border-radius: var(--radius); padding: 12px 14px; margin: 14px 0 10px; }
.seed-card h4 {
margin: 0 0 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--warn);
}
.seed-card .row { display: flex; align-items: center; gap: 8px; }
.seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
.seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); }
#settings details { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 10px; }
#settings summary { font-size: 12px; color: var(--muted); cursor: pointer; }
#settings details .hint { margin: 8px 0; }
.kv { list-style: none; padding: 0; margin: 6px 0; font-size: 13px; }
.kv li { display: flex; gap: 10px; padding: 3px 0; align-items: baseline; }
.kv .key { font-family: var(--mono); word-break: break-all; color: var(--muted); flex: 1; }
#identity-card { font-family: var(--mono); font-size: 12px; }
#identity-card div { margin: 4px 0; word-break: break-all; }
#identity-card .seed { border: 1px dashed var(--warn); padding: 6px; }
.copy { font-size: 12px; padding: 4px 10px; }
/* --- toast ------------------------------------------------------------------------ */
@ -251,12 +430,49 @@ dialog::backdrop { background: rgba(32, 33, 36, 0.3); }
margin: 0;
padding: 12px 18px;
border-radius: var(--radius);
background: #202124;
color: #e8eaed;
background: var(--toast-bg);
color: var(--toast-fg);
font-size: 13px;
white-space: pre-wrap;
box-shadow: var(--shadow);
cursor: pointer;
}
.toast.warn { background: var(--warn); color: #fff; }
.toast.warn { background: var(--warn); color: var(--on-warn); }
.toast[hidden] { display: none; }
/* --- narrow layouts ----------------------------------------------------------------- */
/* The reader takes the whole pane and the list steps aside, rather than both
being squeezed below the width either one needs. */
@media (max-width: 1000px) {
.listpane { flex: 1; min-width: 0; max-width: none; border-right: 0; }
.view { display: none; }
#app.reading .listpane { display: none; }
#app.reading .view { display: block; }
.back { display: inline-flex; }
}
@media (max-width: 640px) {
main { flex-direction: column; }
.rail {
width: auto;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 8px;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.compose { margin: 0; padding: 8px 20px; }
.compose { align-self: center; }
#folders { display: flex; gap: 4px; }
#folders button { width: auto; gap: 8px; padding: 9px 12px; }
#folders .label { display: none; } /* aria-label keeps the accessible name */
.topbar { gap: 8px; padding: 8px 12px; }
.search { max-width: none; padding: 6px 12px; }
.account { display: none; }
.item .who { width: auto; flex: 0 1 110px; }
.view { padding: 16px; }
.toast { left: 12px; right: 12px; bottom: 12px; max-width: none; }
}