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,142 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Mail, Tag } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Email, Identity } from '@/lib/jmap/types';
|
||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||
|
||||
interface EmailIdentityBadgeProps {
|
||||
email: Email;
|
||||
identities: Identity[];
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmailIdentityBadge({
|
||||
email,
|
||||
identities,
|
||||
compact = false,
|
||||
className,
|
||||
}: EmailIdentityBadgeProps) {
|
||||
const t = useTranslations('identities.badge');
|
||||
|
||||
const fromAddress = email.from?.[0]?.email;
|
||||
if (!fromAddress) return null;
|
||||
|
||||
// Parse the from address to check for sub-addressing
|
||||
const parsedFrom = parseSubAddress(fromAddress);
|
||||
|
||||
// Find matching identity (email sent BY the user)
|
||||
const matchingIdentity = identities.find(
|
||||
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
||||
);
|
||||
|
||||
// Check if email was sent TO a sub-address (received email)
|
||||
let receivedToTag: string | null = null;
|
||||
if (!matchingIdentity) {
|
||||
// Check all TO addresses for sub-address tags matching user's identities
|
||||
for (const recipient of email.to || []) {
|
||||
const parsedTo = parseSubAddress(recipient.email);
|
||||
if (parsedTo.tag) {
|
||||
// Check if this base email matches any of the user's identities
|
||||
const matchingToIdentity = identities.find(
|
||||
(identity) => identity.email === `${parsedTo.baseUser}@${parsedTo.domain}`
|
||||
);
|
||||
if (matchingToIdentity) {
|
||||
receivedToTag = parsedTo.tag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which tag to display (sent or received)
|
||||
const displayTag = matchingIdentity ? parsedFrom.tag : receivedToTag;
|
||||
|
||||
// Don't show badge if not from user's identity and not to user's sub-address
|
||||
if (!matchingIdentity && !receivedToTag) return null;
|
||||
|
||||
if (compact) {
|
||||
// Compact view for email list
|
||||
if (displayTag) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
|
||||
'bg-primary/10 text-primary',
|
||||
className
|
||||
)}
|
||||
title={t('sub_address_tag', { tag: displayTag })}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
<span className="font-mono">+{displayTag}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
matchingIdentity &&
|
||||
matchingIdentity.name &&
|
||||
matchingIdentity.name !== matchingIdentity.email &&
|
||||
matchingIdentity.name !== fromAddress
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
|
||||
'bg-secondary text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
title={t('identity_name', { name: matchingIdentity.name })}
|
||||
>
|
||||
<Mail className="w-3 h-3" />
|
||||
<span className="truncate max-w-[100px]">{matchingIdentity.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Full view for email viewer - now shows compact badges only
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-2', className)}>
|
||||
{/* Sub-address tag badge */}
|
||||
{displayTag && (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
|
||||
'bg-primary/10 text-primary border border-primary/20',
|
||||
'text-xs font-semibold'
|
||||
)}
|
||||
title={t('sub_address_tag', { tag: displayTag })}
|
||||
aria-label={t('sub_address_tag', { tag: displayTag })}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Identity badge (only if identity has a name and no sub-address tag) */}
|
||||
{!displayTag &&
|
||||
matchingIdentity &&
|
||||
matchingIdentity.name &&
|
||||
matchingIdentity.name !== matchingIdentity.email &&
|
||||
matchingIdentity.name !== fromAddress && (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
|
||||
'bg-secondary text-muted-foreground border border-border',
|
||||
'text-xs font-medium'
|
||||
)}
|
||||
title={t('identity_name', { name: matchingIdentity.name })}
|
||||
aria-label={t('identity_name', { name: matchingIdentity.name })}
|
||||
>
|
||||
<Mail className="w-3 h-3" />
|
||||
<span>{t('identity_short', { name: matchingIdentity.name })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user