feat: sender favicon avatars
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button';
|
||||
export function AdvancedSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const tCommon = useTranslations('common');
|
||||
const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
const { debugMode, senderFavicons, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
useSettingsStore();
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -66,6 +66,11 @@ export function AdvancedSettings() {
|
||||
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
|
||||
</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 */}
|
||||
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
name?: string;
|
||||
@@ -8,6 +22,9 @@ interface AvatarProps {
|
||||
}
|
||||
|
||||
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
||||
const [faviconError, setFaviconError] = useState(false);
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||
|
||||
const getInitials = () => {
|
||||
if (name) {
|
||||
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",
|
||||
};
|
||||
|
||||
const domain = email?.split("@")[1]?.toLowerCase();
|
||||
const showFavicon =
|
||||
senderFavicons && domain && !PERSONAL_DOMAINS.has(domain) && !faviconError;
|
||||
|
||||
return (
|
||||
<div
|
||||
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],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: getBackgroundColor() }}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -698,6 +698,10 @@
|
||||
"label": "Debug-Modus",
|
||||
"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": {
|
||||
"label": "Tastaturkürzel",
|
||||
"description": "Verfügbare Tastaturkürzel anzeigen",
|
||||
|
||||
@@ -706,6 +706,10 @@
|
||||
"label": "Debug Mode",
|
||||
"description": "Enable detailed logging for troubleshooting"
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Sender Favicons (Experimental)",
|
||||
"description": "Show website icons as profile pictures for business senders"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "Keyboard Shortcuts",
|
||||
"description": "View available keyboard shortcuts",
|
||||
|
||||
@@ -698,6 +698,10 @@
|
||||
"label": "Modo de Depuración",
|
||||
"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": {
|
||||
"label": "Atajos de Teclado",
|
||||
"description": "Ver atajos de teclado disponibles",
|
||||
|
||||
@@ -698,6 +698,10 @@
|
||||
"label": "Mode débogage",
|
||||
"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": {
|
||||
"label": "Raccourcis clavier",
|
||||
"description": "Voir les raccourcis clavier disponibles",
|
||||
|
||||
@@ -698,6 +698,10 @@
|
||||
"label": "Modalità debug",
|
||||
"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": {
|
||||
"label": "Scorciatoie da tastiera",
|
||||
"description": "Visualizza le scorciatoie da tastiera disponibili",
|
||||
|
||||
@@ -698,6 +698,10 @@
|
||||
"label": "デバッグモード",
|
||||
"description": "トラブルシューティング用の詳細ログを有効化"
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "送信者ファビコン(実験的)",
|
||||
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "キーボードショートカット",
|
||||
"description": "利用可能なキーボードショートカットを表示",
|
||||
|
||||
@@ -698,6 +698,10 @@
|
||||
"label": "Debugmodus",
|
||||
"description": "Schakel gedetailleerde logging in voor probleemoplossing"
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Afzender-favicons (Experimenteel)",
|
||||
"description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "Sneltoetsen",
|
||||
"description": "Bekijk beschikbare sneltoetsen",
|
||||
|
||||
@@ -698,6 +698,10 @@
|
||||
"label": "Modo de Depuração",
|
||||
"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": {
|
||||
"label": "Atalhos de Teclado",
|
||||
"description": "Visualizar atalhos de teclado disponíveis",
|
||||
|
||||
@@ -41,6 +41,9 @@ interface SettingsState {
|
||||
calendarNotificationsEnabled: boolean;
|
||||
calendarNotificationSound: boolean;
|
||||
|
||||
// Experimental
|
||||
senderFavicons: boolean;
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
|
||||
@@ -90,6 +93,9 @@ const DEFAULT_SETTINGS = {
|
||||
calendarNotificationsEnabled: true,
|
||||
calendarNotificationSound: true,
|
||||
|
||||
// Experimental
|
||||
senderFavicons: false,
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
};
|
||||
@@ -146,6 +152,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
||||
calendarNotificationSound: state.calendarNotificationSound,
|
||||
senderFavicons: state.senderFavicons,
|
||||
debugMode: state.debugMode,
|
||||
};
|
||||
return JSON.stringify(settings, null, 2);
|
||||
|
||||
Reference in New Issue
Block a user