Files
SRCmail/stores/identity-store.ts
T
Matthieu MALVACHEandMatthieu MALVACHE 5d273e2109 feat: expand internationalization and add identity management
This release significantly expands internationalization support and adds comprehensive identity management features.

Internationalization (i18n):
- Add 5 new languages: Spanish, Italian, German, Dutch, Portuguese
- Expand from 3 to 8 total supported languages
- Redesign language switcher for better scalability (dropdown UI)
- Complete translations for all features across all languages

Identity Management:
- Multiple sender identities with per-identity signatures
- Sub-addressing support (user+tag@domain.com)
- Context-aware tag suggestions for sub-addresses
- Identity badges in email viewer and list
- Full CRUD operations for managing identities

Newsletter Management:
- RFC 2369 List-Unsubscribe support (one-click unsubscribe)
- HTTP and mailto unsubscribe methods
- Security validation prevents XSS attacks
- Two-step confirmation with persistent dismissal

Security & Accessibility:
- Dark mode email readability (intelligent color transformation)
- WCAG 2.0 Level AA color contrast compliance
- Comprehensive XSS prevention with validation utilities
- Unit test coverage for security-critical code (57 validation tests)

Testing:
- Add unit tests for validation utilities
- Add unit tests for email sanitization
- Add unit tests for color transformation
- Full test coverage for XSS attack vectors
2026-01-08 22:15:32 +01:00

128 lines
3.6 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;
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;
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,
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 }),
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)
partialize: (state) => ({
subAddress: state.subAddress
}),
}
)
);