From 59bc7fd64cdfe00ac72dc01b377acbfb17aedf9d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:02:39 +0200 Subject: [PATCH] fix: reply to own thread message addresses original recipients #703 --- app/(main)/[locale]/page.tsx | 40 ++- .../email/__tests__/reply-addressing.test.tsx | 228 ++++++++++++++++++ components/email/email-composer.tsx | 42 ++-- lib/__tests__/reply-recipients.test.ts | 114 +++++++++ lib/reply-recipients.ts | 108 +++++++++ 5 files changed, 501 insertions(+), 31 deletions(-) create mode 100644 components/email/__tests__/reply-addressing.test.tsx create mode 100644 lib/__tests__/reply-recipients.test.ts create mode 100644 lib/reply-recipients.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 7345bb80..f7230321 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -61,7 +61,8 @@ 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 } from "@/lib/reply-identity"; +import { findDraftIdentityId, 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"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -2400,8 +2401,20 @@ export default function Home() { const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; - const sender = selectedEmail.from?.[0]; - if (!sender?.email) { + // Quick reply follows the same addressing rules as the composer: Reply-To + // over From, and for our own messages in a thread the original recipients + // instead of ourselves (#703). + const ownIdentityEmails = identities.map(i => i.email).filter(Boolean); + const replySource = { + from: selectedEmail.from, + replyToAddresses: selectedEmail.replyTo, + to: selectedEmail.to, + cc: selectedEmail.cc, + }; + const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to + .map(r => r.email) + .filter((email): email is string => Boolean(email)); + if (recipients.length === 0) { throw new Error("No sender email found"); } @@ -2410,14 +2423,21 @@ export default function Home() { // Decide the sending identity and (for domain-catch-all) an optional // header From override that matches the address the message was sent to. + // Our own message keeps the identity it was sent from - the recipients are + // the other party, so resolving from them would send as their address. // When the setting is off, fall through to primary-identity behavior. - const resolved = autoSelectReplyIdentity - ? resolveReplyFrom(identities, { - to: selectedEmail.to, - cc: selectedEmail.cc, - bcc: selectedEmail.bcc, - }) + const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails) + ? findDraftIdentityId(identities, selectedEmail.from?.[0]) : null; + const resolved: ReplyFromResolution | null = !autoSelectReplyIdentity + ? null + : selfSentIdentityId + ? { identityId: selfSentIdentityId } + : resolveReplyFrom(identities, { + to: selectedEmail.to, + cc: selectedEmail.cc, + bcc: selectedEmail.bcc, + }); const sendingIdentity = resolved ? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity) : primaryIdentity; @@ -2464,7 +2484,7 @@ export default function Home() { // Send reply with just the body text const result = await sendEmail( client, - [sender.email], + recipients, buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')), finalBody, undefined, diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx new file mode 100644 index 00000000..f009b310 --- /dev/null +++ b/components/email/__tests__/reply-addressing.test.tsx @@ -0,0 +1,228 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ───────────────── + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ ref: { current: null } }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [ + { id: 'id-me', email: 'me@example.com', name: 'Me' }, + { id: 'id-info', email: 'info@example.com', name: 'Info' }, + ], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onSend: { call: async () => [] }, + beforeSend: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + }, + contactHooks: { + search: { call: async () => [] }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: () => {} })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const RECEIVED = { + from: [{ email: 'bob@other.com', name: 'Bob' }], + to: [{ email: 'me@example.com', name: 'Me' }, { email: 'carol@other.com', name: 'Carol' }], + cc: [{ email: 'dave@other.com', name: 'Dave' }], + subject: 'Hello', +}; + +/** The same conversation, but the message opened is the one we sent back. */ +const SELF_SENT = { + from: [{ email: 'me@example.com', name: 'Me' }], + to: [{ email: 'bob@other.com', name: 'Bob' }], + cc: [{ email: 'carol@other.com', name: 'Carol' }], + subject: 'Re: Hello', +}; + +/** Chip labels currently shown in a recipient row, in order. Chips are the + * draggable spans inside the row; next-intl is mocked to return the key, so + * the Cc row is found via its "cc_label" caption. */ +const chipsIn = (row: HTMLElement) => + Array.from(row.querySelectorAll('[draggable]')).map((el) => el.textContent?.trim()); + +const toChips = () => chipsIn(screen.getByTestId('composer-to')); +const ccChips = () => chipsIn(screen.getByText('cc_label').parentElement as HTMLElement); + +const identitySelect = () => screen.getByTestId('composer-from') as HTMLSelectElement; + +describe('composer reply addressing', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('addresses a reply to the sender of a received message', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + }); + + it('reply-all keeps the other recipients but not our own address', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)', 'Carol (carol@other.com)']); + expect(ccChips()).toEqual(['Dave (dave@other.com)']); + }); + + // #703: replying to our own message inside a thread used to address the + // reply back to ourselves instead of continuing the conversation. + it('addresses a reply to our own message to the original recipient', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + }); + + it('reply-all on our own message restores the original To and Cc', () => { + render(); + expect(toChips()).toEqual(['Bob (bob@other.com)']); + expect(ccChips()).toEqual(['Carol (carol@other.com)']); + }); + + it('sends the reply to our own message from the identity that sent it', () => { + render(); + expect(identitySelect().value).toBe('id-info'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index f5abe5d5..0479f183 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -36,7 +36,8 @@ import { TemplatePicker } from "@/components/templates/template-picker"; import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; -import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; +import { findComposeIdentityId, findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; +import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { rewriteCidImagesForEditor, @@ -322,31 +323,17 @@ export function EmailComposer({ const toRecipient = (r: { name?: string; email?: string }): Recipient => ({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" }); + const ownIdentityEmails = identities.map(i => i.email).filter((e): e is string => Boolean(e)); + // Initialize with reply/forward data if provided const getInitialTo = (): Recipient[] => { - if (!replyTo) return []; - // RFC 5322: use Reply-To header if present, otherwise fall back to From - const replyTarget = replyTo.replyToAddresses?.length - ? replyTo.replyToAddresses.filter(r => r.email).map(toRecipient) - : (replyTo.from?.[0]?.email ? [toRecipient(replyTo.from[0])] : []); - if (mode === 'reply') { - return replyTarget; - } else if (mode === 'replyAll') { - const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean)); - const originalTo = (replyTo.to ?? []) - .filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase())) - .map(toRecipient); - return [...replyTarget, ...originalTo]; - } - return []; + if (mode !== 'reply' && mode !== 'replyAll') return []; + return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient); }; const getInitialCc = (): Recipient[] => { - if (!replyTo || mode !== 'replyAll') return []; - const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean)); - return (replyTo.cc ?? []) - .filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase())) - .map(toRecipient); + if (mode !== 'replyAll') return []; + return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient); }; const getInitialSubject = () => { @@ -716,6 +703,18 @@ export function EmailComposer({ if (mode !== 'reply' && mode !== 'replyAll') return; + // Replying to our own message in a thread (#703): keep sending as the + // 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. + if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) { + const senderIdentityId = findDraftIdentityId(identities, replyTo?.from?.[0]); + if (senderIdentityId) { + setSelectedIdentityId(senderIdentityId); + return; + } + } + const resolved = resolveReplyFrom(identities, { to: replyTo?.to, cc: replyTo?.cc, @@ -755,6 +754,7 @@ export function EmailComposer({ replyTo?.accountId, replyTo?.bcc, replyTo?.cc, + replyTo?.from, replyTo?.to, selectedIdentityId, ]); diff --git a/lib/__tests__/reply-recipients.test.ts b/lib/__tests__/reply-recipients.test.ts new file mode 100644 index 00000000..2a50ed49 --- /dev/null +++ b/lib/__tests__/reply-recipients.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients'; + +const OWN = ['me@example.com', 'info@example.com']; + +const emails = (list: { email?: string }[]) => list.map((r) => r.email); + +describe('buildReplyRecipients', () => { + describe('received message', () => { + const received = { + from: [{ email: 'bob@other.com', name: 'Bob' }], + to: [{ email: 'me@example.com' }, { email: 'carol@other.com' }], + cc: [{ email: 'dave@other.com' }], + }; + + it('replies to the sender', () => { + const { to, cc } = buildReplyRecipients(received, 'reply', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(cc).toEqual([]); + }); + + it('prefers the Reply-To header over From', () => { + const { to } = buildReplyRecipients( + { ...received, replyToAddresses: [{ email: 'list@other.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['list@other.com']); + }); + + it('reply-all keeps the other recipients and drops our own address', () => { + const { to, cc } = buildReplyRecipients(received, 'replyAll', OWN); + expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']); + expect(emails(cc)).toEqual(['dave@other.com']); + }); + + it('reply-all drops our own address even with +tag sub-addressing', () => { + const { to } = buildReplyRecipients( + { ...received, to: [{ email: 'me+newsletter@example.com' }, { email: 'carol@other.com' }] }, + 'replyAll', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']); + }); + }); + + describe('self-sent message (#703)', () => { + const sent = { + from: [{ email: 'me@example.com', name: 'Me' }], + to: [{ email: 'bob@other.com', name: 'Bob' }], + cc: [{ email: 'carol@other.com' }], + }; + + it('replies to the original recipient, not to ourselves', () => { + const { to, cc } = buildReplyRecipients(sent, 'reply', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(cc).toEqual([]); + }); + + it('reply-all restores the original To and Cc', () => { + const { to, cc } = buildReplyRecipients(sent, 'replyAll', OWN); + expect(emails(to)).toEqual(['bob@other.com']); + expect(emails(cc)).toEqual(['carol@other.com']); + }); + + it('recognises the sending identity through +tag sub-addressing', () => { + const { to } = buildReplyRecipients( + { ...sent, from: [{ email: 'me+project@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com']); + }); + + it('ignores our own Reply-To header so the reply leaves our mailbox', () => { + const { to } = buildReplyRecipients( + { ...sent, replyToAddresses: [{ email: 'info@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['bob@other.com']); + }); + + it('keeps a self-addressed recipient we chose ourselves', () => { + const { to } = buildReplyRecipients( + { ...sent, to: [{ email: 'info@example.com' }] }, + 'reply', + OWN, + ); + expect(emails(to)).toEqual(['info@example.com']); + }); + + it('falls back to the sender when there is no visible recipient (Bcc-only)', () => { + const { to } = buildReplyRecipients({ ...sent, to: [], cc: [] }, 'reply', OWN); + expect(emails(to)).toEqual(['me@example.com']); + }); + + it('keeps the display names of the original recipients', () => { + const { to } = buildReplyRecipients(sent, 'reply', OWN); + expect(to[0]).toEqual({ email: 'bob@other.com', name: 'Bob' }); + }); + }); + + it('returns nothing without a source message', () => { + expect(buildReplyRecipients(undefined, 'replyAll', OWN)).toEqual({ to: [], cc: [] }); + }); + + it('treats a message as foreign when no identity matches', () => { + expect(isSelfSent({ from: [{ email: 'bob@other.com' }] }, OWN)).toBe(false); + expect(isSelfSent({ from: [{ email: 'ME@Example.com ' }] }, OWN)).toBe(true); + expect(isSelfSent({ from: [] }, OWN)).toBe(false); + expect(isSelfSent(undefined, OWN)).toBe(false); + }); +}); diff --git a/lib/reply-recipients.ts b/lib/reply-recipients.ts new file mode 100644 index 00000000..46d3d560 --- /dev/null +++ b/lib/reply-recipients.ts @@ -0,0 +1,108 @@ +export interface ReplyAddress { + email?: string; + name?: string; +} + +export interface ReplySource { + from?: ReplyAddress[]; + /** Addresses from the original message's Reply-To header. */ + replyToAddresses?: ReplyAddress[]; + to?: ReplyAddress[]; + cc?: ReplyAddress[]; +} + +export interface ReplyRecipientsResult { + to: ReplyAddress[]; + cc: ReplyAddress[]; +} + +function normalize(email: string): string { + return email.trim().toLowerCase(); +} + +function normalizeBase(email: string): string { + const normalized = normalize(email); + const at = normalized.indexOf('@'); + if (at <= 0) return normalized; + + const local = normalized.slice(0, at); + const domain = normalized.slice(at + 1); + const plus = local.indexOf('+'); + + return `${plus >= 0 ? local.slice(0, plus) : local}@${domain}`; +} + +/** + * Does `email` belong to the user? Matches exactly first, then with `+tag` + * sub-addressing stripped (info+news@ is still info@). + */ +function isOwnAddress(email: string | undefined, ownEmails: string[]): boolean { + if (!email?.trim()) return false; + const exact = normalize(email); + if (ownEmails.some((own) => normalize(own) === exact)) return true; + const base = normalizeBase(email); + return ownEmails.some((own) => normalizeBase(own) === base); +} + +/** + * Is this a message the user themself sent? True when the From address is one + * of their own identities - the case that shows up when browsing a thread and + * replying to your own last message. + */ +export function isSelfSent(source: ReplySource | undefined, ownEmails: string[]): boolean { + return isOwnAddress(source?.from?.[0]?.email, ownEmails); +} + +/** + * Work out the To/Cc a reply should open with. + * + * Normal case: reply goes to the Reply-To header if the original carried one, + * else to From (RFC 5322). Reply-all adds the other original recipients, + * minus the user's own addresses. + * + * Self-sent case (#703): replying to your own message inside a thread must + * continue the conversation, not mail yourself. Gmail and Thunderbird address + * the reply to the message's original recipients instead, so that's what we do + * - the original To for reply, plus the original Cc for reply-all. Those + * addresses were the user's own choice, so they're kept verbatim (no self- + * filtering) and the Reply-To header is ignored, since answering your own + * Reply-To would land the mail back in your inbox again. + * + * A self-sent message with no visible recipients (Bcc-only) has nothing to + * continue to, so it falls back to the normal behaviour. + */ +export function buildReplyRecipients( + source: ReplySource | undefined, + mode: 'reply' | 'replyAll', + ownEmails: string[], +): ReplyRecipientsResult { + if (!source) return { to: [], cc: [] }; + + const withEmail = (list: ReplyAddress[] | undefined) => (list ?? []).filter((r) => Boolean(r.email)); + + if (isSelfSent(source, ownEmails)) { + const originalTo = withEmail(source.to); + if (originalTo.length > 0) { + return { + to: originalTo, + cc: mode === 'replyAll' ? withEmail(source.cc) : [], + }; + } + } + + const replyTarget = withEmail(source.replyToAddresses).length + ? withEmail(source.replyToAddresses) + : (source.from?.[0]?.email ? [source.from[0]] : []); + + if (mode === 'reply') { + return { to: replyTarget, cc: [] }; + } + + const others = (list: ReplyAddress[] | undefined) => + withEmail(list).filter((r) => !isOwnAddress(r.email, ownEmails)); + + return { + to: [...replyTarget, ...others(source.to)], + cc: others(source.cc), + }; +}