Sender avatars, PDF password preview, contacts autosave, account-view + Trash/Spam auto-purge, UI polish
Sender avatars (privacy-safe, server-fetched, cached): - New GET /api/avatar resolves Gravatar (d=404) then the sender domain's favicon under the SSRF guard, Redis-cached (hit+miss); gated by SENDER_AVATARS_ENABLED. GuardedResponse gains raw bytes. Shown in the mail list, reading pane, People panel, and Contacts (overlay that falls back to initials on a miss). PDF preview: - Password-protected PDFs now prompt in-place and retry (pdf.js PasswordException), with a clear error for other load failures instead of a blank overlay. The password is remembered per attachment until the thread is left; clicking the field no longer dismisses the overlay. Contacts: - Autosave reply recipients: message.sent now carries to/cc (name+email); the contacts plugin subscribes and adds any non-contact recipients to the Autosaved group. Contact avatars show the gravatar/favicon via /api/avatar. Mail views / retention: - Clicking an account shows all its mail except Trash/Spam (was inbox-only). - Daily cron autopurge_trash_spam permanently deletes Trash/Spam older than TRASH_RETENTION_DAYS (default 30; 0 disables) — server EXPUNGE + local rows. UI polish: - Ultimo signet logo in the Mail/Calendar/Contacts sidebar header and collapsed rail; consistent 11px top margin and vertically-aligned header row across states (fixed-size flex icon buttons); SVG favicon on the browser tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -94,6 +94,7 @@ async def guarded_get(url: str) -> str | None:
|
||||
class GuardedResponse:
|
||||
status: int
|
||||
headers: dict[str, str]
|
||||
content: bytes
|
||||
text: str
|
||||
|
||||
|
||||
@@ -131,8 +132,10 @@ async def guarded_request(
|
||||
if total > max_bytes:
|
||||
raise SsrfError("response exceeds size cap")
|
||||
chunks.append(chunk)
|
||||
body = b"".join(chunks)
|
||||
return GuardedResponse(
|
||||
status=resp.status_code,
|
||||
headers=dict(resp.headers),
|
||||
text=b"".join(chunks).decode("utf-8", "replace"),
|
||||
content=body,
|
||||
text=body.decode("utf-8", "replace"),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
"""
|
||||
from app.api.accounts import router as accounts_router
|
||||
from app.api.actions import router as actions_router
|
||||
from app.api.avatar import router as avatar_router
|
||||
from app.api.compose import router as compose_router
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
from app.api.messages import router as messages_router
|
||||
@@ -37,6 +38,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
settings_router,
|
||||
mailboxes_router,
|
||||
saved_searches_router,
|
||||
avatar_router,
|
||||
plugins_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Sender avatars — Gravatar then the sender domain's favicon (SPEC §9.1).
|
||||
|
||||
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
|
||||
§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
|
||||
``SENDER_AVATARS_ENABLED=false``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.accounts.ssrf import SsrfError, guarded_request
|
||||
from app.config import get_settings
|
||||
from app.identity.deps import CurrentUser
|
||||
from app.redis_client import get_redis
|
||||
|
||||
router = APIRouter(prefix="/avatar", tags=["mail"])
|
||||
|
||||
_CACHE_TTL_HIT = 7 * 24 * 3600 # remember a found avatar for a week
|
||||
_CACHE_TTL_MISS = 24 * 3600 # re-check "no avatar" daily
|
||||
_MAX_BYTES = 200 * 1024
|
||||
_NONE = b"none"
|
||||
|
||||
|
||||
def _email_md5(email: str) -> str:
|
||||
# Gravatar keys on md5(lowercased email); reused as our cache key. Not security.
|
||||
return hashlib.md5(email.encode()).hexdigest() # noqa: S324
|
||||
|
||||
|
||||
async def _fetch_image(url: str) -> tuple[str, bytes] | None:
|
||||
"""GET ``url`` under the SSRF guard; return (content_type, bytes) for a 200 image."""
|
||||
try:
|
||||
resp = await guarded_request("GET", url, timeout=5.0, max_bytes=_MAX_BYTES)
|
||||
except (SsrfError, httpx.HTTPError, OSError):
|
||||
return None
|
||||
if resp.status != 200 or not resp.content:
|
||||
return None
|
||||
ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||
if not ctype.startswith("image/"):
|
||||
return None
|
||||
return ctype, resp.content
|
||||
|
||||
|
||||
async def _resolve(email: str, md5: str) -> tuple[str, bytes] | None:
|
||||
"""Gravatar first (``d=404`` so it 404s when the user has none), then the sender
|
||||
domain's ``/favicon.ico`` as a brand-ish fallback."""
|
||||
gravatar = await _fetch_image(f"https://www.gravatar.com/avatar/{md5}?d=404&s=80")
|
||||
if gravatar is not None:
|
||||
return gravatar
|
||||
domain = email.rsplit("@", 1)[-1]
|
||||
if domain:
|
||||
favicon = await _fetch_image(f"https://{domain}/favicon.ico")
|
||||
if favicon is not None:
|
||||
return favicon
|
||||
return None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def sender_avatar(_user: CurrentUser, email: str = Query(...)) -> Response:
|
||||
email = email.strip().lower()
|
||||
if not get_settings().sender_avatars_enabled or "@" not in email:
|
||||
return Response(status_code=404)
|
||||
|
||||
redis = get_redis()
|
||||
md5 = _email_md5(email)
|
||||
key = f"avatar:{md5}"
|
||||
headers = {"Cache-Control": "private, max-age=86400"}
|
||||
|
||||
cached: bytes | None = await redis.get(key)
|
||||
if cached == _NONE:
|
||||
return Response(status_code=404)
|
||||
if cached:
|
||||
cached_ctype, _, cached_body = cached.partition(b"\n")
|
||||
return Response(
|
||||
content=cached_body,
|
||||
media_type=cached_ctype.decode("ascii", "ignore"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
result = await _resolve(email, md5)
|
||||
if result is None:
|
||||
await redis.set(key, _NONE, ex=_CACHE_TTL_MISS)
|
||||
return Response(status_code=404)
|
||||
ctype, body = result
|
||||
await redis.set(key, ctype.encode("ascii", "ignore") + b"\n" + body, ex=_CACHE_TTL_HIT)
|
||||
return Response(content=body, media_type=ctype, headers=headers)
|
||||
@@ -98,23 +98,25 @@ async def list_threads(
|
||||
raise_api_error(400, "bad_ids", "invalid thread id in ids")
|
||||
conditions.append(Thread.id.in_(wanted or [uuid.uuid4()]))
|
||||
elif view.startswith("account:"):
|
||||
# A single account's inbox: threads with a message in THIS account's
|
||||
# inbox mailbox. Keeps the per-account view consistent with the unified
|
||||
# Inbox (archived/trashed/sent threads don't leak in).
|
||||
# A single account's mail: threads with a message in ANY of THIS account's
|
||||
# mailboxes except Trash/Spam (those are only seen via their own folders).
|
||||
try:
|
||||
account_id = uuid.UUID(view.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raise_api_error(400, "bad_view", "invalid account id in view")
|
||||
in_account_inbox = (
|
||||
in_account = (
|
||||
select(Message.thread_id)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(
|
||||
Message.user_id == user.id,
|
||||
Message.account_id == account_id,
|
||||
Mailbox.special_use == "inbox",
|
||||
or_(
|
||||
Mailbox.special_use.is_(None),
|
||||
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
|
||||
),
|
||||
)
|
||||
)
|
||||
conditions.append(Thread.id.in_(in_account_inbox))
|
||||
conditions.append(Thread.id.in_(in_account))
|
||||
elif view.startswith("folder:"):
|
||||
# Threads with at least one message in a mailbox of this special_use,
|
||||
# across all of the user's accounts (unified per-folder view).
|
||||
|
||||
@@ -62,6 +62,11 @@ class Settings(BaseSettings):
|
||||
# Gates the "Sign in with Google instead" OAuth fallback in the wizard (SPEC
|
||||
# §6.2.1). Off until an operator configures a verified Cloud project.
|
||||
google_oauth_enabled: bool = Field(default=False, alias="GOOGLE_OAUTH_ENABLED")
|
||||
# Server-side sender avatars (Gravatar + domain favicon). Fetched by the server
|
||||
# (never the browser) so senders can't see the user's IP or read activity.
|
||||
sender_avatars_enabled: bool = Field(default=True, alias="SENDER_AVATARS_ENABLED")
|
||||
# Auto-purge Trash/Spam messages older than this many days (0 disables).
|
||||
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
@@ -12,6 +12,7 @@ import contextlib
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import getaddresses
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -37,6 +38,17 @@ def _is_gmail(account: Account) -> bool:
|
||||
return "gmail.com" in host or "googlemail.com" in host
|
||||
|
||||
|
||||
def _addr_dicts(values: object) -> list[dict[str, str]]:
|
||||
"""Parse a draft payload's ``["Name <email>", …]`` list into name+email dicts."""
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
return [
|
||||
{"name": name, "email": email}
|
||||
for name, email in getaddresses([str(v) for v in values])
|
||||
if email
|
||||
]
|
||||
|
||||
|
||||
async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails: list[str]) -> None:
|
||||
now = datetime.now(UTC)
|
||||
for email in {e.lower() for e in emails if e}:
|
||||
@@ -190,7 +202,15 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
|
||||
bus = get_event_bus()
|
||||
await bus.publish(
|
||||
"message.sent",
|
||||
{"user_id": str(user_id), "outbox_id": str(oid), "thread_id": str(draft.thread_id)},
|
||||
{
|
||||
"user_id": str(user_id),
|
||||
"outbox_id": str(oid),
|
||||
"thread_id": str(draft.thread_id),
|
||||
# Recipients (name+email) so consumers (e.g. contacts autosave) don't
|
||||
# need to read the outbox row. payload to/cc are "Name <email>" strings.
|
||||
"to": _addr_dicts(draft.payload.get("to")),
|
||||
"cc": _addr_dicts(draft.payload.get("cc")),
|
||||
},
|
||||
)
|
||||
await bus.publish(
|
||||
"outbox.state_changed",
|
||||
|
||||
@@ -10,12 +10,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import session_scope
|
||||
from app.events import get_event_bus
|
||||
from app.models import Account, Draft, Mailbox, Message, Thread
|
||||
@@ -438,3 +441,88 @@ async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str)
|
||||
await denormalize_thread(session, tid)
|
||||
|
||||
await get_event_bus().publish("message.deleted", {"user_id": str(uid)})
|
||||
|
||||
|
||||
async def autopurge_trash_spam(ctx: dict[str, Any]) -> None:
|
||||
"""Cron: permanently delete Trash/Spam messages older than the retention window.
|
||||
|
||||
Same mechanics as :func:`empty_folder_job` (server EXPUNGE, drop rows, re-denorm
|
||||
threads) but scoped to messages whose internal date predates
|
||||
``TRASH_RETENTION_DAYS`` — across every user/account. A no-op when retention is 0.
|
||||
"""
|
||||
days = get_settings().trash_retention_days
|
||||
if days <= 0:
|
||||
return
|
||||
cutoff = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
async with session_scope() as session:
|
||||
stale = (
|
||||
await session.execute(
|
||||
select(
|
||||
Message.id,
|
||||
Message.user_id,
|
||||
Message.account_id,
|
||||
Message.uid,
|
||||
Message.thread_id,
|
||||
Mailbox.path,
|
||||
)
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(Mailbox.special_use.in_(_REMOVAL_FOLDERS), Message.internal_date < cutoff)
|
||||
)
|
||||
).all()
|
||||
if not stale:
|
||||
return
|
||||
account_ids = {r.account_id for r in stale}
|
||||
accounts = {
|
||||
a.id: a
|
||||
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
|
||||
}
|
||||
|
||||
# Server-side EXPUNGE, grouped by (account, mailbox path).
|
||||
by_box: dict[tuple[uuid.UUID, str], list[int]] = defaultdict(list)
|
||||
for r in stale:
|
||||
by_box[(r.account_id, r.path)].append(r.uid)
|
||||
for account_id in account_ids:
|
||||
account = accounts.get(account_id)
|
||||
if account is None:
|
||||
continue
|
||||
provider = build_provider(account)
|
||||
try:
|
||||
for (acct, path), uids in by_box.items():
|
||||
if acct != account_id:
|
||||
continue
|
||||
try:
|
||||
await provider.delete_messages(path, uids)
|
||||
except Exception:
|
||||
logger.exception("autopurge: expunge failed for %s in %s", uids, path)
|
||||
finally:
|
||||
await provider.close()
|
||||
|
||||
# Drop the local rows and re-denormalize (or delete) affected threads.
|
||||
msg_ids = [r.id for r in stale]
|
||||
thread_ids = {r.thread_id for r in stale if r.thread_id is not None}
|
||||
user_ids = {r.user_id for r in stale}
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Draft).where(Draft.reply_to_message_id.in_(msg_ids)).values(
|
||||
reply_to_message_id=None
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Message).where(Message.id.in_(msg_ids)))
|
||||
await session.flush()
|
||||
for tid in thread_ids:
|
||||
remaining = await session.scalar(
|
||||
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
||||
)
|
||||
if not remaining:
|
||||
await session.execute(
|
||||
update(Draft).where(Draft.thread_id == tid).values(thread_id=None)
|
||||
)
|
||||
await denormalize_thread(session, tid)
|
||||
|
||||
bus = get_event_bus()
|
||||
for user_id in user_ids:
|
||||
await bus.publish("message.deleted", {"user_id": str(user_id)})
|
||||
logger.info(
|
||||
"autopurge: removed %d Trash/Spam message(s) older than %dd", len(msg_ids), days
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user