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>
176 lines
7.0 KiB
Python
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
|