diff --git a/README.md b/README.md index 048ac57..1485e2b 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/bridge.py b/bridge.py index 04cd1e4..465d52d 100755 --- a/bridge.py +++ b/bridge.py @@ -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) diff --git a/test/vectors.test.mjs b/test/vectors.test.mjs index baacbc7..c969c42 100644 --- a/test/vectors.test.mjs +++ b/test/vectors.test.mjs @@ -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`); diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..fdde838 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/web/index.html b/web/index.html index 5f53c05..c0f7425 100644 --- a/web/index.html +++ b/web/index.html @@ -4,17 +4,33 @@ smol mail + + +
smol mail
- -
- - + +
@@ -30,16 +46,32 @@
-
@@ -50,48 +82,46 @@

settings

+
-

identity

-
- -
-
-

account

-
- - - -
-

registration requires a pinned server key (SPEC.md §4). Pin one first.

-
-
-

server keys

+

1 your server

+

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).

- - - + + +
+
-

contacts

-
- - +

2 your address

+

Claim a username on a server you have pinned. This is what people write to.

+ + + +
+ +
+

your identity

+
+
+
- + diff --git a/web/js/app.js b/web/js/app.js index ae3d5f0..888461c 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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 ? `` - : (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 + ? `` : (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 = `

` + - `
signature verified
`; - view.querySelector("h2").textContent = opened.subject || "(no subject)"; - view.querySelector(".meta").textContent = folder === "sent" - ? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}` - : `from: ${opened.from}\nkey: ${b32encode(opened.sender)}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}`; - view.querySelector(".fields").textContent = fields.map(([k, v]) => `${k}: ${v}`).join("\n"); - view.querySelector("pre").textContent = opened.body; - const actions = view.querySelector(".actions"); - if (folder === "inbox") { - if (opened.knownSender) { - actions.append(replyButton(opened, row)); - } else if (opened.replyAddress) { - // The sender signed their own smol:// address into the payload, so - // binding it is one click. A pinned conflicting key is refused (§8). - const note = document.createElement("p"); - note.className = "placeholder"; - note.textContent = - `this message carries the sender's signed address: ${opened.replyAddress.short}`; - actions.before(note); - const save = document.createElement("button"); - save.textContent = "save sender & reply"; - save.onclick = () => { - try { - saveReplyAddress(opened.replyAddress, opened.sender); - openMessage(row.id); - openCompose({ - to: opened.replyAddress.short, - subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "", - replyTo: row.id, - }); - } catch (err) { - status(err.message, true); - } - }; - actions.append(save); - } else { - // The wire gives only a signed key; a reply needs the sender's address, - // which the user supplies once and we bind to that exact key. - const note = document.createElement("p"); - note.className = "placeholder"; - note.textContent = "The wire carries no sender name, only this signed key. " + - "To reply, give the sender's address — it is checked against this key:"; - const form = document.createElement("form"); - form.className = "name-sender"; - const input = document.createElement("input"); - input.placeholder = "alice@example.org or smol://…"; - input.spellcheck = false; - const name = document.createElement("button"); - name.textContent = "name sender & reply"; - form.append(input, name); - form.onsubmit = async (event) => { - event.preventDefault(); - name.disabled = true; - try { - await nameSender(input.value, opened.sender); - openMessage(row.id); // the sender is named now; the reply button appears - openCompose({ - to: store.addressForKey(opened.sender), - subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "", - replyTo: row.id, - }); - } catch (err) { - status(err.message, true); - } finally { - name.disabled = false; - } - }; - actions.append(note, form); - } + 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"); diff --git a/web/js/noise.js b/web/js/noise.js index bd29f51..771536e 100644 --- a/web/js/noise.js +++ b/web/js/noise.js @@ -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; -} diff --git a/web/js/proto.js b/web/js/proto.js index e58445a..3908879 100644 --- a/web/js/proto.js +++ b/web/js/proto.js @@ -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 }) { diff --git a/web/js/store.js b/web/js/store.js index 60eb671..c2dc813 100644 --- a/web/js/store.js +++ b/web/js/store.js @@ -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; } diff --git a/web/style.css b/web/style.css index 321100e..d9922cf 100644 --- a/web/style.css +++ b/web/style.css @@ -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