Files
webmail-client/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py
T
lukasandClaude Opus 4.8 421b44a8ab Mail mark-all-read + iframe fixes; Contacts detail/selection/groups + resizable panels
Mail:
- New "More" (⋯) menu in the list sorting bar with "Mark all as read": POST
  /threads/mark-read marks every unread thread in the current view (unified /
  folder:<special> / account:<uuid>) as \Seen, pushes + publishes updates.
- Message iframe: no longer shows a spurious vertical scrollbar (display:block,
  getBoundingClientRect+ceil sizing, overflow-y:hidden on the doc) and has
  rounded corners.
- Deep links from Contacts: /mail?compose=1&to= opens a maximized prefilled
  composer; /mail?q= prefills the search box.

Contacts:
- Detail pane shows all populated fields (work subtitle, phones, address, urls,
  nickname, birthday). Company name is used as the display name when there's no
  personal name (existing rows too). Editable "Add notes".
- Selection: checkbox reveals on avatar hover only; Groups/More bulk menus in
  select mode; "N contacts selected" panel; filled yellow VIP star in list +
  detail; pagination bottom bar; Lato pinned on form controls.
- Groups: "+" on the GROUPS header and "Create new" in the group menus (bulk
  add/remove via POST /groups/{id}/add|remove, ownership-guarded).
- Rounded Mail-style list rows; removed the ⋯ button from the search bar;
  Compose→Mail, Find-all-conversations→Mail search; Message button removed;
  delete confirmation; resizable sidebar + list/detail split.

Calendar: the right panel (agenda / search) is now resizable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
2026-08-02 21:34:22 +02:00

521 lines
18 KiB
Python

