feat: carry a signed Reply-To address for first contact
This commit is contained in:
parent
fd2d9298f8
commit
aab98ff598
6 changed files with 69 additions and 6 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue