Putting IMAP settings in environment variables was my call to make and it was the wrong one: it meant editing a file on the server and restarting to change a folder name, and it was never offered as a choice. The mailbox is now a row in mailbox_accounts, edited under Settings → Admin: host, user, password, port, folder, interval, on/off. Administrators only — it is a shared forwarding target, not a per-user account. The password is never returned by any endpoint, the same as the CalDAV account routes, and leaving the field empty on edit keeps the stored one so changing a port does not mean retyping it. The panel shows when the mailbox was last read and what went wrong if it did, because a mailbox that quietly stopped working looks exactly like one that has no mail. Worth stating plainly: the password now lives in the database, in clear text, like the CalDAV, Google and Home Assistant credentials beside it. Unlike those three, an IMAP password opens an entire mailbox. The scheduler job is registered unconditionally now — whether a mailbox exists can change without a restart, and fetch_invites returns immediately when none does. test_scheduler knows about it. Also adds the requests list to the sidebar with accept and decline, 26 translation keys across all ten languages, and tests covering that the password is never returned, that an edit without one keeps it, and that a disabled mailbox counts as unconfigured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
5.8 KiB
Python
170 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)
|
|
# Always registered: the mailbox is configured in the UI, so whether there
|
|
# is one to read can change without a restart. fetch_invites returns at once
|
|
# when none is set up.
|
|
mail_minutes = float(os.environ.get("MAIL_POLL_MINUTES", "5"))
|
|
register("mail.invites", mail_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
|