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
128 lines
3.6 KiB
TypeScript
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
|
|
}),
|
|
}
|
|
)
|
|
);
|