Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/reminders.py
T
lukasandClaude Opus 5.5 aa617a3316 Calendar reminders open the event; phones get the Calendar panel
A reminder notification links to its day, event and occurrence
(/p/calendar?date=…&event=…&start=…). The calendar opens on that day with the
event's details showing — the full calendar on a computer; on a phone any
calendar link leads to Mail with the Calendar panel slid in (the big-screen
page never opens there), scrolled to the event, details open.

The panel's agenda kept no scroll position across re-renders: opening an
event's details lower down jumped the list back to the top. It stays put now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:07:40 +02:00

176 lines
7.0 KiB
Python

"""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 urllib.parse import quote
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)}"
f"&event={inst['id']}&start={quote(inst['start'])}"
),
# 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