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
|
|
@ -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");
|
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) -----------------------------------------------------------
|
// --- addresses (§3) -----------------------------------------------------------
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,8 @@
|
||||||
<form id="compose-form" method="dialog">
|
<form id="compose-form" method="dialog">
|
||||||
<input name="to" required spellcheck="false" placeholder="to: alice@example.org or smol://…">
|
<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">
|
<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>
|
<textarea name="body" rows="12" data-l10n-placeholder="compose-body-placeholder" placeholder="message"></textarea>
|
||||||
<div id="compose-unpinned" class="seed-card" hidden>
|
<div id="compose-unpinned" class="seed-card" hidden>
|
||||||
<h4 id="compose-unpinned-title"></h4>
|
<h4 id="compose-unpinned-title"></h4>
|
||||||
|
|
@ -264,6 +266,7 @@
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<button type="button" id="confirm-cancel" autofocus data-l10n-id="action-cancel">Cancel</button>
|
<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>
|
<button type="button" id="confirm-ok"></button>
|
||||||
</footer>
|
</footer>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
|
|
||||||
import { hex, timingSafeEqual, unhex, utf8Bytes } from "./crypto.js";
|
import { hex, timingSafeEqual, unhex, utf8Bytes } from "./crypto.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PORT, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, b32decode,
|
DEFAULT_PORT, FieldError, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate,
|
||||||
b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, identitySeed, identityFromSeed,
|
b32decode, b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, headerRow, identitySeed,
|
||||||
makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, registerOp,
|
identityFromSeed, makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter,
|
||||||
resolveOp, seal, sendOp, tokenFor, unseal, walkChain,
|
registerOp, replyRows, resolveOp, seal, sendOp, tokenFor, unseal, validateHeaders, walkChain,
|
||||||
} from "./proto.js";
|
} from "./proto.js";
|
||||||
import * as store from "./store.js";
|
import * as store from "./store.js";
|
||||||
import { connectStream } from "./transport.js";
|
import { connectStream } from "./transport.js";
|
||||||
|
|
@ -736,12 +736,29 @@ const replySubject = (subject) =>
|
||||||
!subject ? "" : subject.startsWith("Re:") ? subject : t("reply-subject-prefix", { 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.
|
// 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 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({
|
openCompose({
|
||||||
to: store.addressForKey(opened.sender) ?? "",
|
to: store.addressForKey(opened.sender) ?? "",
|
||||||
subject: replySubject(opened.subject),
|
subject: replySubject(opened.subject),
|
||||||
replyTo: row.id,
|
replyTo: row.id,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -808,10 +825,11 @@ function bindReplyAddress(addr, senderKey) {
|
||||||
|
|
||||||
const compose = $("compose");
|
const compose = $("compose");
|
||||||
|
|
||||||
function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) {
|
function openCompose({ to = "", subject = "", body = "", replyTo = null, headers = [] } = {}) {
|
||||||
const form = compose.querySelector("form");
|
const form = compose.querySelector("form");
|
||||||
form.to.value = to;
|
form.to.value = to;
|
||||||
form.subject.value = subject;
|
form.subject.value = subject;
|
||||||
|
$("compose-headers").replaceChildren(...headers.map(headerRowEl));
|
||||||
form.body.value = body;
|
form.body.value = body;
|
||||||
form.dataset.replyTo = replyTo ?? "";
|
form.dataset.replyTo = replyTo ?? "";
|
||||||
$("compose-error").textContent = "";
|
$("compose-error").textContent = "";
|
||||||
|
|
@ -821,6 +839,45 @@ function openCompose({ to = "", subject = "", body = "", replyTo = null } = {})
|
||||||
|
|
||||||
$("write").onclick = () => openCompose();
|
$("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();
|
$("compose-cancel").onclick = () => compose.close();
|
||||||
|
|
||||||
// Holds the address a refused send is waiting on — "fetch their key, pin it,
|
// 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
|
send.disabled = true; // one click, one envelope: a second seals a second message
|
||||||
try {
|
try {
|
||||||
await sendMail(toText, form.subject.value, form.body.value, form.dataset.replyTo,
|
await sendMail(toText, form.subject.value, form.body.value, form.dataset.replyTo,
|
||||||
form.elements.anonymous.checked);
|
form.elements.anonymous.checked, composeHeaders());
|
||||||
compose.close();
|
compose.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UnpinnedServerError) {
|
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 me = store.identity();
|
||||||
const master = store.master();
|
const master = store.master();
|
||||||
if (!me || !master) throw new SmolError(t("identity-missing-error"));
|
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.
|
// why this isn't on every message.
|
||||||
const owed = store.tokenToDeliver(addr.short);
|
const owed = store.tokenToDeliver(addr.short);
|
||||||
if (owed) fields.push(["Accept", b32encode(tokenFor(master, owed))]);
|
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
|
// 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
|
// 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
|
// 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
|
// A reusable Cancel/confirm modal for the advanced panel's two irreversible
|
||||||
// actions. <dialog>'s own cancel path (Escape, backdrop click) counts as
|
// actions. <dialog>'s own cancel path (Escape, backdrop click) counts as
|
||||||
// Cancel, same as clicking the button.
|
// 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) => {
|
return new Promise((resolve) => {
|
||||||
$("confirm-title").textContent = title;
|
$("confirm-title").textContent = title;
|
||||||
$("confirm-text").textContent = body;
|
$("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");
|
const ok = $("confirm-ok");
|
||||||
ok.textContent = confirmLabel;
|
ok.textContent = confirmLabel;
|
||||||
ok.className = danger ? "danger" : "filled";
|
ok.className = danger ? "danger" : "filled";
|
||||||
|
|
@ -1381,6 +1447,7 @@ function confirmDialog({ title, body, confirmLabel, danger = false }) {
|
||||||
let decided = false;
|
let decided = false;
|
||||||
const finish = (result) => { decided = true; resolve(result); dialog.close(); };
|
const finish = (result) => { decided = true; resolve(result); dialog.close(); };
|
||||||
$("confirm-cancel").onclick = () => finish(false);
|
$("confirm-cancel").onclick = () => finish(false);
|
||||||
|
alt.onclick = () => finish("alt");
|
||||||
ok.onclick = () => finish(true);
|
ok.onclick = () => finish(true);
|
||||||
dialog.addEventListener("close", () => { if (!decided) resolve(false); }, { once: true });
|
dialog.addEventListener("close", () => { if (!decided) resolve(false); }, { once: true });
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,64 @@ export function buildFrontmatter(fields, body) {
|
||||||
return `---\n${fields.map(([k, v]) => `${k}: ${v}\n`).join("")}---\n${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) -------------------------------------------------------
|
// --- key rotation (§7) -------------------------------------------------------
|
||||||
|
|
||||||
// old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both keys
|
// old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both keys
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,11 @@ dialog::backdrop { background: var(--backdrop); }
|
||||||
#compose form { display: flex; flex-direction: column; gap: 10px; padding: 20px 22px; }
|
#compose form { display: flex; flex-direction: column; gap: 10px; padding: 20px 22px; }
|
||||||
#compose input, #compose textarea { width: 100%; }
|
#compose input, #compose textarea { width: 100%; }
|
||||||
#compose textarea { font-family: inherit; resize: vertical; }
|
#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 .seed-card { margin: 0; }
|
||||||
#compose .actions { display: flex; gap: 8px; margin: 4px 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; }
|
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue