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:
Linus Rath
2026-10-14 03:44:33 +02:00
parent f530f5849d
commit 74b194c3e5
34 changed files with 539 additions and 17 deletions
@@ -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 () => [] },
+14 -4
View File
@@ -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 -11
View File
@@ -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) {
+62
View File
@@ -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);
});
});
+73 -2
View File
@@ -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));
}