fix: sync identity stores and append signatures to outgoing emails (#15)
- Add syncIdentities() to auth store to propagate identity changes from identity store, fixing stale data that caused save failures and duplicates - Call syncIdentities() after every create, update, and delete in the identity manager modal - Switch email composer to read identities from identity store for consistency with the rest of the app - Append identity text signature (with RFC 3676 separator) to email body when sending from the composer and quick reply paths - Add tests for syncIdentities and signature appending logic
This commit is contained in:
@@ -761,12 +761,18 @@ export default function Home() {
|
|||||||
|
|
||||||
const primaryIdentity = identities[0];
|
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
|
// Send reply with just the body text
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
client,
|
client,
|
||||||
[sender.email],
|
[sender.email],
|
||||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||||
body,
|
finalBody,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
primaryIdentity?.id,
|
primaryIdentity?.id,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { cn, formatFileSize } from "@/lib/utils";
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useTemplateStore } from "@/stores/template-store";
|
import { useTemplateStore } from "@/stores/template-store";
|
||||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||||
@@ -159,7 +160,9 @@ export function EmailComposer({
|
|||||||
restoreFocus: true,
|
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 getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||||
|
|
||||||
@@ -549,13 +552,19 @@ export function EmailComposer({
|
|||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Append signature from the selected identity
|
||||||
|
let finalBody = body;
|
||||||
|
if (currentIdentity?.textSignature) {
|
||||||
|
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSend?.({
|
await onSend?.({
|
||||||
to: toAddresses,
|
to: toAddresses,
|
||||||
cc: ccAddresses,
|
cc: ccAddresses,
|
||||||
bcc: bccAddresses,
|
bcc: bccAddresses,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body: finalBody,
|
||||||
draftId: finalDraftId || undefined,
|
draftId: finalDraftId || undefined,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
fromName: currentIdentity?.name || undefined,
|
fromName: currentIdentity?.name || undefined,
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|||||||
import { IdentityForm } from './identity-form';
|
import { IdentityForm } from './identity-form';
|
||||||
import { useIdentityStore } from '@/stores/identity-store';
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
import { useAuthStore } from '@/stores/auth-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 type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||||
@@ -34,6 +39,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
|
|
||||||
const client = useAuthStore((state) => state.client);
|
const client = useAuthStore((state) => state.client);
|
||||||
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
|
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
|
||||||
|
const syncIdentities = useSyncIdentities();
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -82,6 +88,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
);
|
);
|
||||||
|
|
||||||
addIdentity(newIdentity);
|
addIdentity(newIdentity);
|
||||||
|
syncIdentities();
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
toast.success(tNotif('identity_created'));
|
toast.success(tNotif('identity_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -104,6 +111,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateIdentityLocal(identity.id, data);
|
updateIdentityLocal(identity.id, data);
|
||||||
|
syncIdentities();
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
toast.success(tNotif('identity_updated'));
|
toast.success(tNotif('identity_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -133,6 +141,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
try {
|
try {
|
||||||
await client.deleteIdentity(identity.id);
|
await client.deleteIdentity(identity.id);
|
||||||
removeIdentity(identity.id);
|
removeIdentity(identity.id);
|
||||||
|
syncIdentities();
|
||||||
toast.success(tNotif('identity_deleted'));
|
toast.success(tNotif('identity_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,7 @@ interface AuthState {
|
|||||||
logout: () => void;
|
logout: () => void;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
syncIdentities: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
||||||
@@ -479,6 +480,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
syncIdentities: () => {
|
||||||
|
const identityState = useIdentityStore.getState();
|
||||||
|
const identities = identityState.identities;
|
||||||
|
const primaryIdentity = identities[0] ?? null;
|
||||||
|
set({ identities, primaryIdentity });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
|
|||||||
Reference in New Issue
Block a user