From a971d5a11931473a4d200d188bbda1c75544aede Mon Sep 17 00:00:00 2001 From: Patrick Rotter Date: Thu, 13 Aug 2026 10:42:55 +0200 Subject: [PATCH] fix: preselect the shared folder identity for new messages Composing a new message while a shared/group folder was selected started it as the reaching login's primary address instead of the shared one, even when a matching send-as identity existed. `composeFromAccountEmail` was resolved from `getAccountById(viewingAccountId ?? activeAccountId)`. A delegated mailbox has no separate login and no AccountEntry, and `setViewingAccount` is only ever called with `null`, so selecting a shared folder leaves both ids pointing at the primary account: `findComposeIdentityId` was handed the wrong address and correctly matched the primary identity. Resolve the address from the selected mailbox instead, falling back to the account lookup for own folders. `accountName` is the JMAP `Account.name`, which RFC 8620 describes as "e.g., the email address of the account"; a server that puts a human label there produces no identity match and the composer keeps the primary identity, which is the previous behaviour. Replies are unaffected - `composeFromAccountEmail` is only read when `mode === 'compose'`. Co-Authored-By: Claude Opus 5 (1M context) --- components/mail/mail-app.tsx | 10 +++--- lib/__tests__/reply-identity.test.ts | 53 ++++++++++++++++++++++++++-- lib/reply-identity.ts | 32 +++++++++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 2f00fdcf1..0335e6424 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -62,7 +62,7 @@ import { isFilePreviewable } from "@/lib/file-preview"; import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity"; +import { findDraftIdentityId, resolveComposeAccountEmail, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; @@ -3656,11 +3656,13 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { { expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] })) .toBeNull(); }); -}); \ No newline at end of file +}); + +describe('resolveComposeAccountEmail', () => { + // Selecting a shared/group folder in the "Shared" sidebar does not move the + // active account, so the composer used to preselect the reaching login's + // primary identity and start every new message as the wrong sender. + const mailboxes = [ + { id: 'a-inbox' }, + { id: 'owner-x:x-inbox', isShared: true, accountName: 'team@shared.example' }, + { id: 'owner-y:y-inbox', isShared: true, accountName: 'Support Team' }, + ]; + + it('uses the shared folder owner address when a shared folder is selected', () => { + expect(resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox', 'me@primary.example')) + .toBe('team@shared.example'); + }); + + it('keeps the active account address for an own folder', () => { + expect(resolveComposeAccountEmail(mailboxes, 'a-inbox', 'me@primary.example')) + .toBe('me@primary.example'); + }); + + it('falls back to the active account when the account name is not an address', () => { + // RFC 8620 suggests the address for Account.name, but a server may put a + // human label there; that must not become a From address. + expect(resolveComposeAccountEmail(mailboxes, 'owner-y:y-inbox', 'me@primary.example')) + .toBe('me@primary.example'); + }); + + it('handles an unknown or empty selection', () => { + expect(resolveComposeAccountEmail(mailboxes, 'nope', 'me@primary.example')).toBe('me@primary.example'); + expect(resolveComposeAccountEmail(mailboxes, null, 'me@primary.example')).toBe('me@primary.example'); + expect(resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox', null)).toBe('team@shared.example'); + expect(resolveComposeAccountEmail(mailboxes, 'a-inbox', null)).toBeUndefined(); + }); + + it('preselects the shared identity end-to-end (the reported repro)', () => { + // Shared folder selected -> resolved address -> matching send-as identity. + const identities: Identity[] = [ + { id: 'primary', name: 'D R', email: 'me@primary.example', mayDelete: false }, + { id: 'shared', name: 'D R', email: 'team@shared.example', mayDelete: false }, + ]; + const address = resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox', 'me@primary.example'); + expect(findComposeIdentityId(identities, address)).toBe('shared'); + + // Same call on an own folder keeps the primary identity. + const own = resolveComposeAccountEmail(mailboxes, 'a-inbox', 'me@primary.example'); + expect(findComposeIdentityId(identities, own)).toBe('primary'); + }); +}); diff --git a/lib/reply-identity.ts b/lib/reply-identity.ts index 69b59c77f..1a707d540 100644 --- a/lib/reply-identity.ts +++ b/lib/reply-identity.ts @@ -95,6 +95,38 @@ export function findComposeIdentityId( return baseIdentity?.id ?? null; } +/** + * Address whose identity `findComposeIdentityId` should preselect, based on the + * mailbox currently open. + * + * Selecting a shared/group folder in the "Shared" sidebar section does not move + * the active account - a delegated mailbox has no separate login, it is reached + * through the viewer's - so the composer fell back to the reaching login's + * primary address and started every new message as the wrong sender. The owner + * of the selected folder is the correct default there. + * + * `accountName` is the JMAP `Account.name`, which RFC 8620 describes as "e.g., + * the email address of the account". Servers that put a human label there + * instead simply produce no identity match, and the caller keeps the primary + * identity - exactly the previous behaviour. The `@` test keeps that intent + * explicit rather than relying on the match to fail. + */ +export function resolveComposeAccountEmail( + mailboxes: Array<{ id: string; isShared?: boolean; accountName?: string }>, + selectedMailbox?: string | null, + activeAccountEmail?: string | null, +): string | undefined { + const current = selectedMailbox + ? mailboxes.find((mailbox) => mailbox.id === selectedMailbox) + : undefined; + + if (current?.isShared && current.accountName?.includes('@')) { + return current.accountName; + } + + return activeAccountEmail ?? undefined; +} + /** * Restore the identity a draft was composed with from its saved From. A draft * stores only the From address+name, not an identityId, so when two identities