diff --git a/components/email/__tests__/composer-org-signature.test.tsx b/components/email/__tests__/composer-org-signature.test.tsx
index 29e346295..9f79ead6f 100644
--- a/components/email/__tests__/composer-org-signature.test.tsx
+++ b/components/email/__tests__/composer-org-signature.test.tsx
@@ -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();
+ await waitFor(() => expect(editorState.html).toContain('Alice'));
+ 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();
+ 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();
+ await waitFor(() => expect(editorState.html).toContain('CEO'));
+ useSettingsStore.setState({ signatureDetails: { title: 'CTO', department: '', phone: '+49 30 1', mobile: '' } } as never);
+ rerender();
+ 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();
+ 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', () => {
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 7fccade1f..2e9765875 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -1171,18 +1171,29 @@ export function EmailComposer({
const prevSignatureSeparatorRef = useRef(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) => {