"""Contacts plugin API (SPEC §B.5). Local address book: contact CRUD with
structured fields, groups, alphabetically grouped listing + search, and the
``recipients.suggest`` capability endpoint used by the composer.
Uses raw SQL through the RLS-scoped ``sdk.session`` (plugin tables aren't in the
core ORM). jsonb columns are written as ``json.dumps`` strings and read back as
already-parsed Python objects — same discipline as the calendar plugin.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import date
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
from webmail_plugin_contacts.schemas import ContactIn, GroupIn, MembersIn
_log = logging.getLogger("plg.contacts")
_SDK = sdk("contacts")
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
# Columns selected for both the list and detail responses.
_COLS = (
"id, book_id, name_display, name_parts, nickname, emails, default_email, phones, "
"urls, addresses, org, department, title, birthday, notes, starred, updated_at"
)
def _json(value: object) -> Any:
"""Read a jsonb column defensively (SQLAlchemy usually returns parsed objects,
but tolerate a raw JSON string too)."""
if isinstance(value, str):
try:
return json.loads(value)
except ValueError:
return []
return value if value is not None else []
def _display_name(row: dict[str, object]) -> str:
# 1) a real personal name (N parts) always wins.
parts = _json(row.get("name_parts")) or {}
if isinstance(parts, dict):
joined = " ".join(
str(parts.get(k, "")).strip()
for k in ("prefix", "given", "middle", "family", "suffix")
if str(parts.get(k, "")).strip()
)
if joined:
return joined
org = str(row.get("org") or "").strip()
nd = str(row.get("name_display") or "").strip()
primary = _primary_email(row)
# 2) a stored FN that is a real name (not just the legacy email fallback) wins.
if nd and nd != primary:
return nd
# 3) otherwise the company name, 4) otherwise the email.
return org or primary
def _primary_email(row: dict[str, object]) -> str:
if row.get("default_email"):
return str(row["default_email"])
emails = _json(row.get("emails"))
if isinstance(emails, list) and emails:
pref = next((e for e in emails if isinstance(e, dict) and e.get("pref")), None)
chosen = pref or emails[0]
if isinstance(chosen, dict):
return str(chosen.get("value", ""))
return ""
def _detail(row: dict[str, object], groups: list[dict[str, str]]) -> dict[str, object]:
bday = row.get("birthday")
return {
"id": str(row["id"]),
"display_name": _display_name(row),
"name_parts": _json(row.get("name_parts")) or {},
"nickname": row.get("nickname") or "",
"org": row.get("org") or "",
"department": row.get("department") or "",
"title": row.get("title") or "",
"birthday": bday.isoformat() if isinstance(bday, date) else None,
"emails": _json(row.get("emails")),
"default_email": _primary_email(row),
"phones": _json(row.get("phones")),
"urls": _json(row.get("urls")),
"addresses": _json(row.get("addresses")),
"notes": row.get("notes") or "",
"starred": bool(row.get("starred")),
"groups": groups,
}
async def _ensure_book(s: AsyncSession, user_id: uuid.UUID) -> str:
"""Return the user's default local book id, creating it (and an 'Autosaved'
group) on first use so the page always has somewhere to put new contacts."""
row = (
await s.execute(
text(
"SELECT id FROM plg_contacts_books "
"WHERE user_id = :u ORDER BY name LIMIT 1"
),
{"u": str(user_id)},
)
).first()
if row is not None:
book_id = str(row[0])
else:
book_id = str(
(
await s.execute(
text(
"INSERT INTO plg_contacts_books (user_id, name) "
"VALUES (:u, 'Contacts') RETURNING id"
),
{"u": str(user_id)},
)
).scalar_one()
)
await s.execute(
text(
"INSERT INTO plg_contacts_groups (user_id, name) VALUES (:u, 'Autosaved') "
"ON CONFLICT (user_id, name) DO NOTHING"
),
{"u": str(user_id)},
)
return book_id
async def _group_map(s: AsyncSession) -> dict[str, list[dict[str, str]]]:
"""contact_id -> [{id, name}] for every membership (RLS-scoped)."""
rows = (
await s.execute(
text(
"SELECT m.contact_id, g.id AS gid, g.name FROM plg_contacts_group_members m "
"JOIN plg_contacts_groups g ON g.id = m.group_id"
)
)
).mappings().all()
out: dict[str, list[dict[str, str]]] = {}
for r in rows:
out.setdefault(str(r["contact_id"]), []).append(
{"id": str(r["gid"]), "name": str(r["name"])}
)
return out
# --- bundle ------------------------------------------------------------------
_UI_FILES = {
"page.js": "text/javascript",
"contacts-illustration.svg": "image/svg+xml", # empty reading-pane illustration
}
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
media_type = _UI_FILES.get(filename)
if media_type is None:
raise HTTPException(status_code=404, detail="not_found")
return FileResponse(
_UI / filename,
media_type=media_type,
headers={"Cache-Control": "no-cache"},
)
# --- groups ------------------------------------------------------------------
@router.get("/groups")
async def list_groups(user_id: CurrentUserId) -> list[dict[str, object]]:
async with _SDK.session(user_id) as s:
await _ensure_book(s, user_id)
rows = (
await s.execute(
text(
"SELECT g.id, g.name, count(m.contact_id) AS n "
"FROM plg_contacts_groups g "
"LEFT JOIN plg_contacts_group_members m ON m.group_id = g.id "
"GROUP BY g.id, g.name ORDER BY lower(g.name)"
)
)
).mappings().all()
return [{"id": str(r["id"]), "name": str(r["name"]), "count": int(r["n"])} for r in rows]
@router.post("/groups", dependencies=[Depends(require_csrf)])
async def create_group(body: GroupIn, user_id: CurrentUserId) -> dict[str, str]:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="empty_name")
async with _SDK.session(user_id) as s:
gid = (
await s.execute(
text(
"INSERT INTO plg_contacts_groups (user_id, name) VALUES (:u, :n) "
"ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name "
"RETURNING id"
),
{"u": str(user_id), "n": name},
)
).scalar_one()
return {"id": str(gid), "name": name}
@router.delete("/groups/{group_id}", dependencies=[Depends(require_csrf)])
async def delete_group(group_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await s.execute(
text("DELETE FROM plg_contacts_groups WHERE id = :id"), {"id": str(group_id)}
)
return {"status": "ok"}
async def _require_own_group(s: AsyncSession, group_id: uuid.UUID) -> None:
"""404 unless the group belongs to the (RLS-scoped) caller."""
exists = (
await s.execute(
text("SELECT 1 FROM plg_contacts_groups WHERE id = :g"), {"g": str(group_id)}
)
).first()
if exists is None:
raise HTTPException(status_code=404, detail="group_not_found")
@router.post("/groups/{group_id}/add", dependencies=[Depends(require_csrf)])
async def group_add(
group_id: uuid.UUID, body: MembersIn, user_id: CurrentUserId
) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await _require_own_group(s, group_id)
for cid in body.ids:
await s.execute(
text(
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
"SELECT :u, :g, :c WHERE EXISTS "
"(SELECT 1 FROM plg_contacts_contacts WHERE id = :c) "
"ON CONFLICT DO NOTHING"
),
{"u": str(user_id), "g": str(group_id), "c": cid},
)
return {"status": "ok"}
@router.post("/groups/{group_id}/remove", dependencies=[Depends(require_csrf)])
async def group_remove(
group_id: uuid.UUID, body: MembersIn, user_id: CurrentUserId
) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await _require_own_group(s, group_id)
for cid in body.ids:
await s.execute(
text(
"DELETE FROM plg_contacts_group_members "
"WHERE group_id = :g AND contact_id = :c"
),
{"g": str(group_id), "c": cid},
)
return {"status": "ok"}
# --- contacts ----------------------------------------------------------------
@router.get("/contacts")
async def list_contacts(
user_id: CurrentUserId,
filter: str = Query("all"), # all | vip | recent | nogroup
group: str = Query(""),
q: str = Query("", max_length=200),
) -> dict[str, object]:
async with _SDK.session(user_id) as s:
await _ensure_book(s, user_id)
where = ["c.user_id = :u"]
params: dict[str, object] = {"u": str(user_id)}
if filter == "vip":
where.append("c.starred")
elif filter == "nogroup":
where.append(
"NOT EXISTS (SELECT 1 FROM plg_contacts_group_members m "
"WHERE m.contact_id = c.id)"
)
if group:
where.append(
"EXISTS (SELECT 1 FROM plg_contacts_group_members m "
"WHERE m.contact_id = c.id AND m.group_id = :g)"
)
params["g"] = group
if q.strip():
where.append(
"(c.name_display ILIKE :q OR c.emails::text ILIKE :q OR c.org ILIKE :q)"
)
params["q"] = f"%{q.strip()}%"
order = (
"c.updated_at DESC"
if filter == "recent"
else "lower(c.name_display), c.updated_at DESC"
)
limit = " LIMIT 50" if filter == "recent" else ""
rows = (
await s.execute(
text(
f"SELECT {_COLS} FROM plg_contacts_contacts c "
f"WHERE {' AND '.join(where)} ORDER BY {order}{limit}"
),
params,
)
).mappings().all()
groups = await _group_map(s)
contacts = []
for r in rows:
d = dict(r)
contacts.append(
{
"id": str(r["id"]),
"display_name": _display_name(d),
"primary_email": _primary_email(d),
"org": r["org"] or "",
"title": r["title"] or "",
"starred": bool(r["starred"]),
"groups": groups.get(str(r["id"]), []),
}
)
return {"contacts": contacts}
@router.get("/contacts/{contact_id}")
async def get_contact(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, object]:
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(f"SELECT {_COLS} FROM plg_contacts_contacts c WHERE c.id = :id"),
{"id": str(contact_id)},
)
).mappings().first()
if row is None:
raise HTTPException(status_code=404, detail="not_found")
groups = (await _group_map(s)).get(str(contact_id), [])
return _detail(dict(row), groups)
def _derive_name(body: ContactIn) -> str:
"""FN: the client's display_name, else the assembled name parts, else first email."""
name = body.display_name.strip()
if not name:
np = body.name_parts
name = " ".join(
x.strip()
for x in (np.prefix, np.given, np.middle, np.family, np.suffix)
if x.strip()
).strip()
if not name and body.org.strip():
name = body.org.strip()
# Deliberately NOT falling back to the email — name_display holds only a real
# name or company; the email fallback for display happens at read time.
return name
def _contact_params(body: ContactIn) -> dict[str, object]:
bday: date | None = None
if body.birthday:
try:
bday = date.fromisoformat(body.birthday)
except ValueError:
bday = None
return {
"name": _derive_name(body),
"parts": json.dumps(body.name_parts.model_dump()),
"nick": body.nickname.strip(),
"emails": json.dumps([e.model_dump() for e in body.emails]),
"defemail": body.default_email.strip(),
"phones": json.dumps([p.model_dump() for p in body.phones]),
"urls": json.dumps([u.model_dump() for u in body.urls]),
"addrs": json.dumps([a.model_dump() for a in body.addresses]),
"org": body.org.strip(),
"dept": body.department.strip(),
"title": body.title.strip(),
"bday": bday,
"notes": body.notes,
"starred": body.starred,
}
async def _set_groups(
s: AsyncSession, user_id: uuid.UUID, contact_id: str, group_ids: list[str]
) -> None:
await s.execute(
text("DELETE FROM plg_contacts_group_members WHERE contact_id = :c"),
{"c": contact_id},
)
for gid in group_ids:
await s.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": gid, "c": contact_id},
)
@router.post("/contacts", dependencies=[Depends(require_csrf)])
async def create_contact(body: ContactIn, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
book_id = await _ensure_book(s, user_id)
p = _contact_params(body)
p.update({"u": str(user_id), "book": book_id, "uid": str(uuid.uuid4())})
cid = (
await s.execute(
text(
"INSERT INTO plg_contacts_contacts "
"(user_id, book_id, vcard_uid, name_display, name_parts, nickname, emails, "
" default_email, phones, urls, addresses, org, department, title, birthday, "
" notes, starred) "
"VALUES (:u, :book, :uid, :name, :parts, :nick, :emails, :defemail, :phones, "
" :urls, :addrs, :org, :dept, :title, :bday, :notes, :starred) RETURNING id"
),
p,
)
).scalar_one()
await _set_groups(s, user_id, str(cid), body.group_ids)
return {"id": str(cid)}
@router.put("/contacts/{contact_id}", dependencies=[Depends(require_csrf)])
async def update_contact(
contact_id: uuid.UUID, body: ContactIn, user_id: CurrentUserId
) -> dict[str, str]:
async with _SDK.session(user_id) as s:
p = _contact_params(body)
p["id"] = str(contact_id)
result = await s.execute(
text(
"UPDATE plg_contacts_contacts SET "
"name_display = :name, name_parts = :parts, nickname = :nick, emails = :emails, "
"default_email = :defemail, phones = :phones, urls = :urls, addresses = :addrs, "
"org = :org, department = :dept, title = :title, birthday = :bday, notes = :notes, "
"starred = :starred, updated_at = now() WHERE id = :id"
),
p,
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="not_found")
await _set_groups(s, user_id, str(contact_id), body.group_ids)
return {"status": "ok"}
@router.delete("/contacts/{contact_id}", dependencies=[Depends(require_csrf)])
async def delete_contact(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await s.execute(
text("DELETE FROM plg_contacts_contacts WHERE id = :id"), {"id": str(contact_id)}
)
return {"status": "ok"}
@router.post("/contacts/{contact_id}/star", dependencies=[Depends(require_csrf)])
async def toggle_star(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, object]:
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(
"UPDATE plg_contacts_contacts SET starred = NOT starred WHERE id = :id "
"RETURNING starred"
),
{"id": str(contact_id)},
)
).first()
if row is None:
raise HTTPException(status_code=404, detail="not_found")
return {"starred": bool(row[0])}
# --- recipients.suggest capability (SPEC §B.5 / §11.3) -----------------------
@router.get("/suggest")
async def suggest(
user_id: CurrentUserId, q: str = Query("", max_length=200)
) -> list[dict[str, object]]:
term = q.strip()
if not term:
return []
like = f"%{term}%"
async with _SDK.session(user_id) as s:
rows = (
await s.execute(
text(
"SELECT id, name_display, name_parts, org, emails, default_email "
"FROM plg_contacts_contacts "
"WHERE name_display ILIKE :q OR emails::text ILIKE :q OR org ILIKE :q "
"ORDER BY lower(name_display) LIMIT 10"
),
{"q": like},
)
).mappings().all()
out: list[dict[str, object]] = []
for r in rows:
email = _primary_email(dict(r))
if not email:
continue
out.append(
{
"name": _display_name(dict(r)),
"email": email,
"source": "contact",
"contact_id": str(r["id"]),
}
)
return out