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