"""The plugin SDK — the ONLY ``app.*`` surface a plugin may import (SPEC §11). Everything a plugin needs is reached through :func:`sdk`, which returns a :class:`PluginSdk` bound to the plugin's name: a plugin-scoped DB session, the event bus (publishing under ``plugin.{name}.*``), the blob store, outbox draft/send helpers, the settings accessor, an SSRF-guarded HTTP client (§10.6), and the namespaced KV store (§11). The frozen contract types (:class:`Plugin`, :class:`UiManifest`, :class:`JobSpec`, …) are re-exported here so plugin packages import only from ``app.plugins.sdk``. """ from __future__ import annotations import uuid from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from typing import Annotated, Any from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from app.accounts.ssrf import guarded_get from app.config import Settings, get_settings from app.events import get_event_bus from app.identity.deps import current_user, require_csrf from app.indexer import blobstore from app.models import User from app.outbox import service as outbox_service from app.plugins.actions import PluginActions, PluginMessages from app.plugins.db import plugin_session from app.plugins.http import PluginHttp from app.plugins.kv import PluginKvStore from app.plugins.mail import PluginMail from app.plugins.types import ( Hook, JobFunc, JobSpec, Page, PageNav, Plugin, Slot, UiManifest, ) from app.plugins.vault import PluginVault from app.queue import enqueue_job __all__ = [ "CurrentUserId", "Hook", "JobFunc", "JobSpec", "Page", "PageNav", "Plugin", "PluginSdk", "Slot", "UiManifest", "blobstore", "require_csrf", "sdk", ] async def _current_user_id(user: Annotated[User, Depends(current_user)]) -> uuid.UUID: return user.id # Auth for plugin routers (plugins must not import app.identity directly, SPEC §11): # `CurrentUserId` resolves the authenticated user's id; `require_csrf` guards mutating # routes. Use with the RLS-scoped `sdk(name).session(user_id)`. CurrentUserId = Annotated[uuid.UUID, Depends(_current_user_id)] @dataclass(frozen=True) class PluginSdk: """A lightweight facade over shared services, scoped to one plugin. Cheap to construct (wraps process-wide singletons + the plugin name), so handlers/routes obtain it on demand via ``sdk("")``.""" name: str # --- config ------------------------------------------------------------- @property def settings(self) -> Settings: return get_settings() # --- database ----------------------------------------------------------- def session(self, user_id: uuid.UUID) -> AbstractAsyncContextManager[AsyncSession]: """An RLS-enforced transactional session scoped to ``user_id`` (SPEC §11.1). Row-level security limits every query to this user's rows on ``plg_*`` tables — even a query that forgets ``WHERE user_id`` cannot leak or write across users (see :mod:`app.plugins.db`).""" return plugin_session(user_id) def kv(self, user_id: uuid.UUID) -> PluginKvStore: """The namespaced KV store for this plugin and ``user_id`` (SPEC §11).""" return PluginKvStore(self.name, user_id) # --- events ------------------------------------------------------------- async def publish(self, event: str, payload: dict[str, object]) -> None: """Publish ``plugin.{name}.{event}`` on the bus (payload needs ``user_id``).""" await get_event_bus().publish(f"plugin.{self.name}.{event}", payload) # --- background jobs ---------------------------------------------------- async def enqueue(self, job_func: JobFunc, **kwargs: Any) -> None: """Enqueue one of this plugin's jobs on the shared worker (SPEC §11.2). ``job_func`` must be a job declared in this plugin's ``jobs=[...]``. Pass ``user_id=`` for singleton jobs so the per-user lock applies.""" await enqueue_job(f"plg.{self.name}.{job_func.__name__}", **kwargs) # --- blob store --------------------------------------------------------- @property def blob(self) -> object: """The blob store (``store``/``load``/``exists``/``remove``), keyed by user.""" return blobstore # --- outbox ------------------------------------------------------------- @property def outbox(self) -> object: """Draft/send helpers (``upsert_draft``, ``create_outbox``, …).""" return outbox_service # --- messages ----------------------------------------------------------- @property def mail(self) -> PluginMail: """Send an email from one of the user's own accounts (iMIP invites, …).""" return PluginMail() @property def messages(self) -> PluginMessages: """Read a user's message envelope (subject/sender/flags) for matching.""" return PluginMessages() @property def actions(self) -> PluginActions: """User-scoped message actions (``set_flags``) — e.g. for filter rules.""" return PluginActions() # --- outbound HTTP ------------------------------------------------------ async def http_get(self, url: str) -> str | None: """GET ``url`` through the §10.6 SSRF guard (blocks private IPs/oversize).""" return await guarded_get(url) @property def http(self) -> PluginHttp: """Multi-method SSRF-guarded HTTP (GET/PUT/DELETE/PROPFIND/REPORT) for CalDAV/CardDAV/ICS. Returns status + headers + body.""" return PluginHttp() # --- secret vault ------------------------------------------------------- @property def vault(self) -> PluginVault: """Envelope encrypt/decrypt for a plugin's own ``secret_ct`` columns (CalDAV/CardDAV credentials, tokens) — §10.2 at-rest protection.""" return PluginVault() def sdk(name: str) -> PluginSdk: """Return the SDK facade bound to plugin ``name`` (SPEC §11).""" return PluginSdk(name)