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