From 565d5c1095cb6f785bb09d7907e853e36d640925 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:27:08 +0200 Subject: [PATCH] feat: choose exact-address or same-domain matching for reply from received address #1000 --- app/(main)/admin/_tabs/policy.tsx | 1 + .../email/__tests__/reply-addressing.test.tsx | 16 ++++++++++++++++ components/email/email-composer.tsx | 8 ++++++-- components/settings/composing-settings.tsx | 16 +++++++++++++++- lib/__tests__/reply-identity.test.ts | 15 +++++++++++++++ lib/reply-identity.ts | 18 +++++++++++++++++- lib/settings-search.ts | 1 + locales/ar/common.json | 6 ++++++ locales/ca/common.json | 6 ++++++ locales/cs/common.json | 6 ++++++ locales/da/common.json | 6 ++++++ locales/de/common.json | 6 ++++++ locales/en/common.json | 8 +++++++- locales/es/common.json | 6 ++++++ locales/fa/common.json | 6 ++++++ locales/fr/common.json | 6 ++++++ locales/he/common.json | 6 ++++++ locales/hu/common.json | 6 ++++++ locales/it/common.json | 6 ++++++ locales/ja/common.json | 6 ++++++ locales/ko/common.json | 6 ++++++ locales/lv/common.json | 6 ++++++ locales/mn/common.json | 6 ++++++ locales/nb/common.json | 6 ++++++ locales/nl/common.json | 6 ++++++ locales/pl/common.json | 6 ++++++ locales/pt/common.json | 6 ++++++ locales/ro/common.json | 6 ++++++ locales/ru/common.json | 6 ++++++ locales/sk/common.json | 6 ++++++ locales/tr/common.json | 6 ++++++ locales/uk/common.json | 6 ++++++ locales/zh-TW/common.json | 6 ++++++ locales/zh/common.json | 6 ++++++ stores/settings-store.ts | 4 ++++ 35 files changed, 238 insertions(+), 5 deletions(-) diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index d2b560bae..98ad51957 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -52,6 +52,7 @@ const RESTRICTABLE_SETTINGS = [ { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] }, { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' }, + { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] }, { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' }, { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx index 3e5c3366f..07173e979 100644 --- a/components/email/__tests__/reply-addressing.test.tsx +++ b/components/email/__tests__/reply-addressing.test.tsx @@ -102,6 +102,7 @@ vi.mock('@/stores/settings-store', () => { plainTextMode: false, subAddressDelimiter: '+', autoSelectReplyIdentity: false, + replyIdentityMatch: 'domain', attachmentReminderEnabled: false, attachmentReminderKeywords: [], sendDelaySeconds: 0, @@ -323,6 +324,21 @@ describe('composer reply addressing', () => { } }); + // #1000: on a domain whose other addresses are distribution lists rather + // than catch-all aliases, the setting can stay on but limited to configured + // identities, so a reply to list@ gets no From override. + it('does not rewrite From when matching is limited to exact addresses', () => { + const settings = useSettingsStore as unknown as { setState: (p: Record) => void }; + settings.setState({ autoSelectReplyIdentity: true, replyIdentityMatch: 'exact' }); + try { + render(); + expect(screen.queryByDisplayValue('colleague@example.com')).toBeNull(); + expect(identitySelect().value).toBe('id-me'); + } finally { + settings.setState({ autoSelectReplyIdentity: false, replyIdentityMatch: 'domain' }); + } + }); + it('never rewrites From on a forward, even with the setting on', () => { setAutoSelect(true); try { diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index dfb0bbca3..04d63e49a 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -312,6 +312,7 @@ export function EmailComposer({ const plainTextMode = useSettingsStore((state) => state.plainTextMode); const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); + const replyIdentityMatch = useSettingsStore((state) => state.replyIdentityMatch); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const emptySubjectWarningEnabled = useSettingsStore((state) => state.emptySubjectWarningEnabled); @@ -878,9 +879,11 @@ export function EmailComposer({ // Catch-all From rewrite: opt-in, and never on a forward. A reply continues // a thread whose participants already know the addressing; a forward // introduces the rewritten From to a recipient the user just typed, who has - // no way to tell it is not really from that person. + // no way to tell it is not really from that person. `replyIdentityMatch` + // lets a user keep the setting on but limit it to configured identities, + // for domains where the other addresses are distribution lists (#1000). if (autoSelectReplyIdentity && mode !== 'forward') { - const resolved = resolveReplyFrom(identities, recipients); + const resolved = resolveReplyFrom(identities, recipients, replyIdentityMatch); if (resolved) { setSelectedIdentityId(resolved.identityId); if (resolved.overrideEmail && !fromOverrideEnabled) { @@ -907,6 +910,7 @@ export function EmailComposer({ } }, [ autoSelectReplyIdentity, + replyIdentityMatch, composeFromAccountEmail, fromOverrideEnabled, identities, diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 4d70bfd90..38dca62e2 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; -import type { SendDelaySeconds } from '@/stores/settings-store'; +import type { ReplyIdentityMatch, SendDelaySeconds } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { X } from 'lucide-react'; @@ -22,6 +22,7 @@ export function ComposingSettings() { const { autoSelectReplyIdentity, + replyIdentityMatch, plainTextMode, rtlEditingSupport, attachmentReminderEnabled, @@ -47,6 +48,19 @@ export function ComposingSettings() { /> + {autoSelectReplyIdentity && ( + +