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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
// 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.
|
||||
// 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. */
|
||||
async function lookingAtApp() {
|
||||
@@ -517,16 +519,21 @@ async function showMail(data) {
|
||||
if (typeof data.unread === "number" && self.navigator.setAppBadge) {
|
||||
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.
|
||||
// Not on Apple's push service, which withdraws permission from a site whose
|
||||
// New mail while the app is open in front of the user: it is already on
|
||||
// screen. (Other notifications — a calendar reminder — show regardless.) Not
|
||||
// on Apple's push service, which withdraws permission from a site whose
|
||||
// pushes show nothing.
|
||||
const sub = await self.registration.pushManager.getSubscription();
|
||||
const apple = Boolean(sub?.endpoint.includes(".push.apple.com"));
|
||||
if (!apple && (await lookingAtApp())) return;
|
||||
if (!data.type || data.type === "mail") {
|
||||
const sub = await self.registration.pushManager.getSubscription();
|
||||
const apple = Boolean(sub?.endpoint.includes(".push.apple.com"));
|
||||
if (!apple && (await lookingAtApp())) return;
|
||||
}
|
||||
await self.registration.showNotification(data.title || "New mail", {
|
||||
body: data.body || "",
|
||||
tag: data.tag || "new-mail",
|
||||
renotify: true,
|
||||
// A reminder stays until dismissed (where the platform allows it).
|
||||
requireInteraction: Boolean(data.sticky),
|
||||
icon: "/icon-192.png",
|
||||
badge: "/badge-96.png",
|
||||
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 () => {
|
||||
await loadChrome();
|
||||
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();
|
||||
render();
|
||||
// Embed mode ("Invite all to new event" from Mail's People tab): open a new-event
|
||||
|
||||
Reference in New Issue
Block a user