diff --git a/src/ReplyBuilder.js b/src/ReplyBuilder.js index e5f49fb7f5..b0e9a4e14b 100644 --- a/src/ReplyBuilder.js +++ b/src/ReplyBuilder.js @@ -55,22 +55,67 @@ const RecipientType = Object.seal({ Cc: 2, }) -export function buildRecipients(envelope, ownAddress, replyTo) { +export function isSameEmailAddress(left, right) { + const normalizedLeft = left?.trim().toLowerCase() + return !!normalizedLeft && normalizedLeft === right?.trim().toLowerCase() +} + +export function selectReplyIdentity(envelope, accounts, preferSender = false) { + const identities = accounts + .filter((account) => !account.isUnified && account.connectionStatus !== false) + .flatMap((account) => [ + { + accountId: account.id, + aliasId: null, + email: account.emailAddress, + label: account.name, + }, + ...(account.aliases ?? []).map((alias) => ({ + accountId: account.id, + aliasId: alias.id, + email: alias.alias, + label: alias.name, + })), + ]) + const identityGroups = [ + identities.filter((identity) => identity.accountId === envelope.accountId), + identities.filter((identity) => identity.accountId !== envelope.accountId), + ] + const addressLists = preferSender + ? [envelope.from, envelope.to, envelope.cc] + : [envelope.to, envelope.cc, envelope.from] + + for (const addresses of addressLists) { + for (const candidates of identityGroups) { + for (const address of addresses ?? []) { + const identity = candidates.find((candidate) => isSameEmailAddress(candidate.email, address.email)) + if (identity) { + return identity + } + } + } + } +} + +export function buildRecipients(envelope, ownAddress, replyTo, followUp = false) { let recipientType = RecipientType.None - const isOwnAddress = (a) => a.email === ownAddress.email + const isOwnAddress = (a) => isSameEmailAddress(a.email, ownAddress.email) const isNotOwnAddress = negate(isOwnAddress) + const originalTo = envelope.to ?? [] + const originalCc = envelope.cc ?? [] // The Reply-To header has higher precedence than the From header. // This reuses Horde's handling of the reply_to field directly. - const from = replyTo?.length > 0 ? replyTo : envelope.from + const from = !followUp && replyTo?.length > 0 ? replyTo : (envelope.from ?? []) + const senders = followUp ? [] : from.filter(isNotOwnAddress) // Locate why we received this envelope // Can be in 'to', 'cc' or unknown - let replyingAddress = envelope.to.find(isOwnAddress) + let replyingAddress = originalTo.find(isOwnAddress) if (replyingAddress !== undefined) { recipientType = RecipientType.To } else { - replyingAddress = envelope.cc.find(isOwnAddress) + replyingAddress = originalCc.find(isOwnAddress) if (replyingAddress !== undefined) { recipientType = RecipientType.Cc } else { @@ -82,29 +127,27 @@ export function buildRecipients(envelope, ownAddress, replyTo) { let cc if (recipientType === RecipientType.To) { // Send to everyone except yourself, plus the original sender if not ourself - to = envelope.to.filter(isNotOwnAddress) - to = to.concat(from.filter(isNotOwnAddress)) + to = originalTo.filter(isNotOwnAddress) + to = to.concat(senders) - // CC remains the same - cc = envelope.cc + cc = originalCc.filter(isNotOwnAddress) } else if (recipientType === RecipientType.Cc) { // Send to the same people, plus the sender if not ourself - to = envelope.to.concat(from.filter(isNotOwnAddress)) + to = originalTo.concat(senders) // All CC values are being kept except the replying address - cc = envelope.cc.filter(isNotOwnAddress) + cc = originalCc.filter(isNotOwnAddress) } else { // Send to the same recipient and the sender (if not ourself) -> answer all - to = envelope.to - to = to.concat(from.filter(isNotOwnAddress)) + to = originalTo + to = to.concat(senders) - // Keep CC values - cc = envelope.cc + cc = originalCc.filter(isNotOwnAddress) } // edge case: pure self-sent email - if (to.length === 0) { - to = from + if (to.length === 0 && cc.length === 0) { + to = followUp ? (originalTo.length > 0 ? originalTo : originalCc) : from } return { diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 09997c5e37..96987ade58 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -1478,9 +1478,7 @@ export default { this.changeSignature = true this.$emit('update:fromAccount', alias.id) - if (alias.aliasId) { - this.$emit('update:fromAlias', alias.aliasId) - } + this.$emit('update:fromAlias', alias.aliasId) if (this.wantsSmimeSign || this.wantsSmimeEncrypt) { if (!this.smimeCertificateForAlias(alias)) { diff --git a/src/components/ThreadEnvelope.vue b/src/components/ThreadEnvelope.vue index 90fcb1eb22..ec635c5f55 100644 --- a/src/components/ThreadEnvelope.vue +++ b/src/components/ThreadEnvelope.vue @@ -629,10 +629,8 @@ export default { accountId: this.envelope.accountId, }) } - const recipients = buildReplyRecipients(this.envelope, { - label: this.account.name, - email: this.account.emailAddress, - }) + const { identity, followUp } = this.mainStore.getReplyContext(this.envelope, { followUp: this.showFollowUpHeader }) + const recipients = buildReplyRecipients(this.envelope, identity, undefined, followUp) return recipients.to.concat(recipients.cc).length > 1 }, diff --git a/src/store/mainStore/actions.js b/src/store/mainStore/actions.js index ff69b0ada6..96fabbcc8f 100644 --- a/src/store/mainStore/actions.js +++ b/src/store/mainStore/actions.js @@ -41,6 +41,8 @@ import { buildForwardSubject, buildRecipients as buildReplyRecipients, buildReplySubject, + isSameEmailAddress, + selectReplyIdentity, } from '../../ReplyBuilder.js' import { create as createAccount, @@ -461,6 +463,22 @@ export default function mainStoreActions() { }) }) }, + getReplyContext(envelope, { followUp = false } = {}) { + const account = this.getAccount(envelope.accountId) + const fallback = { + accountId: account.id, + aliasId: null, + email: account.emailAddress, + label: account.name, + } + const mailbox = this.getMailbox(envelope.mailboxId) + const preferSender = followUp || mailbox?.specialRole === 'sent' + || (envelope.mailboxId !== undefined && envelope.mailboxId === account.sentMailboxId) + const identity = selectReplyIdentity(envelope, this.getAccounts, preferSender) ?? fallback + const isOwnMessage = envelope.from?.length > 0 + && envelope.from.every((address) => isSameEmailAddress(address.email, identity.email)) + return { identity, followUp: preferSender || isOwnMessage } + }, async startComposerSession({ type = 'imap', data = {}, @@ -520,9 +538,16 @@ export default function mainStoreActions() { } } + let replyIdentity + let isFollowUp = false + if (reply.mode === 'reply' || reply.mode === 'replyAll') { + const context = this.getReplyContext(reply.data, { followUp: reply.followUp }) + replyIdentity = context.identity + isFollowUp = context.followUp + } + if (reply.mode === 'reply') { logger.debug('Show simple reply composer', { reply }) - const account = this.getAccount(reply.data.accountId) // For mailing list emails, "Reply to sender" must use From because // Reply-To points to the list address, not the original sender. // For regular emails, honor Reply-To if the sender set one. @@ -530,18 +555,20 @@ export default function mainStoreActions() { let to = (!isMailingList && original.replyTo?.length > 0) ? original.replyTo : reply.data.from + let cc = [] // Replying to a message we sent ourselves: follow up with the // original recipient(s) instead of addressing ourselves. - const isOwnMessage = to.length > 0 - && to.every((addr) => addr.email === account.emailAddress) - if (reply.followUp || isOwnMessage) { - to = reply.data.to + if (isFollowUp) { + const recipients = buildReplyRecipients(reply.data, replyIdentity, undefined, true) + to = recipients.to + cc = recipients.cc } this.startComposerSessionMutation({ data: { - accountId: reply.data.accountId, + accountId: replyIdentity.accountId, + aliasId: replyIdentity.aliasId, to, - cc: [], + cc, subject: buildReplySubject(reply.data.subject), isHtml: data.isHtml, bodyHtml: data.bodyHtml, @@ -554,14 +581,16 @@ export default function mainStoreActions() { return } else if (reply.mode === 'replyAll') { logger.debug('Show reply all reply composer', { reply }) - const account = this.getAccount(reply.data.accountId) - const recipients = buildReplyRecipients(reply.data, { - email: account.emailAddress, - label: account.name, - }, original.replyTo) + const recipients = buildReplyRecipients( + reply.data, + replyIdentity, + original.replyTo, + isFollowUp, + ) this.startComposerSessionMutation({ data: { - accountId: reply.data.accountId, + accountId: replyIdentity.accountId, + aliasId: replyIdentity.aliasId, to: recipients.to, cc: recipients.cc, subject: buildReplySubject(reply.data.subject), diff --git a/src/tests/unit/ReplyBuilder.spec.js b/src/tests/unit/ReplyBuilder.spec.js index 8d6efda274..ce143380b1 100644 --- a/src/tests/unit/ReplyBuilder.spec.js +++ b/src/tests/unit/ReplyBuilder.spec.js @@ -7,6 +7,8 @@ import { buildRecipients, buildReplyBody, buildReplySubject, + isSameEmailAddress, + selectReplyIdentity, } from '../../ReplyBuilder.js' import { html, plain } from '../../util/text.js' @@ -237,6 +239,65 @@ describe('ReplyBuilder', () => { assertSameAddressList(reply.cc, []) }) + it('excludes only the selected identity from both To and Cc regardless of case', () => { + const own = createAddress('work@example.com') + const sender = createAddress('sender@example.com') + const otherIdentity = createAddress('me@example.com') + const message = { + from: [sender], + to: [createAddress(' Work@Example.com '), otherIdentity], + cc: [createAddress('WORK@example.com'), sender], + } + + const reply = buildRecipients(message, own) + + assertSameAddressList(reply.to, [otherIdentity, sender]) + assertSameAddressList(reply.cc, [sender]) + }) + + it('keeps Cc-only follow-ups without adding the sending identity to To', () => { + const own = createAddress('work@example.com') + const recipient = createAddress('recipient@example.com') + + const reply = buildRecipients({ from: [own], to: [], cc: [recipient] }, own) + + assertSameAddressList(reply.to, []) + assertSameAddressList(reply.cc, [recipient]) + }) + + it('handles absent recipient lists', () => { + const own = createAddress('work@example.com') + const sender = createAddress('sender@example.com') + + const reply = buildRecipients({ from: [sender] }, own) + + assertSameAddressList(reply.to, [sender]) + assertSameAddressList(reply.cc, []) + }) + + it('keeps original follow-up recipients without adding From or Reply-To', () => { + const own = createAddress('me@example.com') + const alias = createAddress('work@example.com') + const recipient = createAddress('recipient@example.com') + const otherIdentity = createAddress('other@example.com') + const message = { from: [alias], to: [own, recipient], cc: [own, otherIdentity] } + + const reply = buildRecipients(message, own, [createAddress('reply-elsewhere@example.com')], true) + + assertSameAddressList(reply.to, [recipient]) + assertSameAddressList(reply.cc, [otherIdentity]) + }) + + it.each(['to', 'cc'])('keeps pure self-message follow-ups originally addressed in %s', (field) => { + const own = createAddress('me@example.com') + const message = { from: [own], to: [], cc: [], [field]: [own] } + + const reply = buildRecipients(message, own, undefined, true) + + assertSameAddressList(reply.to, [own]) + assertSameAddressList(reply.cc, []) + }) + it('adds re: to a reply subject', () => { const orig = 'Hello' @@ -253,3 +314,92 @@ describe('ReplyBuilder', () => { expect(replySubject).toEqual('Re: Hello') }) }) + +describe('reply identity selection', () => { + const accounts = [ + { id: 0, isUnified: true, emailAddress: 'unified@example.com' }, + { + id: 1, + emailAddress: 'me@example.com', + name: 'Me', + aliases: [ + { id: 11, alias: 'work@example.com', name: 'Work' }, + { id: 12, alias: 'second@example.com', name: 'Second' }, + { id: 13, alias: 'me@example.com', name: 'Duplicate' }, + ], + }, + { + id: 2, + emailAddress: 'other@example.com', + name: 'Other', + aliases: [ + { id: 21, alias: 'work@example.com', name: 'Other work' }, + { id: 22, alias: 'shared@example.com', name: 'Shared' }, + ], + }, + { id: 3, emailAddress: 'shared@example.com', name: 'Third' }, + { id: 4, emailAddress: 'disabled@example.com', connectionStatus: false }, + ] + const addresses = (...emails) => emails.map((email) => ({ email })) + const envelope = { + accountId: 1, + from: addresses('sender@example.com'), + to: [], + cc: [], + } + + it.each([ + ['primary address before its duplicate alias', { to: addresses('me@example.com') }, false, 1, null], + ['alias in To', { to: addresses('work@example.com') }, false, 1, 11], + ['alias in Cc', { cc: addresses('work@example.com') }, false, 1, 11], + ['another account primary', { to: addresses('other@example.com') }, false, 2, null], + ['another account alias', { to: addresses('shared@example.com') }, false, 2, 22], + ['To before containing account Cc', { to: addresses('shared@example.com'), cc: addresses('me@example.com') }, false, 2, 22], + ['containing account within To', { to: addresses('shared@example.com', 'work@example.com') }, false, 1, 11], + ['header order within containing account', { to: addresses('second@example.com', 'work@example.com') }, false, 1, 12], + ['alias before a different primary in header order', { to: addresses('work@example.com', 'me@example.com') }, false, 1, 11], + ['header order across other accounts', { to: addresses('other@example.com', 'shared@example.com') }, false, 2, null], + ['containing account for duplicate addresses', { accountId: 3, to: addresses('shared@example.com') }, false, 3, null], + ['configured account order for duplicate addresses', { to: addresses('shared@example.com') }, false, 2, 22], + ['sender for sent messages', { from: addresses('work@example.com'), to: addresses('me@example.com') }, true, 1, 11], + ['recipient for received self-mail', { from: addresses('work@example.com'), to: addresses('me@example.com') }, false, 1, null], + ['sender for archived outgoing messages', { from: addresses('work@example.com'), to: addresses('external@example.com') }, false, 1, 11], + ])('selects %s', (_, overrides, preferSender, accountId, aliasId) => { + const identity = selectReplyIdentity({ ...envelope, ...overrides }, accounts, preferSender) + + expect(identity).toMatchObject({ accountId, aliasId }) + }) + + it('returns the configured spelling and display name without changing its inputs', () => { + const message = { ...envelope, to: addresses(' WORK@Example.com ') } + const originalAccounts = JSON.stringify(accounts) + const originalMessage = JSON.stringify(message) + + const identity = selectReplyIdentity(message, accounts) + + expect(identity).toEqual({ accountId: 1, aliasId: 11, email: 'work@example.com', label: 'Work' }) + expect(JSON.stringify(accounts)).toBe(originalAccounts) + expect(JSON.stringify(message)).toBe(originalMessage) + }) + + it.each([ + ['unknown address', { ...envelope, to: addresses('unknown@example.com') }], + ['disabled account', { ...envelope, to: addresses('disabled@example.com') }], + ['unified account', { ...envelope, to: addresses('unified@example.com') }], + ['Reply-To address', { ...envelope, replyTo: addresses('work@example.com') }], + ['absent lists', { accountId: 1 }], + ])('returns no match for %s', (_, message) => { + expect(selectReplyIdentity(message, accounts)).toBeUndefined() + }) + + it.each([ + [' Work@Example.com ', 'work@example.com', true], + ['work+tag@example.com', 'work@example.com', false], + ['', '', false], + [' ', ' ', false], + [undefined, undefined, false], + ['work@example.com', undefined, false], + ])('compares %s and %s as %s', (left, right, matches) => { + expect(isSameEmailAddress(left, right)).toBe(matches) + }) +}) diff --git a/src/tests/unit/components/Composer.vue.spec.js b/src/tests/unit/components/Composer.vue.spec.js index eee5eb4355..7f58ffecfa 100644 --- a/src/tests/unit/components/Composer.vue.spec.js +++ b/src/tests/unit/components/Composer.vue.spec.js @@ -63,6 +63,84 @@ describe('Composer', () => { } }) + it('initializes the alias identity, signature, certificate, and reply message ID', () => { + const alias = { id: 21, alias: 'work@example.com', name: 'Work', signature: 'Work signature', smimeCertificateId: 31 } + const view = shallowMount(Composer, { + props: { + fromAccount: 456, + fromAlias: alias.id, + isFirstOpen: true, + replyTo: { messageId: 'original-message', from: [{ email: 'sender@example.com' }], dateInt: 0 }, + accounts: [{ + id: 456, + emailAddress: 'other@example.com', + name: 'Other', + editorMode: 'plaintext', + connectionStatus: true, + aliases: [alias], + }], + }, + global: { + mixins: [Nextcloud], + mocks: { $route }, + }, + }) + + const data = view.vm.getMessageData() + + expect(data).toMatchObject({ accountId: 456, aliasId: 21, inReplyToMessageId: 'original-message' }) + expect(view.vm.selectedAlias).toMatchObject({ + emailAddress: alias.alias, + signature: alias.signature, + smimeCertificateId: alias.smimeCertificateId, + }) + }) + + it.each([456, 789])('keeps a manually selected primary address on account %s when reopening', (accountId) => { + const accounts = [456, 789].map((id) => ({ + id, + emailAddress: `account${id}@example.com`, + name: `Account ${id}`, + editorMode: 'plaintext', + connectionStatus: true, + aliases: id === 456 ? [{ id: 21, alias: 'work@example.com', name: 'Work' }] : [], + })) + for (const account of accounts) { + store.addAccountMutation(account) + } + store.startComposerSessionMutation({ data: { accountId: 456, aliasId: 21 } }) + const mountComposer = () => shallowMount(Composer, { + props: { + accounts, + fromAccount: store.composerMessage.data.accountId, + fromAlias: store.composerMessage.data.aliasId, + isFirstOpen: true, + 'onUpdate:fromAccount': (accountId) => store.patchComposerDataMutation({ accountId }), + 'onUpdate:fromAlias': (aliasId) => store.patchComposerDataMutation({ aliasId }), + }, + global: { + mixins: [Nextcloud], + mocks: { $route }, + stubs: { + TextEditor: { + template: '
', + methods: { editorExecute: vi.fn() }, + }, + }, + }, + }) + const view = mountComposer() + const primary = view.vm.aliases.find((identity) => identity.id === accountId && identity.aliasId === null) + + view.vm.onAliasChange(primary) + + expect(store.composerMessage.data).toMatchObject({ accountId, aliasId: null }) + view.unmount() + const reopened = mountComposer() + expect(reopened.vm.getMessageData()).toMatchObject({ accountId, aliasId: null }) + reopened.unmount() + }) + it('does not drop the reply message ID', () => { const view = shallowMount(Composer, { props: { diff --git a/src/tests/unit/components/ThreadEnvelope.vue.spec.js b/src/tests/unit/components/ThreadEnvelope.vue.spec.js index c1b3720d6c..b10f7e33fd 100644 --- a/src/tests/unit/components/ThreadEnvelope.vue.spec.js +++ b/src/tests/unit/components/ThreadEnvelope.vue.spec.js @@ -7,8 +7,11 @@ import { shallowMount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import ThreadEnvelope from '../../../components/ThreadEnvelope.vue' import Nextcloud from '../../../mixins/Nextcloud.js' +import * as MessageService from '../../../service/MessageService.js' import useMainStore from '../../../store/mainStore.js' +vi.mock('../../../service/MessageService.js') + const MAILBOX_ID = 1 const ARCHIVE_MAILBOX_ID = 2 const ACCOUNT_ID = 123 @@ -35,6 +38,68 @@ describe('ThreadEnvelope', () => { threadIndex: 0, }) + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + vi.unstubAllGlobals() + }) + + it.each([ + ['one-to-one alias mail', [{ email: 'work@example.com' }], 'Reply'], + ['group mail', [{ email: 'work@example.com' }, { email: 'other@example.com' }], 'Reply all'], + ])('labels the reply action for %s', (_, to, label) => { + vi.stubGlobal('t', vi.fn((_, message) => message)) + const store = useMainStore() + store.addAccountMutation({ + id: 123, + emailAddress: 'me@example.com', + aliases: [{ id: 21, alias: 'work@example.com', name: 'Work' }], + }) + const envelope = { databaseId: 42, accountId: 123, from: [{ email: 'sender@example.com' }], to, cc: [] } + const context = { mainStore: store, account: store.getAccount(123), envelope, showFollowUpHeader: false } + + const hasMultipleRecipients = ThreadEnvelope.computed.hasMultipleRecipients.call(context) + const replyLabel = ThreadEnvelope.computed.replyButtonLabel.call({ ...context, hasMultipleRecipients }) + + expect(replyLabel).toBe(label) + }) + + it('retains a Cc-only follow-up through the reply action', async () => { + const store = useMainStore() + store.addAccountMutation({ + id: 123, + emailAddress: 'me@example.com', + aliases: [{ id: 21, alias: 'work@example.com', name: 'Work' }], + }) + store.mailboxes[11] = { databaseId: 11, accountId: 123, specialRole: 'sent' } + MessageService.fetchMessage.mockResolvedValue({ body: '', attachments: [] }) + const recipient = { email: 'recipient@example.com' } + const envelope = { + databaseId: 42, + accountId: 123, + mailboxId: 11, + from: [{ email: 'work@example.com' }], + to: [], + cc: [recipient], + subject: 'Test subject', + } + const context = { mainStore: store, account: store.getAccount(123), envelope, showFollowUpHeader: false } + context.hasMultipleRecipients = ThreadEnvelope.computed.hasMultipleRecipients.call(context) + const startComposer = vi.spyOn(store, 'startComposerSession') + + ThreadEnvelope.methods.onReply.call(context) + await startComposer.mock.results[0].value + + expect(context.hasMultipleRecipients).toBe(false) + expect(startComposer.mock.calls[0][0].reply.mode).toBe('reply') + expect(store.composerMessage.data).toMatchObject({ + accountId: 123, + aliasId: 21, + to: [], + cc: [recipient], + }) + }) + it('allows toggling seen flag without ACLs', () => { store.mailboxes[MAILBOX_ID] = { myAcls: undefined } const view = shallowMount(ThreadEnvelope, { diff --git a/src/tests/unit/store/actions.spec.js b/src/tests/unit/store/actions.spec.js index 74bea51ac2..c0ccf86a3e 100644 --- a/src/tests/unit/store/actions.spec.js +++ b/src/tests/unit/store/actions.spec.js @@ -740,6 +740,169 @@ describe('Vuex store actions', () => { }) }) + describe('reply sender identities', () => { + const primary = { email: 'me@example.com', label: 'Me' } + const alias = { email: 'work@example.com', label: 'Work' } + const external = { email: 'sender@example.com', label: 'Sender' } + const other = { email: 'other@example.com', label: 'Other' } + const envelope = { + databaseId: 42, + accountId: 1, + mailboxId: 10, + from: [external], + to: [alias], + cc: [], + subject: 'Test subject', + messageId: 'original-message', + } + + beforeEach(() => { + store.addAccountMutation({ + id: 1, + emailAddress: primary.email, + name: primary.label, + sentMailboxId: 11, + aliases: [{ id: 21, alias: alias.email, name: alias.label }], + }) + store.addAccountMutation({ + id: 2, + emailAddress: other.email, + name: other.label, + aliases: [{ id: 22, alias: 'other-alias@example.com', name: 'Other alias' }], + }) + store.mailboxes[10] = { databaseId: 10, accountId: 1, specialRole: 'inbox' } + store.mailboxes[11] = { databaseId: 11, accountId: 1, specialRole: 'sent' } + MessageService.fetchMessage.mockResolvedValue({ + databaseId: 42, + hasHtmlBody: false, + body: 'Message body', + attachments: [], + }) + }) + + it.each(['reply', 'replyAll'])('initializes %s with the receiving alias', async (mode) => { + await store.startComposerSession({ reply: { mode, data: envelope } }) + + expect(store.composerMessage.data).toMatchObject({ + accountId: 1, + aliasId: 21, + to: [external], + cc: [], + subject: 'Re: Test subject', + bodyPlain: 'Message body', + replyTo: envelope, + }) + }) + + it.each([ + ['reply', [other], null], + ['replyAll', [other], null], + ['reply', [{ email: 'other-alias@example.com' }], 22], + ['replyAll', [{ email: 'other-alias@example.com' }], 22], + ])('switches account for %s to %j', async (mode, to, aliasId) => { + await store.startComposerSession({ reply: { mode, data: { ...envelope, to } } }) + + expect(store.composerMessage.data).toMatchObject({ accountId: 2, aliasId, to: [external], cc: [] }) + }) + + it('uses the alias in Cc and keeps other identities in reply-all recipients', async () => { + await store.startComposerSession({ reply: { + mode: 'replyAll', + data: { ...envelope, to: [external], cc: [alias, other] }, + } }) + + expect(store.composerMessage.data).toMatchObject({ accountId: 1, aliasId: 21, cc: [other] }) + expect(store.composerMessage.data.to).not.toContainEqual(alias) + }) + + it.each([ + ['no match', { ...envelope, to: [external] }], + ['hidden receiving alias', { ...envelope, to: [], cc: [], bcc: [alias] }], + ])('falls back to the containing account primary for %s', (_, message) => { + expect(store.getReplyContext(message).identity).toEqual({ accountId: 1, aliasId: null, ...primary }) + }) + + it.each([ + ['Sent role', { specialRole: 'sent' }], + ['configured Sent mailbox', { specialRole: '' }], + ])('recognizes %s using the message mailbox', (_, mailbox) => { + store.mailboxes[11] = { databaseId: 11, accountId: 1, ...mailbox } + + const { identity } = store.getReplyContext({ ...envelope, mailboxId: 11, from: [alias], to: [primary] }) + + expect(identity).toMatchObject({ accountId: 1, aliasId: 21 }) + }) + + it.each(['reply', 'replyAll'])('preserves the sent alias and original recipients for %s', async (mode) => { + MessageService.fetchMessage.mockResolvedValue({ body: '', replyTo: [{ email: 'reply-elsewhere@example.com' }] }) + + await store.startComposerSession({ reply: { + mode, + data: { ...envelope, mailboxId: 11, from: [alias], to: [other] }, + } }) + + expect(store.composerMessage.data).toMatchObject({ accountId: 1, aliasId: 21, to: [other], cc: [] }) + }) + + it.each([ + ['received copy', 10, false, 1, null, [alias]], + ['Sent copy', 11, false, 1, 21, [primary]], + ['explicit follow-up', 10, true, 1, 21, [primary]], + ])('uses mailbox context for the %s of mail between own identities', async (_, mailboxId, followUp, accountId, aliasId, to) => { + await store.startComposerSession({ reply: { + mode: 'reply', + data: { ...envelope, mailboxId, from: [alias], to: [primary] }, + followUp, + } }) + + expect(store.composerMessage.data).toMatchObject({ accountId, aliasId, to }) + }) + + it('preserves the alias for archived outgoing messages', async () => { + await store.startComposerSession({ reply: { + mode: 'reply', + data: { ...envelope, from: [alias], to: [external] }, + } }) + + expect(store.composerMessage.data).toMatchObject({ accountId: 1, aliasId: 21, to: [external] }) + }) + + it.each(['reply', 'replyAll'])('keeps outgoing recipients for %s when the original alias is no longer configured', async (mode) => { + const previousAlias = { email: 'previous-alias@example.com' } + const cc = [{ email: 'cc-recipient@example.com' }] + MessageService.fetchMessage.mockResolvedValue({ body: '', replyTo: [previousAlias] }) + + await store.startComposerSession({ reply: { + mode, + data: { ...envelope, mailboxId: 11, from: [previousAlias], to: [external], cc }, + } }) + + expect(store.composerMessage.data).toMatchObject({ accountId: 1, aliasId: null, to: [external], cc }) + }) + + it.each([ + ['Sent reply', 'reply', 11, false], + ['Sent reply-all', 'replyAll', 11, false], + ['explicit follow-up', 'reply', 10, true], + ['explicit group follow-up', 'replyAll', 10, true], + ])('retains Cc-only recipients for %s', async (_, mode, mailboxId, followUp) => { + await store.startComposerSession({ reply: { + mode, + data: { ...envelope, mailboxId, from: [alias], to: [], cc: [external] }, + followUp, + } }) + + expect(store.composerMessage.data).toMatchObject({ accountId: 1, aliasId: 21, to: [], cc: [external] }) + }) + + it('does not select a matching alias when forwarding', async () => { + await store.startComposerSession({ reply: { mode: 'forward', data: envelope } }) + + expect(store.composerMessage.data.accountId).toBe(1) + expect(store.composerMessage.data.aliasId).toBeUndefined() + }) + }) + describe('startComposerSession reply-to resolution', () => { const account = { id: 1,