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>
This commit is contained in:
2026-10-09 21:49:04 +02:00
co-authored by Claude Opus 5.5
parent 9611aedfcd
commit fa552392c8
6 changed files with 385 additions and 10 deletions
+39
View File
@@ -179,6 +179,12 @@ class PluginSdk:
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:
@@ -187,6 +193,39 @@ class PluginSdk:
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)
+20 -4
View File
@@ -200,6 +200,26 @@ async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
pushes were delivered to push services."""
from app.db import session_scope
async with session_scope() as db:
if not await db.scalar(
select(PushSubscription.id).where(PushSubscription.user_id == user_id).limit(1)
):
return 0
messages = await _news(db, user_id, message_ids)
if not messages:
return 0
payloads = _payloads(messages, await _unread(db, user_id))
return await deliver(user_id, payloads)
async def deliver(user_id: uuid.UUID, payloads: list[dict[str, object]]) -> int:
"""Push ``payloads`` to every device of the user that is still signed in
(for new mail, and for plugins: calendar reminders); returns how many were
handed to push services. Devices that are gone are forgotten."""
from app.db import session_scope
if not payloads:
return 0
async with session_scope() as db:
# Plain values: the rows expire when the key pair is first saved below.
subs = [
@@ -210,10 +230,6 @@ async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
]
if not subs:
return 0
messages = await _news(db, user_id, message_ids)
if not messages:
return 0
payloads = _payloads(messages, await _unread(db, user_id))
key = await vapid(db)
subject = _subject(await db.get(AppSettings, 1))