@@ -858,7 +868,6 @@ export default function mount(el, ctx) {
function topbar() {
return `
- ${state.leftCollapsed ? `` : ""}
@@ -867,6 +876,26 @@ export default function mount(el, ctx) {
`;
}
+ // Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
+ // toggle, so it can always be re-opened.
+ function railPanel() {
+ const TODAY_IC = ``;
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ }
+
// Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css):
// switcher + collapse/theme, primary button, an app-specific scrollable body
// (mini-month + calendars here), and the shared foot. Identical to Mail.
diff --git a/plugins/webmail_plugin_contacts/pyproject.toml b/plugins/webmail_plugin_contacts/pyproject.toml
new file mode 100644
index 0000000..a002f6e
--- /dev/null
+++ b/plugins/webmail_plugin_contacts/pyproject.toml
@@ -0,0 +1,20 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "webmail-plugin-contacts"
+version = "0.1.0"
+description = "Contacts — the second heavy plugin (SPEC Appendix B). Local contact books, groups, recipients.suggest; vCard + CardDAV later."
+requires-python = ">=3.11"
+# B1 (local contacts) needs no external deps; vobject is added at B2 (vCard I/O).
+dependencies = []
+
+[project.entry-points."webmail.plugins"]
+contacts = "webmail_plugin_contacts:plugin"
+
+[tool.setuptools]
+packages = ["webmail_plugin_contacts"]
+
+[tool.setuptools.package-data]
+webmail_plugin_contacts = ["ui/*.js", "migrations/versions/*.py"]
diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/__init__.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/__init__.py
new file mode 100644
index 0000000..d01b707
--- /dev/null
+++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/__init__.py
@@ -0,0 +1,37 @@
+"""``contacts`` — the second heavy plugin (SPEC Appendix B).
+
+A **core, non-removable** plugin (``builtin=True``) that ships a `/contacts` page
+beside Mail and Calendar. Its own ``plg_contacts_*`` tables (RLS-isolated) hold
+address books, contacts (structured vCard-style fields), and groups. Core owns the
+*implicit* recipient history (``known_recipients``); this plugin owns the *explicit*,
+user-curated contacts and enriches the composer via the ``recipients.suggest``
+capability. Uses the SDK only — zero core diffs. Phase B1 (local contacts) is the
+current scope; vCard import/export (B2) and CardDAV sync (B3) build on the same model.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
+from webmail_plugin_contacts.router import router
+
+_HERE = Path(__file__).resolve().parent
+
+plugin = Plugin(
+ name="contacts",
+ version="0.1.0",
+ builtin=True,
+ router=router,
+ ui_manifest=UiManifest(
+ pages=[
+ Page(
+ route="/contacts",
+ nav=PageNav(icon="contacts", label="Contacts", order=20),
+ bundle="/api/plugins/contacts/ui/page.js",
+ )
+ ],
+ capabilities={"recipients.suggest": "/api/plugins/contacts/suggest"},
+ ),
+ migrations=str(_HERE / "migrations" / "versions"),
+)
diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/migrations/versions/0001_init.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/migrations/versions/0001_init.py
new file mode 100644
index 0000000..610c238
--- /dev/null
+++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/migrations/versions/0001_init.py
@@ -0,0 +1,108 @@
+"""contacts plugin: books + contacts + groups tables with RLS (SPEC §B.3)
+
+Revision ID: contacts0001
+Revises:
+Create Date: 2026-08-02
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+from app.plugins.rls import enable_rls
+
+revision: str = "contacts0001"
+down_revision: str | None = None
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ CREATE TABLE plg_contacts_books (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid NOT NULL,
+ name text NOT NULL,
+ kind text NOT NULL DEFAULT 'local', -- local|carddav
+ source_config jsonb NOT NULL DEFAULT '{}',
+ secret_ct bytea, -- CardDAV creds (vault, §10.2)
+ sync_token text,
+ read_only boolean NOT NULL DEFAULT false,
+ UNIQUE (user_id, name)
+ )
+ """
+ )
+ op.execute(
+ """
+ CREATE TABLE plg_contacts_contacts (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid NOT NULL,
+ book_id uuid NOT NULL
+ REFERENCES plg_contacts_books(id) ON DELETE CASCADE,
+ vcard_uid text NOT NULL,
+ etag text,
+ name_display text NOT NULL DEFAULT '', -- FN
+ name_parts jsonb NOT NULL DEFAULT '{}', -- {prefix,given,middle,family,suffix}
+ nickname text NOT NULL DEFAULT '',
+ emails jsonb NOT NULL DEFAULT '[]', -- [{value,type,pref}]
+ default_email text NOT NULL DEFAULT '',
+ phones jsonb NOT NULL DEFAULT '[]', -- [{value,type}]
+ addresses jsonb NOT NULL DEFAULT '[]', -- [{type,country,street,postal_code,city}]
+ urls jsonb NOT NULL DEFAULT '[]', -- [{value,type}]
+ org text NOT NULL DEFAULT '',
+ department text NOT NULL DEFAULT '',
+ title text NOT NULL DEFAULT '',
+ birthday date,
+ notes text NOT NULL DEFAULT '',
+ starred boolean NOT NULL DEFAULT false, -- VIP / favourite
+ photo_sha256 text,
+ raw_vcard text, -- round-trip fidelity (B2/B3)
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (book_id, vcard_uid)
+ )
+ """
+ )
+ op.execute(
+ "CREATE INDEX ix_plg_contacts_contacts_user_name "
+ "ON plg_contacts_contacts (user_id, name_display)"
+ )
+ op.execute(
+ "CREATE INDEX ix_plg_contacts_contacts_emails "
+ "ON plg_contacts_contacts USING gin (emails jsonb_path_ops)"
+ )
+ op.execute(
+ """
+ CREATE TABLE plg_contacts_groups (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid NOT NULL,
+ name text NOT NULL,
+ UNIQUE (user_id, name)
+ )
+ """
+ )
+ op.execute(
+ """
+ CREATE TABLE plg_contacts_group_members (
+ user_id uuid NOT NULL,
+ group_id uuid NOT NULL
+ REFERENCES plg_contacts_groups(id) ON DELETE CASCADE,
+ contact_id uuid NOT NULL
+ REFERENCES plg_contacts_contacts(id) ON DELETE CASCADE,
+ PRIMARY KEY (group_id, contact_id)
+ )
+ """
+ )
+ enable_rls("plg_contacts_books")
+ enable_rls("plg_contacts_contacts")
+ enable_rls("plg_contacts_groups")
+ enable_rls("plg_contacts_group_members")
+
+
+def downgrade() -> None:
+ op.execute("DROP TABLE IF EXISTS plg_contacts_group_members")
+ op.execute("DROP TABLE IF EXISTS plg_contacts_groups")
+ op.execute("DROP TABLE IF EXISTS plg_contacts_contacts")
+ op.execute("DROP TABLE IF EXISTS plg_contacts_books")
diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py
new file mode 100644
index 0000000..c6f79a2
--- /dev/null
+++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py
@@ -0,0 +1,458 @@
+"""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
+
+_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:
+ if row.get("name_display"):
+ return str(row["name_display"])
+ 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
+ emails = _json(row.get("emails"))
+ if emails and isinstance(emails, list):
+ return str(emails[0].get("value", "")) if isinstance(emails[0], dict) else ""
+ return ""
+
+
+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 ------------------------------------------------------------------
+
+@router.get("/ui/{filename}")
+async def bundle(filename: str) -> FileResponse:
+ if filename != "page.js":
+ raise HTTPException(status_code=404, detail="not_found")
+ return FileResponse(
+ _UI / filename,
+ media_type="text/javascript",
+ 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"}
+
+
+# --- 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)")
+ 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.emails:
+ name = body.emails[0].value.strip()
+ 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, emails, default_email FROM plg_contacts_contacts "
+ "WHERE name_display ILIKE :q OR emails::text 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
diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py
new file mode 100644
index 0000000..609150a
--- /dev/null
+++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py
@@ -0,0 +1,59 @@
+"""Request models for the contacts API (SPEC §B.5)."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+
+class NameParts(BaseModel):
+ prefix: str = "" # "Title" in the edit form (Dr, Mr, …)
+ given: str = ""
+ middle: str = ""
+ family: str = ""
+ suffix: str = ""
+
+
+class EmailEntry(BaseModel):
+ value: str
+ type: str = "personal" # personal | work | other
+ pref: bool = False
+
+
+class PhoneEntry(BaseModel):
+ value: str
+ type: str = "mobile" # mobile | home | work | other
+
+
+class UrlEntry(BaseModel):
+ value: str
+ type: str = "website"
+
+
+class AddressEntry(BaseModel):
+ type: str = "home" # home | work | other
+ country: str = ""
+ street: str = ""
+ postal_code: str = ""
+ city: str = ""
+
+
+class ContactIn(BaseModel):
+ display_name: str = "" # FN; derived from name parts / first email when blank
+ name_parts: NameParts = NameParts()
+ nickname: str = ""
+ org: str = "" # Company
+ department: str = ""
+ title: str = "" # Job title
+ birthday: str | None = None # "YYYY-MM-DD" (full date) or None
+ emails: list[EmailEntry] = []
+ default_email: str = ""
+ phones: list[PhoneEntry] = []
+ urls: list[UrlEntry] = []
+ addresses: list[AddressEntry] = []
+ notes: str = ""
+ starred: bool = False
+ group_ids: list[str] = []
+
+
+class GroupIn(BaseModel):
+ name: str
diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js
new file mode 100644
index 0000000..9ad96e1
--- /dev/null
+++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js
@@ -0,0 +1,591 @@
+// contacts · app.page bundle (SPEC §B.6). A full-bleed three-pane contacts app:
+// shared sidebar template (.wm-side-*, identical to Mail/Calendar) with a
+// "Contacts" header, an alphabetically grouped contact list, and a detail pane
+// with an edit-in-place form. Framework-agnostic vanilla DOM; theme-aware via
+// rgb(var(--app-*)); data comes from the plugin API.
+
+const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
+const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
+const EMAIL_TYPES = ["personal", "work", "other"];
+const PHONE_TYPES = ["mobile", "home", "work", "other"];
+const ADDR_TYPES = ["home", "work", "other"];
+const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
+
+// Icons copied from Mail/Calendar (frontend/icons/*.svg) so the apps match exactly.
+const HIDE_ICON = ``;
+const SHOW_ICON = ``;
+const SETTINGS_ICON = ``;
+const LOGOUT_ICON = ``;
+const PLUS_ICON = ``;
+const IC_BOOK = ``;
+const IC_STAR = ``;
+const IC_CLOCK = ``;
+const IC_GROUP = ``;
+const IC_PERSON = ``;
+const IC_MAIL = ``;
+const IC_PHONE = ``;
+const IC_CHAT = ``;
+const IC_COMPOSE = ``;
+const IC_CAL = ``;
+const IC_PENCIL = IC_COMPOSE;
+const IC_SORT = ``;
+const IC_MORE = ``;
+const CHEV = ``;
+const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
+
+const AVATAR_COLORS = ["#ef4444","#f97316","#eab308","#22c55e","#14b8a6","#3b82f6","#8b5cf6","#ec4899","#6b7280"];
+function avatarColor(key) { let h = 0; for (const c of String(key || "?")) h = (h * 31 + c.charCodeAt(0)) >>> 0; return AVATAR_COLORS[h % AVATAR_COLORS.length]; }
+function initials(name) {
+ const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
+ if (!parts.length) return "?";
+ return ((parts[0][0] || "") + (parts.length > 1 ? parts[parts.length - 1][0] : "")).toUpperCase();
+}
+function avatarHtml(name, email, size) {
+ const s = size || 40;
+ return `${esc(initials(name || email))}`;
+}
+function shortDate(iso) {
+ const d = new Date(iso);
+ if (isNaN(d)) return "";
+ const now = new Date();
+ const opts = d.getFullYear() === now.getFullYear() ? { month: "short", day: "numeric" } : { year: "numeric", month: "short", day: "numeric" };
+ return d.toLocaleDateString("en-GB", opts);
+}
+
+const CSS = `
+.ct-app{display:flex;height:100vh;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px}
+.ct-app .wm-side-item.active{background:rgb(var(--app-active));color:rgb(var(--app-fg));font-weight:600}
+.ct-app .wm-side-item .ct-dico{margin-top:0}
+/* collapsed icon-only rail (matches Mail's 60px rail) */
+.ct-rail{width:60px;align-items:center}
+.ct-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
+.ct-railprimary{padding:2px 0 8px}
+.ct-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
+.ct-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
+.ct-railbtn{display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:9px;color:rgb(var(--app-muted));cursor:pointer;background:none;border:0}
+.ct-railbtn:hover{background:rgb(var(--app-hover));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: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-listcol{flex:0 0 46%;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-bar{display:flex;align-items:center;gap:6px;height:49px;padding:0 14px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto}
+.ct-bar .sp{flex:1}
+.ct-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;color:rgb(var(--app-muted));cursor:pointer}
+.ct-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
+.ct-barbtn{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:6px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px}
+.ct-barbtn:hover{background:rgb(var(--app-hover))}
+.ct-cbx{width:16px;height:16px;accent-color:rgb(var(--app-accent))}
+.ct-list{flex:1;min-height:0;overflow:auto}
+.ct-alpha{position:sticky;top:0;background:rgb(var(--app-bg));color:rgb(var(--app-muted));font-size:12px;font-weight:700;padding:6px 18px 2px;z-index:1}
+.ct-row{display:flex;align-items:center;gap:12px;padding:9px 18px;cursor:pointer;border-bottom:1px solid rgb(var(--app-border) / .5)}
+.ct-row:hover{background:rgb(var(--app-hover))}
+.ct-row.sel{background:rgb(var(--app-active))}
+.ct-rowmain{min-width:0;flex:1}
+.ct-rowname{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ct-rowmail{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ct-empty{padding:60px 20px;text-align:center;color:rgb(var(--app-muted))}
+.ct-detail{flex:1;min-height:0;overflow:auto;padding:20px}
+.ct-card{max-width:760px;margin:0 auto;border:1px solid rgb(var(--app-border));border-radius:12px;padding:22px 26px}
+.ct-cardhead{display:flex;gap:20px;align-items:flex-start;position:relative}
+.ct-headinfo{flex:1;min-width:0}
+.ct-name{font-size:22px;font-weight:700;margin:6px 0 12px}
+.ct-star{position:absolute;top:0;right:0;color:rgb(var(--app-muted));cursor:pointer}
+.ct-star.on{color:#eab308}
+.ct-actions{display:flex;gap:8px;flex-wrap:wrap}
+.ct-act{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border:1px solid rgb(var(--app-border));border-radius:7px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px;background:rgb(var(--app-surface))}
+.ct-act:hover{background:rgb(var(--app-hover))}
+.ct-sec{margin-top:26px}
+.ct-sectitle{font-size:15px;font-weight:700;margin-bottom:6px}
+.ct-drow{display:flex;gap:14px;align-items:flex-start;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
+.ct-dico{color:rgb(var(--app-muted));margin-top:2px}
+.ct-dlabel{color:rgb(var(--app-muted));font-size:12px}
+.ct-dval{margin-top:1px}
+.ct-mailrow{display:flex;gap:12px;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
+.ct-mailmain{min-width:0;flex:1}
+.ct-mailsub{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ct-mailsnip{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ct-maildate{color:rgb(var(--app-muted));font-size:12px;flex:0 0 auto}
+.ct-findbtn{margin-top:14px;display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:7px;background:rgb(var(--app-hover));cursor:pointer;font-size:13px}
+/* --- edit form --- */
+.ct-form{max-width:640px;margin:0 auto;padding:6px 0 40px}
+.ct-formav{text-align:center;margin-bottom:6px}
+.ct-formav .ct-av{margin:0 auto}
+.ct-formav .cap{color:rgb(var(--app-muted));font-size:12px;margin-top:6px}
+.ct-frow{display:flex;gap:10px;margin-bottom:12px}
+.ct-fsec{font-weight:700;margin:22px 0 10px}
+.ct-flabel{width:90px;flex:0 0 auto;color:rgb(var(--app-muted));font-size:13px;padding-top:9px}
+.ct-in,.ct-sel{width:100%;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:6px;padding:8px 10px;color:rgb(var(--app-fg));font-size:14px;outline:none}
+.ct-in:focus,.ct-sel:focus{border-color:rgb(var(--app-accent))}
+.ct-multi{display:flex;gap:8px;align-items:center;margin-bottom:8px}
+.ct-multi .ct-sel{width:130px;flex:0 0 auto}
+.ct-rm{color:rgb(var(--app-muted));cursor:pointer;padding:4px;flex:0 0 auto}
+.ct-rm:hover{color:rgb(var(--app-fg))}
+.ct-addline{color:rgb(var(--app-accent));cursor:pointer;font-size:13px;display:inline-flex;align-items:center;gap:6px;margin-top:2px}
+.ct-menu{position:absolute;z-index:20;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:8px;box-shadow:0 8px 24px rgb(0 0 0 / .3);padding:6px;min-width:180px}
+.ct-menu button,.ct-menu label{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:7px 9px;border-radius:6px;cursor:pointer;font-size:13px;color:rgb(var(--app-fg))}
+.ct-menu button:hover,.ct-menu label:hover{background:rgb(var(--app-hover))}
+.ct-primary{background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));padding:6px 16px;border-radius:6px;font-weight:600;font-size:13px;cursor:pointer}
+.ct-primary:hover{opacity:.9}
+.ct-ghostbtn{padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px}
+.ct-ghostbtn:hover{background:rgb(var(--app-hover))}
+`;
+
+export default function mount(el, ctx) {
+ if (!document.getElementById("ct-styles")) {
+ const style = document.createElement("style");
+ style.id = "ct-styles";
+ style.textContent = CSS;
+ document.head.appendChild(style);
+ }
+
+ const state = {
+ filter: "all", group: "", groupLabel: "All contacts",
+ q: "",
+ contacts: [], groups: [],
+ selectedId: null, detail: null,
+ mode: "view", // view | edit | new
+ mail: null, // { loading, threads }
+ sortAsc: true,
+ menu: null, // "groups" | "more" | null
+ leftW: Number(localStorage.getItem("ct.leftW")) || 240,
+ leftCollapsed: localStorage.getItem("ct.leftCollapsed") === "1",
+ menuOpen: false,
+ me: "", quota: "",
+ apps: [{ label: "Mail", to: "/mail" }, { label: "Contacts", to: null }],
+ };
+
+ const api = ctx.api;
+
+ // ---- data loading --------------------------------------------------------
+ async function loadGroups() {
+ try { state.groups = await api.pluginFetch("/groups"); } catch { state.groups = []; }
+ }
+ async function loadContacts() {
+ const qs = `filter=${encodeURIComponent(state.filter)}&group=${encodeURIComponent(state.group)}&q=${encodeURIComponent(state.q)}`;
+ const res = await api.pluginFetch(`/contacts?${qs}`);
+ state.contacts = res.contacts || [];
+ if (!state.contacts.find((c) => c.id === state.selectedId)) {
+ state.selectedId = state.contacts[0] ? state.contacts[0].id : null;
+ }
+ }
+ async function loadDetail(id) {
+ if (!id) { state.detail = null; state.mail = null; return; }
+ state.detail = await api.pluginFetch(`/contacts/${id}`);
+ loadMail(state.detail.default_email);
+ }
+ async function loadMail(email) {
+ state.mail = { loading: true, threads: [] };
+ if (!email) { state.mail = { loading: false, threads: [] }; render(); return; }
+ try {
+ const res = await api.fetch(`/search?q=${encodeURIComponent('"' + email + '"')}&limit=5`);
+ state.mail = { loading: false, threads: (res.threads || []).map((h) => h.thread) };
+ } catch { state.mail = { loading: false, threads: [] }; }
+ render();
+ }
+
+ async function selectContact(id) {
+ state.selectedId = id;
+ state.mode = "view";
+ state.menu = null;
+ await loadDetail(id);
+ render();
+ }
+
+ async function reloadList(keepSelection) {
+ if (!keepSelection) state.selectedId = state.selectedId;
+ await loadContacts();
+ await loadDetail(state.selectedId);
+ render();
+ }
+
+ // ---- sidebar (shared .wm-side-* template; identical to Mail/Calendar) -----
+ function navItem(icon, label, active, act) {
+ return ``;
+ }
+ function leftPanel() {
+ const f = state.filter;
+ const grp = (g) => ``;
+ return `
+
+
+
+ ${state.menuOpen ? `
${state.apps.map((a, i) => ``).join("")}
` : ""}
+
+
+
+
+
+
+
+
+
+
+
+ ${navItem(IC_BOOK, "All contacts", !state.group && f === "all", "filter-all")}
+ ${navItem(IC_STAR, "VIPs", !state.group && f === "vip", "filter-vip")}
+ ${navItem(IC_CLOCK, "Recent", !state.group && f === "recent", "filter-recent")}
+
+
Groups
+
+ ${state.groups.map(grp).join("")}
+ ${navItem(IC_PERSON, "No group", f === "nogroup", "filter-nogroup")}
+
+
+
+
+
+ ${state.quota ? `
${esc(state.quota)}
` : ""}
+
`;
+ }
+
+ // Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
+ // toggle, so it can always be re-opened.
+ function railPanel() {
+ const nav = (icon, act, on, title) => ``;
+ return `