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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user