feat: let admins turn off the custom From address in the composer

This commit is contained in:
Linus Rath
2026-10-15 14:43:07 +02:00
parent 6ef0d97900
commit a852001d4f
6 changed files with 92 additions and 25 deletions
+1
View File
@@ -39,6 +39,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. No mailbox passwords are stored. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' },
customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' },
};
const RESTRICTABLE_SETTINGS = [
@@ -1,7 +1,8 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { usePolicyStore } from '@/stores/policy-store';
// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─
@@ -247,4 +248,35 @@ describe('composer From override envelope (#1009)', () => {
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(<EmailComposer initialData={{ ...DRAFT, fromOverrideEmail: 'shop@example.com' }} onSend={onSend} />);
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(<EmailComposer />);
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(<EmailComposer />);
expect(screen.getByText('from_override.toggle_off')).toBeInTheDocument();
});
});
});
@@ -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(<EmailComposer mode="reply" replyTo={RECEIVED_CATCH_ALL} />);
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 {
+32 -23
View File
@@ -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<string>(initialData?.subAddressTag ?? '');
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
const [fromOverrideChosen, setFromOverrideEnabled] = useState<boolean>(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<string>(initialData?.fromOverrideEmail ?? '');
const [fromOverrideName, setFromOverrideName] = useState<string>(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({
<X className="w-3 h-3" />
</Button>
)}
<Button
type="button"
variant={fromOverrideEnabled ? 'outline' : 'ghost'}
size="sm"
onClick={() => {
if (fromOverrideEnabled) {
setFromOverrideEnabled(false);
} else {
setFromOverrideEnabled(true);
if (!fromOverrideEmail && currentIdentity?.email) {
setFromOverrideEmail(currentIdentity.email);
{customFromEnabled && (
<Button
type="button"
variant={fromOverrideEnabled ? 'outline' : 'ghost'}
size="sm"
onClick={() => {
if (fromOverrideEnabled) {
setFromOverrideEnabled(false);
} else {
setFromOverrideEnabled(true);
if (!fromOverrideEmail && currentIdentity?.email) {
setFromOverrideEmail(currentIdentity.email);
}
if (!fromOverrideName && currentIdentity?.name) {
setFromOverrideName(currentIdentity.name);
}
}
if (!fromOverrideName && currentIdentity?.name) {
setFromOverrideName(currentIdentity.name);
}
}
}}
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')}
</Button>
}}
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')}
</Button>
)}
</div>
</div>
{overrideEnvelopeFallback && (
+4 -1
View File
@@ -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 (
<SettingsSection title={t('title')} description={t('description')}>
@@ -49,7 +52,7 @@ export function ComposingSettings() {
/>
</SettingItem>
{autoSelectReplyIdentity && (
{autoSelectReplyIdentity && customFromEnabled && (
<SettingItem label={t('reply_identity_match.label')} description={t('reply_identity_match.description')}>
<Select
value={replyIdentityMatch}
+3
View File
@@ -63,6 +63,8 @@ export interface FeatureGates {
unifiedCrossAccountEnabled: boolean;
/** Encrypted account list kept on the server, restorable on another device. */
accountArchiveEnabled: boolean;
/** Composer "Custom" From: free-text sender address instead of an identity. */
customFromEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -91,6 +93,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
crossAllViewEnabled: false,
unifiedCrossAccountEnabled: false,
accountArchiveEnabled: false,
customFromEnabled: true,
};
export interface ThemePolicy {