fix: swap the signature in an open composer when the organisation policy or the user's details change

This commit is contained in:
Linus Rath
2026-10-28 04:12:15 +01:00
parent 3fbd5a7818
commit da5d7baf51
2 changed files with 67 additions and 6 deletions
@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -413,6 +413,56 @@ describe('organisation signature in the composer', () => {
expectOrgSignatureOnce(await send(onSend), 'Team');
});
});
describe('a signature that changes while the composer is open', () => {
const PROPS = { prefill: { to: ['bob@example.com'] } };
it('swaps in a locked signature whose policy arrives after the composer opened', async () => {
setOrgPolicy({ signature: null });
const onSend = vi.fn();
render(<EmailComposer {...PROPS} onSend={onSend} />);
await waitFor(() => expect(editorState.html).toContain('<b>Alice</b>'));
act(() => setOrgPolicy({ locked: true }));
await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
expectOrgSignatureOnce(await send(onSend), 'Me');
});
it('brings in a default that arrives late for an identity without a signature', async () => {
setOrgPolicy({ signature: null });
const onSend = vi.fn();
render(<EmailComposer {...PROPS} onSend={onSend} />);
fireEvent.change(fromSelect(), { target: { value: 'id-team' } });
await waitFor(() => expect(editorState.html).not.toContain('Alice'));
act(() => setOrgPolicy());
await waitFor(() => expect(editorState.html).toContain('ACME Ltd'));
expectOrgSignatureOnce(await send(onSend), 'Team');
});
it('fills in the details again when they change', async () => {
setOrgPolicy({ locked: true });
const onSend = vi.fn();
const { rerender } = render(<EmailComposer {...PROPS} onSend={onSend} />);
await waitFor(() => expect(editorState.html).toContain('CEO'));
useSettingsStore.setState({ signatureDetails: { title: 'CTO', department: '', phone: '+49 30 1', mobile: '' } } as never);
rerender(<EmailComposer {...PROPS} onSend={onSend} />);
await waitFor(() => expect(editorState.html).toContain('CTO'));
const sent = await send(onSend);
expect(countOf(sent.htmlBody!, 'ACME Ltd')).toBe(1);
expect(sent.htmlBody).not.toContain('CEO');
});
it('swaps a plain-text signature whose policy arrives late', async () => {
useSettingsStore.setState({ plainTextMode: true });
setOrgPolicy({ signature: null });
const onSend = vi.fn();
render(<EmailComposer {...PROPS} onSend={onSend} />);
const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement;
await waitFor(() => expect(textarea().value).toContain('Alice'));
act(() => setOrgPolicy({ locked: true }));
await waitFor(() => expect(textarea().value).toContain('ACME Ltd'));
expectOrgSignatureOnce(await send(onSend), 'Me');
});
});
});
describe('organisation disclaimer in the composer', () => {
+16 -5
View File
@@ -1171,18 +1171,29 @@ export function EmailComposer({
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
const prevPluginSignatureRevisionRef = useRef(pluginSignatureRevision);
const prevSignatureIdentityRef = useRef(signatureIdentity);
// The signature's text, for a change under the same identity: the
// organisation policy arriving after the composer opened, or the user's
// details changing.
const signatureContent = JSON.stringify([signatureIdentity?.htmlSignature ?? "", signatureIdentity?.textSignature ?? ""]);
const prevSignatureContentRef = useRef(signatureContent);
useEffect(() => {
const editor = editorRef.current;
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
const pluginSignatureChanged = prevPluginSignatureRevisionRef.current !== pluginSignatureRevision;
const contentChanged = prevSignatureContentRef.current !== signatureContent;
const previousIdentity = prevSignatureIdentityRef.current;
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
prevPluginSignatureRevisionRef.current = pluginSignatureRevision;
prevSignatureIdentityRef.current = signatureIdentity;
if (!identityChanged && !separatorChanged && !pluginSignatureChanged) return;
prevSignatureContentRef.current = signatureContent;
if (!identityChanged && !separatorChanged && !pluginSignatureChanged && !contentChanged) return;
// A signature that arrives where there was none comes into the body like
// a new identity's; one the user removed stays removed.
const hadSignature = !!(previousIdentity?.htmlSignature || previousIdentity?.textSignature);
const bringsSignature = identityChanged || (contentChanged && !hadSignature);
// Replies/forwards only embed when configured for "above quote". Compose
// always embeds (see getInitialBody), so swap on identity change there too.
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
@@ -1192,7 +1203,7 @@ export function EmailComposer({
if (plainTextMode) {
// A new plain-text message carries the signature at the end of the
// body, a reply or forward above its quote header.
if (!identityChanged) return;
if (!identityChanged && !contentChanged) return;
const options = { separator: signatureSeparatorEnabled };
if (mode === 'compose') {
setBody((prev) => replacePlainTextSignature(prev, previousIdentity, signatureIdentity, options));
@@ -1215,8 +1226,8 @@ export function EmailComposer({
// quoted body - see removeSignatureRange), or removed when the new
// identity has no signature.
const currentHtml = serializeEditorContent(editor);
// Only a new identity brings a signature back into a body without one.
if (!identityChanged && !containsEmbeddedSignature(currentHtml)) return;
// Only a new identity (or a first signature) brings one into a body without one.
if (!bringsSignature && !containsEmbeddedSignature(currentHtml)) return;
const nextHtml = replaceEmbeddedSignature(currentHtml, buildEmbeddedSignatureHtml(signatureIdentity, {
embed: true,
separator: signatureSeparatorEnabled,
@@ -1229,7 +1240,7 @@ export function EmailComposer({
// would re-run on unrelated identity-field changes and re-splice the
// signature into the live editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
useEffect(() => {
const handleClickOutsideSendMenu = (event: MouseEvent) => {