diff --git a/src/lib/__tests__/reply-identity.test.ts b/src/lib/__tests__/reply-identity.test.ts index 284b898f..b828dc1e 100644 --- a/src/lib/__tests__/reply-identity.test.ts +++ b/src/lib/__tests__/reply-identity.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { findReplyIdentityId, findDraftIdentityId, resolveReplyFrom, findComposeIdentityId, resolveReplyIdentity, + resolveComposeAccountEmail, } from '../reply-identity'; import type { Identity } from '../../api/types'; @@ -136,3 +137,32 @@ describe('resolveReplyIdentity', () => { expect(resolveReplyIdentity(identities, { to: [{ email: 'x@other.com' }] }, { ownEmails, catchAll: true })).toBeNull(); }); }); + +describe('resolveComposeAccountEmail', () => { + 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 open', () => { + expect(resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox', 'me@primary.example')).toBe('team@shared.example'); + expect(resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox')).toBe('team@shared.example'); + }); + + it('keeps the fallback for an own folder, a non-address account name or no selection', () => { + expect(resolveComposeAccountEmail(mailboxes, 'a-inbox', 'me@primary.example')).toBe('me@primary.example'); + expect(resolveComposeAccountEmail(mailboxes, 'owner-y:y-inbox', 'me@primary.example')).toBe('me@primary.example'); + expect(resolveComposeAccountEmail(mailboxes, null, 'me@primary.example')).toBe('me@primary.example'); + expect(resolveComposeAccountEmail(mailboxes, 'a-inbox')).toBeUndefined(); + }); + + it('preselects the shared identity end-to-end', () => { + const ids: Identity[] = [ + { id: 'primary', name: 'D R', email: 'me@primary.example', mayDelete: false }, + { id: 'shared', name: 'D R', email: 'team@shared.example', mayDelete: false }, + ]; + expect(findComposeIdentityId(ids, resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox'))).toBe('shared'); + expect(findComposeIdentityId(ids, resolveComposeAccountEmail(mailboxes, 'a-inbox'))).toBeNull(); + }); +}); diff --git a/src/lib/reply-identity.ts b/src/lib/reply-identity.ts index a38deb10..35a1ae29 100644 --- a/src/lib/reply-identity.ts +++ b/src/lib/reply-identity.ts @@ -130,6 +130,33 @@ export function findComposeIdentityId( return baseIdentity?.id ?? null; } +/** + * Address whose identity `findComposeIdentityId` should preselect, based on the + * mailbox currently open. + * + * A shared/group folder has no login of its own - it is reached through the + * user's - so the owner of the open folder is the right default sender there. + * `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 match (the `@` test keeps that explicit) and the caller keeps + * its default identity. + */ +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 diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 660ef546..cf869fdf 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -54,7 +54,9 @@ import { buildQuoteHeader, formatQuoteDate, type QuoteHeaderLabels } from '../li import { isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient, } from '../lib/recipients'; -import { findDraftIdentityId, resolveReplyIdentity } from '../lib/reply-identity'; +import { + findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, +} from '../lib/reply-identity'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; import { hasSignature, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature, @@ -834,12 +836,22 @@ export default function ComposeScreen({ route, navigation }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // A new message started while a shared folder is open sends as that + // mailbox's owner when the user has an identity for it (webmail 129f545a). + // Read once: it is the folder the message was started from. Own folders + // resolve to nothing, so they keep the preferred identity below. + const composeFromAccountEmail = React.useMemo(() => { + const { mailboxes: all, currentMailboxId } = useEmailStore.getState(); + return resolveComposeAccountEmail(all, currentMailboxId); + }, []); + // Choose the identity once we know both the loaded identities and the // compose context: a re-opened draft keeps the identity it was written - // with; a reply or forward sends from the own identity the original was - // delivered to (a reply to our own message, from the one that sent it); - // with auto-select on, a reply to a catch-all alias on an owned domain - // takes that address as a From override; otherwise the preferred ("Use as + // with; a new message in a shared folder uses that folder's identity; a + // reply or forward sends from the own identity the original was delivered + // to (a reply to our own message, from the one that sent it); with + // auto-select on, a reply to a catch-all alias on an owned domain takes + // that address as a From override; otherwise the preferred ("Use as // default") identity, else the one matching the active account. React.useEffect(() => { if (selectedIdentityId || identities.length === 0) return; @@ -856,6 +868,13 @@ export default function ComposeScreen({ route, navigation }: Props) { setSelectedIdentityId(matched ?? defaultIdentity.id); return; } + if (!replyTo) { + const composeId = findComposeIdentityId(identities, composeFromAccountEmail); + if (composeId) { + setSelectedIdentityId(composeId); + return; + } + } if (replyTo) { // The catch-all From rewrite is opt-in and never used on a forward. const resolved = resolveReplyIdentity(identities, replyTo, {