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:
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user