feat: enhance contact management and vCard support

- Added support for parsing and generating additional vCard properties including GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, and SOURCE.
- Extended ContactCard interface to include new fields such as gender, media, anniversaries, online services, and personal info.
- Implemented logic to handle multi-part TLDs for domain extraction in avatars.
- Improved avatar component to prioritize contact photos and handle inline images in emails.
- Updated localization files to include new fields and labels for contact details.
- Refactored contact store to expose a method for retrieving contact photos.
- Enhanced unit tests to cover new vCard properties and ensure correct parsing and generation.
This commit is contained in:
Linus Rath
2026-03-12 15:54:09 +01:00
parent 4737f2928b
commit 7fedcb8e58
12 changed files with 2042 additions and 361 deletions
+188 -23
View File
@@ -431,7 +431,7 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities } = useAuthStore();
const { identities, client } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false);
@@ -662,6 +662,30 @@ export function EmailViewer({
// If we should use HTML version and it exists
if (useHtmlVersion && htmlContent) {
// Replace cid: references with actual blob download URLs for inline images
const cidReplacedUrls = new Set<string>();
if (client && email.attachments) {
const cidMap = new Map<string, string>();
for (const att of email.attachments) {
if (att.cid && att.blobId) {
const cidValue = att.cid.replace(/^<|>$/g, '');
try {
const url = client.getBlobDownloadUrl(att.blobId, att.name || 'inline', att.type);
cidMap.set(cidValue, url);
cidReplacedUrls.add(url);
} catch {
// downloadUrl not available yet, skip
}
}
}
if (cidMap.size > 0) {
htmlContent = htmlContent.replace(
/\bcid:([^"'\s)]+)/gi,
(match, cidRef) => cidMap.get(cidRef) || match
);
}
}
// Create a custom DOMPurify hook to handle external content
let blockedExternalContent = false;
@@ -690,7 +714,7 @@ export function EmailViewer({
if (shouldBlockExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
if (src && !cidReplacedUrls.has(src) && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
node.setAttribute('alt', '');
@@ -801,7 +825,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme, client]);
// Print only the email content in a new window
const handlePrint = () => {
@@ -1424,20 +1448,57 @@ export function EmailViewer({
{/* === SENDER INFO (Desktop) === */}
<div className="hidden lg:block bg-background border-b border-border px-6 py-4">
<div className="flex items-start gap-4">
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-12 h-12"
/>
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="cursor-pointer group flex-shrink-0"
title={sender?.email || undefined}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-12 h-12 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/>
</button>
<div className="flex-1 min-w-0">
{/* Sender line with compact badges */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
{sender?.name || sender?.email || t('unknown_sender')}
</span>
<EmailIdentityBadge email={email} identities={identities} />
{/* Sender line with email and badges */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
title={t('view_contact')}
>
{sender?.name || sender?.email || t('unknown_sender')}
</button>
<EmailIdentityBadge email={email} identities={identities} />
</div>
{sender?.email && (
<div className="text-sm text-muted-foreground mt-0.5 truncate">
{sender.email}
</div>
)}
</div>
{/* Date and size on the right */}
<div className="text-right flex-shrink-0">
<div className="text-sm text-muted-foreground whitespace-nowrap">
{new Date(email.receivedAt).toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/70 mt-0.5">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
{/* Recipient section - separate line */}
@@ -1466,6 +1527,16 @@ export function EmailViewer({
)}
</div>
)}
{email.bcc && email.bcc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">{t('bcc')}:</span>
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)}
{email.bcc.length > 2 && (
<span className="text-muted-foreground text-sm">+{email.bcc.length - 2}</span>
)}
</div>
)}
</div>
{/* Details toggle - stays in place when expanded */}
@@ -1489,6 +1560,91 @@ export function EmailViewer({
{/* Expandable Details */}
{showFullHeaders && (
<div className="mt-3 space-y-3">
{/* Full Recipients Section */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
<Mail className="w-3.5 h-3.5" />
{t('message_details')}
</h3>
</div>
<div className="bg-background p-4 space-y-2 text-sm">
{/* From */}
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('from')}:</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
<RecipientPopover
name={sender?.name}
email={sender?.email || ''}
onViewContact={handleViewContactSidebar}
className="text-sm"
/>
</div>
</div>
{/* To - show all */}
{email.to && email.to.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('to')}:</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</div>
)}
{/* CC - show all */}
{email.cc && email.cc.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('cc')}:</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</div>
)}
{/* BCC - show all */}
{email.bcc && email.bcc.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('bcc')}:</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</div>
)}
{/* Date */}
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('date')}:</span>
<span className="text-foreground">
{new Date(email.receivedAt).toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
})}
</span>
</div>
{/* Reply-To if different */}
{email.replyTo && email.replyTo.length > 0 &&
(!email.from || email.replyTo[0].email !== email.from[0]?.email) && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('reply_to_label').replace(':', '')}</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
{email.replyTo.map((r, i) => (
<RecipientPopover
key={r.email + i}
name={r.name}
email={r.email}
onViewContact={handleViewContactSidebar}
className="text-sm"
/>
))}
</div>
</div>
)}
</div>
</div>
{/* Security & Authentication Section */}
{(email.authenticationResults || email.spamScore !== undefined) && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@@ -1811,18 +1967,27 @@ export function EmailViewer({
{/* Mobile/Tablet Sender Info - scrolls with content */}
<div className="lg:hidden bg-background border-b border-border px-4 py-3">
<div className="flex items-start gap-3">
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10"
/>
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="cursor-pointer group flex-shrink-0"
title={sender?.email || undefined}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/>
</button>
<div className="flex-1 min-w-0">
{/* Mobile 2-line layout */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-foreground">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
>
{sender?.name || sender?.email || t('unknown_sender')}
</span>
</button>
<EmailIdentityBadge email={email} identities={identities} />
</div>
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
+28 -2
View File
@@ -29,6 +29,7 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
interface ThreadConversationViewProps {
thread: ThreadGroup;
@@ -224,6 +225,7 @@ function EmailCard({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const { client } = useAuthStore();
// Mark as read when email is expanded
useEffect(() => {
@@ -267,6 +269,30 @@ function EmailCard({
}
if (useHtmlVersion && htmlContent) {
// Replace cid: references with actual blob download URLs for inline images
const cidReplacedUrls = new Set<string>();
if (client && email.attachments) {
const cidMap = new Map<string, string>();
for (const att of email.attachments) {
if (att.cid && att.blobId) {
const cidValue = att.cid.replace(/^<|>$/g, '');
try {
const url = client.getBlobDownloadUrl(att.blobId, att.name || 'inline', att.type);
cidMap.set(cidValue, url);
cidReplacedUrls.add(url);
} catch {
// downloadUrl not available yet, skip
}
}
}
if (cidMap.size > 0) {
htmlContent = htmlContent.replace(
/\bcid:([^"'\s)]+)/gi,
(match, cidRef) => cidMap.get(cidRef) || match
);
}
}
let blockedExternalContent = false;
// Use shared sanitization config as base (more secure)
@@ -278,7 +304,7 @@ function EmailCard({
if (!allowExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
if (src && !cidReplacedUrls.has(src) && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.removeAttribute('src');
node.setAttribute('alt', '[Image blocked]');
@@ -352,7 +378,7 @@ function EmailCard({
}
return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme]);
}, [email, allowExternal, resolvedTheme, client]);
return (
<div className={cn(