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:
|
class GuardedResponse:
|
||||||
status: int
|
status: int
|
||||||
headers: dict[str, str]
|
headers: dict[str, str]
|
||||||
|
content: bytes
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
@@ -131,8 +132,10 @@ async def guarded_request(
|
|||||||
if total > max_bytes:
|
if total > max_bytes:
|
||||||
raise SsrfError("response exceeds size cap")
|
raise SsrfError("response exceeds size cap")
|
||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
|
body = b"".join(chunks)
|
||||||
return GuardedResponse(
|
return GuardedResponse(
|
||||||
status=resp.status_code,
|
status=resp.status_code,
|
||||||
headers=dict(resp.headers),
|
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.accounts import router as accounts_router
|
||||||
from app.api.actions import router as actions_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.compose import router as compose_router
|
||||||
from app.api.mailboxes import router as mailboxes_router
|
from app.api.mailboxes import router as mailboxes_router
|
||||||
from app.api.messages import router as messages_router
|
from app.api.messages import router as messages_router
|
||||||
@@ -37,6 +38,7 @@ def register_routers(app: FastAPI) -> None:
|
|||||||
settings_router,
|
settings_router,
|
||||||
mailboxes_router,
|
mailboxes_router,
|
||||||
saved_searches_router,
|
saved_searches_router,
|
||||||
|
avatar_router,
|
||||||
plugins_router,
|
plugins_router,
|
||||||
):
|
):
|
||||||
app.include_router(router, prefix="/api")
|
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")
|
raise_api_error(400, "bad_ids", "invalid thread id in ids")
|
||||||
conditions.append(Thread.id.in_(wanted or [uuid.uuid4()]))
|
conditions.append(Thread.id.in_(wanted or [uuid.uuid4()]))
|
||||||
elif view.startswith("account:"):
|
elif view.startswith("account:"):
|
||||||
# A single account's inbox: threads with a message in THIS account's
|
# A single account's mail: threads with a message in ANY of THIS account's
|
||||||
# inbox mailbox. Keeps the per-account view consistent with the unified
|
# mailboxes except Trash/Spam (those are only seen via their own folders).
|
||||||
# Inbox (archived/trashed/sent threads don't leak in).
|
|
||||||
try:
|
try:
|
||||||
account_id = uuid.UUID(view.split(":", 1)[1])
|
account_id = uuid.UUID(view.split(":", 1)[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise_api_error(400, "bad_view", "invalid account id in view")
|
raise_api_error(400, "bad_view", "invalid account id in view")
|
||||||
in_account_inbox = (
|
in_account = (
|
||||||
select(Message.thread_id)
|
select(Message.thread_id)
|
||||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||||
.where(
|
.where(
|
||||||
Message.user_id == user.id,
|
Message.user_id == user.id,
|
||||||
Message.account_id == account_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:"):
|
elif view.startswith("folder:"):
|
||||||
# Threads with at least one message in a mailbox of this special_use,
|
# Threads with at least one message in a mailbox of this special_use,
|
||||||
# across all of the user's accounts (unified per-folder view).
|
# 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
|
# 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.
|
# §6.2.1). Off until an operator configures a verified Cloud project.
|
||||||
google_oauth_enabled: bool = Field(default=False, alias="GOOGLE_OAUTH_ENABLED")
|
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)
|
@lru_cache(maxsize=1)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import contextlib
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from email.utils import getaddresses
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -37,6 +38,17 @@ def _is_gmail(account: Account) -> bool:
|
|||||||
return "gmail.com" in host or "googlemail.com" in host
|
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:
|
async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails: list[str]) -> None:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
for email in {e.lower() for e in emails if e}:
|
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()
|
bus = get_event_bus()
|
||||||
await bus.publish(
|
await bus.publish(
|
||||||
"message.sent",
|
"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(
|
await bus.publish(
|
||||||
"outbox.state_changed",
|
"outbox.state_changed",
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.db import session_scope
|
from app.db import session_scope
|
||||||
from app.events import get_event_bus
|
from app.events import get_event_bus
|
||||||
from app.models import Account, Draft, Mailbox, Message, Thread
|
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 denormalize_thread(session, tid)
|
||||||
|
|
||||||
await get_event_bus().publish("message.deleted", {"user_id": str(uid)})
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -170,30 +170,41 @@ async def test_folder_view(api: ApiCtx) -> None:
|
|||||||
assert sent.json()["threads"] == []
|
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
|
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}"})
|
resp = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert len(resp.json()["threads"]) == 2
|
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")
|
lunch_id = next(t["id"] for t in resp.json()["threads"] if t["subject"] == "Lunch")
|
||||||
msgs = list(
|
msgs = list(
|
||||||
await api.session.scalars(select(Message).where(Message.thread_id == uuid.UUID(lunch_id)))
|
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:
|
for m in msgs:
|
||||||
m.mailbox_id = api.archive_id
|
m.mailbox_id = api.archive_id
|
||||||
await api.session.commit()
|
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"})
|
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
|
||||||
assert [t["subject"] for t in inbox.json()["threads"]] == ["Project kickoff"]
|
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}"})
|
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"]
|
assert [t["subject"] for t in account.json()["threads"]] == ["Project kickoff"]
|
||||||
# ...but still be reachable under the Archive folder.
|
trash_view = await api.client.get("/api/threads", params={"view": "folder:trash"})
|
||||||
archive = await api.client.get("/api/threads", params={"view": "folder:archive"})
|
assert [t["subject"] for t in trash_view.json()["threads"]] == ["Lunch"]
|
||||||
assert [t["subject"] for t in archive.json()["threads"]] == ["Lunch"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_attachments_list_and_zip(api: ApiCtx) -> None:
|
async def test_attachments_list_and_zip(api: ApiCtx) -> None:
|
||||||
|
|||||||
@@ -21,13 +21,19 @@ import logging
|
|||||||
import signal
|
import signal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from arq import cron
|
||||||
from arq.worker import Worker
|
from arq.worker import Worker
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.queue import QUEUE_BULK, QUEUE_INTERACTIVE, redis_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 app.sync.coordinator import SyncCoordinator
|
||||||
from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SHUTDOWN_DRAIN_S = 10.0 # §7: release leases + drain within this budget
|
_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,
|
QUEUE_INTERACTIVE,
|
||||||
s.interactive_concurrency,
|
s.interactive_concurrency,
|
||||||
120,
|
120,
|
||||||
cron_jobs=plugin_cron_jobs(),
|
cron_jobs=[*_CORE_CRON_JOBS, *plugin_cron_jobs()],
|
||||||
),
|
),
|
||||||
_worker(BULK_FUNCTIONS, QUEUE_BULK, s.bulk_concurrency, 3600),
|
_worker(BULK_FUNCTIONS, QUEUE_BULK, s.bulk_concurrency, 3600),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||||
<title>Webmail</title>
|
<title>Webmail</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
if (!email || !email.includes("@") || failedFor === email) return null;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={`/api/avatar?email=${encodeURIComponent(email)}`}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setFailedFor(email)}
|
||||||
|
className="absolute inset-0 h-full w-full rounded-full bg-white object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export function ThemeToggle() {
|
|||||||
const { resolved, setTheme } = useTheme();
|
const { resolved, setTheme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="rounded p-2 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"
|
||||||
title={t("theme.toggle")}
|
title={t("theme.toggle")}
|
||||||
aria-label={t("theme.toggle")}
|
aria-label={t("theme.toggle")}
|
||||||
onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { api } from "../../lib/api";
|
|||||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||||
import type { Address, ThreadSummary } from "../../lib/types";
|
import type { Address, ThreadSummary } from "../../lib/types";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
|
import { AvatarImage } from "../SenderAvatar";
|
||||||
import { LabelChips } from "../mail/LabelChips";
|
import { LabelChips } from "../mail/LabelChips";
|
||||||
|
|
||||||
function timeLabel(iso: string): string {
|
function timeLabel(iso: string): string {
|
||||||
@@ -165,10 +166,11 @@ export function MessageList({
|
|||||||
)}
|
)}
|
||||||
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
|
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
|
||||||
<span
|
<span
|
||||||
className="flex h-[33px] w-[33px] items-center justify-center rounded-full text-[13px] font-semibold text-white"
|
className="relative flex h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white"
|
||||||
style={{ backgroundColor: avatarBg }}
|
style={{ backgroundColor: avatarBg }}
|
||||||
>
|
>
|
||||||
{initials(primary)}
|
{initials(primary)}
|
||||||
|
<AvatarImage email={primary?.email} />
|
||||||
</span>
|
</span>
|
||||||
{extra > 0 && (
|
{extra > 0 && (
|
||||||
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[10px] font-medium text-app-muted ring-2 ring-app-surface">
|
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[10px] font-medium text-app-muted ring-2 ring-app-surface">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api, apiFetch } from "../../lib/api";
|
import { api, apiFetch } from "../../lib/api";
|
||||||
import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
|
import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
|
||||||
|
import { AvatarImage } from "../SenderAvatar";
|
||||||
import { loadBundle } from "../../plugins/bundleLoader";
|
import { loadBundle } from "../../plugins/bundleLoader";
|
||||||
import { usePluginContext } from "../../plugins/context";
|
import { usePluginContext } from "../../plugins/context";
|
||||||
import { isPreviewable } from "../thread/previewKind";
|
import { isPreviewable } from "../thread/previewKind";
|
||||||
@@ -39,10 +40,11 @@ function initials(name: string) {
|
|||||||
function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) {
|
function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white"
|
className="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold text-white"
|
||||||
style={{ width: size, height: size, background: avatarColor(email || name), fontSize: Math.round(size * 0.38) }}
|
style={{ width: size, height: size, background: avatarColor(email || name), fontSize: Math.round(size * 0.38) }}
|
||||||
>
|
>
|
||||||
{initials(name || email)}
|
{initials(name || email)}
|
||||||
|
<AvatarImage email={email} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
import PdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?worker";
|
import PdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?worker";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
@@ -16,6 +16,14 @@ import { extOf, kindOf } from "./previewKind";
|
|||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerPort = new PdfWorker();
|
pdfjs.GlobalWorkerOptions.workerPort = new PdfWorker();
|
||||||
|
|
||||||
|
// Remembers a protected PDF's password (in memory) per attachment id, so re-opening
|
||||||
|
// the same file doesn't re-prompt. Cleared when leaving the thread (ThreadView calls
|
||||||
|
// clearPdfPasswordCache on thread change) — never persisted.
|
||||||
|
const pdfPasswordCache = new Map<string, string>();
|
||||||
|
export function clearPdfPasswordCache(): void {
|
||||||
|
pdfPasswordCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export function AttachmentPreview({
|
export function AttachmentPreview({
|
||||||
messageId,
|
messageId,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -166,7 +174,8 @@ export function AttachmentPreview({
|
|||||||
<div
|
<div
|
||||||
className="relative min-h-0 flex-1"
|
className="relative min-h-0 flex-1"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!(e.target as Element).closest("img, iframe, canvas, pre, button, a")) onClose();
|
if (!(e.target as Element).closest("img, iframe, canvas, pre, button, a, form, input"))
|
||||||
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{kind === "image" && <ImageView url={url} zoom={zoom} alt={att.filename} />}
|
{kind === "image" && <ImageView url={url} zoom={zoom} alt={att.filename} />}
|
||||||
@@ -174,7 +183,12 @@ export function AttachmentPreview({
|
|||||||
<TextView url={url} zoom={zoom} onLoaded={(tx) => (textRef.current = tx)} />
|
<TextView url={url} zoom={zoom} onLoaded={(tx) => (textRef.current = tx)} />
|
||||||
)}
|
)}
|
||||||
{kind === "pdf" && (
|
{kind === "pdf" && (
|
||||||
<PdfView url={url} zoom={zoom} onPage={(current, total) => setPage({ current, total })} />
|
<PdfView
|
||||||
|
url={url}
|
||||||
|
cacheKey={att.id}
|
||||||
|
zoom={zoom}
|
||||||
|
onPage={(current, total) => setPage({ current, total })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{kind === "office" && (
|
{kind === "office" && (
|
||||||
<OfficeView
|
<OfficeView
|
||||||
@@ -449,28 +463,55 @@ function OfficeView({
|
|||||||
|
|
||||||
function PdfView({
|
function PdfView({
|
||||||
url,
|
url,
|
||||||
|
cacheKey,
|
||||||
zoom,
|
zoom,
|
||||||
onPage,
|
onPage,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
|
cacheKey: string;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
onPage: (current: number, total: number) => void;
|
onPage: (current: number, total: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
||||||
const [numPages, setNumPages] = useState(0);
|
const [numPages, setNumPages] = useState(0);
|
||||||
|
// Password-protected PDFs: pdf.js rejects with a PasswordException until the right
|
||||||
|
// password is supplied. We prompt in-place and retry; the file never leaves the
|
||||||
|
// browser. `password` undefined = "not tried yet"; changing it re-runs the load.
|
||||||
|
// Seed from the per-attachment cache so re-opening within a thread skips the prompt.
|
||||||
|
const [password, setPassword] = useState<string | undefined>(() => pdfPasswordCache.get(cacheKey));
|
||||||
|
const [needPassword, setNeedPassword] = useState(false);
|
||||||
|
const [wrongPassword, setWrongPassword] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [pwInput, setPwInput] = useState("");
|
||||||
|
|
||||||
// Load the document once.
|
// Load the document; re-runs when the file (url) or a submitted password changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer();
|
setFailed(false);
|
||||||
if (cancelled) return;
|
try {
|
||||||
const doc = await pdfjs.getDocument({ data: buf }).promise;
|
const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
docRef.current = doc;
|
const doc = await pdfjs.getDocument({ data: buf, password }).promise;
|
||||||
setNumPages(doc.numPages);
|
if (cancelled) return;
|
||||||
onPage(1, doc.numPages);
|
docRef.current = doc;
|
||||||
|
setNeedPassword(false);
|
||||||
|
setWrongPassword(false);
|
||||||
|
if (password !== undefined) pdfPasswordCache.set(cacheKey, password);
|
||||||
|
setNumPages(doc.numPages);
|
||||||
|
onPage(1, doc.numPages);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const ex = e as { name?: string; code?: number };
|
||||||
|
if (ex?.name === "PasswordException") {
|
||||||
|
setNeedPassword(true);
|
||||||
|
setWrongPassword(ex.code === 2); // PasswordResponses.INCORRECT_PASSWORD
|
||||||
|
} else {
|
||||||
|
setFailed(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -478,7 +519,7 @@ function PdfView({
|
|||||||
docRef.current = null;
|
docRef.current = null;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [url]);
|
}, [url, password]);
|
||||||
|
|
||||||
// (Re)render all pages to canvases when the doc or zoom changes.
|
// (Re)render all pages to canvases when the doc or zoom changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -524,5 +565,65 @@ function PdfView({
|
|||||||
onPage(current, docRef.current.numPages);
|
onPage(current, docRef.current.numPages);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitPassword = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPassword(pwInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-white/70">
|
||||||
|
{t("preview.pdfError")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needPassword) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center px-6">
|
||||||
|
<form
|
||||||
|
onSubmit={submitPassword}
|
||||||
|
className="w-full max-w-xs rounded-xl border border-app-border bg-app-elevated p-5 text-center shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-app-hover text-app-muted">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2" />
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-app-fg">{t("preview.pdfProtected")}</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
value={pwInput}
|
||||||
|
onChange={(ev) => setPwInput(ev.target.value)}
|
||||||
|
placeholder={t("preview.password")}
|
||||||
|
className="mt-3 w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none focus:border-app-accent"
|
||||||
|
/>
|
||||||
|
{wrongPassword && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">{t("preview.pdfWrongPassword")}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!pwInput}
|
||||||
|
className="mt-3 w-full rounded-lg bg-app-accent py-2 text-sm font-medium text-app-accent-fg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("preview.unlock")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <div ref={scrollRef} onScroll={onScroll} className="h-full overflow-auto py-4" />;
|
return <div ref={scrollRef} onScroll={onScroll} className="h-full overflow-auto py-4" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
|||||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
|
import { AvatarImage } from "../SenderAvatar";
|
||||||
|
import { clearPdfPasswordCache } from "./AttachmentPreview";
|
||||||
import { LabelChips } from "../mail/LabelChips";
|
import { LabelChips } from "../mail/LabelChips";
|
||||||
import { MessageBody } from "./MessageBody";
|
import { MessageBody } from "./MessageBody";
|
||||||
|
|
||||||
@@ -345,10 +347,11 @@ function MessageItem({
|
|||||||
const nameColor = accentColor ?? `hsl(${h} 55% 62%)`;
|
const nameColor = accentColor ?? `hsl(${h} 55% 62%)`;
|
||||||
const avatar = () => (
|
const avatar = () => (
|
||||||
<span
|
<span
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
|
className="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full text-sm font-semibold text-white"
|
||||||
style={{ backgroundColor: avatarBg }}
|
style={{ backgroundColor: avatarBg }}
|
||||||
>
|
>
|
||||||
{initials(message)}
|
{initials(message)}
|
||||||
|
<AvatarImage email={message.from_addr[0]?.email} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
const senderName = (
|
const senderName = (
|
||||||
@@ -534,6 +537,8 @@ export function ThreadView({
|
|||||||
// --- labels: per-message storage + a thread union for display (chips + list) ---
|
// --- labels: per-message storage + a thread union for display (chips + list) ---
|
||||||
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
||||||
const threadId = messages[0]?.thread_id ?? null;
|
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 allLabelsQuery = useQuery({ queryKey: ["labels-all"], queryFn: api.labelsAll });
|
||||||
const msgLabelsQuery = useQuery({
|
const msgLabelsQuery = useQuery({
|
||||||
queryKey: ["message-labels", messageIds],
|
queryKey: ["message-labels", messageIds],
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export const en = {
|
|||||||
"mail.previewResetZoom": "Reset zoom",
|
"mail.previewResetZoom": "Reset zoom",
|
||||||
"mail.previewPrev": "Previous",
|
"mail.previewPrev": "Previous",
|
||||||
"mail.previewNext": "Next",
|
"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.orderOldest": "Oldest first",
|
||||||
"mail.orderNewest": "Newest first",
|
"mail.orderNewest": "Newest first",
|
||||||
"filter.all": "All mail",
|
"filter.all": "All mail",
|
||||||
|
|||||||
@@ -145,28 +145,30 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.wm-side-head {
|
.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 {
|
.wm-side-switch {
|
||||||
@apply relative flex items-center gap-2;
|
@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 <img h-7>) rather
|
||||||
|
than letting `contain` shrink it to fit the box's smaller dimension. */
|
||||||
.wm-side-switch::before {
|
.wm-side-switch::before {
|
||||||
content: "";
|
content: "";
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 28px;
|
width: 30px;
|
||||||
height: 28px;
|
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. */
|
/* And at the top of the collapsed rail, above the expand/theme buttons. */
|
||||||
.cal-rail::before,
|
.cal-rail::before,
|
||||||
.ct-rail::before {
|
.ct-rail::before {
|
||||||
content: "";
|
content: "";
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 30px;
|
width: 32px;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
margin: 10px auto 6px;
|
margin: 11px auto 6px;
|
||||||
background: url("/ultimo-logo-signet.svg") center / contain no-repeat;
|
background: url("/ultimo-logo-signet.svg") center / auto 28px no-repeat;
|
||||||
}
|
}
|
||||||
.wm-side-switch > button {
|
.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;
|
@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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.wm-side-themebtn {
|
.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 {
|
.wm-side-primary {
|
||||||
@apply px-3 pb-2;
|
@apply px-3 pb-2 pt-2.5;
|
||||||
}
|
}
|
||||||
.wm-side-primary > button {
|
.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;
|
@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;
|
||||||
|
|||||||
@@ -509,7 +509,7 @@ export function MailLayout() {
|
|||||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
|
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex pb-1 pt-3 ${
|
className={`flex pb-1 pt-[11px] ${
|
||||||
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
|
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -526,14 +526,14 @@ export function MailLayout() {
|
|||||||
onClick={() => setCollapsed((v) => !v)}
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
||||||
aria-label={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"
|
||||||
>
|
>
|
||||||
<Icon name={collapsed ? "show" : "hide"} size={16} />
|
<Icon name={collapsed ? "show" : "hide"} size={16} />
|
||||||
</button>
|
</button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`pb-2 ${collapsed ? "px-2" : "px-3"}`}>
|
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"}`}>
|
||||||
<button
|
<button
|
||||||
className="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"
|
className="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"
|
||||||
title={collapsed ? t("nav.compose") : undefined}
|
title={collapsed ? t("nav.compose") : undefined}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
|
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
|
||||||
|
from webmail_plugin_contacts.hook import on_message_sent
|
||||||
from webmail_plugin_contacts.router import router
|
from webmail_plugin_contacts.router import router
|
||||||
|
|
||||||
_HERE = Path(__file__).resolve().parent
|
_HERE = Path(__file__).resolve().parent
|
||||||
@@ -22,6 +23,7 @@ plugin = Plugin(
|
|||||||
name="contacts",
|
name="contacts",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
builtin=True,
|
builtin=True,
|
||||||
|
hooks={"message.sent": on_message_sent},
|
||||||
router=router,
|
router=router,
|
||||||
ui_manifest=UiManifest(
|
ui_manifest=UiManifest(
|
||||||
pages=[
|
pages=[
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""message.sent hook: autosave reply recipients as contacts (SPEC App. B).
|
||||||
|
|
||||||
|
When the user sends a reply, its recipients (To/Cc) are people they've now
|
||||||
|
corresponded with, so any who aren't already contacts are added to the default
|
||||||
|
book's "Autosaved" group. Runs in the sync worker wherever message.sent fires;
|
||||||
|
uses the RLS-scoped SDK session, so it can only ever touch this user's rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.plugins.sdk import sdk
|
||||||
|
from webmail_plugin_contacts.router import _ensure_book
|
||||||
|
|
||||||
|
logger = logging.getLogger("plg.contacts")
|
||||||
|
|
||||||
|
_SDK = sdk("contacts")
|
||||||
|
|
||||||
|
|
||||||
|
def _emails_value(email: str) -> str:
|
||||||
|
return json.dumps([{"value": email, "type": "", "pref": True}])
|
||||||
|
|
||||||
|
|
||||||
|
async def on_message_sent(payload: dict[str, Any]) -> None:
|
||||||
|
user_raw = payload.get("user_id")
|
||||||
|
if not isinstance(user_raw, str):
|
||||||
|
return
|
||||||
|
# De-dupe within this send; keep the first display name seen for each address.
|
||||||
|
recipients: dict[str, str] = {}
|
||||||
|
for field in ("to", "cc"):
|
||||||
|
for addr in payload.get(field) or []:
|
||||||
|
if not isinstance(addr, dict):
|
||||||
|
continue
|
||||||
|
email = str(addr.get("email") or "").strip().lower()
|
||||||
|
if "@" in email and email not in recipients:
|
||||||
|
recipients[email] = str(addr.get("name") or "").strip()
|
||||||
|
if not recipients:
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = uuid.UUID(user_raw)
|
||||||
|
async with _SDK.session(user_id) as session:
|
||||||
|
book_id = await _ensure_book(session, user_id) # also ensures the Autosaved group
|
||||||
|
group_row = (
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id FROM plg_contacts_groups "
|
||||||
|
"WHERE user_id = :u AND name = 'Autosaved' LIMIT 1"
|
||||||
|
),
|
||||||
|
{"u": str(user_id)},
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
autosaved_gid = str(group_row[0]) if group_row is not None else None
|
||||||
|
|
||||||
|
for email, name in recipients.items():
|
||||||
|
already = (
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT 1 FROM plg_contacts_contacts "
|
||||||
|
"WHERE user_id = :u "
|
||||||
|
"AND (lower(default_email) = :e OR emails @> cast(:match as jsonb)) "
|
||||||
|
"LIMIT 1"
|
||||||
|
),
|
||||||
|
{"u": str(user_id), "e": email, "match": json.dumps([{"value": email}])},
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if already is not None:
|
||||||
|
continue
|
||||||
|
contact_id = (
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_contacts_contacts "
|
||||||
|
"(user_id, book_id, vcard_uid, name_display, emails, default_email) "
|
||||||
|
"VALUES (:u, :book, :uid, :name, :emails, :defemail) RETURNING id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"u": str(user_id),
|
||||||
|
"book": book_id,
|
||||||
|
"uid": str(uuid.uuid4()),
|
||||||
|
"name": name or email,
|
||||||
|
"emails": _emails_value(email),
|
||||||
|
"defemail": email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
if autosaved_gid is not None:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
|
||||||
|
"VALUES (:u, :g, :c) ON CONFLICT DO NOTHING"
|
||||||
|
),
|
||||||
|
{"u": str(user_id), "g": autosaved_gid, "c": str(contact_id)},
|
||||||
|
)
|
||||||
|
logger.info("contacts: autosaved %s from a sent message", email)
|
||||||
@@ -50,7 +50,12 @@ function initials(name) {
|
|||||||
}
|
}
|
||||||
function avatarHtml(name, email, size) {
|
function avatarHtml(name, email, size) {
|
||||||
const s = size || 40;
|
const s = size || 40;
|
||||||
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}</span>`;
|
// Overlay the server-resolved gravatar/favicon (if any) on the initials circle;
|
||||||
|
// a miss (404) is removed by the error handler wired in wire(), leaving initials.
|
||||||
|
const img = email && email.includes("@")
|
||||||
|
? `<img class="ct-avimg" data-cav src="/api/avatar?email=${encodeURIComponent(email)}" alt="" aria-hidden="true" loading="lazy"/>`
|
||||||
|
: "";
|
||||||
|
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}${img}</span>`;
|
||||||
}
|
}
|
||||||
function fmtBirthday(s) {
|
function fmtBirthday(s) {
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s));
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s));
|
||||||
@@ -81,7 +86,8 @@ const CSS = `
|
|||||||
.ct-railbtn.active{background:rgb(var(--app-active));color:rgb(var(--app-fg))}
|
.ct-railbtn.active{background:rgb(var(--app-active));color:rgb(var(--app-fg))}
|
||||||
.ct-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
|
.ct-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
|
||||||
.ct-railnew:hover{opacity:.9}
|
.ct-railnew:hover{opacity:.9}
|
||||||
.ct-av{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
|
.ct-av{position:relative;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
|
||||||
|
.ct-avimg{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;object-fit:cover;background:#fff}
|
||||||
.ct-listcol{flex:0 0 auto;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
|
.ct-listcol{flex:0 0 auto;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
|
||||||
.ct-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
|
.ct-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||||
.ct-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
|
.ct-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
|
||||||
@@ -700,6 +706,11 @@ export default function mount(el, ctx) {
|
|||||||
|
|
||||||
function wire() {
|
function wire() {
|
||||||
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
|
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
|
||||||
|
// Drop avatar images that fail to resolve (no gravatar/favicon) so the initials show.
|
||||||
|
el.querySelectorAll("img[data-cav]").forEach((img) => {
|
||||||
|
img.addEventListener("error", () => img.remove());
|
||||||
|
if (img.complete && img.naturalWidth === 0) img.remove();
|
||||||
|
});
|
||||||
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
||||||
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; state.checked.clear(); state.page = 0; reloadList(false); };
|
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; state.checked.clear(); state.page = 0; reloadList(false); };
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Reference in New Issue
Block a user