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:
@@ -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