// UI wiring for the smol mail client: identity setup, folders, reading, // composing, fetching with verify-before-ack, and the §4/§7 trust surfaces. import { hex, timingSafeEqual, utf8Bytes } from "./crypto.js"; import { DEFAULT_PORT, SmolError, authenticate, b32decode, b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, makeCert, messageId, newIdentity, openSession, parseAddress, parseFrontmatter, registerOp, resolveOp, seal, sendOp, unseal, walkChain, } from "./proto.js"; import * as store from "./store.js"; import { connectStream } from "./transport.js"; const $ = (id) => document.getElementById(id); function status(text, warn = false) { $("status").textContent = text; $("status").className = warn ? "status warn" : "status"; } const fmtTime = (seconds) => new Date(seconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); // The stored account as an address object, default port included when custom. function accountAddress() { const account = store.account(); if (!account) return null; const suffix = account.port === DEFAULT_PORT ? "" : `:${account.port}`; return parseAddress(`${account.user}@${account.host}${suffix}`); } // --- connection -------------------------------------------------------------- // §4: registration and fetching demand a pinned key; sending to a recipient // whose key we already hold tolerates an unpinned server. async function connect(addr, { requirePin }) { const pinned = store.serverPin(addr.host); if (requirePin && !pinned) throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` + `trusted channel, then pin it in settings.`); const stream = await connectStream(addr.host, addr.port); const opened = await openSession(stream, addr.host, pinned); if (!pinned) status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` + `RESOLVE results from this session are UNVERIFIED (SPEC.md §8).`, true); return opened; } // --- identity setup ------------------------------------------------------------ function showSetup() { $("setup").hidden = false; $("app").hidden = true; $("account").textContent = ""; } function showApp() { $("setup").hidden = true; $("app").hidden = false; renderAccount(); renderFolder(); } $("create-identity").onclick = () => { store.setIdentity(newIdentity().seed); showApp(); status("identity created. Back up the seed in settings now; it is the only secret."); openSettings(); }; $("restore-identity").onclick = () => { const text = $("restore-seed").value.trim(); if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true); const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16)); try { store.setIdentity(seedBytes); } catch (err) { return status(err.message, true); } showApp(); status("identity restored."); }; // --- header --------------------------------------------------------------------- function renderAccount() { const me = store.identity(); const account = store.account(); $("account").textContent = me ? `${account ? account.user + "@" + account.host : "(not registered)"} · ${fingerprint(me.publicKey)}` : ""; } // --- folders and list --------------------------------------------------------- let folder = "inbox"; let selectedId = null; 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); selectedId = null; renderFolder(); }; async function renderFolder() { const list = $("list"); if (folder === "contacts") return renderContacts(list); const rows = await store.listMessages(folder); list.replaceChildren(); for (const row of rows) { const opened = describe(row); const item = document.createElement("div"); item.className = "item" + (row.id === selectedId ? " selected" : ""); const who = document.createElement("div"); who.className = "who" + (store.isRead(row.id) ? "" : " unread"); who.textContent = folder === "sent" ? row.recipient : (opened.from ?? "?"); const subject = document.createElement("div"); subject.className = "subject"; subject.textContent = opened.error ? `` : (opened.subject || "(no subject)"); const when = document.createElement("span"); when.className = "when"; when.textContent = fmtTime(folder === "sent" ? row.sentAt : opened.time ?? row.receivedAt); item.append(who, subject, when); item.onclick = () => openMessage(row.id); list.append(item); } if (!rows.length) list.append(Object.assign(document.createElement("p"), { className: "placeholder", textContent: folder === "inbox" ? "no mail — fetch" : "nothing sent" })); } // Open an envelope and split its body; anything unreadable shows as such // rather than throwing, so one bad message cannot blank the list. function describe(row) { try { const opened = unseal(store.identities(), row.envelope); const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body)); return { ...opened, fields, body, subject: fields.Subject || "", from: store.addressForKey(opened.sender) ?? `<${b32encode(opened.sender).slice(0, 16)}…>`, }; } catch (err) { return { error: err.message }; } } // --- reading ------------------------------------------------------------------ async function openMessage(id) { const row = await store.getMessage(folder, id); if (!row) return; selectedId = id; store.markRead(id); renderFolder(); const opened = describe(row); const view = $("view"); if (opened.error) { view.innerHTML = `

${opened.error}

`; return; } 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;
  if (folder === "inbox") {
    const reply = document.createElement("button");
    reply.textContent = "reply";
    reply.onclick = () => openCompose({
      to: store.addressForKey(opened.sender) ?? "",
      subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
      replyTo: row.id,
    });
    view.append(reply);
  }
}

// --- compose and send -----------------------------------------------------------

const compose = $("compose");

function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) {
  const form = compose.querySelector("form");
  form.to.value = to;
  form.subject.value = subject;
  form.body.value = body;
  form.dataset.replyTo = replyTo ?? "";
  $("compose-error").textContent = "";
  compose.showModal();
}

$("write").onclick = () => openCompose();

compose.querySelector("form").onsubmit = async (event) => {
  event.preventDefault();
  if (event.submitter?.value !== "send") {
    compose.close();
    return;
  }
  const form = event.target;
  $("compose-error").textContent = "";
  try {
    await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo);
    compose.close();
  } catch (err) {
    $("compose-error").textContent = err.message;
  }
};

async function sendMail(toText, subject, body, replyTo) {
  const me = store.identity();
  if (!me) throw new SmolError("no identity yet");
  const addr = parseAddress(toText);
  const recipient = await resolveRecipient(addr);
  const fields = [["Subject", subject], ["In-Reply-To", replyTo]].filter(([, v]) => v);
  const bodyBytes = utf8Bytes(buildFrontmatter(fields, body.replace(/\s+$/, "") + "\n"));
  const envelope = seal(me, recipient, bodyBytes);
  const opened = await connect(addr, { requirePin: false });
  try {
    await sendOp(opened.session, envelope);
  } finally {
    opened.session.stream.close();
  }
  // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
  await store.storeMessage("sent", {
    id: hex(messageId(envelope)),
    recipient: addr.short,
    envelope: seal(me, me.publicKey, bodyBytes),
    sentAt: Math.floor(Date.now() / 1000),
  });
  status(`sent to ${addr.short}`);
  if (folder === "sent") renderFolder();
}

// Prefer a key we already trust; fall back to RESOLVE with trust on first use.
async function resolveRecipient(addr) {
  if (addr.identity) {
    store.saveContact(addr.short, addr.identity, true);
    return addr.identity;
  }
  const known = store.contact(addr.short);
  if (known) return known.key;
  const opened = await connect(addr, { requirePin: false });
  let current;
  try {
    ({ identity: current } = await resolveOp(opened.session, addr.user));
  } finally {
    opened.session.stream.close();
  }
  store.saveContact(addr.short, current, false);
  status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
  return current;
}

// --- fetch ----------------------------------------------------------------------

$("fetch").onclick = async () => {
  const me = store.identity();
  const addr = accountAddress();
  if (!me) return status("no identity yet", true);
  if (!addr) return status("not registered; register in settings first", true);
  status("fetching…");
  let stored = 0, rejected = 0;
  try {
    const opened = await connect(addr, { requirePin: true });
    try {
      await authenticate(opened.session, opened.handshakeHash, addr.user, me);
      for (;;) {
        const records = await fetchOp(opened.session);
        if (!records.length) break;
        const acked = [];
        for (const record of records) {
          try {
            if (!timingSafeEqual(messageId(record.envelope), record.id))
              throw new SmolError("id does not match the envelope");
            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);
            continue;
          }
          if (await store.storeIfNew("inbox", {
            id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt,
          })) stored++;
          acked.push(record.id);
        }
        if (!acked.length) break;
        await deleteOp(opened.session, acked);
      }
    } finally {
      opened.session.stream.close();
    }
    status(`${stored} new, ${rejected} rejected` + (rejected ? " (left on the server)" : ""));
  } catch (err) {
    status(err.message, true);
  }
  renderFolder();
};

// --- 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";
    const who = document.createElement("div");
    who.className = "who";
    who.textContent = address;
    const fp = document.createElement("div");
    fp.className = "subject";
    fp.textContent = `${fingerprint(b32decode(key))} · ${verified ? "verified key" : "tofu"}`;
    const refresh = document.createElement("button");
    refresh.textContent = "refresh";
    refresh.onclick = () => refreshContact(address);
    item.append(who, fp, 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" }));
}

// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
// surfaced; anything else requires out-of-band verification.
async function refreshContact(address) {
  try {
    const addr = parseAddress(address);
    if (addr.identity) throw new SmolError("that address already carries a key; use import instead");
    const known = store.contact(addr.short);
    const opened = await connect(addr, { requirePin: false });
    let current, chain;
    try {
      ({ identity: current, chain } = await resolveOp(opened.session, addr.user));
    } finally {
      opened.session.stream.close();
    }
    if (!known) {
      store.saveContact(addr.short, current, false);
      status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
    } else if (timingSafeEqual(known.key, current)) {
      status(`${addr.short}: key unchanged`);
    } else if (walkChain(known.key, current, chain)) {
      store.saveContact(addr.short, current, known.verified);
      status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true);
    } else {
      status(`${addr.short} presents a different key with no valid rotation chain.\n` +
        `Verify out of band, then import the new smol:// address.`, true);
    }
  } catch (err) {
    status(err.message, true);
  }
  renderFolder();
}

