From a852001d4f42875d78f729692c869397ff3aa685 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:41:16 +0200 Subject: [PATCH] feat: let admins turn off the custom From address in the composer --- app/(main)/admin/_tabs/policy.tsx | 1 + .../composer-from-override-envelope.test.tsx | 34 +++++++++++- .../email/__tests__/reply-addressing.test.tsx | 19 +++++++ components/email/email-composer.tsx | 55 +++++++++++-------- components/settings/composing-settings.tsx | 5 +- lib/admin/types.ts | 3 + 6 files changed, 92 insertions(+), 25 deletions(-) diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index b7cdfa667..e42478bac 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -39,6 +39,7 @@ const FEATURE_GATE_LABELS: Partial { expect(sent.fromEmail).toBe('me@example.com'); expect(sent.envelopeMailFrom).toBeUndefined(); }); + + describe('with custom From switched off by the admin', () => { + const setCustomFrom = (on: boolean) => usePolicyStore.setState((state) => ({ + policy: { ...state.policy, features: { ...state.policy.features, customFromEnabled: on } }, + })); + beforeEach(() => setCustomFrom(false)); + afterEach(() => setCustomFrom(true)); + + it('sends a draft saved with a custom From from its identity', async () => { + const onSend = vi.fn(); + render(); + expect(screen.queryByDisplayValue('shop@example.com')).not.toBeInTheDocument(); + + const sent = await send(onSend); + expect(sent.fromEmail).toBe('me@example.com'); + expect(sent.envelopeMailFrom).toBeUndefined(); + }); + + it('offers no Custom button', () => { + render(); + expect(screen.queryByText('from_override.toggle_off')).not.toBeInTheDocument(); + }); + + // Control: the button is there with the gate on, so the test above is not + // passing on a renamed label. + it('offers the Custom button with the gate on', () => { + setCustomFrom(true); + render(); + expect(screen.getByText('from_override.toggle_off')).toBeInTheDocument(); + }); + }); }); diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx index 86330916b..f8afaa5e8 100644 --- a/components/email/__tests__/reply-addressing.test.tsx +++ b/components/email/__tests__/reply-addressing.test.tsx @@ -5,6 +5,7 @@ import { EmailComposer } from '../email-composer'; import { useSettingsStore } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountStore } from '@/stores/account-store'; +import { usePolicyStore } from '@/stores/policy-store'; // ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ───────────────── @@ -357,6 +358,24 @@ describe('composer reply addressing', () => { } }); + // The admin's custom From gate takes the rewrite away even when the user + // asked for domain matching. + it('does not rewrite From when the admin turned custom From off', () => { + const setCustomFrom = (on: boolean) => usePolicyStore.setState((state) => ({ + policy: { ...state.policy, features: { ...state.policy.features, customFromEnabled: on } }, + })); + setAutoSelect(true); + setCustomFrom(false); + try { + render(); + expect(screen.queryByDisplayValue('colleague@example.com')).toBeNull(); + expect(identitySelect().value).toBe('id-me'); + } finally { + setAutoSelect(false); + setCustomFrom(true); + } + }); + 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 2453260c4..683be505c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -483,6 +483,7 @@ export function EmailComposer({ const { isFeatureEnabled } = usePolicyStore(); const templatesEnabled = isFeatureEnabled('templatesEnabled'); const filesEnabled = isFeatureEnabled('filesEnabled'); + const customFromEnabled = isFeatureEnabled('customFromEnabled'); // The signature identity used when embedding the signature into the initial // body for "above quote" mode. Mirrors the signatureIdentity derivation @@ -799,7 +800,10 @@ export function EmailComposer({ // signature, also when it has none (see ownSignatureIdentity). const [identityPickedByUser, setIdentityPickedByUser] = useState(false); const [subAddressTag, setSubAddressTag] = useState(initialData?.subAddressTag ?? ''); - const [fromOverrideEnabled, setFromOverrideEnabled] = useState(initialData?.fromOverrideEnabled ?? false); + const [fromOverrideChosen, setFromOverrideEnabled] = useState(initialData?.fromOverrideEnabled ?? false); + // With the admin's customFromEnabled gate off, a draft saved with a custom + // From opens, sends and saves again from its identity instead. + const fromOverrideEnabled = customFromEnabled && fromOverrideChosen; const [fromOverrideEmail, setFromOverrideEmail] = useState(initialData?.fromOverrideEmail ?? ''); const [fromOverrideName, setFromOverrideName] = useState(initialData?.fromOverrideName ?? ''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); @@ -1108,7 +1112,9 @@ export function EmailComposer({ // 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(sourceAccountIdentities, recipients, replyIdentityMatch); + // Without custom From the catch-all rewrite has nothing to write into. + const matchMode = customFromEnabled ? replyIdentityMatch : 'exact'; + const resolved = resolveReplyFrom(sourceAccountIdentities, recipients, matchMode); if (resolved) { setSelectedIdentityId(resolved.identityId); if (resolved.overrideEmail && !fromOverrideEnabled) { @@ -1134,6 +1140,7 @@ export function EmailComposer({ }, [ autoSelectReplyIdentity, replyIdentityMatch, + customFromEnabled, composeFromAccountEmail, fromOverrideEnabled, identities, @@ -3620,28 +3627,30 @@ export function EmailComposer({ )} - + }} + className="h-6 px-2 text-xs shrink-0" + title={t('from_override.toggle_tooltip')} + > + {fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')} + + )} {overrideEnvelopeFallback && ( diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 3e5163d1b..72a42558a 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import type { ReplyIdentityMatch, SendDelaySeconds } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { X } from '@/components/icons'; import { @@ -39,6 +40,8 @@ export function ComposingSettings() { } = useSettingsStore(); const { client } = useAuthStore(); const delayedSendSupported = client?.hasDelayedSend() ?? false; + // Catch-all matching only rewrites From, which the admin can switch off. + const customFromEnabled = usePolicyStore((s) => s.isFeatureEnabled('customFromEnabled')); return ( @@ -49,7 +52,7 @@ export function ComposingSettings() { /> - {autoSelectReplyIdentity && ( + {autoSelectReplyIdentity && customFromEnabled && (