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.""" CalDAV/CardDAV/ICS. Returns status + headers + body."""
return PluginHttp() return PluginHttp()
# --- notifications ------------------------------------------------------
@property
def push(self) -> PluginPush:
"""Notifications on the user's devices (Web Push) — calendar reminders."""
return PluginPush(self.name)
# --- secret vault ------------------------------------------------------- # --- secret vault -------------------------------------------------------
@property @property
def vault(self) -> PluginVault: def vault(self) -> PluginVault:
@@ -187,6 +193,39 @@ class PluginSdk:
return PluginVault() 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: def sdk(name: str) -> PluginSdk:
"""Return the SDK facade bound to plugin ``name`` (SPEC §11).""" """Return the SDK facade bound to plugin ``name`` (SPEC §11)."""
return PluginSdk(name) 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.""" pushes were delivered to push services."""
from app.db import session_scope 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: async with session_scope() as db:
# Plain values: the rows expire when the key pair is first saved below. # Plain values: the rows expire when the key pair is first saved below.
subs = [ subs = [
@@ -210,10 +230,6 @@ async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
] ]
if not subs: if not subs:
return 0 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) key = await vapid(db)
subject = _subject(await db.get(AppSettings, 1)) subject = _subject(await db.get(AppSettings, 1))
@@ -0,0 +1,130 @@
"""Event reminders as notifications (calendar plugin, sdk.push): due reminders
are sent once, repeating events remind on each occurrence, cancelled and
declined ones stay quiet."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from webmail_plugin_calendar import reminders # type: ignore[import-not-found]
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
from app.plugins.sdk import PluginPush
CAL = "/api/plugins/calendar"
# Far from any real event in the dev database.
MON_9 = datetime(2031, 6, 2, 9, 0, tzinfo=UTC) # a Monday
@pytest_asyncio.fixture
async def ctx(
monkeypatch: pytest.MonkeyPatch,
) -> AsyncIterator[tuple[httpx.AsyncClient, uuid.UUID, list[dict[str, Any]], Any]]:
sent: list[dict[str, Any]] = []
async def fake_notify(self: PluginPush, user_id: uuid.UUID, **kw: Any) -> int:
sent.append({"user_id": user_id, **kw})
return 1
monkeypatch.setattr(PluginPush, "notify", fake_notify)
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as s:
user = User(
handle=f"rem{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
)
s.add(user)
await s.commit()
token = await create_session(user.id, "pytest")
client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": "c"},
headers={"X-CSRF": "c"},
)
try:
yield client, user.id, sent, sm
finally:
await client.aclose()
async with sm() as s:
await s.execute(
text("DELETE FROM plg_calendar_calendars WHERE user_id = :u"), {"u": str(user.id)}
)
await s.execute(text("DELETE FROM plugin_kv WHERE user_id = :u"), {"u": str(user.id)})
await s.execute(delete(User).where(User.id == user.id))
await s.commit()
await engine.dispose()
async def _event(client: httpx.AsyncClient, **fields: Any) -> str:
cal = (await client.get(f"{CAL}/calendars")).json()[0]["id"]
r = await client.post(f"{CAL}/events", json={"calendar_id": cal, **fields})
assert r.status_code == 200, r.text
return str(r.json()["id"])
async def test_due_reminders_are_sent_once(ctx) -> None: # type: ignore[no-untyped-def]
client, user_id, sent, sm = ctx
await _event(
client,
summary="Dentist",
location="Main St 4",
dtstart=MON_9.isoformat(),
dtend=(MON_9 + timedelta(hours=1)).isoformat(),
reminders=[10, 60],
)
called_off = await _event(
client, summary="Called off", dtstart=MON_9.isoformat(), reminders=[10]
)
async with sm() as db: # cancelled by its organizer: never reminds
await db.execute(
text("UPDATE plg_calendar_events SET status = 'cancelled' WHERE id = :id"),
{"id": called_off},
)
await db.commit()
async def run(at: datetime) -> int:
return await reminders.reminder_job({}, now=at, only_user=user_id)
assert await run(MON_9 - timedelta(minutes=61)) == 0 # nothing due yet
assert await run(MON_9 - timedelta(minutes=60)) == 1 # the 1-hour reminder
assert sent[-1]["title"] == "Dentist"
assert sent[-1]["body"] == "In 1 hour, 09:00 · Main St 4"
assert sent[-1]["url"] == "/p/calendar?date=2031-06-02" and sent[-1]["sticky"] is True
assert await run(MON_9 - timedelta(minutes=59)) == 0 # not again
# The worker was down when the 10-minute reminder came due: sent late, once.
assert await run(MON_9 - timedelta(minutes=4)) == 1
assert sent[-1]["body"] == "In 10 minutes, 09:00 · Main St 4"
assert await run(MON_9 - timedelta(minutes=3)) == 0
assert len(sent) == 2
async def test_repeating_events_remind_on_each_occurrence(ctx) -> None: # type: ignore[no-untyped-def]
client, user_id, sent, _ = ctx
await _event(
client,
summary="Weekly sync",
dtstart=MON_9.isoformat(),
dtend=(MON_9 + timedelta(minutes=30)).isoformat(),
rrule="FREQ=WEEKLY;BYDAY=MO",
reminders=[15],
)
for week in range(3):
at = MON_9 + timedelta(weeks=week) - timedelta(minutes=15)
assert await reminders.reminder_job({}, now=at, only_user=user_id) == 1
tags = {s["tag"] for s in sent}
assert len(sent) == 3 and len(tags) == 3 # one notification per occurrence
+13 -6
View File
@@ -501,11 +501,13 @@ self.addEventListener("activate", (event) => {
); );
}); });
// ---- notifications about new mail --------------------------------------------------- // ---- notifications: new mail, calendar reminders ------------------------------------
// //
// The server (app/push/service.py) sends { type: "mail", title, body, tag, url, // The server (app/push/service.py) sends { type: "mail", title, body, tag, url,
// unread } encrypted to this browser. One notification per conversation (`tag`), // unread } encrypted to this browser. One notification per conversation (`tag`),
// replaced when more arrives in it; the app icon's badge shows unread Inbox mail. // replaced when more arrives in it; the app icon's badge shows unread Inbox mail.
// A plugin (sdk.push) sends { type: "plugin.<name>", title, body, tag, url,
// sticky } — a calendar reminder.
/** A window of the app the user is looking at right now. */ /** A window of the app the user is looking at right now. */
async function lookingAtApp() { async function lookingAtApp() {
@@ -517,16 +519,21 @@ async function showMail(data) {
if (typeof data.unread === "number" && self.navigator.setAppBadge) { if (typeof data.unread === "number" && self.navigator.setAppBadge) {
await (data.unread ? self.navigator.setAppBadge(data.unread) : self.navigator.clearAppBadge()).catch(() => {}); await (data.unread ? self.navigator.setAppBadge(data.unread) : self.navigator.clearAppBadge()).catch(() => {});
} }
// The app is open in front of the user: the new mail is already on screen. // New mail while the app is open in front of the user: it is already on
// Not on Apple's push service, which withdraws permission from a site whose // screen. (Other notifications — a calendar reminder — show regardless.) Not
// on Apple's push service, which withdraws permission from a site whose
// pushes show nothing. // pushes show nothing.
const sub = await self.registration.pushManager.getSubscription(); if (!data.type || data.type === "mail") {
const apple = Boolean(sub?.endpoint.includes(".push.apple.com")); const sub = await self.registration.pushManager.getSubscription();
if (!apple && (await lookingAtApp())) return; const apple = Boolean(sub?.endpoint.includes(".push.apple.com"));
if (!apple && (await lookingAtApp())) return;
}
await self.registration.showNotification(data.title || "New mail", { await self.registration.showNotification(data.title || "New mail", {
body: data.body || "", body: data.body || "",
tag: data.tag || "new-mail", tag: data.tag || "new-mail",
renotify: true, renotify: true,
// A reminder stays until dismissed (where the platform allows it).
requireInteraction: Boolean(data.sticky),
icon: "/icon-192.png", icon: "/icon-192.png",
badge: "/badge-96.png", badge: "/badge-96.png",
timestamp: Date.now(), timestamp: Date.now(),
@@ -0,0 +1,171 @@
"""Event reminders as notifications on the user's devices (Web Push).
Every minute, the events with reminders that fall due are found — repeating
events expanded and moved occurrences honoured by the same code the calendar
view uses (queries.expand_range) — and each due reminder is pushed through
``sdk.push`` to every device where the user turned notifications on. A
reminder is sent once (remembered in the plugin's KV store); one that came due
while the worker was down is still sent within CATCH_UP. Cancelled events and
invitations the user declined stay quiet.
"""
from __future__ import annotations
import logging
import uuid
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import text
from app.plugins.sdk import sdk
from webmail_plugin_calendar.queries import expand_range
logger = logging.getLogger("plg.calendar")
_SDK = sdk("calendar")
CATCH_UP = timedelta(minutes=10) # a reminder due this long ago is still sent
MAX_LEAD = timedelta(days=8) # reminders this far ahead of an event are looked for
_SENT_KEY = "reminders_sent" # KV: {"<event>|<start>|<minutes>": due-time ISO}
_COLUMNS = (
"e.id, e.user_id, e.calendar_id, e.ical_uid, e.summary, e.description, e.location, "
"e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, e.exdates, "
"e.recurrence_id, e.status, e.my_partstat, e.origin_message_id, e.show_as, "
"e.conference_url, e.attendees, e.organizer, COALESCE(e.color, c.color) AS color"
)
def _when(start: datetime, minutes_before: int, all_day: bool, tz: str, hour12: bool) -> str:
"""The notification's line under the title: when the event starts."""
if all_day: # date-anchored at UTC midnight
return f"All day, {start.astimezone(UTC).strftime('%a %d %b')}"
local = start.astimezone(_zone(tz))
clock = local.strftime("%I:%M %p").lstrip("0") if hour12 else local.strftime("%H:%M")
if minutes_before <= 0:
return f"Now, {clock}"
if minutes_before < 60:
return f"In {minutes_before} minutes, {clock}"
if minutes_before < 24 * 60:
hours = minutes_before // 60
return f"In {hours} hour{'s' if hours > 1 else ''}, {clock}"
days = minutes_before // (24 * 60)
day = "Tomorrow" if days == 1 else local.strftime("%a %d %b")
return f"{day}, {clock}"
def due(
instances: list[dict[str, Any]], lo: datetime, now: datetime
) -> list[tuple[dict[str, Any], int]]:
"""The (instance, minutes before) reminders falling due in ``(lo, now]``."""
out = []
for inst in instances:
if inst["status"] == "cancelled" or inst.get("my_partstat") == "declined":
continue
start = datetime.fromisoformat(inst["start"])
for minutes in sorted(set(inst.get("reminders") or [])):
fire = start - timedelta(minutes=minutes)
if lo < fire <= now:
out.append((inst, minutes))
return out
async def _notify_user(
user_id: uuid.UUID, hits: list[tuple[dict[str, Any], int]], now: datetime
) -> int:
kv = _SDK.kv(user_id)
sent: dict[str, str] = dict(await kv.get(_SENT_KEY, {}) or {})
settings = await _SDK.accounts.settings_of(user_id)
hour12 = str(settings.get("time_format", "24")) == "12"
tz = str(settings.get("tz") or "UTC")
delivered = 0
for inst, minutes in hits:
key = f"{inst['id']}|{inst['start']}|{minutes}"
if key in sent:
continue
start = datetime.fromisoformat(inst["start"])
body = _when(start, minutes, inst["all_day"], tz, hour12)
if inst.get("location"):
body += f" · {inst['location']}"
title = inst["summary"] or "(no title)"
delivered += await _SDK.push.notify(
user_id,
title=title,
body=body,
url=f"/p/calendar?date={_day(start, inst['all_day'], tz)}",
# One notification per occurrence: a later reminder replaces an earlier one.
tag=f"event-{inst['id']}-{inst['start']}",
sticky=True,
)
sent[key] = (start - timedelta(minutes=minutes)).isoformat()
# Forget reminders of events long past.
horizon = now - timedelta(days=2)
sent = {k: v for k, v in sent.items() if datetime.fromisoformat(v) > horizon}
await kv.set(_SENT_KEY, sent)
return delivered
def _day(start: datetime, all_day: bool, tz: str) -> str:
"""The event's day (YYYY-MM-DD) as the user's calendar shows it."""
return start.astimezone(UTC if all_day else _zone(tz)).date().isoformat()
def _zone(tz: str) -> ZoneInfo:
try:
return ZoneInfo(tz or "UTC")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("UTC")
async def reminder_job(
ctx: dict[str, Any], now: datetime | None = None, only_user: uuid.UUID | None = None
) -> int:
"""Cron job (every minute): push the reminders that fell due. ``now`` and
``only_user`` are for tests."""
now = now or datetime.now(UTC)
lo = now - CATCH_UP
hi = now + MAX_LEAD
async with _SDK.public_session() as pub:
rows = (
(
await pub.execute(
text(
f"SELECT {_COLUMNS} FROM plg_calendar_events e "
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
"WHERE e.dav_deleted = false "
+ ("AND e.user_id = :uid " if only_user else "")
+ "AND ("
# events with reminders: repeating ones, or ones starting soon
" (e.recurrence_id IS NULL AND e.reminders <> '{}' "
" AND (e.rrule IS NOT NULL OR e.dtstart BETWEEN :lo AND :hi))"
# moved / cancelled occurrences of repeating events
" OR (e.recurrence_id IS NOT NULL "
" AND (e.dtstart BETWEEN :lo AND :hi "
" OR e.recurrence_id BETWEEN :lo AND :hi)))"
),
{"lo": lo, "hi": hi, **({"uid": only_user} if only_user else {})},
)
)
.mappings()
.all()
)
by_user: dict[uuid.UUID, list[Any]] = defaultdict(list)
for r in rows:
by_user[uuid.UUID(str(r["user_id"]))].append(r)
total = 0
for user_id, user_rows in by_user.items():
base = [r for r in user_rows if r["recurrence_id"] is None]
if not base:
continue
overrides = [r for r in user_rows if r["recurrence_id"] is not None]
hits = due(expand_range(base, overrides, lo, hi), lo, now)
if not hits:
continue
try:
total += await _notify_user(user_id, hits, now)
except Exception: # noqa: BLE001 - one user's trouble must not stop the rest
logger.warning("calendar reminders failed for user %s", user_id, exc_info=True)
return total
@@ -1850,6 +1850,18 @@ export default function mount(el, ctx, props) {
(async () => { (async () => {
await loadChrome(); await loadChrome();
await loadCalendars(); await loadCalendars();
// Deep link (a reminder notification): /p/calendar?date=YYYY-MM-DD opens that
// day, then strips the param so refresh is clean.
try {
const day = new URLSearchParams(window.location.search).get("date");
if (!state.embed && day && /^\d{4}-\d{2}-\d{2}$/.test(day)) {
const [y, m, d] = day.split("-").map(Number);
state.anchor = startOfDay(new Date(y, m - 1, d));
state.mini = startOfMonth(state.anchor);
state.view = "day";
window.history.replaceState({}, "", window.location.pathname);
}
} catch { /* ignore */ }
await loadEvents(); await loadEvents();
render(); render();
// Embed mode ("Invite all to new event" from Mail's People tab): open a new-event // Embed mode ("Invite all to new event" from Mail's People tab): open a new-event