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
This commit is contained in:
Matthieu MALVACHE
2026-01-08 22:15:32 +01:00
committed by Matthieu MALVACHE
parent 0d68851b63
commit 5d273e2109
49 changed files with 11018 additions and 432 deletions
+7
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
import { useIdentityStore } from './identity-store';
import type { Identity } from '@/lib/jmap/types';
interface AuthState {
@@ -46,6 +47,9 @@ export const useAuthStore = create<AuthState>()(
const identities = await client.getIdentities();
const primaryIdentity = identities.length > 0 ? identities[0] : null;
// Sync identities to identity store
useIdentityStore.getState().setIdentities(identities);
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
@@ -117,6 +121,9 @@ export const useAuthStore = create<AuthState>()(
searchQuery: "",
quota: null,
});
// Clear identity store state
useIdentityStore.getState().clearIdentities();
},
checkAuth: async () => {
+142 -5
View File
@@ -46,7 +46,7 @@ interface EmailStore {
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], draftId?: string, fromEmail?: string, identityId?: string) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -58,6 +58,13 @@ interface EmailStore {
batchDelete: (client: JMAPClient) => Promise<void>;
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
// Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
markAsSpam: (client: JMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: JMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
// Push notification handlers
setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
@@ -99,6 +106,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
threadEmailsCache: new Map(),
isLoadingThread: null,
// Spam undo cache
spamUndoCache: new Map(),
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
selectEmail: (email) => set({ selectedEmail: email }),
@@ -282,12 +292,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendEmail: async (client, to, subject, body, cc, bcc, draftId, fromEmail, identityId) => {
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, draftId, fromEmail, identityId);
// Refresh emails after sending
await get().fetchEmails(client);
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId);
// Refresh handled by UI layer for immediate feedback
set({ isLoading: false });
} catch (error) {
set({
@@ -763,6 +772,134 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
// Spam operations
markAsSpam: async (client, emailId) => {
const { selectedMailbox, mailboxes, emails } = get();
const email = emails.find(e => e.id === emailId);
if (!email) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
get().spamUndoCache.set(emailId, {
emailId,
originalMailboxId: currentMailbox.originalId || currentMailbox.id,
accountId: currentMailbox.accountId,
});
try {
await client.markAsSpam(emailId, currentMailbox.accountId);
set(state => ({
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
}));
const currentIndex = emails.findIndex(e => e.id === emailId);
if (currentIndex >= 0 && currentIndex < emails.length - 1) {
set({ selectedEmail: emails[currentIndex + 1] });
}
} catch (error) {
console.error('Failed to mark as spam:', error);
throw error;
}
},
undoSpam: async (client, emailId) => {
const { mailboxes, selectedMailbox } = get();
// Try cache first (preserves exact original mailbox for toast undo)
const cachedData = get().spamUndoCache.get(emailId);
let targetMailboxId: string;
let accountId: string | undefined;
if (cachedData) {
// Use cached original mailbox (more accurate for immediate undo)
targetMailboxId = cachedData.originalMailboxId;
accountId = cachedData.accountId;
get().spamUndoCache.delete(emailId);
} else {
// Fall back to finding Inbox (generic "not spam" button/menu)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
accountId = currentMailbox?.accountId;
// Find inbox in same account
const inboxMailbox = mailboxes.find(m =>
m.role === 'inbox' &&
(accountId ? m.accountId === accountId : !m.accountId)
);
if (!inboxMailbox) {
throw new Error('Inbox not found');
}
targetMailboxId = inboxMailbox.id;
}
try {
await client.undoSpam(emailId, targetMailboxId, accountId);
await get().fetchEmails(client, selectedMailbox);
} catch (error) {
console.error('Failed to restore email:', error);
throw error;
}
},
batchMarkAsSpam: async (client, emailIds) => {
const { selectedMailbox, mailboxes } = get();
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
try {
for (const emailId of emailIds) {
await client.markAsSpam(emailId, currentMailbox.accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch mark as spam:', error);
throw error;
}
},
batchUndoSpam: async (client: JMAPClient, emailIds: string[]) => {
const { mailboxes, selectedMailbox } = get();
// Find inbox (batch operations don't preserve original mailboxes)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const accountId = currentMailbox?.accountId;
const inboxMailbox = mailboxes.find(m =>
m.role === 'inbox' &&
(accountId ? m.accountId === accountId : !m.accountId)
);
if (!inboxMailbox) {
throw new Error('Inbox not found');
}
try {
for (const emailId of emailIds) {
await client.undoSpam(emailId, inboxMailbox.id, accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch restore emails:', error);
throw error;
}
},
// Push notification handlers
setPushConnected: (connected) => {
set({ isPushConnected: connected });
+127
View File
@@ -0,0 +1,127 @@
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
}),
}
)
);