Files
SRCmail/components/email/unsubscribe-banner.tsx
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

136 lines
4.2 KiB
TypeScript

'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>
);
}