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>
This commit is contained in:
randogoth 2026-10-15 15:10:50 +03:00
parent d19568c1c0
commit 0ee4a7a65f
5 changed files with 178 additions and 9 deletions

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