// --- settings ---------------------------------------------------------------------

const settings = $("settings");

function openSettings() {
  renderSettings();
  settings.showModal();
}

$("settings-open").onclick = openSettings;
$("settings-close").onclick = () => settings.close();

function renderSettings() {
  const me = store.identity();
  const account = store.account();
  const card = $("identity-card");
  if (!me) { card.textContent = ""; return; }
  const share = account ? accountAddress().uri(me.publicKey) : "(register to get a shareable address)";
  card.innerHTML = `
fingerprint: ${fingerprint(me.publicKey)}
` + `
address: ${account ? account.user + "@" + account.host : "(not registered)"}
` + `
share: ${share}
` + `
${hex(me.seed)}
` + `
back this seed up; it is the only secret
` + `
retired keys: ${store.identities().length - 1} (kept to read old mail)
`; 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" })); } $("pin-form").onsubmit = (event) => { event.preventDefault(); const form = event.target; try { const host = form.host.value.trim().toLowerCase(); const key = b32decode(form.key.value.trim()); if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`); store.pinServer(host, key); status(`pinned ${host}`); renderSettings(); form.reset(); } catch (err) { status(err.message, true); } }; $("import-form").onsubmit = (event) => { event.preventDefault(); const form = event.target; try { const addr = parseAddress(form.uri.value.trim()); if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key"); 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) { status(err.message, true); } }; $("register-form").onsubmit = async (event) => { event.preventDefault(); const form = event.target; try { const me = store.identity(); if (!me) throw new SmolError("no identity yet"); const addr = parseAddress(form.address.value.trim()); const opened = await connect(addr, { requirePin: true }); try { await registerOp(opened.session, { username: addr.user, identity: me, token: form.token.value.trim() }); } finally { opened.session.stream.close(); } store.setAccount(addr); status(`registered ${addr.short}\nshare: ${addr.uri(me.publicKey)}`); renderAccount(); form.reset(); } catch (err) { status(err.message, true); } }; $("rotate").onclick = async () => { const me = store.identity(); const addr = accountAddress(); if (!me || !addr) return status("rotate needs a registered account", true); if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " + "chain. The old key is kept, since mail sealed to it stays readable with nothing else.")) return; try { const fresh = newIdentity(); const cert = makeCert(me, fresh.seed); const opened = await connect(addr, { requirePin: true }); try { await registerOp(opened.session, { username: addr.user, identity: fresh, cert }); } finally { opened.session.stream.close(); } store.rotateIdentity(fresh.seed); status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`); renderAccount(); renderSettings(); } catch (err) { status(err.message, true); } }; // --- boot -------------------------------------------------------------------------- store.identity() ? showApp() : showSetup(); status(store.identity() ? "" : "create an identity to begin");