Files
lukasandClaude Opus 5.5 fa552392c8 Calendar reminders as push notifications
A job every minute finds the event reminders that fell due — repeating events
and moved occurrences expanded as the calendar view does — and pushes each
once to every device with notifications on (sdk.push, new: plugins can notify
through the new-mail push delivery). A reminder missed while the worker was down
is still sent within 10 minutes; cancelled events and declined invitations stay
quiet. Title, then "In 15 minutes, 14:00 · Room 2" in the user's zone and clock
format; it stays until dismissed and shows even with the app open; clicking it
opens that day (/p/calendar?date=…, new deep link).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00

232 lines
8.4 KiB
Python

"""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.db import session_scope
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 (
PluginAccounts,
PluginActions,
PluginMessages,
PluginThreads,
)
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>")``."""
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 public_session(self) -> AbstractAsyncContextManager[AsyncSession]:
"""A privileged, NON-RLS session for PUBLIC, unauthenticated endpoints that
resolve their own opaque token and therefore must look up rows before any
user is known (e.g. a public ``.ics`` feed keyed by an unguessable slug).
There is no row-level safety net here: EVERY query MUST filter explicitly by
the token (or an id derived from it). Never use this for authenticated
handlers — use :meth:`session` so RLS scopes queries to the user."""
return session_scope()
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), one header, or
the whole RFC822 blob for engines that need it."""
return PluginMessages()
@property
def actions(self) -> PluginActions:
"""User-scoped message actions: ``set_flags``, ``trash`` (thread), and
``move_to_special`` (one message into e.g. Junk)."""
return PluginActions()
@property
def accounts(self) -> PluginAccounts:
"""Cross-user account listing for scheduled jobs (`live()`)."""
return PluginAccounts()
@property
def threads(self) -> PluginThreads:
"""Read-only thread queries (e.g. pruning stale references)."""
return PluginThreads()
# --- 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()
# --- notifications ------------------------------------------------------
@property
def push(self) -> PluginPush:
"""Notifications on the user's devices (Web Push) — calendar reminders."""
return PluginPush(self.name)
# --- 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()
class PluginPush:
"""``sdk.push``: show a notification on every device where the user turned
notifications on (Settings → Accounts → Notifications)."""
def __init__(self, plugin: str) -> None:
self._plugin = plugin
async def notify(
self,
user_id: uuid.UUID,
*,
title: str,
body: str = "",
url: str = "/mail",
tag: str | None = None,
sticky: bool = False,
) -> int:
"""Send one notification; returns how many devices it went to. ``tag``
replaces an earlier notification with the same tag (it is prefixed with
the plugin's name); ``sticky`` keeps it on screen until dismissed."""
from app.push.service import deliver
payload: dict[str, object] = {
"type": f"plugin.{self._plugin}",
"title": title[:200],
"body": body[:500],
"url": url if url.startswith("/") else "/mail",
"tag": f"{self._plugin}-{tag}" if tag else self._plugin,
"sticky": sticky,
}
return await deliver(user_id, [payload])
def sdk(name: str) -> PluginSdk:
"""Return the SDK facade bound to plugin ``name`` (SPEC §11)."""
return PluginSdk(name)