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:
parent
d19568c1c0
commit
0ee4a7a65f
5 changed files with 178 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue