Avatars: a sender without one logged a console error; phone list rows spaced 20px

A missing avatar was a 404, which the browser reports as an error for every
such sender on every page. It is now an empty 204: the <img> still fails to
decode it and the initials show through, but nothing is logged, and the miss
carries the same one-day browser cache as a hit so it stops being re-fetched.

On a phone the mail list's rows sit 20px apart instead of 7px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 21:35:26 +02:00
co-authored by Claude Opus 5.5
parent 0ddfc8c658
commit 8dc60dbf5c
4 changed files with 24 additions and 15 deletions
+14 -9
View File
@@ -3,8 +3,9 @@
The browser only ever calls ``GET /api/avatar`` (same origin), so a sender can never The browser only ever calls ``GET /api/avatar`` (same origin), so a sender can never
see the user's IP or read activity: the server does the outbound fetch under the see the user's IP or read activity: the server does the outbound fetch under the
§10.6 SSRF guard and caches the result (both hits and misses) in Redis. A miss §10.6 SSRF guard and caches the result (both hits and misses) in Redis. A miss
returns 404, which the UI renders as its initials avatar. Disable with returns 204 No Content: the <img> fails to decode it and the UI falls back to its
``SENDER_AVATARS_ENABLED=false``. initials, without the browser logging an error for every sender who has no avatar
(a 404 did, filling the console). Disable with ``SENDER_AVATARS_ENABLED=false``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,6 +26,12 @@ _CACHE_TTL_HIT = 7 * 24 * 3600 # remember a found avatar for a week
_CACHE_TTL_MISS = 24 * 3600 # re-check "no avatar" daily _CACHE_TTL_MISS = 24 * 3600 # re-check "no avatar" daily
_MAX_BYTES = 200 * 1024 _MAX_BYTES = 200 * 1024
_NONE = b"none" _NONE = b"none"
# The browser keeps an answer as long as we would, so it stops re-asking per page.
_BROWSER_CACHE = {"Cache-Control": "private, max-age=86400"}
def _no_avatar() -> Response:
return Response(status_code=204, headers=_BROWSER_CACHE)
def _email_md5(email: str) -> str: def _email_md5(email: str) -> str:
@@ -64,29 +71,27 @@ async def _resolve(email: str, md5: str) -> tuple[str, bytes] | None:
async def sender_avatar(_user: CurrentUser, email: str = Query(...)) -> Response: async def sender_avatar(_user: CurrentUser, email: str = Query(...)) -> Response:
email = email.strip().lower() email = email.strip().lower()
if not get_settings().sender_avatars_enabled or "@" not in email: if not get_settings().sender_avatars_enabled or "@" not in email:
return Response(status_code=404) return _no_avatar()
# Binary-safe client: the cache holds raw image bytes (and a b"none" marker). # Binary-safe client: the cache holds raw image bytes (and a b"none" marker).
redis = get_redis_bytes() redis = get_redis_bytes()
md5 = _email_md5(email) md5 = _email_md5(email)
key = f"avatar:{md5}" key = f"avatar:{md5}"
headers = {"Cache-Control": "private, max-age=86400"}
cached: bytes | None = await redis.get(key) cached: bytes | None = await redis.get(key)
if cached == _NONE: if cached == _NONE:
return Response(status_code=404) return _no_avatar()
if cached: if cached:
cached_ctype, _, cached_body = cached.partition(b"\n") cached_ctype, _, cached_body = cached.partition(b"\n")
return Response( return Response(
content=cached_body, content=cached_body,
media_type=cached_ctype.decode("ascii", "ignore"), media_type=cached_ctype.decode("ascii", "ignore"),
headers=headers, headers=_BROWSER_CACHE,
) )
result = await _resolve(email, md5) result = await _resolve(email, md5)
if result is None: if result is None:
await redis.set(key, _NONE, ex=_CACHE_TTL_MISS) await redis.set(key, _NONE, ex=_CACHE_TTL_MISS)
return Response(status_code=404) return _no_avatar()
ctype, body = result ctype, body = result
await redis.set(key, ctype.encode("ascii", "ignore") + b"\n" + body, ex=_CACHE_TTL_HIT) await redis.set(key, ctype.encode("ascii", "ignore") + b"\n" + body, ex=_CACHE_TTL_HIT)
return Response(content=body, media_type=ctype, headers=headers) return Response(content=body, media_type=ctype, headers=_BROWSER_CACHE)
+6 -3
View File
@@ -2,7 +2,7 @@
The shared Redis client decodes replies as text, so the cache of raw image The shared Redis client decodes replies as text, so the cache of raw image
bytes broke on the second request for any sender — a 500 instead of the image bytes broke on the second request for any sender — a 500 instead of the image
(or instead of the 404 that means "draw initials"). (or instead of the 204 that means "draw initials").
""" """
from __future__ import annotations from __future__ import annotations
@@ -68,7 +68,7 @@ async def test_cached_avatar_is_served_again(
assert calls == 1 # the second came from the cache assert calls == 1 # the second came from the cache
async def test_cached_miss_is_a_404_again( async def test_cached_miss_is_a_204_again(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
calls = 0 calls = 0
@@ -81,5 +81,8 @@ async def test_cached_miss_is_a_404_again(
monkeypatch.setattr(avatar, "_resolve", fake_resolve) monkeypatch.setattr(avatar, "_resolve", fake_resolve)
for _ in range(2): for _ in range(2):
resp = await client.get("/api/avatar", params={"email": "nobody@example.com"}) resp = await client.get("/api/avatar", params={"email": "nobody@example.com"})
assert resp.status_code == 404 # 204, not 404: a miss is the normal case, and a 404 put a console
# error in the browser for every sender without an avatar.
assert resp.status_code == 204
assert "max-age" in resp.headers["cache-control"]
assert calls == 1 assert calls == 1
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
// Overlay for a server-fetched sender avatar (Gravatar / domain favicon). Drop it // Overlay for a server-fetched sender avatar (Gravatar / domain favicon). Drop it
// inside an existing initials circle: on success the image covers the initials; on a // inside an existing initials circle: on success the image covers the initials; on a
// miss (404/error) it renders nothing so the initials show through. The parent must // miss (an empty 204, or an error) it renders nothing so the initials show through. The parent must
// be `position: relative` and round. Fetched via /api/avatar (same origin), so the // be `position: relative` and round. Fetched via /api/avatar (same origin), so the
// sender never sees the user's IP or read activity. // sender never sees the user's IP or read activity.
export function AvatarImage({ email }: { email?: string | null }) { export function AvatarImage({ email }: { email?: string | null }) {
+3 -2
View File
@@ -146,7 +146,8 @@ export function MessageList({
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: threads.length, count: threads.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 75, // 68 row + 7px gap // 68 row + the gap above it: 7px, 20px on a phone.
estimateSize: () => 68 + (compact ? 20 : 7),
overscan: 8, overscan: 8,
}); });
@@ -203,7 +204,7 @@ export function MessageList({
transform: `translateY(${item.start}px)`, transform: `translateY(${item.start}px)`,
}} }}
// A row leaving the list lets the ones below glide up into its place. // A row leaving the list lets the ones below glide up into its place.
className={`group absolute inset-x-0 px-2 pt-[7px] motion-safe:transition-transform motion-safe:duration-200 motion-safe:ease-out ${ className={`group absolute inset-x-0 px-2 ${compact ? "pt-5" : "pt-[7px]"} motion-safe:transition-transform motion-safe:duration-200 motion-safe:ease-out ${
dimmed ? "pointer-events-none opacity-40" : "cursor-pointer" dimmed ? "pointer-events-none opacity-40" : "cursor-pointer"
}`} }`}
> >