feat: harden bridge origin/CSP, cache decryption, and overhaul the web client UI
This commit is contained in:
parent
f637d5be57
commit
80133da100
10 changed files with 975 additions and 352 deletions
|
|
@ -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).
|
||||
|
|
|
|||
50
bridge.py
50
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)
|
||||
|
|
|
|||
|
|
@ -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
6
web/favicon.svg
Normal 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 |
104
web/index.html
104
web/index.html
|
|
@ -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>
|
||||
|
|
|
|||
694
web/js/app.js
694
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 ? `<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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
316
web/style.css
316
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 <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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue