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() {
|
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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -698,6 +698,10 @@
|
|||||||
"label": "デバッグモード",
|
"label": "デバッグモード",
|
||||||
"description": "トラブルシューティング用の詳細ログを有効化"
|
"description": "トラブルシューティング用の詳細ログを有効化"
|
||||||
},
|
},
|
||||||
|
"sender_favicons": {
|
||||||
|
"label": "送信者ファビコン(実験的)",
|
||||||
|
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "キーボードショートカット",
|
"label": "キーボードショートカット",
|
||||||
"description": "利用可能なキーボードショートカットを表示",
|
"description": "利用可能なキーボードショートカットを表示",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user