Compare commits

...

2 commits

Author SHA1 Message Date
randogoth
5c4e472b19 chore: release 0.3.0
Custom frontmatter headers: write your own Key: value fields under the
subject, and carry a sender's fields into a reply.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 15:11:05 +03:00
randogoth
0ee4a7a65f feat: custom frontmatter headers in compose and replies
Adds Key: value header rows under the subject, validated with carrier's
rules: keys of 1-64 letters, digits and dashes, unique case-insensitively,
none of the fields the client sets itself, and the whole block inside the
reader's budget. A reply to a message with the sender's own fields asks
whether to carry copies over.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 15:11:05 +03:00
6 changed files with 179 additions and 10 deletions

View file

@ -21,7 +21,7 @@ assert lib.assertMsg ((serverHost == null) == (serverPublicKey == null))
"gsmol-bridge: serverHost and serverPublicKey must be set together, or not at all";
stdenvNoCC.mkDerivation {
pname = "gsmol-bridge";
version = "0.2.1";
version = "0.3.0";
# Respects the repo's own .gitignore, so .venv/.devbox/.jj never enter the
# store path even when building from an unclean local checkout.

View file

@ -155,6 +155,42 @@ for (const v of vectors.ed_to_x25519) {
eq("fm-too-many-keys", proto.parseFrontmatter("---\n" + "X: y\n".repeat(65) + "---\nbody").fields.subject ?? "none", "none");
}
// --- compose headers ----------------------------------------------------------
{
const refused = (text) => {
try { proto.headerRow(text); return null; } catch (err) { return err instanceof proto.FieldError ? err.id : err; }
};
eq("hdr-pair", proto.headerRow("Urgency: high").join("|"), "Urgency|high");
eq("hdr-trimmed", proto.headerRow(" spaced-key : trimmed value ").join("|"), "spaced-key|trimmed value");
eq("hdr-empty", proto.headerRow(" "), null);
eq("hdr-no-colon", refused("no colon"), "compose-header-colon-missing");
eq("hdr-empty-key", refused(": value"), "compose-header-invalid-key");
eq("hdr-bad-key", refused("bad key: v"), "compose-header-invalid-key");
eq("hdr-long-key", refused(`${"k".repeat(65)}: v`), "compose-header-invalid-key");
eq("hdr-inner-newline", refused("multi\nline: v"), "compose-header-invalid-key");
eq("hdr-multiline-value", refused("k: a\nb"), "compose-header-value-multiline");
// the client's own fields would be shadowed by a copy
for (const reserved of ["Subject: x", "subject: x", "Reply-To: x", "In-Reply-To: x", "accept: x"])
eq(`hdr-reserved[${reserved}]`, refused(reserved), "compose-header-reserved-key");
const rejects = (pairs) => {
try { proto.validateHeaders(pairs); return null; } catch (err) { return err.id; }
};
eq("hdr-valid", rejects([["Urgency", "high"]]), null);
eq("hdr-duplicate", rejects([["Urgency", "high"], ["urgency", "low"]]), "compose-header-duplicate-key");
eq("hdr-too-large", rejects([0, 1, 2, 3, 4].map(i => [`X-Key${i}`, "v".repeat(1024)])), "compose-headers-too-large");
eq("hdr-too-many", rejects(Array.from({ length: 61 }, (_, i) => [`K${i}`, "v"])), "compose-headers-too-many");
// only the sender's own fields carry into a reply
const rows = proto.replyRows({ priority: "high", "in-reply-to": "ab".repeat(32), subject: "shadow", accept: "token" });
eq("hdr-reply-rows", rows.join("|"), "priority: high");
// user rows round-trip through the block the reader parses
const built = proto.buildFrontmatter([["Subject", "hi"], ["X-Mood", "fine"]], "body");
eq("hdr-roundtrip", proto.parseFrontmatter(built).fields["x-mood"], "fine");
}
// --- addresses (§3) -----------------------------------------------------------
{

View file

@ -144,6 +144,8 @@
<form id="compose-form" method="dialog">
<input name="to" required spellcheck="false" placeholder="to: alice@example.org or smol://…">
<input name="subject" spellcheck="false" data-l10n-placeholder="compose-subject-label" placeholder="subject">
<div id="compose-headers"></div>
<button type="button" id="compose-add-header" class="add-header" data-l10n-id="compose-add-header-button">+ header</button>
<textarea name="body" rows="12" data-l10n-placeholder="compose-body-placeholder" placeholder="message"></textarea>
<div id="compose-unpinned" class="seed-card" hidden>
<h4 id="compose-unpinned-title"></h4>
@ -264,6 +266,7 @@
</div>
<footer>
<button type="button" id="confirm-cancel" autofocus data-l10n-id="action-cancel">Cancel</button>
<button type="button" id="confirm-alt" hidden></button>
<button type="button" id="confirm-ok"></button>
</footer>
</dialog>

View file

@ -3,10 +3,10 @@
import { hex, timingSafeEqual, unhex, utf8Bytes } from "./crypto.js";
import {
DEFAULT_PORT, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, b32decode,
b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, identitySeed, identityFromSeed,
makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, registerOp,
resolveOp, seal, sendOp, tokenFor, unseal, walkChain,
DEFAULT_PORT, FieldError, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate,
b32decode, b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, headerRow, identitySeed,
identityFromSeed, makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter,
registerOp, replyRows, resolveOp, seal, sendOp, tokenFor, unseal, validateHeaders, walkChain,
} from "./proto.js";
import * as store from "./store.js";
import { connectStream } from "./transport.js";
@ -736,12 +736,29 @@ const replySubject = (subject) =>
!subject ? "" : subject.startsWith("Re:") ? subject : t("reply-subject-prefix", { subject });
// One path for all three ways a reply can start, so the Re: rule lives once.
function startReply(row) {
// The sender's own fields are the one thing a reply has to decide about:
// carry copies, or start clean. Only rows that would pass send-time
// validation ask, so a mail with nothing but threading replies straight on.
async function startReply(row) {
const opened = describe(row);
const rows = replyRows(opened.fields);
let headers = [];
if (rows.length) {
const choice = await confirmDialog({
title: t("reply-dialog-title"),
body: t("reply-dialog-body", { list: rows.join("\n") }),
cancelLabel: t("reply-dialog-cancel-button"),
altLabel: t("reply-dialog-without-button"),
confirmLabel: t("reply-dialog-copy-button"),
});
if (!choice) return;
if (choice === true) headers = rows;
}
openCompose({
to: store.addressForKey(opened.sender) ?? "",
subject: replySubject(opened.subject),
replyTo: row.id,
headers,
});
}
@ -808,10 +825,11 @@ function bindReplyAddress(addr, senderKey) {
const compose = $("compose");
function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) {
function openCompose({ to = "", subject = "", body = "", replyTo = null, headers = [] } = {}) {
const form = compose.querySelector("form");
form.to.value = to;
form.subject.value = subject;
$("compose-headers").replaceChildren(...headers.map(headerRowEl));
form.body.value = body;
form.dataset.replyTo = replyTo ?? "";
$("compose-error").textContent = "";
@ -821,6 +839,45 @@ function openCompose({ to = "", subject = "", body = "", replyTo = null } = {})
$("write").onclick = () => openCompose();
// One `Key: value` frontmatter row, parsed only at send so typing is never
// interrupted by a half-written key.
function headerRowEl(value = "") {
const input = el("input", { value, spellcheck: false, placeholder: t("compose-header-placeholder") });
const row = el("div", { className: "header-row" }, input);
row.append(el("button", { type: "button", className: "icon-only",
title: t("compose-header-remove-tooltip"), onclick: () => row.remove() }, icon("close")));
row.lastChild.setAttribute("aria-label", t("compose-header-remove-tooltip"));
return row;
}
$("compose-add-header").onclick = () => {
const row = headerRowEl();
$("compose-headers").append(row);
row.querySelector("input").focus();
};
// The typed rows as pairs, refused with a translated reason when the reader
// would drop the block for them.
function composeHeaders() {
const pairs = [];
try {
[...$("compose-headers").querySelectorAll("input")].forEach((input, index) => {
try {
const pair = headerRow(input.value);
if (pair) pairs.push(pair);
} catch (err) {
if (!(err instanceof FieldError)) throw err;
throw new SmolError(t("compose-header-row-error", { index: index + 1, reason: t(err.id, err.args) }));
}
});
validateHeaders(pairs);
} catch (err) {
if (err instanceof FieldError) throw new SmolError(t(err.id, err.args));
throw err;
}
return pairs;
}
$("compose-cancel").onclick = () => compose.close();
// Holds the address a refused send is waiting on — "fetch their key, pin it,
@ -862,7 +919,7 @@ async function attemptSend(form) {
send.disabled = true; // one click, one envelope: a second seals a second message
try {
await sendMail(toText, form.subject.value, form.body.value, form.dataset.replyTo,
form.elements.anonymous.checked);
form.elements.anonymous.checked, composeHeaders());
compose.close();
} catch (err) {
if (err instanceof UnpinnedServerError) {
@ -935,7 +992,7 @@ $("compose-unpinned-pin").onclick = async () => {
}
};
async function sendMail(toText, subject, body, replyTo, anonymous) {
async function sendMail(toText, subject, body, replyTo, anonymous, headers = []) {
const me = store.identity();
const master = store.master();
if (!me || !master) throw new SmolError(t("identity-missing-error"));
@ -951,6 +1008,9 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
// why this isn't on every message.
const owed = store.tokenToDeliver(addr.short);
if (owed) fields.push(["Accept", b32encode(tokenFor(master, owed))]);
// The user's own rows go last; headerRow already refused any that would
// shadow the fields above.
fields.push(...headers);
// Trimmed before the payload is signed, so the signature covers exactly the
// text the author meant. A trailing newline is not content — but it becomes
// content the moment it is signed, and nothing downstream can strip it
@ -1370,10 +1430,16 @@ function renderSettings() {
// A reusable Cancel/confirm modal for the advanced panel's two irreversible
// actions. <dialog>'s own cancel path (Escape, backdrop click) counts as
// Cancel, same as clicking the button.
function confirmDialog({ title, body, confirmLabel, danger = false }) {
// Resolves true on confirm, false on cancel or dismiss, and "alt" for the
// optional middle button.
function confirmDialog({ title, body, confirmLabel, danger = false, cancelLabel = null, altLabel = null }) {
return new Promise((resolve) => {
$("confirm-title").textContent = title;
$("confirm-text").textContent = body;
$("confirm-cancel").textContent = cancelLabel ?? t("action-cancel");
const alt = $("confirm-alt");
alt.hidden = !altLabel;
alt.textContent = altLabel ?? "";
const ok = $("confirm-ok");
ok.textContent = confirmLabel;
ok.className = danger ? "danger" : "filled";
@ -1381,6 +1447,7 @@ function confirmDialog({ title, body, confirmLabel, danger = false }) {
let decided = false;
const finish = (result) => { decided = true; resolve(result); dialog.close(); };
$("confirm-cancel").onclick = () => finish(false);
alt.onclick = () => finish("alt");
ok.onclick = () => finish(true);
dialog.addEventListener("close", () => { if (!decided) resolve(false); }, { once: true });
dialog.showModal();

View file

@ -239,6 +239,64 @@ export function buildFrontmatter(fields, body) {
return `---\n${fields.map(([k, v]) => `${k}: ${v}\n`).join("")}---\n${body}`;
}
// A compose header the reader would reject the whole block for. Carries a
// message id and its arguments rather than English, so the UI translates it.
export class FieldError extends SmolError {
constructor(id, args = {}) {
super(id);
this.id = id;
this.args = args;
}
}
// Fields the client attaches itself (§5.5, §5.8); a user copy of any of them
// would shadow the client's own, since the reader keeps the first occurrence.
const RESERVED_FIELDS = ["subject", "in-reply-to", "reply-to", "accept"];
// One compose header row as typed — `Key: value`. An empty row is skipped
// (null); anything that breaks the block is refused, because a single
// malformed line makes the reader drop every field.
export function headerRow(text) {
text = text.trim();
if (!text) return null;
const colon = text.indexOf(":");
if (colon < 0) throw new FieldError("compose-header-colon-missing");
const key = text.slice(0, colon).trim(), value = text.slice(colon + 1);
if (RESERVED_FIELDS.includes(key.toLowerCase())) throw new FieldError("compose-header-reserved-key", { key });
if (!FM_KEY.test(key)) throw new FieldError("compose-header-invalid-key");
if (/[\r\n]/.test(value)) throw new FieldError("compose-header-value-multiline");
return [key, value.trim()];
}
// The rules across all rows: keys are unique case-insensitively, and the
// whole block — the client's own fields included — stays inside the reader's
// budget (§5.5).
export function validateHeaders(pairs) {
const seen = new Set();
for (const [key] of pairs) {
if (seen.has(key.toLowerCase())) throw new FieldError("compose-header-duplicate-key", { key });
seen.add(key.toLowerCase());
}
const budget = pairs.reduce((sum, [k, v]) => sum + utf8Bytes(`${k}: ${v}`).length + 1, 0);
// Subject, In-Reply-To, a Reply-To URI and maybe an Accept token go on top
// of the user's rows; 512 bytes covers them all.
if (budget + 512 > FRONTMATTER_MAX) throw new FieldError("compose-headers-too-large");
if (pairs.length + RESERVED_FIELDS.length > FRONTMATTER_KEYS) throw new FieldError("compose-headers-too-many");
}
// The rows a reply could carry over from the fields it answers: only the
// sender's own, writable ones — the reserved fields are the reply's context.
export function replyRows(fields) {
return Object.entries(fields).flatMap(([key, value]) => {
try {
const pair = headerRow(`${key}: ${value}`);
return pair ? [`${pair[0]}: ${pair[1]}`] : [];
} catch {
return [];
}
});
}
// --- key rotation (§7) -------------------------------------------------------
// old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both keys

View file

@ -437,6 +437,11 @@ dialog::backdrop { background: var(--backdrop); }
#compose form { display: flex; flex-direction: column; gap: 10px; padding: 20px 22px; }
#compose input, #compose textarea { width: 100%; }
#compose textarea { font-family: inherit; resize: vertical; }
#compose-headers { display: flex; flex-direction: column; gap: 6px; }
#compose-headers:empty { display: none; }
#compose .header-row { display: flex; gap: 4px; align-items: center; }
#compose .header-row input { font-family: var(--mono); font-size: 13px; }
#compose .add-header { align-self: flex-start; padding: 4px 10px; font-size: 13px; }
#compose .seed-card { margin: 0; }
#compose .actions { display: flex; gap: 8px; margin: 4px 0; }
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }