mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let admins turn off the custom From address in the composer
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user