The default sender identity (`preferredPrimaryId`) lived only in the browser-local `identity-storage` Zustand store and was never written to the server-side synced settings. As a result the choice was lost when clearing site data or switching browsers, and never appeared in the exported settings JSON. Persist the default identity in the synced settings store, keyed per account (`preferredIdentityIds: Record<accountId, identityId>`), mirroring the existing per-account `allMailFolderIds`. Per-account keying is required because JMAP identity ids are account-scoped and would otherwise collide across accounts / the unified mailbox. - settings-store: add `preferredIdentityIds` to state, defaults, export (so it shows in exported JSON), import (with a non-record guard), rehydrate coercion, and a v6->v7 migration. - auth-store: add `applyPreferredIdentity()`, invoked in every `loadFromServer().finally()` (login / OAuth / SSO / switch / restore) so the synced default reorders the active account's identities once server settings load (the composer defaults From to identities[0]). - identity-manager-modal: the star action also writes the choice to the synced per-account map, triggering server sync + export inclusion. - identity-store: keep `preferredPrimaryId` in local persist as a sync-off fallback; synced settings are the durable cross-device source of truth. - tests: per-account independence, export/import round-trip, non-record import guard, and v6->v7 migration.
141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { Identity } from '@/lib/jmap/types';
|
|
|
|
// Constants for sub-addressing limits
|
|
const MAX_RECENT_TAGS = 10;
|
|
const MAX_DOMAIN_SUGGESTIONS = 5;
|
|
|
|
interface SubAddressState {
|
|
recentTags: string[];
|
|
tagSuggestions: Record<string, string[]>;
|
|
}
|
|
|
|
interface IdentityStore {
|
|
// Identity state (from server)
|
|
identities: Identity[];
|
|
selectedIdentityId: string | null;
|
|
preferredPrimaryId: string | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
// Sub-addressing state (persisted locally)
|
|
subAddress: SubAddressState;
|
|
|
|
// Actions - Identity CRUD
|
|
setIdentities: (identities: Identity[]) => void;
|
|
addIdentity: (identity: Identity) => void;
|
|
updateIdentityLocal: (identityId: string, updates: Partial<Identity>) => void;
|
|
removeIdentity: (identityId: string) => void;
|
|
selectIdentity: (identityId: string | null) => void;
|
|
setPreferredPrimary: (identityId: string | null) => void;
|
|
setLoading: (loading: boolean) => void;
|
|
setError: (error: string | null) => void;
|
|
clearIdentities: () => void;
|
|
|
|
// Sub-addressing actions
|
|
addRecentTag: (tag: string) => void;
|
|
addTagSuggestion: (domain: string, tag: string) => void;
|
|
getTagSuggestionsForDomain: (domain: string) => string[];
|
|
clearRecentTags: () => void;
|
|
}
|
|
|
|
export const useIdentityStore = create<IdentityStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
identities: [],
|
|
selectedIdentityId: null,
|
|
preferredPrimaryId: null,
|
|
isLoading: false,
|
|
error: null,
|
|
subAddress: {
|
|
recentTags: [],
|
|
tagSuggestions: {},
|
|
},
|
|
|
|
setIdentities: (identities) => set({ identities }),
|
|
|
|
addIdentity: (identity) => set((state) => ({
|
|
identities: [...state.identities, identity]
|
|
})),
|
|
|
|
updateIdentityLocal: (identityId, updates) => set((state) => ({
|
|
identities: state.identities.map(id =>
|
|
id.id === identityId ? { ...id, ...updates } : id
|
|
)
|
|
})),
|
|
|
|
removeIdentity: (identityId) => set((state) => ({
|
|
identities: state.identities.filter(id => id.id !== identityId),
|
|
selectedIdentityId: state.selectedIdentityId === identityId
|
|
? null
|
|
: state.selectedIdentityId
|
|
})),
|
|
|
|
selectIdentity: (identityId) => set({ selectedIdentityId: identityId }),
|
|
|
|
setPreferredPrimary: (identityId) => set({ preferredPrimaryId: identityId }),
|
|
|
|
setLoading: (loading) => set({ isLoading: loading }),
|
|
|
|
setError: (error) => set({ error }),
|
|
|
|
clearIdentities: () => set({
|
|
identities: [],
|
|
selectedIdentityId: null,
|
|
error: null,
|
|
}),
|
|
|
|
addRecentTag: (tag) => set((state) => {
|
|
const recent = [tag, ...state.subAddress.recentTags.filter(t => t !== tag)];
|
|
return {
|
|
subAddress: {
|
|
...state.subAddress,
|
|
recentTags: recent.slice(0, MAX_RECENT_TAGS),
|
|
}
|
|
};
|
|
}),
|
|
|
|
addTagSuggestion: (domain, tag) => set((state) => {
|
|
const suggestions = { ...state.subAddress.tagSuggestions };
|
|
const existing = suggestions[domain] || [];
|
|
if (!existing.includes(tag)) {
|
|
suggestions[domain] = [...existing, tag].slice(0, MAX_DOMAIN_SUGGESTIONS);
|
|
}
|
|
return {
|
|
subAddress: {
|
|
...state.subAddress,
|
|
tagSuggestions: suggestions,
|
|
}
|
|
};
|
|
}),
|
|
|
|
getTagSuggestionsForDomain: (domain) => {
|
|
return get().subAddress.tagSuggestions[domain] || [];
|
|
},
|
|
|
|
clearRecentTags: () => set((state) => ({
|
|
subAddress: {
|
|
...state.subAddress,
|
|
recentTags: [],
|
|
}
|
|
})),
|
|
}),
|
|
{
|
|
name: 'identity-storage',
|
|
// Only persist sub-addressing data, not identities (they're server-side).
|
|
// The default sender identity (`preferredPrimaryId`) is the per-account
|
|
// value for the *active* account; it is kept here purely as a local
|
|
// fallback so the choice survives a reload when settings sync is off.
|
|
// The durable, cross-device, exportable source of truth is the synced
|
|
// settings store, keyed per account (`preferredIdentityIds`), which is
|
|
// re-applied via applyPreferredIdentity() once server settings load and
|
|
// overrides this value per account (issue #507).
|
|
partialize: (state) => ({
|
|
subAddress: state.subAddress,
|
|
preferredPrimaryId: state.preferredPrimaryId,
|
|
}),
|
|
}
|
|
)
|
|
);
|