feat: carry a signed Reply-To address for first contact

This commit is contained in:
randogoth 2026-09-26 16:36:12 +03:00
parent fd2d9298f8
commit aab98ff598
6 changed files with 69 additions and 6 deletions

View file

@ -133,18 +133,30 @@ async function renderFolder() {
// Open an envelope and split its body; anything unreadable shows as such
// rather than throwing, so one bad message cannot blank the list. The wire
// carries no sender name, so an unknown key can only be shown as its
// fingerprint until the user imports the sender's smol:// address.
// fingerprint until the user binds it to an address — which a signed Reply-To
// field lets the sender do in-band.
function describe(row) {
try {
const opened = unseal(store.identities(), row.envelope);
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
const known = store.addressForKey(opened.sender);
let replyAddress = null;
if (fields["Reply-To"]) {
try {
const claimed = parseAddress(fields["Reply-To"]);
// Only a full smol:// address whose key matches the signer counts;
// anything else is ordinary text.
if (claimed.identity && timingSafeEqual(claimed.identity, opened.sender))
replyAddress = claimed;
} catch { /* malformed claim: display, never bind */ }
}
return {
...opened,
fields, body,
subject: fields.Subject || "",
from: known ?? `unknown · ${fingerprint(opened.sender)}`,
knownSender: Boolean(known),
replyAddress,
};
} catch (err) {
return { error: err.message };
@ -177,6 +189,29 @@ async function openMessage(id) {
if (folder === "inbox") {
if (opened.knownSender) {
view.append(replyButton(opened, row));
} else if (opened.replyAddress) {
// The sender signed their own smol:// address into the payload, so
// binding it is one click. A pinned conflicting key is refused (§8).
const note = document.createElement("p");
note.className = "placeholder";
note.textContent =
`this message carries the sender's signed address: ${opened.replyAddress.short}`;
const save = document.createElement("button");
save.textContent = "save sender & reply";
save.onclick = () => {
try {
saveReplyAddress(opened.replyAddress, opened.sender);
openMessage(row.id);
openCompose({
to: opened.replyAddress.short,
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
} catch (err) {
status(err.message, true);
}
};
view.append(note, save);
} else {
// The wire gives only a signed key; a reply needs the sender's address,
// which the user supplies once and we bind to that exact key.
@ -247,6 +282,19 @@ async function nameSender(text, senderKey) {
renderFolder();
}
// A signed Reply-To is the sender's own claim, so it saves as verified — but
// never over an address already pinned to a different key (§8: a key change
// without a rotation chain needs out-of-band confirmation).
function saveReplyAddress(addr, senderKey) {
const existing = store.contact(addr.short);
if (existing && !timingSafeEqual(existing.key, senderKey))
throw new SmolError(`${addr.short} is already known with a different key — ` +
`verify out of band before replying`);
store.saveContact(addr.short, senderKey, true);
status(`${addr.short} saved for this sender (verified key)`);
renderFolder();
}
// --- compose and send -----------------------------------------------------------
const compose = $("compose");
@ -272,20 +320,26 @@ compose.querySelector("form").onsubmit = async (event) => {
const form = event.target;
$("compose-error").textContent = "";
try {
await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo);
await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo,
form.elements.anonymous.checked);
compose.close();
} catch (err) {
$("compose-error").textContent = err.message;
}
};
async function sendMail(toText, subject, body, replyTo) {
async function sendMail(toText, subject, body, replyTo, anonymous) {
const me = store.identity();
if (!me) throw new SmolError("no identity yet");
const addr = parseAddress(toText);
const recipient = await resolveRecipient(addr);
const fields = [["Subject", subject], ["In-Reply-To", replyTo]].filter(([, v]) => v);
const bodyBytes = utf8Bytes(buildFrontmatter(fields, body.replace(/\s+$/, "") + "\n"));
const account = accountAddress();
const fields = [["Subject", subject], ["In-Reply-To", replyTo]];
// A signed Reply-To lets a first-time recipient name and answer us (§5.5
// allows unknown keys); "anonymous" omits it.
if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]);
const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v),
body.replace(/\s+$/, "") + "\n"));
const envelope = seal(me, recipient, bodyBytes);
const opened = await connect(addr, { requirePin: false });
try {