feat: name an unknown sender from the reader to enable replies

This commit is contained in:
randogoth 2026-09-26 16:29:38 +03:00
parent 025963371f
commit fd2d9298f8
2 changed files with 72 additions and 15 deletions

View file

@ -174,25 +174,79 @@ async function openMessage(id) {
: `from: ${opened.from}\nkey: ${b32encode(opened.sender)}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}`;
view.querySelector(".fields").textContent = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
view.querySelector("pre").textContent = opened.body;
if (folder === "inbox" && !opened.knownSender) {
const note = document.createElement("p");
note.className = "placeholder";
note.textContent = "The wire carries no sender name, only this signed key. " +
"Import the sender's smol:// address in settings to name future mail.";
view.append(note);
}
if (folder === "inbox") {
const reply = document.createElement("button");
reply.textContent = "reply";
reply.onclick = () => openCompose({
to: store.addressForKey(opened.sender) ?? "",
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
view.append(reply);
if (opened.knownSender) {
view.append(replyButton(opened, row));
} 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.
const note = document.createElement("p");
note.className = "placeholder";
note.textContent = "The wire carries no sender name, only this signed key. " +
"To reply, give the sender's address — it is checked against this key:";
const form = document.createElement("form");
form.className = "name-sender";
const input = document.createElement("input");
input.placeholder = "alice@example.org or smol://…";
input.spellcheck = false;
const name = document.createElement("button");
name.textContent = "name sender & reply";
form.append(input, name);
form.onsubmit = async (event) => {
event.preventDefault();
name.disabled = true;
try {
await nameSender(input.value, opened.sender);
openMessage(row.id); // the sender is named now; the reply button appears
openCompose({
to: store.addressForKey(opened.sender),
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
} catch (err) {
status(err.message, true);
} finally {
name.disabled = false;
}
};
view.append(note, form);
}
}
}
function replyButton(opened, row) {
const reply = document.createElement("button");
reply.textContent = "reply";
reply.onclick = () => openCompose({
to: store.addressForKey(opened.sender) ?? "",
subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
replyTo: row.id,
});
return reply;
}
// Bind a user-supplied address to the key that signed a message. A smol://
// address carries its own key (verified); a short address is resolved and the
// result kept on first use. Anything that binds a different key is refused.
async function nameSender(text, senderKey) {
const addr = parseAddress(text.trim());
let key = addr.identity, verified = true;
if (!key) {
const opened = await connect(addr, { requirePin: false });
try {
({ identity: key } = await resolveOp(opened.session, addr.user));
} finally {
opened.session.stream.close();
}
verified = false; // trust on first use, as with any RESOLVE
}
if (!timingSafeEqual(key, senderKey))
throw new SmolError("that address carries a different key than this message's sender");
store.saveContact(addr.short, senderKey, verified);
status(`${addr.short} saved for this sender${verified ? " (verified key)" : ""}`);
renderFolder();
}
// --- compose and send -----------------------------------------------------------
const compose = $("compose");