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:
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { isValidUnsubscribeUrl } from '@/lib/validation';
|
||||
|
||||
interface UnsubscribeBannerProps {
|
||||
listUnsubscribe: {
|
||||
http?: string;
|
||||
mailto?: string;
|
||||
preferred?: 'http' | 'mailto';
|
||||
};
|
||||
senderEmail: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function UnsubscribeBanner({
|
||||
listUnsubscribe,
|
||||
senderEmail: _senderEmail,
|
||||
onDismiss
|
||||
}: UnsubscribeBannerProps) {
|
||||
const t = useTranslations();
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const unsubMethod = listUnsubscribe.preferred;
|
||||
const unsubUrl = unsubMethod === 'http'
|
||||
? listUnsubscribe.http
|
||||
: listUnsubscribe.mailto;
|
||||
|
||||
if (!unsubUrl || !unsubMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleUnsubscribe = async () => {
|
||||
if (!isValidUnsubscribeUrl(unsubUrl)) {
|
||||
setError(true);
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
if (unsubMethod === 'http') {
|
||||
window.open(unsubUrl, '_blank', 'noopener,noreferrer');
|
||||
setSuccess(true);
|
||||
setProcessing(false);
|
||||
setTimeout(onDismiss, 3000);
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.href = unsubUrl;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
setSuccess(true);
|
||||
setProcessing(false);
|
||||
setTimeout(onDismiss, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unsubscribe error:', err);
|
||||
setError(true);
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(unsubMethod === 'http'
|
||||
? 'email_viewer.unsubscribe_banner.success_http'
|
||||
: 'email_viewer.unsubscribe_banner.success_mailto'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{t('email_viewer.unsubscribe_banner.error')}
|
||||
</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
|
||||
>
|
||||
{t('email_viewer.unsubscribe_banner.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{showConfirm ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('email_viewer.unsubscribe_banner.confirm_title')}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleUnsubscribe}
|
||||
disabled={processing}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] md:min-h-0"
|
||||
>
|
||||
{processing && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{t('email_viewer.unsubscribe_banner.confirm_button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
|
||||
>
|
||||
{t('email_viewer.unsubscribe_banner.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
|
||||
>
|
||||
{t('email_viewer.unsubscribe_banner.button')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user