feat: sender favicon avatars

This commit is contained in:
Linus Rath
2026-03-09 18:55:44 +01:00
parent cf02f587de
commit 481d566eb6
12 changed files with 176 additions and 3 deletions
+99
View File
@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server';
// In-memory LRU cache: domain -> { data, contentType, fetchedAt }
const CACHE_MAX_SIZE = 1000;
const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 2 weeks
interface CacheEntry {
data: ArrayBuffer;
contentType: string;
fetchedAt: number;
}
const cache = new Map<string, CacheEntry>();
// Strict domain validation to prevent SSRF
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
function isValidDomain(domain: string): boolean {
if (domain.length > 253) return false;
if (!DOMAIN_RE.test(domain)) return false;
// Block internal/private hostnames
const lower = domain.toLowerCase();
if (
lower === 'localhost' ||
lower.endsWith('.local') ||
lower.endsWith('.internal') ||
lower.endsWith('.arpa')
) {
return false;
}
return true;
}
function evictOldest() {
if (cache.size < CACHE_MAX_SIZE) return;
// Evict the oldest entry
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of cache) {
if (entry.fetchedAt < oldestTime) {
oldestTime = entry.fetchedAt;
oldestKey = key;
}
}
if (oldestKey) cache.delete(oldestKey);
}
export async function GET(request: NextRequest) {
const domain = request.nextUrl.searchParams.get('domain');
if (!domain || !isValidDomain(domain)) {
return new NextResponse(null, { status: 400 });
}
const normalizedDomain = domain.toLowerCase();
// Check cache
const cached = cache.get(normalizedDomain);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return new NextResponse(cached.data, {
headers: {
'Content-Type': cached.contentType,
'Cache-Control': 'public, max-age=1209600', // 2 weeks
},
});
}
try {
const upstream = await fetch(
`https://icons.duckduckgo.com/ip3/${encodeURIComponent(normalizedDomain)}.ico`,
{ signal: AbortSignal.timeout(5000) }
);
if (!upstream.ok) {
return new NextResponse(null, { status: 404 });
}
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
const data = await upstream.arrayBuffer();
// Don't cache empty/tiny responses (likely no real favicon)
if (data.byteLength < 10) {
return new NextResponse(null, { status: 404 });
}
// Cache the result
evictOldest();
cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() });
return new NextResponse(data, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=1209600',
},
});
} catch {
return new NextResponse(null, { status: 502 });
}
}
+6 -1
View File
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button';
export function AdvancedSettings() { export function AdvancedSettings() {
const t = useTranslations('settings.advanced'); const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } = const { debugMode, senderFavicons, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore(); useSettingsStore();
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -66,6 +66,11 @@ export function AdvancedSettings() {
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} /> <ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem> </SettingItem>
{/* Sender Favicons (Experimental) */}
<SettingItem label={t('sender_favicons.label')} description={t('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
{/* Export Settings */} {/* Export Settings */}
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}> <SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}> <Button variant="outline" size="sm" onClick={handleExport}>
+32 -2
View File
@@ -1,4 +1,18 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
// Personal email domains where the favicon is the mail provider logo, not the sender
const PERSONAL_DOMAINS = new Set([
"gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com",
"msn.com", "yahoo.com", "yahoo.fr", "yahoo.co.uk", "yahoo.co.jp",
"aol.com", "icloud.com", "me.com", "mac.com", "mail.com",
"proton.me", "protonmail.com", "pm.me", "tutanota.com", "tuta.com",
"zoho.com", "yandex.com", "yandex.ru", "gmx.com", "gmx.net",
"fastmail.com", "hey.com", "posteo.de", "mailbox.org",
]);
interface AvatarProps { interface AvatarProps {
name?: string; name?: string;
@@ -8,6 +22,9 @@ interface AvatarProps {
} }
export function Avatar({ name, email, size = "md", className }: AvatarProps) { export function Avatar({ name, email, size = "md", className }: AvatarProps) {
const [faviconError, setFaviconError] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const getInitials = () => { const getInitials = () => {
if (name) { if (name) {
const parts = name.trim().split(/\s+/); const parts = name.trim().split(/\s+/);
@@ -38,17 +55,30 @@ export function Avatar({ name, email, size = "md", className }: AvatarProps) {
lg: "w-12 h-12 text-base", lg: "w-12 h-12 text-base",
}; };
const domain = email?.split("@")[1]?.toLowerCase();
const showFavicon =
senderFavicons && domain && !PERSONAL_DOMAINS.has(domain) && !faviconError;
return ( return (
<div <div
className={cn( className={cn(
"rounded-full flex items-center justify-center font-semibold text-white", "rounded-full flex items-center justify-center font-semibold text-white overflow-hidden",
sizeClasses[size], sizeClasses[size],
className className
)} )}
style={{ backgroundColor: getBackgroundColor() }} style={{ backgroundColor: getBackgroundColor() }}
title={name || email} title={name || email}
> >
{getInitials()} {showFavicon ? (
<img
src={`/api/favicon?domain=${encodeURIComponent(domain)}`}
alt=""
className="w-full h-full object-cover"
onError={() => setFaviconError(true)}
/>
) : (
getInitials()
)}
</div> </div>
); );
} }
+4
View File
@@ -698,6 +698,10 @@
"label": "Debug-Modus", "label": "Debug-Modus",
"description": "Detaillierte Protokollierung zur Fehlerbehebung aktivieren" "description": "Detaillierte Protokollierung zur Fehlerbehebung aktivieren"
}, },
"sender_favicons": {
"label": "Absender-Favicons (Experimentell)",
"description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Tastaturkürzel", "label": "Tastaturkürzel",
"description": "Verfügbare Tastaturkürzel anzeigen", "description": "Verfügbare Tastaturkürzel anzeigen",
+4
View File
@@ -706,6 +706,10 @@
"label": "Debug Mode", "label": "Debug Mode",
"description": "Enable detailed logging for troubleshooting" "description": "Enable detailed logging for troubleshooting"
}, },
"sender_favicons": {
"label": "Sender Favicons (Experimental)",
"description": "Show website icons as profile pictures for business senders"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Keyboard Shortcuts", "label": "Keyboard Shortcuts",
"description": "View available keyboard shortcuts", "description": "View available keyboard shortcuts",
+4
View File
@@ -698,6 +698,10 @@
"label": "Modo de Depuración", "label": "Modo de Depuración",
"description": "Habilitar registro detallado para solución de problemas" "description": "Habilitar registro detallado para solución de problemas"
}, },
"sender_favicons": {
"label": "Favicons de remitente (Experimental)",
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Atajos de Teclado", "label": "Atajos de Teclado",
"description": "Ver atajos de teclado disponibles", "description": "Ver atajos de teclado disponibles",
+4
View File
@@ -698,6 +698,10 @@
"label": "Mode débogage", "label": "Mode débogage",
"description": "Activer la journalisation détaillée pour le dépannage" "description": "Activer la journalisation détaillée pour le dépannage"
}, },
"sender_favicons": {
"label": "Favicons des expéditeurs (Expérimental)",
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Raccourcis clavier", "label": "Raccourcis clavier",
"description": "Voir les raccourcis clavier disponibles", "description": "Voir les raccourcis clavier disponibles",
+4
View File
@@ -698,6 +698,10 @@
"label": "Modalità debug", "label": "Modalità debug",
"description": "Abilita registrazione dettagliata per la risoluzione dei problemi" "description": "Abilita registrazione dettagliata per la risoluzione dei problemi"
}, },
"sender_favicons": {
"label": "Favicon dei mittenti (Sperimentale)",
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Scorciatoie da tastiera", "label": "Scorciatoie da tastiera",
"description": "Visualizza le scorciatoie da tastiera disponibili", "description": "Visualizza le scorciatoie da tastiera disponibili",
+4
View File
@@ -698,6 +698,10 @@
"label": "デバッグモード", "label": "デバッグモード",
"description": "トラブルシューティング用の詳細ログを有効化" "description": "トラブルシューティング用の詳細ログを有効化"
}, },
"sender_favicons": {
"label": "送信者ファビコン(実験的)",
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "キーボードショートカット", "label": "キーボードショートカット",
"description": "利用可能なキーボードショートカットを表示", "description": "利用可能なキーボードショートカットを表示",
+4
View File
@@ -698,6 +698,10 @@
"label": "Debugmodus", "label": "Debugmodus",
"description": "Schakel gedetailleerde logging in voor probleemoplossing" "description": "Schakel gedetailleerde logging in voor probleemoplossing"
}, },
"sender_favicons": {
"label": "Afzender-favicons (Experimenteel)",
"description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Sneltoetsen", "label": "Sneltoetsen",
"description": "Bekijk beschikbare sneltoetsen", "description": "Bekijk beschikbare sneltoetsen",
+4
View File
@@ -698,6 +698,10 @@
"label": "Modo de Depuração", "label": "Modo de Depuração",
"description": "Habilitar registro detalhado para solução de problemas" "description": "Habilitar registro detalhado para solução de problemas"
}, },
"sender_favicons": {
"label": "Favicons de remetente (Experimental)",
"description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais"
},
"keyboard_shortcuts": { "keyboard_shortcuts": {
"label": "Atalhos de Teclado", "label": "Atalhos de Teclado",
"description": "Visualizar atalhos de teclado disponíveis", "description": "Visualizar atalhos de teclado disponíveis",
+7
View File
@@ -41,6 +41,9 @@ interface SettingsState {
calendarNotificationsEnabled: boolean; calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean; calendarNotificationSound: boolean;
// Experimental
senderFavicons: boolean;
// Advanced // Advanced
debugMode: boolean; debugMode: boolean;
@@ -90,6 +93,9 @@ const DEFAULT_SETTINGS = {
calendarNotificationsEnabled: true, calendarNotificationsEnabled: true,
calendarNotificationSound: true, calendarNotificationSound: true,
// Experimental
senderFavicons: false,
// Advanced // Advanced
debugMode: false, debugMode: false,
}; };
@@ -146,6 +152,7 @@ export const useSettingsStore = create<SettingsState>()(
sessionTimeout: state.sessionTimeout, sessionTimeout: state.sessionTimeout,
calendarNotificationsEnabled: state.calendarNotificationsEnabled, calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound, calendarNotificationSound: state.calendarNotificationSound,
senderFavicons: state.senderFavicons,
debugMode: state.debugMode, debugMode: state.debugMode,
}; };
return JSON.stringify(settings, null, 2); return JSON.stringify(settings, null, 2);