mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-30 16:01:32 +00:00
feat(ui): optional domain favicon avatars
When a contact has no photo, fall back to the sender domain's favicon instead of coloured initials. The lookup is opt-in (settings → email behaviour) and off by default. The favicon is fetched from DuckDuckGo's public icon service using only the domain name — the email address never leaves the browser, and nothing is hashed or correlated back to the user. Initials stay rendered behind the image so the avatar still reads right if the favicon 404s or the user is offline. Adds a settings toggle + translations across all 10 locales. Closes #22.
This commit is contained in:
@@ -18,6 +18,7 @@ export function EmailSettings() {
|
||||
emailsPerPage,
|
||||
externalContentPolicy,
|
||||
trustedSenders,
|
||||
domainFaviconAvatars,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
@@ -101,6 +102,14 @@ export function EmailSettings() {
|
||||
</button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Domain Favicon Avatars */}
|
||||
<SettingItem label={t('domain_favicon_avatars.label')} description={t('domain_favicon_avatars.description')}>
|
||||
<ToggleSwitch
|
||||
checked={domainFaviconAvatars}
|
||||
onChange={(checked) => updateSetting('domainFaviconAvatars', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders Modal */}
|
||||
<TrustedSendersModal
|
||||
isOpen={showTrustedModal}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface AvatarProps {
|
||||
name?: string;
|
||||
@@ -7,7 +11,36 @@ interface AvatarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function extractDomain(email?: string): string | null {
|
||||
if (!email) return null;
|
||||
const at = email.lastIndexOf("@");
|
||||
if (at < 0 || at === email.length - 1) return null;
|
||||
const domain = email.slice(at + 1).trim().toLowerCase();
|
||||
// Reject obviously invalid or local-only domains so we never send a
|
||||
// request for hostnames a public favicon service would never resolve.
|
||||
if (!domain.includes(".") || domain === "localhost") return null;
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* DuckDuckGo's favicon endpoint is used instead of Gravatar because it
|
||||
* operates on the sender's domain, not on a hash of their email address —
|
||||
* we never send anything that can be correlated back to the user.
|
||||
*/
|
||||
function faviconUrlFor(domain: string): string {
|
||||
return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
|
||||
}
|
||||
|
||||
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
||||
const domainFaviconAvatars = useSettingsStore((s) => s.domainFaviconAvatars);
|
||||
const [faviconOk, setFaviconOk] = useState(false);
|
||||
const domain = domainFaviconAvatars ? extractDomain(email) : null;
|
||||
|
||||
useEffect(() => {
|
||||
// Reset load state when the domain changes so we retry for a new sender.
|
||||
setFaviconOk(false);
|
||||
}, [domain]);
|
||||
|
||||
const getInitials = () => {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
@@ -41,14 +74,30 @@ export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
||||
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 relative",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: getBackgroundColor() }}
|
||||
title={name || email}
|
||||
>
|
||||
{getInitials()}
|
||||
{/* Initials render behind so there's no flash if the favicon loads late or fails */}
|
||||
<span aria-hidden={faviconOk}>{getInitials()}</span>
|
||||
{domain && (
|
||||
<img
|
||||
src={faviconUrlFor(domain)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => setFaviconOk(true)}
|
||||
onError={() => setFaviconOk(false)}
|
||||
className={cn(
|
||||
"absolute inset-0 w-full h-full object-cover transition-opacity duration-150",
|
||||
faviconOk ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,6 +668,10 @@
|
||||
"close": "Schließen",
|
||||
"invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"already_added": "Dieser Absender ist bereits vertrauenswürdig"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Domain-Avatare",
|
||||
"description": "Wenn ein Kontakt kein Bild hat, wird das Favicon der Absenderdomain anstelle der Initialen angezeigt. Wird von einem öffentlichen Favicon-Dienst nur anhand des Domain-Namens bezogen (keine E-Mail-Adresse, kein Hashing). Standardmäßig deaktiviert."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -676,6 +676,10 @@
|
||||
"close": "Close",
|
||||
"invalid_email": "Please enter a valid email address",
|
||||
"already_added": "This sender is already trusted"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Domain avatars",
|
||||
"description": "When a contact has no picture, show the sender domain's favicon instead of initials. Fetched from a public favicon service using only the domain name (no email address, no hashing). Off by default."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -668,6 +668,10 @@
|
||||
"close": "Cerrar",
|
||||
"invalid_email": "Por favor ingrese una dirección de correo válida",
|
||||
"already_added": "Este remitente ya es de confianza"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Avatares por dominio",
|
||||
"description": "Cuando un contacto no tiene foto, se muestra el favicon del dominio del remitente en lugar de las iniciales. Se obtiene de un servicio público de favicons usando únicamente el nombre de dominio (sin dirección de correo ni hash). Desactivado por defecto."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -668,6 +668,10 @@
|
||||
"close": "Fermer",
|
||||
"invalid_email": "Veuillez entrer une adresse email valide",
|
||||
"already_added": "Cet expéditeur est déjà de confiance"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Avatars par domaine",
|
||||
"description": "Quand un contact n'a pas de photo, afficher le favicon du domaine de l'expéditeur au lieu des initiales. Récupéré via un service public de favicons en utilisant uniquement le nom de domaine (sans adresse e-mail ni hachage). Désactivé par défaut."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -668,6 +668,10 @@
|
||||
"close": "Chiudi",
|
||||
"invalid_email": "Inserisci un indirizzo email valido",
|
||||
"already_added": "Questo mittente è già attendibile"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Avatar per dominio",
|
||||
"description": "Quando un contatto non ha una foto, viene mostrato il favicon del dominio del mittente invece delle iniziali. Recuperato da un servizio pubblico di favicon usando solo il nome di dominio (nessun indirizzo email, nessun hash). Disattivato per impostazione predefinita."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -668,6 +668,10 @@
|
||||
"close": "閉じる",
|
||||
"invalid_email": "有効なメールアドレスを入力してください",
|
||||
"already_added": "この送信者はすでに信頼されています"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "ドメインアバター",
|
||||
"description": "連絡先に写真がない場合、イニシャルの代わりに送信者ドメインのファビコンを表示します。公開ファビコンサービスからドメイン名のみを使って取得します(メールアドレスやハッシュは送信しません)。既定では無効です。"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -668,6 +668,10 @@
|
||||
"close": "Sluiten",
|
||||
"invalid_email": "Voer een geldig e-mailadres in",
|
||||
"already_added": "Deze afzender wordt al vertrouwd"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Domein-avatars",
|
||||
"description": "Als een contact geen foto heeft, wordt het favicon van het domein van de afzender getoond in plaats van initialen. Opgehaald van een openbare favicon-dienst op basis van alleen de domeinnaam (geen e-mailadres, geen hashing). Standaard uitgeschakeld."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -668,6 +668,10 @@
|
||||
"close": "Fechar",
|
||||
"invalid_email": "Por favor, digite um endereço de e-mail válido",
|
||||
"already_added": "Este remetente já é confiável"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Avatares por domínio",
|
||||
"description": "Quando um contacto não tem fotografia, o favicon do domínio do remetente é apresentado em vez das iniciais. Obtido de um serviço público de favicons usando apenas o nome de domínio (sem endereço de e-mail nem hash). Desativado por omissão."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -676,6 +676,10 @@
|
||||
"close": "Закрыть",
|
||||
"invalid_email": "Пожалуйста, введите действительный адрес почты",
|
||||
"already_added": "Этому отправителю уже доверяют"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Аватары по домену",
|
||||
"description": "Если у контакта нет фотографии, вместо инициалов показывается фавикон домена отправителя. Берётся из публичного сервиса фавиконов только по имени домена (без адреса электронной почты и хеша). По умолчанию выключено."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -676,6 +676,10 @@
|
||||
"close": "Закрити",
|
||||
"invalid_email": "Введіть дійсну адресу пошти",
|
||||
"already_added": "Цей відправник уже є надійним"
|
||||
},
|
||||
"domain_favicon_avatars": {
|
||||
"label": "Аватари за доменом",
|
||||
"description": "Якщо в контакта немає фотографії, замість ініціалів показується фавікон домену відправника. Отримується з публічного сервісу фавіконів лише за іменем домену (без адреси електронної пошти й хешу). Стандартно вимкнено."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -36,6 +36,7 @@ interface SettingsState {
|
||||
// Privacy & Security
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
trustedSenders: string[]; // Email addresses that can load external content
|
||||
domainFaviconAvatars: boolean; // Use sender domain favicons as avatars (off by default; opt-in to avoid any third-party request)
|
||||
|
||||
// Calendar Notifications
|
||||
calendarNotificationsEnabled: boolean;
|
||||
@@ -88,6 +89,7 @@ const DEFAULT_SETTINGS = {
|
||||
// Privacy & Security
|
||||
sessionTimeout: 0, // Never
|
||||
trustedSenders: [] as string[],
|
||||
domainFaviconAvatars: false,
|
||||
|
||||
// Calendar Notifications
|
||||
calendarNotificationsEnabled: true,
|
||||
|
||||
Reference in New Issue
Block a user