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
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue