fix: reply to a message from another connected account goes to that account

With several accounts connected, the composer treated every account's
identities as "own" when deciding whether a message was self-sent (#703).
A message that account B sent to account A therefore looked self-sent
when replying from A, so the reply was addressed to the original To -
account A itself - and From was switched to B.

Self-sent now means "sent by the account holding the original":
buildReplyRecipients takes an optional selfSentEmails list (defaulting to
ownEmails, so single-account behaviour is unchanged), and the composer
passes the source account's identities there and to the From selection.
Reply-all still drops every connected account's addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Viktor Shulika MacBook Air M5
2026-10-17 20:31:25 +02:00
co-authored by Claude Opus 5.5
parent 107d731af7
commit 76dbaf532a
3 changed files with 58 additions and 8 deletions
+15 -7
View File
@@ -538,17 +538,25 @@ export function EmailComposer({
identities.map(i => i.email),
[composerAccount?.email, composerAccount?.username, replyAccount?.email, replyAccount?.username],
);
const ownIdentityEmailsKey = ownIdentityEmails.map((e) => e.toLowerCase()).join(',');
// "Self-sent" means sent by the account holding the original. A message
// another connected account sent to this one was received here, and its
// reply must go back to that sender, not to this account's own address.
const sourceAccount = replyAccount ?? composerAccount;
const sourceAccountEmails = ownReplyAddresses(
sourceAccountIdentities.map(i => i.email),
[sourceAccount?.email, sourceAccount?.username],
);
const ownIdentityEmailsKey = [...ownIdentityEmails, '|', ...sourceAccountEmails].map((e) => e.toLowerCase()).join(',');
// Initialize with reply/forward data if provided
const getInitialTo = (): Recipient[] => {
if (mode !== 'reply' && mode !== 'replyAll') return [];
return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient);
return buildReplyRecipients(replyTo, mode, ownIdentityEmails, sourceAccountEmails).to.map(toRecipient);
};
const getInitialCc = (): Recipient[] => {
if (mode !== 'replyAll') return [];
return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient);
return buildReplyRecipients(replyTo, mode, ownIdentityEmails, sourceAccountEmails).cc.map(toRecipient);
};
const getInitialSubject = () => {
@@ -1194,10 +1202,10 @@ export function EmailComposer({
// identity that sent it. Resolving from the recipients here would pick the
// *other* party's address - and on a catch-all domain it would even set a
// From override to their address. An identity on the message's own account
// wins when two accounts share the address.
if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) {
const senderIdentityId = findDraftIdentityId(sourceAccountIdentities, replyTo?.from?.[0])
?? findDraftIdentityId(identities, replyTo?.from?.[0]);
// wins when two accounts share the address. Only the message's own account
// counts: mail another connected account sent here is a received message.
if (isSelfSent({ from: replyTo?.from }, sourceAccountIdentities.map(i => i.email).filter(Boolean))) {
const senderIdentityId = findDraftIdentityId(sourceAccountIdentities, replyTo?.from?.[0]);
if (senderIdentityId) {
setSelectedIdentityId(senderIdentityId);
return;
+34
View File
@@ -124,6 +124,40 @@ describe('buildReplyRecipients', () => {
});
});
describe('message from another connected account', () => {
// Two accounts connected: book@ sent this to mail@, and we reply from mail@.
const ALL = ['mail@hotel.example', 'book@hotel.example'];
const THIS_ACCOUNT = ['mail@hotel.example'];
const fromOtherAccount = {
from: [{ email: 'book@hotel.example', name: 'Booking' }],
to: [{ email: 'mail@hotel.example' }, { email: 'guest@other.com' }],
cc: [{ email: 'book@hotel.example' }],
};
it('replies to the other account, not back to ourselves', () => {
const { to, cc } = buildReplyRecipients(fromOtherAccount, 'reply', ALL, THIS_ACCOUNT);
expect(emails(to)).toEqual(['book@hotel.example']);
expect(cc).toEqual([]);
});
it('reply-all still drops every connected account from the other recipients', () => {
const { to, cc } = buildReplyRecipients(fromOtherAccount, 'replyAll', ALL, THIS_ACCOUNT);
expect(emails(to)).toEqual(['book@hotel.example', 'guest@other.com']);
expect(cc).toEqual([]);
});
it('still continues the thread for a message this account sent', () => {
const ownSent = { ...fromOtherAccount, from: [{ email: 'mail@hotel.example' }], to: [{ email: 'guest@other.com' }] };
const { to } = buildReplyRecipients(ownSent, 'reply', ALL, THIS_ACCOUNT);
expect(emails(to)).toEqual(['guest@other.com']);
});
it('without selfSentEmails every connected account counts as self (single-account behaviour)', () => {
const { to } = buildReplyRecipients(fromOtherAccount, 'reply', ALL);
expect(emails(to)).toEqual(['mail@hotel.example', 'guest@other.com']);
});
});
it('returns nothing without a source message', () => {
expect(buildReplyRecipients(undefined, 'replyAll', OWN)).toEqual({ to: [], cc: [] });
});
+9 -1
View File
@@ -95,11 +95,19 @@ export function isSelfSent(source: ReplySource | undefined, ownEmails: string[])
*
* A self-sent message with no visible recipients (Bcc-only) has nothing to
* continue to, so it falls back to the normal behaviour.
*
* `selfSentEmails` decides what counts as "self-sent" and defaults to
* `ownEmails`. With several connected accounts, `ownEmails` holds every
* account's addresses (so reply-all still drops all of them), but a message
* one of those accounts sent to another is a received message for the
* account it landed in: pass only that account's addresses here, or the
* reply goes back to the original To - the account itself.
*/
export function buildReplyRecipients(
source: ReplySource | undefined,
mode: 'reply' | 'replyAll',
ownEmails: string[],
selfSentEmails: string[] = ownEmails,
): ReplyRecipientsResult {
if (!source) return { to: [], cc: [] };
@@ -108,7 +116,7 @@ export function buildReplyRecipients(
const replyToList = withEmail(source.replyToAddresses);
const externalReplyTo = replyToList.filter((r) => !isOwnAddress(r.email, ownEmails));
if (!externalReplyTo.length && isSelfSent(source, ownEmails)) {
if (!externalReplyTo.length && isSelfSent(source, selfSentEmails)) {
const originalTo = withEmail(source.to);
if (originalTo.length > 0) {
return {