From da5d7baf511295b58f446fd8f8bb3662d73654d7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:12:15 +0100 Subject: [PATCH] fix: swap the signature in an open composer when the organisation policy or the user's details change --- .../__tests__/composer-org-signature.test.tsx | 52 ++++++++++++++++++- components/email/email-composer.tsx | 21 ++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) 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) => {