diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b8b25216..e1c140c5 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -761,12 +761,18 @@ export default function Home() { const primaryIdentity = identities[0]; + // Append signature from the primary identity + let finalBody = body; + if (primaryIdentity?.textSignature) { + finalBody = body + '\n\n-- \n' + primaryIdentity.textSignature; + } + // Send reply with just the body text await sendEmail( client, [sender.email], `Re: ${selectedEmail.subject || "(no subject)"}`, - body, + finalBody, undefined, undefined, primaryIdentity?.id, diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4cb42320..f8960043 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -10,6 +10,7 @@ import { cn, formatFileSize } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { useAuthStore } from "@/stores/auth-store"; +import { useIdentityStore } from "@/stores/identity-store"; import { useContactStore } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; @@ -159,7 +160,9 @@ export function EmailComposer({ restoreFocus: true, }); - const { client, identities, primaryIdentity } = useAuthStore(); + const { client } = useAuthStore(); + const identities = useIdentityStore((s) => s.identities); + const primaryIdentity = identities[0] ?? null; const getAutocomplete = useContactStore((s) => s.getAutocomplete); const addTemplate = useTemplateStore((s) => s.addTemplate); @@ -549,13 +552,19 @@ export function EmailComposer({ : currentIdentity.email : undefined; + // Append signature from the selected identity + let finalBody = body; + if (currentIdentity?.textSignature) { + finalBody = body + '\n\n-- \n' + currentIdentity.textSignature; + } + try { await onSend?.({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, - body, + body: finalBody, draftId: finalDraftId || undefined, fromEmail, fromName: currentIdentity?.name || undefined, diff --git a/components/identity/identity-manager-modal.tsx b/components/identity/identity-manager-modal.tsx index c2c82f32..7125f10c 100644 --- a/components/identity/identity-manager-modal.tsx +++ b/components/identity/identity-manager-modal.tsx @@ -9,6 +9,11 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { IdentityForm } from './identity-form'; import { useIdentityStore } from '@/stores/identity-store'; import { useAuthStore } from '@/stores/auth-store'; + +function useSyncIdentities() { + const syncIdentities = useAuthStore((state) => state.syncIdentities); + return syncIdentities; +} import type { Identity, EmailAddress } from '@/lib/jmap/types'; import { toast } from '@/stores/toast-store'; import { useFocusTrap } from '@/hooks/use-focus-trap'; @@ -34,6 +39,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr const client = useAuthStore((state) => state.client); const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore(); + const syncIdentities = useSyncIdentities(); const [editingId, setEditingId] = useState(null); const [isCreating, setIsCreating] = useState(false); @@ -82,6 +88,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr ); addIdentity(newIdentity); + syncIdentities(); setIsCreating(false); toast.success(tNotif('identity_created')); } catch (error) { @@ -104,6 +111,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr }); updateIdentityLocal(identity.id, data); + syncIdentities(); setEditingId(null); toast.success(tNotif('identity_updated')); } catch (error) { @@ -133,6 +141,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr try { await client.deleteIdentity(identity.id); removeIdentity(identity.id); + syncIdentities(); toast.success(tNotif('identity_deleted')); } catch (error) { const message = error instanceof Error ? error.message : t('validation_errors.unknown_error'); diff --git a/lib/__tests__/signature-append.test.ts b/lib/__tests__/signature-append.test.ts new file mode 100644 index 00000000..896ec9a9 --- /dev/null +++ b/lib/__tests__/signature-append.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Tests for the signature appending logic used in the email composer + * and quick-reply paths. These test the pure transformation that should + * be applied when an identity has a textSignature. + */ + +function appendSignature(body: string, textSignature: string | undefined): string { + if (textSignature) { + return body + '\n\n-- \n' + textSignature; + } + return body; +} + +describe('signature appending', () => { + it('should append text signature with standard separator', () => { + const result = appendSignature('Hello world', 'Best regards,\nAlice'); + expect(result).toBe('Hello world\n\n-- \nBest regards,\nAlice'); + }); + + it('should not modify body when signature is undefined', () => { + const result = appendSignature('Hello world', undefined); + expect(result).toBe('Hello world'); + }); + + it('should not modify body when signature is empty string', () => { + const result = appendSignature('Hello world', ''); + expect(result).toBe('Hello world'); + }); + + it('should handle empty body with signature', () => { + const result = appendSignature('', 'My Signature'); + expect(result).toBe('\n\n-- \nMy Signature'); + }); + + it('should handle multiline body and signature', () => { + const body = 'Dear Bob,\n\nHow are you?\n\nCheers'; + const sig = 'Alice Smith\nCompany Inc.\nhttp://example.com'; + const result = appendSignature(body, sig); + expect(result).toContain('Dear Bob,'); + expect(result).toContain('-- \n'); + expect(result).toContain('Alice Smith'); + expect(result).toContain('Company Inc.'); + }); + + it('should use RFC 3676 signature separator (dash dash space newline)', () => { + const result = appendSignature('body', 'sig'); + // The separator should be "-- \n" (two dashes, a space, then newline) + expect(result).toContain('-- \n'); + }); + + it('should place signature after two blank lines from body', () => { + const result = appendSignature('body text', 'sig'); + expect(result).toBe('body text\n\n-- \nsig'); + // Verify the structure: body + \n\n + "-- \n" + signature + const parts = result.split('\n\n'); + expect(parts[0]).toBe('body text'); + expect(parts[1]).toBe('-- \nsig'); + }); + + it('should handle body that already ends with newlines', () => { + const result = appendSignature('body\n\n', 'sig'); + // Still adds the separator - this matches the composer behavior + expect(result).toBe('body\n\n\n\n-- \nsig'); + }); +}); diff --git a/stores/__tests__/auth-store-sync.test.ts b/stores/__tests__/auth-store-sync.test.ts new file mode 100644 index 00000000..a1479664 --- /dev/null +++ b/stores/__tests__/auth-store-sync.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAuthStore } from '../auth-store'; +import { useIdentityStore } from '../identity-store'; +import type { Identity } from '@/lib/jmap/types'; + +const makeIdentity = (overrides: Partial = {}): Identity => ({ + id: 'id-1', + name: 'Test User', + email: 'test@example.com', + mayDelete: true, + ...overrides, +}); + +describe('auth-store syncIdentities', () => { + beforeEach(() => { + useIdentityStore.setState({ + identities: [], + selectedIdentityId: null, + isLoading: false, + error: null, + subAddress: { recentTags: [], tagSuggestions: {} }, + }); + useAuthStore.setState({ + identities: [], + primaryIdentity: null, + }); + }); + + it('should copy identities from identity store to auth store', () => { + const identities = [ + makeIdentity({ id: 'id-1', name: 'Alice' }), + makeIdentity({ id: 'id-2', name: 'Bob', email: 'bob@example.com' }), + ]; + useIdentityStore.getState().setIdentities(identities); + + useAuthStore.getState().syncIdentities(); + + expect(useAuthStore.getState().identities).toHaveLength(2); + expect(useAuthStore.getState().identities[0].name).toBe('Alice'); + expect(useAuthStore.getState().identities[1].name).toBe('Bob'); + }); + + it('should set primaryIdentity to first identity', () => { + const identities = [ + makeIdentity({ id: 'id-1', name: 'Primary' }), + makeIdentity({ id: 'id-2', name: 'Secondary' }), + ]; + useIdentityStore.getState().setIdentities(identities); + + useAuthStore.getState().syncIdentities(); + + expect(useAuthStore.getState().primaryIdentity?.id).toBe('id-1'); + expect(useAuthStore.getState().primaryIdentity?.name).toBe('Primary'); + }); + + it('should set primaryIdentity to null when no identities', () => { + // Auth store starts with an identity + useAuthStore.setState({ + identities: [makeIdentity()], + primaryIdentity: makeIdentity(), + }); + useIdentityStore.getState().setIdentities([]); + + useAuthStore.getState().syncIdentities(); + + expect(useAuthStore.getState().identities).toEqual([]); + expect(useAuthStore.getState().primaryIdentity).toBeNull(); + }); + + it('should reflect identity store changes after addIdentity', () => { + useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' })]); + useAuthStore.getState().syncIdentities(); + expect(useAuthStore.getState().identities).toHaveLength(1); + + useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-2', email: 'new@example.com' })); + useAuthStore.getState().syncIdentities(); + + expect(useAuthStore.getState().identities).toHaveLength(2); + }); + + it('should reflect identity store changes after removeIdentity', () => { + useIdentityStore.getState().setIdentities([ + makeIdentity({ id: 'id-1' }), + makeIdentity({ id: 'id-2' }), + ]); + useAuthStore.getState().syncIdentities(); + expect(useAuthStore.getState().identities).toHaveLength(2); + + useIdentityStore.getState().removeIdentity('id-1'); + useAuthStore.getState().syncIdentities(); + + expect(useAuthStore.getState().identities).toHaveLength(1); + expect(useAuthStore.getState().identities[0].id).toBe('id-2'); + expect(useAuthStore.getState().primaryIdentity?.id).toBe('id-2'); + }); + + it('should reflect identity store changes after updateIdentityLocal', () => { + useIdentityStore.getState().setIdentities([ + makeIdentity({ id: 'id-1', name: 'Old Name', textSignature: '' }), + ]); + useAuthStore.getState().syncIdentities(); + + useIdentityStore.getState().updateIdentityLocal('id-1', { + name: 'New Name', + textSignature: 'Regards, Me', + }); + useAuthStore.getState().syncIdentities(); + + expect(useAuthStore.getState().identities[0].name).toBe('New Name'); + expect(useAuthStore.getState().identities[0].textSignature).toBe('Regards, Me'); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 17561495..d23f2e5b 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -33,6 +33,7 @@ interface AuthState { logout: () => void; checkAuth: () => Promise; clearError: () => void; + syncIdentities: () => void; } const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ @@ -479,6 +480,13 @@ export const useAuthStore = create()( }, clearError: () => set({ error: null }), + + syncIdentities: () => { + const identityState = useIdentityStore.getState(); + const identities = identityState.identities; + const primaryIdentity = identities[0] ?? null; + set({ identities, primaryIdentity }); + }, }), { name: 'auth-storage',