A watched mailbox is a trust boundary this app did not have: anyone who knows the address can hand the server an iCalendar file. So a mail can do exactly one thing — create a row in event_invites. No calendar read path touches that table, and an invitation becomes an event only when a person accepts it. Routing is by the ATTENDEE address, never the From header, which a sender can claim freely. Addresses are matched against users.email plus a dedicated table, so forwarded mail still finds its owner: the invitation names the original address, not the mailbox it landed in. An accepted invitation gets a fresh UID; the organiser's goes to external_uid. LocalEvent.uid is globally unique, so reusing it would let a stranger's mail collide with an event already there. That case has a test, because it is the one that would be a real problem. parse_ics now reads METHOD, SEQUENCE, the organiser address, the ATTENDEE list and RECURRENCE-ID. All additive — existing callers read the keys they knew. METHOD sits on the VCALENDAR, so it is read outside the component loop; that is the only reliable way to tell an invitation from a plain calendar file. A VEVENT carrying RECURRENCE-ID is skipped rather than applied, since treating one occurrence as the series would move every other date. The mailbox job registers only when IMAP_HOST is set: an unconditional job would log every few minutes that it has nothing to do, and test_scheduler asserts the exact job set. Credentials come from the environment, which keeps the one secret that opens a whole mailbox out of a table an endpoint could return. Replies to the organiser are the next step; accepting and declining work without them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
5.8 KiB
Python
171 lines
5.8 KiB
Python
"""A small in-process scheduler for periodic maintenance jobs.
|
|
|
|
The server runs as a single uvicorn process (``main.py`` calls ``uvicorn.run``
|
|
with no ``workers=``), so an in-process loop runs exactly once — no locking or
|
|
leader election needed. If the deployment ever grows to multiple workers this
|
|
has to be revisited, because every worker would run every job.
|
|
|
|
Jobs are plain synchronous callables taking a ``Session``. They run in a worker
|
|
thread with their own session: SQLAlchemy is synchronous here, and a job doing
|
|
file I/O or an HTTP fetch on the event loop would stall every request.
|
|
|
|
Failures are caught per job, so a broken iCal feed cannot stop the attachment
|
|
sweep and neither can stop the loop.
|
|
|
|
Environment:
|
|
SCHEDULER_ENABLED "0" disables the loop entirely (default on)
|
|
ATTACHMENT_SWEEP_HOURS how often orphaned attachments are cleaned (24)
|
|
ICAL_REFRESH_MINUTES how often subscriptions are checked (15)
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from typing import Callable
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import SessionLocal
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ENABLED = os.environ.get("SCHEDULER_ENABLED", "1") != "0"
|
|
# How often the loop wakes to see whether anything is due. Jobs have their own,
|
|
# much longer intervals; this only bounds how late a job can start.
|
|
TICK_SECONDS = 30
|
|
# Delay before the first run so a restart is not slowed by maintenance work.
|
|
FIRST_RUN_DELAY_SECONDS = 20
|
|
|
|
|
|
class _Job:
|
|
def __init__(self, name: str, interval: float, fn: Callable[[Session], None]):
|
|
self.name = name
|
|
self.interval = interval
|
|
self.fn = fn
|
|
self.next_run = time.monotonic() + FIRST_RUN_DELAY_SECONDS
|
|
|
|
|
|
_jobs: list[_Job] = []
|
|
|
|
|
|
def register(name: str, interval_seconds: float, fn: Callable[[Session], None]) -> None:
|
|
"""Add a job. Registering the same name twice replaces the first."""
|
|
_jobs[:] = [j for j in _jobs if j.name != name]
|
|
_jobs.append(_Job(name, interval_seconds, fn))
|
|
|
|
|
|
def _run_job(job: _Job) -> None:
|
|
"""Run one job in a worker thread, with its own session."""
|
|
db = SessionLocal()
|
|
try:
|
|
job.fn(db)
|
|
except Exception:
|
|
# Never let one job's failure escape: the loop must survive it, and so
|
|
# must the other jobs.
|
|
logger.exception("Scheduled job %s failed", job.name)
|
|
with contextlib.suppress(Exception):
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def _runner() -> None:
|
|
logger.info(
|
|
"Scheduler started with %d job(s): %s",
|
|
len(_jobs),
|
|
", ".join(j.name for j in _jobs),
|
|
)
|
|
while True:
|
|
await asyncio.sleep(TICK_SECONDS)
|
|
now = time.monotonic()
|
|
for job in list(_jobs):
|
|
if now < job.next_run:
|
|
continue
|
|
job.next_run = now + job.interval
|
|
await asyncio.to_thread(_run_job, job)
|
|
|
|
|
|
# ── The jobs ──────────────────────────────────────────────
|
|
|
|
def sweep_attachments(db: Session) -> None:
|
|
"""Remove attachment rows whose event is gone, and files with no row.
|
|
|
|
The explicit purge at each delete path is the normal mechanism; this is the
|
|
safety net for what slips past it — an external CalDAV client deleting an
|
|
event, a request that died between writing the file and committing the row,
|
|
or a delete path added later that forgets the helper.
|
|
"""
|
|
import attachments_store
|
|
|
|
attachments_store.sweep(db)
|
|
|
|
|
|
def refresh_ical_subscriptions(db: Session) -> None:
|
|
"""Re-fetch iCal subscriptions that are due.
|
|
|
|
``_refresh_if_needed`` already honours each subscription's own
|
|
``refresh_minutes`` and swallows network errors — except when a
|
|
subscription has no cached data at all, where it re-raises. So every
|
|
subscription is wrapped separately: one unreachable URL must not stop the
|
|
rest.
|
|
|
|
The lazy refresh in the merged read stays as it is. This only adds the case
|
|
nobody was covering: keeping subscriptions current while no one has the app
|
|
open.
|
|
"""
|
|
import models
|
|
from routers.ical_router import _refresh_if_needed
|
|
|
|
subs = (
|
|
db.query(models.ICalSubscription)
|
|
.filter(models.ICalSubscription.enabled == True) # noqa: E712
|
|
.all()
|
|
)
|
|
for sub in subs:
|
|
try:
|
|
_refresh_if_needed(sub, db)
|
|
except Exception:
|
|
logger.warning("Scheduled refresh of iCal subscription %s failed", sub.id)
|
|
with contextlib.suppress(Exception):
|
|
db.rollback()
|
|
|
|
|
|
def fetch_mail_invites(db: Session) -> None:
|
|
"""Read the watched mailbox for new invitations."""
|
|
import mail_invites
|
|
|
|
mail_invites.fetch_invites(db)
|
|
|
|
|
|
def install_default_jobs() -> None:
|
|
sweep_hours = float(os.environ.get("ATTACHMENT_SWEEP_HOURS", "24"))
|
|
ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15"))
|
|
register("attachments.sweep", sweep_hours * 3600, sweep_attachments)
|
|
register("ical.refresh", ical_minutes * 60, refresh_ical_subscriptions)
|
|
# Only when a mailbox is configured: an unconditional job would log every
|
|
# few minutes that it has nothing to do, and test_scheduler asserts the
|
|
# exact job set.
|
|
if os.environ.get("IMAP_HOST"):
|
|
poll_minutes = float(os.environ.get("MAIL_POLL_MINUTES", "5"))
|
|
register("mail.invites", poll_minutes * 60, fetch_mail_invites)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
task = None
|
|
if ENABLED:
|
|
install_default_jobs()
|
|
task = asyncio.create_task(_runner())
|
|
else:
|
|
logger.info("Scheduler disabled via SCHEDULER_ENABLED=0")
|
|
try:
|
|
yield
|
|
finally:
|
|
if task is not None:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|