diff --git a/backend/app/accounts/ssrf.py b/backend/app/accounts/ssrf.py index f8add73..be0ef05 100644 --- a/backend/app/accounts/ssrf.py +++ b/backend/app/accounts/ssrf.py @@ -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"), ) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 30490ff..eb75511 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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") diff --git a/backend/app/api/avatar.py b/backend/app/api/avatar.py new file mode 100644 index 0000000..7f97a95 --- /dev/null +++ b/backend/app/api/avatar.py @@ -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) diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py index d4ddd38..7413647 100644 --- a/backend/app/api/threads.py +++ b/backend/app/api/threads.py @@ -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). diff --git a/backend/app/config.py b/backend/app/config.py index 49438e6..a52b5e0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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) diff --git a/backend/app/outbox/jobs.py b/backend/app/outbox/jobs.py index b20fe5c..87a492f 100644 --- a/backend/app/outbox/jobs.py +++ b/backend/app/outbox/jobs.py @@ -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 ", …]`` 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 " strings. + "to": _addr_dicts(draft.payload.get("to")), + "cc": _addr_dicts(draft.payload.get("cc")), + }, ) await bus.publish( "outbox.state_changed", diff --git a/backend/app/sync/actions.py b/backend/app/sync/actions.py index c3a98ee..27c09a0 100644 --- a/backend/app/sync/actions.py +++ b/backend/app/sync/actions.py @@ -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 + ) diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py index 6fc05ce..9cbcfcc 100644 --- a/backend/tests/api/test_api.py +++ b/backend/tests/api/test_api.py @@ -170,30 +170,41 @@ async def test_folder_view(api: ApiCtx) -> None: assert sent.json()["threads"] == [] -async def test_account_view_shows_only_that_accounts_inbox(api: ApiCtx) -> None: +async def test_account_view_shows_all_but_trash_spam(api: ApiCtx) -> None: assert api.session is not None - # The account view starts equal to the inbox (all seeded mail is in inbox). + # The account view shows the account's mail (both seeded threads are in inbox). resp = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"}) assert resp.status_code == 200 assert len(resp.json()["threads"]) == 2 - # Move the whole "Lunch" thread out of the inbox into Archive. lunch_id = next(t["id"] for t in resp.json()["threads"] if t["subject"] == "Lunch") msgs = list( await api.session.scalars(select(Message).where(Message.thread_id == uuid.UUID(lunch_id))) ) + + # Moving "Lunch" to Archive drops it from the inbox but KEEPS it in the account + # view — only Trash/Spam are excluded there. for m in msgs: m.mailbox_id = api.archive_id await api.session.commit() - - # It must drop out of both the unified inbox and the account view... inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"}) assert [t["subject"] for t in inbox.json()["threads"]] == ["Project kickoff"] account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"}) + assert sorted(t["subject"] for t in account.json()["threads"]) == ["Lunch", "Project kickoff"] + + # Moving it to Trash removes it from the account view (still reachable via Trash). + trash = Mailbox( + account_id=api.account_id, user_id=api.user_id, path="Trash", special_use="trash" + ) + api.session.add(trash) + await api.session.flush() + for m in msgs: + m.mailbox_id = trash.id + await api.session.commit() + account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"}) assert [t["subject"] for t in account.json()["threads"]] == ["Project kickoff"] - # ...but still be reachable under the Archive folder. - archive = await api.client.get("/api/threads", params={"view": "folder:archive"}) - assert [t["subject"] for t in archive.json()["threads"]] == ["Lunch"] + trash_view = await api.client.get("/api/threads", params={"view": "folder:trash"}) + assert [t["subject"] for t in trash_view.json()["threads"]] == ["Lunch"] async def test_attachments_list_and_zip(api: ApiCtx) -> None: diff --git a/backend/workers/run.py b/backend/workers/run.py index 7c84cfd..49e1615 100644 --- a/backend/workers/run.py +++ b/backend/workers/run.py @@ -21,13 +21,19 @@ import logging import signal from typing import Any +from arq import cron from arq.worker import Worker from app.config import get_settings from app.queue import QUEUE_BULK, QUEUE_INTERACTIVE, redis_settings +from app.sync.actions import autopurge_trash_spam from app.sync.coordinator import SyncCoordinator from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS +# Core scheduled jobs. Daily Trash/Spam retention purge at 03:17 (off-peak, and a +# minute that won't collide with typical top-of-hour crons). +_CORE_CRON_JOBS = [cron(autopurge_trash_spam, hour=3, minute=17)] + logger = logging.getLogger(__name__) _SHUTDOWN_DRAIN_S = 10.0 # §7: release leases + drain within this budget @@ -67,7 +73,7 @@ def build_arq_workers(role: str) -> list[Worker]: QUEUE_INTERACTIVE, s.interactive_concurrency, 120, - cron_jobs=plugin_cron_jobs(), + cron_jobs=[*_CORE_CRON_JOBS, *plugin_cron_jobs()], ), _worker(BULK_FUNCTIONS, QUEUE_BULK, s.bulk_concurrency, 3600), ] diff --git a/frontend/index.html b/frontend/index.html index 38b7e3f..f40cb86 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + Webmail diff --git a/frontend/src/components/SenderAvatar.tsx b/frontend/src/components/SenderAvatar.tsx new file mode 100644 index 0000000..b5162ac --- /dev/null +++ b/frontend/src/components/SenderAvatar.tsx @@ -0,0 +1,21 @@ +import { useState } from "react"; + +// 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 +// miss (404/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 +// sender never sees the user's IP or read activity. +export function AvatarImage({ email }: { email?: string | null }) { + const [failedFor, setFailedFor] = useState(null); + if (!email || !email.includes("@") || failedFor === email) return null; + return ( + setFailedFor(email)} + className="absolute inset-0 h-full w-full rounded-full bg-white object-cover" + /> + ); +} diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index 48a31c6..2201ee7 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -7,7 +7,7 @@ export function ThemeToggle() { const { resolved, setTheme } = useTheme(); return ( + + + ); + } + return
; } diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index db10788..bcca9cc 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -9,6 +9,8 @@ import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types"; import { PluginSlot } from "../../plugins/PluginSlot"; import { Composer, type ComposerInit } from "../compose/Composer"; import { Icon } from "../Icon"; +import { AvatarImage } from "../SenderAvatar"; +import { clearPdfPasswordCache } from "./AttachmentPreview"; import { LabelChips } from "../mail/LabelChips"; import { MessageBody } from "./MessageBody"; @@ -345,10 +347,11 @@ function MessageItem({ const nameColor = accentColor ?? `hsl(${h} 55% 62%)`; const avatar = () => ( {initials(message)} + ); const senderName = ( @@ -534,6 +537,8 @@ export function ThreadView({ // --- labels: per-message storage + a thread union for display (chips + list) --- const messageIds = useMemo(() => messages.map((m) => m.id), [messages]); const threadId = messages[0]?.thread_id ?? null; + // Forget remembered protected-PDF passwords when the open thread changes or closes. + useEffect(() => () => clearPdfPasswordCache(), [threadId]); const allLabelsQuery = useQuery({ queryKey: ["labels-all"], queryFn: api.labelsAll }); const msgLabelsQuery = useQuery({ queryKey: ["message-labels", messageIds], diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index a8cbb75..a6aaa8c 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -69,6 +69,11 @@ export const en = { "mail.previewResetZoom": "Reset zoom", "mail.previewPrev": "Previous", "mail.previewNext": "Next", + "preview.pdfProtected": "This PDF is password-protected", + "preview.password": "Password", + "preview.pdfWrongPassword": "Incorrect password — try again", + "preview.unlock": "Unlock", + "preview.pdfError": "Couldn't open this PDF.", "mail.orderOldest": "Oldest first", "mail.orderNewest": "Newest first", "filter.all": "All mail", diff --git a/frontend/src/plugins/plugin-ui.css b/frontend/src/plugins/plugin-ui.css index edf5fb3..c789d76 100644 --- a/frontend/src/plugins/plugin-ui.css +++ b/frontend/src/plugins/plugin-ui.css @@ -145,28 +145,30 @@ line-height: 1.5; } .wm-side-head { - @apply flex items-center justify-between pt-3 px-3 pb-1; + @apply flex items-center justify-between pt-[11px] px-3 pb-1; } .wm-side-switch { @apply relative flex items-center gap-2; } -/* Ultimo signet in front of the app-switcher menu (Calendar / Contacts headers). */ +/* Ultimo signet in front of the app-switcher menu (Calendar / Contacts headers). + `auto 28px` renders it at a true 28px tall (matching Mail's ) rather + than letting `contain` shrink it to fit the box's smaller dimension. */ .wm-side-switch::before { content: ""; flex: 0 0 auto; - width: 28px; + width: 30px; height: 28px; - background: url("/ultimo-logo-signet.svg") center / contain no-repeat; + background: url("/ultimo-logo-signet.svg") center / auto 28px no-repeat; } /* And at the top of the collapsed rail, above the expand/theme buttons. */ .cal-rail::before, .ct-rail::before { content: ""; flex: 0 0 auto; - width: 30px; - height: 30px; - margin: 10px auto 6px; - background: url("/ultimo-logo-signet.svg") center / contain no-repeat; + width: 32px; + height: 28px; + margin: 11px auto 6px; + background: url("/ultimo-logo-signet.svg") center / auto 28px no-repeat; } .wm-side-switch > button { @apply flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover; @@ -193,10 +195,12 @@ cursor: pointer; } .wm-side-themebtn { - @apply p-2; + /* Fixed 28px box (matching the logo/other icons) so the header row height stays + 28px and everything centers on one line — no drift vs the collapsed rail. */ + @apply h-7 w-7 items-center justify-center p-0; } .wm-side-primary { - @apply px-3 pb-2; + @apply px-3 pb-2 pt-2.5; } .wm-side-primary > button { @apply flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90; diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 2171f2c..7339ad5 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -509,7 +509,7 @@ export function MailLayout() { className="flex shrink-0 flex-col border-r border-app-border bg-app-bg" >
@@ -526,14 +526,14 @@ export function MailLayout() { onClick={() => setCollapsed((v) => !v)} title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")} aria-label={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")} - className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" + className="inline-flex h-7 w-7 items-center justify-center rounded text-app-muted hover:bg-app-hover hover:text-app-fg" >
-
+