Ask the folder, not just the identity list, whether a message was mine

Replying to a thread whose last message I sent addressed the reply to me:
Reply put my own address in To, and Reply all put me in To with everyone
I had actually written to demoted to Cc. Following up on your own last
message is an ordinary thing to do, and this made it useless.

There was already a guard for exactly this, and the guard was sound. What
it rested on was not. It asked whether an address was in the identity
list, and that question has a wrong answer in more situations than it has
a right one:

- the list is empty until identities load;
- an alias or a shared mailbox is not in it at all;
- it compared lowercased strings with `includes` where the rest of the
  codebase uses `sameAddress`, so an identity address stored with
  whitespace was enough to break it;
- the check ran on the address the reply was about to go to rather than
  on the sender, so a message of mine carrying a Reply-To skipped it
  entirely and my reply went to my own desk;
- and the Reply all branch never filtered my own address out of To, though
  the Reply branch did.

Every one of those failed silently, which is why five of them accumulated.

So the folder is asked first: a message in Sent is mine whatever address
it went out as, and `mailboxIds` is already fetched in LIST_PROPS with
roleId("sent") on the mail store, so this costs no request. The identity
list stays as a second opinion, now compared with `sameAddress`, and the
whole test keys off the sender rather than off the computed recipient.

Two cases remain unanswerable and are commented rather than papered over:
a message from an unlisted alias that is not in Sent either, and any
message at all when identities failed to load and it is not in Sent.
Neither signal exists. Both are far narrower than what was broken.

Reply addressing had no tests at all, which is how a guard this
load-bearing came to be wrong five ways at once. Fifteen now, seven of
which fail against the old code.
This commit is contained in:
2026-09-04 08:10:28 -07:00
parent 4d23cef511
commit 029f079094
2 changed files with 203 additions and 14 deletions
+38 -14
View File
@@ -299,26 +299,50 @@ export const useCompose = create<ComposeState>((set, get) => ({
const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
const ident = defaultIdentity(identities, full);
const ownEmails = identities.map((i) => i.email.toLowerCase());
const isOwn = (a: EmailAddress) => ownEmails.includes(a.email.toLowerCase());
const ownEmails = identities.map((i) => i.email);
/* `sameAddress` rather than a lowercased `includes`, because an identity
address can carry whitespace and a hand-typed one does. */
const isOwn = (a: EmailAddress) => ownEmails.some((e) => sameAddress(e, a.email));
const withoutOwn = (list: EmailAddress[]) => uniqueAddresses(list).filter((a) => !isOwn(a));
const s = settings();
/*
* Was this message mine?
*
* The folder answers it before the addresses do, and has to: the address
* test fails in exactly the cases where the mistake is least visible. A
* message sent from an alias or a shared mailbox that `Identity/get` does
* not list is not recognisably mine, and neither is anything at all if the
* identities have not loaded yet -- and the failure is silent, addressing
* the reply back to me with everyone I actually wrote to moved to Cc.
*
* A message in Sent is mine whatever address it went out as.
*/
const sentId = mail.roleId("sent");
const sentByMe = (Boolean(full.from?.length) && (full.from ?? []).every(isOwn))
|| Boolean(sentId && full.mailboxIds?.[sentId]);
let to: EmailAddress[] = [];
let cc: EmailAddress[] = [];
if (mode === "reply" || mode === "replyAll") {
const replyTo = full.replyTo?.length ? full.replyTo : (full.from ?? []);
to = uniqueAddresses(replyTo);
if (mode === "replyAll") {
const others = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]).filter((a) => !isOwn(a) && !to.some((t) => sameAddress(t.email, a.email)));
cc = others;
// If the message was sent by me, reply to original recipients instead.
if (to.every(isOwn) && full.to?.length) {
to = uniqueAddresses(full.to);
cc = uniqueAddresses(full.cc ?? []).filter((a) => !isOwn(a));
if (sentByMe && (full.to?.length || full.cc?.length)) {
/*
* Replying to something I sent continues the conversation with the
* people I wrote to. Not with myself, and not with my own Reply-To
* either -- that address is where replies *to me* belong, and following
* it here would send my own reply to my own desk.
*/
to = withoutOwn(full.to ?? []);
cc = mode === "replyAll" ? withoutOwn(full.cc ?? []) : [];
// 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 = []; }
if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]);
} else {
to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));
if (mode === "replyAll") {
cc = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]).filter((a) => !isOwn(a) && !to.some((t) => sameAddress(t.email, a.email)));
}
} else if (to.every(isOwn) && full.to?.length) {
to = uniqueAddresses(full.to.filter((a) => !isOwn(a)));
if (!to.length) to = uniqueAddresses(full.to);
}
}