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
@@ -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