diff --git a/web/js/app.js b/web/js/app.js index 27c22ca..88a5cc1 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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"); diff --git a/web/style.css b/web/style.css index 809d25f..58756de 100644 --- a/web/style.css +++ b/web/style.css @@ -102,6 +102,9 @@ input, textarea { .verified { color: var(--accent); font-size: 12px; } .warn { color: var(--warn); } +.name-sender { display: flex; gap: 8px; margin: 12px 0 0; } +.name-sender input { flex: 1; } + dialog { border: 1px solid var(--line); border-radius: 8px;