mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins about sender identity changes and the chosen From, and let them stop a change or rewrite the signature
This commit is contained in:
@@ -140,6 +140,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -151,6 +151,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -133,6 +133,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -134,6 +134,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -132,6 +132,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -137,6 +137,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -143,6 +143,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -136,6 +136,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -139,6 +139,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -162,6 +162,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -141,6 +141,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -145,6 +145,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -143,6 +143,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -133,6 +133,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -144,6 +144,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -137,6 +137,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -130,6 +130,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -129,6 +129,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -158,6 +158,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -70,6 +70,8 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { notifyIdentitySelected } from "@/stores/identity-store";
|
||||
import { usePluginSignature } from "@/hooks/use-plugin-signature";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
|
||||
@@ -886,9 +888,11 @@ export function EmailComposer({
|
||||
// Alias identities often lack a configured signature - fall back to the primary
|
||||
// identity's signature so replies (which auto-select a matching alias) still
|
||||
// populate the user's signature.
|
||||
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||
const ownSignatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||
? currentIdentity
|
||||
: primaryIdentity;
|
||||
// Plugins may rewrite the signature (onSignatureRender).
|
||||
const { identity: signatureIdentity, revision: pluginSignatureRevision } = usePluginSignature(ownSignatureIdentity);
|
||||
|
||||
// Hold the TipTap editor instance so we can swap the embedded signature
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
@@ -899,15 +903,18 @@ export function EmailComposer({
|
||||
const replyFocusedRef = useRef(false);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
const prevPluginSignatureRevisionRef = useRef(pluginSignatureRevision);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
|
||||
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
|
||||
const pluginSignatureChanged = prevPluginSignatureRevisionRef.current !== pluginSignatureRevision;
|
||||
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
|
||||
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
|
||||
prevPluginSignatureRevisionRef.current = pluginSignatureRevision;
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (!identityChanged && !separatorChanged && !pluginSignatureChanged) return;
|
||||
if (plainTextMode) return;
|
||||
// Replies/forwards only embed when configured for "above quote". Compose
|
||||
// always embeds (see getInitialBody), so swap on identity change there too.
|
||||
@@ -945,7 +952,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]);
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||
@@ -3248,7 +3255,10 @@ export function EmailComposer({
|
||||
<select
|
||||
data-testid="composer-from"
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSelectedIdentityId(e.target.value);
|
||||
notifyIdentitySelected(identities, e.target.value);
|
||||
}}
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||
>
|
||||
{identityGroups.length > 0
|
||||
|
||||
@@ -7,7 +7,9 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IdentityForm } from './identity-form';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import {
|
||||
useIdentityStore, createIdentityWithHooks, updateIdentityWithHooks, deleteIdentityWithHooks,
|
||||
} from '@/stores/identity-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
@@ -137,14 +139,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.createIdentity(
|
||||
data.name,
|
||||
data.email,
|
||||
data.replyTo,
|
||||
data.bcc,
|
||||
data.textSignature,
|
||||
data.htmlSignature
|
||||
);
|
||||
if (!(await createIdentityWithHooks(client, data))) return;
|
||||
|
||||
await refreshIdentities();
|
||||
setIsCreating(false);
|
||||
@@ -160,13 +155,14 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.updateIdentity(identity.id, {
|
||||
const saved = await updateIdentityWithHooks(client, identity, {
|
||||
name: data.name,
|
||||
replyTo: data.replyTo,
|
||||
bcc: data.bcc,
|
||||
textSignature: data.textSignature,
|
||||
htmlSignature: data.htmlSignature,
|
||||
});
|
||||
if (!saved) return;
|
||||
|
||||
await refreshIdentities();
|
||||
setEditingId(null);
|
||||
@@ -196,7 +192,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
setDeletingId(identity.id);
|
||||
|
||||
try {
|
||||
await client.deleteIdentity(identity.id);
|
||||
if (!(await deleteIdentityWithHooks(client, identity))) return;
|
||||
await refreshIdentities();
|
||||
toast.success(tNotif('identity_deleted'));
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
import { identityHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
interface RenderedSignature {
|
||||
identityId: string;
|
||||
source: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const sourceKey = (identity: Identity) => JSON.stringify([identity.htmlSignature ?? '', identity.textSignature ?? '']);
|
||||
|
||||
/**
|
||||
* The signature the composer inserts for `identity`, after plugins had their
|
||||
* say through onSignatureRender. Returns the identity unchanged while no
|
||||
* plugin listens, and until a plugin's result arrives; after that, a copy
|
||||
* with the plugin's htmlSignature and textSignature. Every place that builds
|
||||
* a signature (the initial body, the identity swap, drafts, the send path)
|
||||
* reads the identity this returns, so they all insert the same text. The HTML
|
||||
* goes through the composer's signature sanitizer like any other signature.
|
||||
*
|
||||
* `revision` changes each time a plugin result is applied, so the composer
|
||||
* can swap the signature already in the body.
|
||||
*/
|
||||
export function usePluginSignature(identity: Identity | null | undefined): {
|
||||
identity: Identity | null | undefined;
|
||||
revision: number;
|
||||
} {
|
||||
const [rendered, setRendered] = useState<RenderedSignature | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const id = identity?.id;
|
||||
const source = identity ? sourceKey(identity) : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!identity || !id || identityHooks.onSignatureRender.size === 0) return;
|
||||
let cancelled = false;
|
||||
const initial = { html: identity.htmlSignature ?? '', text: identity.textSignature ?? '' };
|
||||
void identityHooks.onSignatureRender.transform(initial, { identityId: id }).then((result: unknown) => {
|
||||
if (cancelled) return;
|
||||
const r = result as { html?: unknown; text?: unknown } | null;
|
||||
const html = typeof r?.html === 'string' ? r.html : initial.html;
|
||||
const text = typeof r?.text === 'string' ? r.text : initial.text;
|
||||
if (html === initial.html && text === initial.text) return;
|
||||
setRendered({ identityId: id, source, html, text });
|
||||
setRevision((n) => n + 1);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// Keyed to the identity and its signature text, not the object, which is
|
||||
// rebuilt on unrelated store updates.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, source]);
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (!identity || !rendered || rendered.identityId !== identity.id || rendered.source !== sourceKey(identity)) {
|
||||
return identity;
|
||||
}
|
||||
return { ...identity, htmlSignature: rendered.html, textSignature: rendered.text };
|
||||
}, [identity, rendered]);
|
||||
|
||||
return { identity: result, revision };
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
useIdentityStore, createIdentityWithHooks, updateIdentityWithHooks, deleteIdentityWithHooks, notifyIdentitySelected,
|
||||
} from '../identity-store';
|
||||
import { usePluginSignature } from '@/hooks/use-plugin-signature';
|
||||
import { clearAllHooks, identityHooks } from '@/lib/plugin-hooks';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
const identity = (overrides: Partial<Identity> = {}): Identity => ({
|
||||
id: 'id-1',
|
||||
name: 'Ann',
|
||||
email: 'ann@example.com',
|
||||
mayDelete: true,
|
||||
htmlSignature: '<b>Ann</b>',
|
||||
textSignature: 'Ann',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const view = (i: Identity) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
email: i.email,
|
||||
replyTo: null,
|
||||
bcc: null,
|
||||
htmlSignature: i.htmlSignature ?? '',
|
||||
textSignature: i.textSignature ?? '',
|
||||
});
|
||||
|
||||
function mockClient() {
|
||||
return {
|
||||
createIdentity: vi.fn(async () => identity({ id: 'new-1', email: 'new@example.com', name: 'New' })),
|
||||
updateIdentity: vi.fn(async () => {}),
|
||||
deleteIdentity: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('identity plugin hooks', () => {
|
||||
beforeEach(() => {
|
||||
useIdentityStore.setState({ identities: [], selectedIdentityId: null });
|
||||
});
|
||||
afterEach(() => clearAllHooks());
|
||||
|
||||
it('reports loaded identities, but not a reorder of the same ones', () => {
|
||||
const loaded = vi.fn();
|
||||
identityHooks.onIdentitiesLoaded.register('test', loaded);
|
||||
const a = identity();
|
||||
const b = identity({ id: 'id-2', email: 'b@example.com' });
|
||||
|
||||
useIdentityStore.getState().setIdentities([a, b]);
|
||||
expect(loaded).toHaveBeenCalledTimes(1);
|
||||
expect(loaded).toHaveBeenCalledWith([view(a), view(b)]);
|
||||
|
||||
useIdentityStore.getState().setIdentities([b, a]);
|
||||
expect(loaded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks before creating and reports what the server created', async () => {
|
||||
const client = mockClient();
|
||||
const before = vi.fn(() => {
|
||||
expect(client.createIdentity).not.toHaveBeenCalled();
|
||||
return true;
|
||||
});
|
||||
const created = vi.fn();
|
||||
identityHooks.onBeforeIdentityChange.register('test', before);
|
||||
identityHooks.onIdentityCreate.register('test', created);
|
||||
|
||||
const ok = await createIdentityWithHooks(client as unknown as IJMAPClient, { name: 'New', email: 'new@example.com' });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(before).toHaveBeenCalledWith(expect.objectContaining({ change: 'create', identity: expect.objectContaining({ email: 'new@example.com' }) }));
|
||||
expect(created).toHaveBeenCalledTimes(1);
|
||||
expect(created).toHaveBeenCalledWith(expect.objectContaining({ id: 'new-1', email: 'new@example.com' }));
|
||||
});
|
||||
|
||||
it('does not create when a plugin says no', async () => {
|
||||
const client = mockClient();
|
||||
const created = vi.fn();
|
||||
identityHooks.onBeforeIdentityChange.register('test', () => false);
|
||||
identityHooks.onIdentityCreate.register('test', created);
|
||||
|
||||
expect(await createIdentityWithHooks(client as unknown as IJMAPClient, { name: 'x', email: 'x@example.com' })).toBe(false);
|
||||
expect(client.createIdentity).not.toHaveBeenCalled();
|
||||
expect(created).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an update after the server stored it, and not when it failed', async () => {
|
||||
const client = mockClient();
|
||||
const updated = vi.fn();
|
||||
identityHooks.onIdentityUpdate.register('test', updated);
|
||||
|
||||
client.updateIdentity.mockRejectedValueOnce(new Error('down'));
|
||||
await expect(updateIdentityWithHooks(client as unknown as IJMAPClient, identity(), { name: 'Anna' })).rejects.toThrow();
|
||||
expect(updated).not.toHaveBeenCalled();
|
||||
|
||||
await updateIdentityWithHooks(client as unknown as IJMAPClient, identity(), { name: 'Anna' });
|
||||
expect(updated).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-1', name: 'Anna' }));
|
||||
});
|
||||
|
||||
it('stops an update when a plugin says no', async () => {
|
||||
const client = mockClient();
|
||||
const before = vi.fn(() => false);
|
||||
identityHooks.onBeforeIdentityChange.register('test', before);
|
||||
expect(await updateIdentityWithHooks(client as unknown as IJMAPClient, identity(), { name: 'Anna' })).toBe(false);
|
||||
expect(before).toHaveBeenCalledWith(expect.objectContaining({ identityId: 'id-1', change: 'update' }));
|
||||
expect(client.updateIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks before deleting and reports the deleted id', async () => {
|
||||
const client = mockClient();
|
||||
const deleted = vi.fn();
|
||||
identityHooks.onIdentityDelete.register('test', deleted);
|
||||
identityHooks.onBeforeIdentityChange.register('test', () => false);
|
||||
expect(await deleteIdentityWithHooks(client as unknown as IJMAPClient, identity())).toBe(false);
|
||||
expect(client.deleteIdentity).not.toHaveBeenCalled();
|
||||
|
||||
clearAllHooks();
|
||||
identityHooks.onIdentityDelete.register('test', deleted);
|
||||
expect(await deleteIdentityWithHooks(client as unknown as IJMAPClient, identity())).toBe(true);
|
||||
expect(deleted).toHaveBeenCalledWith({ identityId: 'id-1' });
|
||||
});
|
||||
|
||||
it('reports the From identity the user picked', () => {
|
||||
const selected = vi.fn();
|
||||
identityHooks.onIdentitySelect.register('test', selected);
|
||||
const b = identity({ id: 'id-2', email: 'b@example.com' });
|
||||
notifyIdentitySelected([identity(), b], 'id-2');
|
||||
expect(selected).toHaveBeenCalledWith(view(b));
|
||||
});
|
||||
|
||||
it('hands the signature to plugins and uses what they return', async () => {
|
||||
identityHooks.onSignatureRender.register('test', (sig: { html: string; text: string }, ctx: { identityId: string }) => {
|
||||
expect(ctx).toEqual({ identityId: 'id-1' });
|
||||
return { html: `${sig.html}<p>Sent from Bulwark</p>`, text: `${sig.text}\nSent from Bulwark` };
|
||||
});
|
||||
const ident = identity();
|
||||
const { result } = renderHook(() => usePluginSignature(ident));
|
||||
|
||||
await waitFor(() => expect(result.current.revision).toBe(1));
|
||||
expect(result.current.identity?.htmlSignature).toBe('<b>Ann</b><p>Sent from Bulwark</p>');
|
||||
expect(result.current.identity?.textSignature).toBe('Ann\nSent from Bulwark');
|
||||
expect(result.current.identity?.id).toBe('id-1');
|
||||
});
|
||||
|
||||
it('leaves the signature alone when no plugin listens', () => {
|
||||
const ident = identity();
|
||||
const { result } = renderHook(() => usePluginSignature(ident));
|
||||
expect(result.current.identity).toBe(ident);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { Identity } from '@/lib/jmap/types';
|
||||
import type { EmailAddress, Identity } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { identityHooks } from '@/lib/plugin-hooks';
|
||||
import { identityToView } from '@/lib/plugin-projection';
|
||||
|
||||
// Constants for sub-addressing limits
|
||||
const MAX_RECENT_TAGS = 10;
|
||||
@@ -53,7 +56,15 @@ export const useIdentityStore = create<IdentityStore>()(
|
||||
tagSuggestions: {},
|
||||
},
|
||||
|
||||
setIdentities: (identities) => set({ identities }),
|
||||
setIdentities: (identities) => {
|
||||
const changed = identitySetKey(get().identities) !== identitySetKey(identities);
|
||||
set({ identities });
|
||||
// Reordering (a new default sender) is not a load; only tell plugins
|
||||
// when the identities themselves are new or differ.
|
||||
if (changed && identityHooks.onIdentitiesLoaded.size > 0) {
|
||||
void identityHooks.onIdentitiesLoaded.emit(identities.map(identityToView));
|
||||
}
|
||||
},
|
||||
|
||||
addIdentity: (identity) => set((state) => ({
|
||||
identities: [...state.identities, identity]
|
||||
@@ -138,3 +149,63 @@ export const useIdentityStore = create<IdentityStore>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/** Order-independent fingerprint of an identity list. */
|
||||
function identitySetKey(identities: Identity[]): string {
|
||||
return JSON.stringify(identities.map((i) => JSON.stringify(i)).sort());
|
||||
}
|
||||
|
||||
export interface IdentityInput {
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo?: EmailAddress[] | null;
|
||||
bcc?: EmailAddress[] | null;
|
||||
textSignature?: string | null;
|
||||
htmlSignature?: string | null;
|
||||
}
|
||||
|
||||
// Identity changes made by the user (Settings > Identities). Each asks
|
||||
// onBeforeIdentityChange first, makes the JMAP call, and tells plugins only
|
||||
// once the server accepted it. They return false when a plugin stopped the
|
||||
// change, true otherwise; server errors are thrown to the caller.
|
||||
|
||||
export async function createIdentityWithHooks(client: IJMAPClient, data: IdentityInput): Promise<boolean> {
|
||||
const draft = identityToView({ id: '', mayDelete: true, ...data } as Identity);
|
||||
if (!(await identityHooks.onBeforeIdentityChange.intercept({ change: 'create', identity: draft }))) return false;
|
||||
const created = await client.createIdentity(
|
||||
data.name, data.email, data.replyTo, data.bcc, data.textSignature, data.htmlSignature,
|
||||
);
|
||||
void identityHooks.onIdentityCreate.emit(identityToView(created));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateIdentityWithHooks(
|
||||
client: IJMAPClient,
|
||||
identity: Identity,
|
||||
updates: Omit<IdentityInput, 'email'>,
|
||||
): Promise<boolean> {
|
||||
const next = identityToView({ ...identity, ...updates } as Identity);
|
||||
if (!(await identityHooks.onBeforeIdentityChange.intercept({ identityId: identity.id, change: 'update', identity: next }))) {
|
||||
return false;
|
||||
}
|
||||
await client.updateIdentity(identity.id, updates);
|
||||
void identityHooks.onIdentityUpdate.emit(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteIdentityWithHooks(client: IJMAPClient, identity: Identity): Promise<boolean> {
|
||||
if (!(await identityHooks.onBeforeIdentityChange.intercept({
|
||||
identityId: identity.id, change: 'delete', identity: identityToView(identity),
|
||||
}))) {
|
||||
return false;
|
||||
}
|
||||
await client.deleteIdentity(identity.id);
|
||||
void identityHooks.onIdentityDelete.emit({ identityId: identity.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Tells plugins the user picked another From identity in the composer. */
|
||||
export function notifyIdentitySelected(identities: Identity[], identityId: string): void {
|
||||
const identity = identities.find((i) => i.id === identityId);
|
||||
if (identity) void identityHooks.onIdentitySelect.emit(identityToView(identity));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user