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

@ -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