M5 Contacts plugin (builtin) + collapsed icon-rail for Calendar

Contacts (SPEC Appendix B, phase B1) — new builtin plugin `webmail_plugin_contacts`:
- Tables plg_contacts_{books,contacts,groups,group_members} (RLS-isolated,
  migration contacts0001): vCard-style structured contact fields, address books,
  groups with membership.
- API /api/plugins/contacts/: list (filter all|vip|recent|nogroup, group, q),
  get/create/update/delete, star toggle, groups CRUD, and the recipients.suggest
  capability endpoint (enriches the composer). Display name derived server-side
  from name parts so search + suggestions work regardless of client.
- Frontend ui/page.js: three-pane app — shared .wm-side-* sidebar template with a
  "Contacts" header, alphabetically grouped list, detail pane (avatar, VIP star,
  Call/Message/Compose/Invite, Contact details, live Mail search, Events stub) and
  a full edit form (name, org, birthday, repeatable emails/phones, address, notes).

Collapsed sidebar: both Contacts and Calendar now show a 60px icon-only rail (like
Mail) with expand toggle, primary button, quick nav and Settings/Sign out — instead
of hiding the panel (Contacts) / a top-bar button (Calendar).

Deferred: Gravatar photos, vCard import/export + merge (B2), CardDAV sync (B3),
"Add sender to contacts".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-02 10:12:45 +02:00
co-authored by Claude Opus 4.8
parent 57a1f1562f
commit a985b112b1
8 changed files with 1304 additions and 2 deletions
@@ -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")