diff --git a/backend/app/plugins/sdk.py b/backend/app/plugins/sdk.py index b74045c..61fb3a8 100644 --- a/backend/app/plugins/sdk.py +++ b/backend/app/plugins/sdk.py @@ -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) diff --git a/backend/app/push/service.py b/backend/app/push/service.py index f55aeb9..288d65e 100644 --- a/backend/app/push/service.py +++ b/backend/app/push/service.py @@ -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)) diff --git a/backend/tests/plugins/test_calendar_reminders.py b/backend/tests/plugins/test_calendar_reminders.py new file mode 100644 index 0000000..da39d6c --- /dev/null +++ b/backend/tests/plugins/test_calendar_reminders.py @@ -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 diff --git a/frontend/public/sw.js b/frontend/public/sw.js index fb1f3dd..dcd6387 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -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.", 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(), diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/reminders.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/reminders.py new file mode 100644 index 0000000..ff8792a --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/reminders.py @@ -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: {"||": 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 diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index a47f67a..5e2d28e 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -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