From 01dc322aebcff0e8075c54f81eb7d171a32fb9e7 Mon Sep 17 00:00:00 2001 From: jcoffey <51408202+jcoffey-dev@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:50:48 -0700 Subject: [PATCH] A reply to a self-addressed message follows its Reply-To (#415) (#416) A website contact form mails the site's own address: From and To are both info@thesite, and the person who filled the form in is in Reply-To. Replying addressed the draft to info@thesite -- the site's own desk -- instead of to them. The reply already knows two shapes. A message somebody sent me is answered to its Reply-To, which is what that header is for. A message *I* sent is answered to the people I wrote to, and deliberately not to my own Reply-To, which is where answers to me belong and would send my reply to myself. A contact form passes the test for the second: every address in From is mine. So it fell down the chain the second shape keeps for a message with nobody obvious to answer -- To without me, then Cc, then, having run out, every address on the message, which here was mine alone. The Reply-To now goes in that chain, one step before the last: when no recipient but me is left and the message names a Reply-To that is not mine either, that address is who it is really from. Keeping it after the Cc is what leaves a message I did send alone -- somebody I actually wrote to still beats my own Reply-To, which is the case the existing guard was built for and its test still holds. No new strings. --- .../store/__tests__/reply-addressing.test.ts | 24 +++++++++++++++++++ web/src/store/compose.ts | 13 ++++++++++ 2 files changed, 37 insertions(+) diff --git a/web/src/store/__tests__/reply-addressing.test.ts b/web/src/store/__tests__/reply-addressing.test.ts index 437aac8..d3ee2bb 100644 --- a/web/src/store/__tests__/reply-addressing.test.ts +++ b/web/src/store/__tests__/reply-addressing.test.ts @@ -153,6 +153,30 @@ describe("a message of mine with nobody obvious to reply to", () => { const d = await draftFor({ ...MINE, to: [ME], cc: [] } as Email, "reply"); expect(addrs(d.to)).toEqual([ME.email]); }); + + it("answers the Reply-To rather than my own desk when nobody else is on it", async () => { + /* + * A contact form: the site mails itself, From and To both its own address, + * and the person who filled the form in is in Reply-To. From alone makes + * this look like mine, and the fallback used to reply to me (#415). + */ + const form = { ...MINE, to: [ME], cc: [], replyTo: [{ name: "Michael", email: "michael@example.com" }] } as Email; + const d = await draftFor(form, "reply"); + expect(addrs(d.to)).toEqual(["michael@example.com"]); + }); + + it("does the same on a reply all, without cc-ing myself", async () => { + const form = { ...MINE, to: [ME], cc: [], replyTo: [{ name: "Michael", email: "michael@example.com" }] } as Email; + const d = await draftFor(form, "replyAll"); + expect(addrs(d.to)).toEqual(["michael@example.com"]); + expect(d.cc).toEqual([]); + }); + + it("still prefers somebody I actually wrote to over my own Reply-To", async () => { + // The Cc is a person; the Reply-To is where answers to me belong. + const d = await draftFor({ ...MINE, to: [ME], replyTo: [{ name: null, email: "desk@example.org" }] } as Email, "reply"); + expect(addrs(d.to)).toEqual([BOB.email]); + }); }); describe("forwarding", () => { diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 660b8a5..04219d7 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -410,6 +410,19 @@ export const useCompose = create((set, get) => ({ // Addressed only to myself, or only in Cc: there is still somebody this // is a reply to, and an empty To is not it. if (!to.length) { to = cc.length ? cc : withoutOwn(full.cc ?? []); cc = []; } + /* + * Nobody but me on the message, and a Reply-To pointing somewhere that + * is not mine: that address is who this is really from. + * + * A contact form is the shape of it -- From and To are both the site's + * own mailbox, and the person who filled the form in is in Reply-To. + * The address test above calls that mine, correctly as far as it goes, + * and the fallback then addressed the reply to my own desk (#415). + * + * After the Cc, not before it: a message I really did send carries my + * own Reply-To, and somebody I actually wrote to beats it. + */ + if (!to.length) to = withoutOwn(full.replyTo ?? []); if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]); } else { to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));