Take in invitations that arrive by mail
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aa795fde76
commit
9bf7d3797d
@@ -112,6 +112,12 @@ Configuration is done via environment variables. The `install.sh` script generat
|
||||
| `SCHEDULER_ENABLED` | `1` | `0` disables all background jobs |
|
||||
| `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up |
|
||||
| `ICAL_REFRESH_MINUTES` | `15` | How often iCal subscriptions are checked in the background |
|
||||
| `IMAP_HOST` | — | Mailbox watched for invitations. Unset disables the whole feature *(optional)* |
|
||||
| `IMAP_PORT` | `993` | |
|
||||
| `IMAP_SSL` | `1` | `0` for a plain connection |
|
||||
| `IMAP_USER` / `IMAP_PASSWORD` | — | Mailbox credentials |
|
||||
| `IMAP_FOLDER` | `INBOX` | |
|
||||
| `MAIL_POLL_MINUTES` | `5` | How often the mailbox is read |
|
||||
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
|
||||
|
||||
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
|
||||
@@ -239,6 +245,23 @@ The server runs two periodic jobs in-process:
|
||||
|---|---|---|
|
||||
| `attachments.sweep` | 24 h | Removes attachment records whose event is gone, and files with no record |
|
||||
| `ical.refresh` | 15 min | Re-fetches iCal subscriptions that are due |
|
||||
| `mail.invites` | 5 min | Reads the watched mailbox for invitations *(only when `IMAP_HOST` is set)* |
|
||||
|
||||
#### Invitations by mail
|
||||
|
||||
With a mailbox configured, invitations sent to it appear under **Requests** and
|
||||
become calendar entries only once someone accepts them. Nothing a mail contains
|
||||
is written to a calendar on its own.
|
||||
|
||||
An invitation is matched to a user by its `ATTENDEE` address, never by the
|
||||
sender — compared against that user's `email` plus any additional addresses.
|
||||
That matters when mail is forwarded: the invitation still names the original
|
||||
address, not the mailbox it arrived in, so put the address people actually
|
||||
invite into the user's profile.
|
||||
|
||||
Credentials live in the environment rather than the database, so the one secret
|
||||
that opens a whole mailbox is never returned by any endpoint. One mailbox per
|
||||
instance; a forwarding target is the intended setup.
|
||||
|
||||
Attachments are normally cleaned up the moment an event, calendar or user is
|
||||
deleted; the sweep is the safety net for what gets past that — an external
|
||||
|
||||
+52
-2
@@ -60,8 +60,50 @@ def _organizer_name(component) -> str | None:
|
||||
return raw or None
|
||||
|
||||
|
||||
def _cal_address(prop) -> str | None:
|
||||
"""The bare lowercase address of an ORGANIZER/ATTENDEE property."""
|
||||
if prop is None:
|
||||
return None
|
||||
raw = str(prop).strip()
|
||||
if raw.lower().startswith("mailto:"):
|
||||
raw = raw[7:]
|
||||
raw = raw.strip().strip("<>").lower()
|
||||
return raw or None
|
||||
|
||||
|
||||
def _attendees(component) -> list[dict]:
|
||||
"""The ATTENDEE list with the parameters an iTIP reply needs.
|
||||
|
||||
icalendar hands back a single property when there is one attendee and a
|
||||
list when there are several, so both shapes have to be normalised.
|
||||
"""
|
||||
raw = component.get("ATTENDEE")
|
||||
if raw is None:
|
||||
return []
|
||||
items = raw if isinstance(raw, list) else [raw]
|
||||
out = []
|
||||
for prop in items:
|
||||
email = _cal_address(prop)
|
||||
if not email:
|
||||
continue
|
||||
params = getattr(prop, "params", {}) or {}
|
||||
out.append({
|
||||
"email": email,
|
||||
"cn": str(params.get("CN")) if params.get("CN") else None,
|
||||
"partstat": str(params.get("PARTSTAT") or "NEEDS-ACTION").upper(),
|
||||
"role": str(params.get("ROLE") or "REQ-PARTICIPANT").upper(),
|
||||
"rsvp": str(params.get("RSVP") or "").upper() == "TRUE",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def parse_ics(raw: bytes) -> dict:
|
||||
"""Parse .ics bytes into {"events": [dict, ...], "errors": [str, ...]}.
|
||||
"""Parse .ics bytes into {"method": str|None, "events": [...], "errors": [...]}.
|
||||
|
||||
``method`` sits on the VCALENDAR, not on a VEVENT, so it is read outside the
|
||||
component loop — it is the only reliable way to tell an invitation
|
||||
(METHOD:REQUEST) from a plain calendar file. Every key added for iTIP is
|
||||
additive: existing callers read the ones they already knew.
|
||||
|
||||
Raises ValueError if the payload is not a parseable calendar at all.
|
||||
"""
|
||||
@@ -127,11 +169,19 @@ def parse_ics(raw: bytes) -> dict:
|
||||
"rrule": _rrule_to_str(component),
|
||||
"exdate": _exdate_to_csv(component),
|
||||
"organizer": _organizer_name(component),
|
||||
"organizer_email": _cal_address(component.get("ORGANIZER")),
|
||||
"attendees": _attendees(component),
|
||||
"sequence": int(component.get("SEQUENCE", 0) or 0),
|
||||
# A single occurrence of a series. Applying one as if it were
|
||||
# the whole series would silently replace every other date.
|
||||
"recurrence_id": (str(component.get("RECURRENCE-ID").dt)
|
||||
if component.get("RECURRENCE-ID") is not None else None),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("Skipping malformed VEVENT: %s", exc)
|
||||
errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}")
|
||||
return {"events": events, "errors": errors}
|
||||
method = str(cal.get("METHOD") or "").upper() or None
|
||||
return {"method": method, "events": events, "errors": errors}
|
||||
|
||||
|
||||
def _rrule_str_to_vrecur(rrule_str: str) -> vRecur:
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Pull iTIP invitations out of a watched mailbox.
|
||||
|
||||
A mailbox is a trust boundary this app did not have before: anyone who knows the
|
||||
address can hand the server an iCalendar file. So the rule throughout is that a
|
||||
mail can only ever create a row in ``event_invites``. No calendar read path
|
||||
touches that table — an invitation becomes an event when a human accepts it, and
|
||||
not before.
|
||||
|
||||
Credentials live in the environment, never in the database. The service already
|
||||
loads an EnvironmentFile, and this keeps the one secret that grants access to a
|
||||
whole mailbox out of a table that an endpoint could return.
|
||||
|
||||
Single process assumed, same as the rest of the scheduler (see scheduler.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import imaplib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from email import message_from_bytes
|
||||
from email.message import EmailMessage
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import dav_util
|
||||
import ical_io
|
||||
import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# An invitation is a few kilobytes. These caps exist so one oversized mail
|
||||
# cannot exhaust memory, and so a flooded mailbox cannot monopolise a run.
|
||||
MAX_MESSAGE_BYTES = 1 * 1024 * 1024
|
||||
MAX_ICS_BYTES = 256 * 1024
|
||||
MAX_MESSAGES_PER_RUN = 25
|
||||
MAX_CAL_PARTS = 3
|
||||
|
||||
# Checked before an address is ever put into a mail header. Rejecting the
|
||||
# characters that could break a header out is cheaper than trusting the
|
||||
# formatter, and EmailMessage refuses embedded newlines on top of it.
|
||||
_ADDR_RE = re.compile(r"^[^@\s<>\",;:]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
|
||||
|
||||
_HANDLED_METHODS = ("REQUEST", "CANCEL")
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return bool(os.environ.get("IMAP_HOST"))
|
||||
|
||||
|
||||
def _valid_address(addr: str | None) -> str | None:
|
||||
if not addr:
|
||||
return None
|
||||
addr = addr.strip().lower()
|
||||
return addr if _ADDR_RE.match(addr) else None
|
||||
|
||||
|
||||
def user_for_address(db: Session, addr: str | None):
|
||||
"""The user who owns this address, or None.
|
||||
|
||||
Reads the dedicated table and `users.email` both, so someone whose
|
||||
invitation address is already in their profile needs no extra setup.
|
||||
"""
|
||||
addr = _valid_address(addr)
|
||||
if not addr:
|
||||
return None
|
||||
row = (db.query(models.UserEmailAddress)
|
||||
.filter(models.UserEmailAddress.address == addr).first())
|
||||
if row:
|
||||
return row.user
|
||||
return (db.query(models.User)
|
||||
.filter(func.lower(models.User.email) == addr).first())
|
||||
|
||||
|
||||
# ── Ingest ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _match_attendee(db: Session, item: dict):
|
||||
"""Which user this invitation is for, read from the ATTENDEE list.
|
||||
|
||||
Never from the From header: a mail can claim any sender it likes, and that
|
||||
header is what an attacker controls. The ATTENDEE list is what the organiser
|
||||
put in the payload, matched against addresses a user has proven are theirs.
|
||||
"""
|
||||
# Our own invitation coming back to us is not an invitation to accept.
|
||||
if user_for_address(db, item.get("organizer_email")):
|
||||
return None, None
|
||||
for att in item.get("attendees") or ():
|
||||
user = user_for_address(db, att.get("email"))
|
||||
if user:
|
||||
return user, _valid_address(att.get("email"))
|
||||
return None, None
|
||||
|
||||
|
||||
def _apply_request(db: Session, user, attendee: str, item: dict, raw_ics: str) -> None:
|
||||
inv = (db.query(models.EventInvite)
|
||||
.filter(models.EventInvite.user_id == user.id,
|
||||
models.EventInvite.uid == item["uid"]).first())
|
||||
sequence = int(item.get("sequence") or 0)
|
||||
if inv and sequence <= inv.sequence and inv.status != "cancelled":
|
||||
return # Forwarded mail arrives twice; the same SEQUENCE is the same news.
|
||||
|
||||
if inv is None:
|
||||
inv = models.EventInvite(user_id=user.id, uid=item["uid"])
|
||||
db.add(inv)
|
||||
|
||||
inv.sequence = sequence
|
||||
inv.title = (item.get("title") or "(ohne Titel)")[:255]
|
||||
inv.start = item["start"]
|
||||
inv.end = item["end"]
|
||||
inv.all_day = bool(item.get("all_day"))
|
||||
inv.location = (item.get("location") or None) and item["location"][:500]
|
||||
inv.description = (item.get("description") or None) and item["description"][:8000]
|
||||
inv.rrule = item.get("rrule")
|
||||
inv.organizer_email = _valid_address(item.get("organizer_email"))
|
||||
inv.organizer_name = (item.get("organizer") or None) and item["organizer"][:255]
|
||||
inv.attendee_email = attendee
|
||||
inv.raw_ics = raw_ics
|
||||
inv.received_at = datetime.now(timezone.utc)
|
||||
# A changed invitation is a new decision, so an already answered one goes
|
||||
# back to pending. The event created earlier stays until the user answers
|
||||
# again — this never rewrites a calendar entry behind their back.
|
||||
inv.status = "pending"
|
||||
inv.reply_state = None
|
||||
db.commit()
|
||||
logger.info("Einladung %s für Nutzer %s gespeichert (SEQUENCE %s)",
|
||||
item["uid"], user.id, sequence)
|
||||
|
||||
|
||||
def _apply_cancel(db: Session, user, item: dict) -> None:
|
||||
inv = (db.query(models.EventInvite)
|
||||
.filter(models.EventInvite.user_id == user.id,
|
||||
models.EventInvite.uid == item["uid"]).first())
|
||||
if inv is None:
|
||||
return
|
||||
drop_event(db, inv)
|
||||
inv.status = "cancelled"
|
||||
db.commit()
|
||||
logger.info("Einladung %s zurückgezogen", item["uid"])
|
||||
|
||||
|
||||
def drop_event(db: Session, inv) -> None:
|
||||
"""Remove the event an acceptance created, if it still exists."""
|
||||
if not inv.event_id:
|
||||
return
|
||||
ev = db.query(models.LocalEvent).filter(models.LocalEvent.id == inv.event_id).first()
|
||||
if ev:
|
||||
cal = (db.query(models.LocalCalendar)
|
||||
.filter(models.LocalCalendar.id == ev.calendar_id).first())
|
||||
db.delete(ev)
|
||||
if cal:
|
||||
dav_util.bump_dav(cal)
|
||||
inv.event_id = None
|
||||
|
||||
|
||||
def ingest_ics(db: Session, raw: bytes) -> bool:
|
||||
"""Store one invitation from an iCalendar payload. True when something was."""
|
||||
if len(raw) > MAX_ICS_BYTES:
|
||||
logger.info("Kalenderteil zu groß (%s Bytes), übersprungen", len(raw))
|
||||
return False
|
||||
try:
|
||||
parsed = ical_io.parse_ics(raw)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
method = (parsed.get("method") or "").upper()
|
||||
if method not in _HANDLED_METHODS:
|
||||
return False # REPLY/COUNTER/PUBLISH: we are never the organiser.
|
||||
|
||||
text = raw.decode("utf-8", "replace")
|
||||
for item in parsed.get("events") or ():
|
||||
if item.get("recurrence_id"):
|
||||
# One occurrence of a series. Applying it as the whole series would
|
||||
# silently move every other date.
|
||||
logger.info("Einzeltermin einer Serie übersprungen (UID %s)", item.get("uid"))
|
||||
continue
|
||||
user, attendee = _match_attendee(db, item)
|
||||
if not user:
|
||||
continue
|
||||
if method == "CANCEL":
|
||||
_apply_cancel(db, user, item)
|
||||
else:
|
||||
_apply_request(db, user, attendee, item, text)
|
||||
return True # One invitation per mail; never a bulk import.
|
||||
return False
|
||||
|
||||
|
||||
# ── Mailbox ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _connect() -> imaplib.IMAP4:
|
||||
host = os.environ["IMAP_HOST"]
|
||||
port = int(os.environ.get("IMAP_PORT", "993"))
|
||||
use_ssl = os.environ.get("IMAP_SSL", "1") != "0"
|
||||
box = (imaplib.IMAP4_SSL(host, port, timeout=30) if use_ssl
|
||||
else imaplib.IMAP4(host, port, timeout=30))
|
||||
box.login(os.environ["IMAP_USER"], os.environ["IMAP_PASSWORD"])
|
||||
return box
|
||||
|
||||
|
||||
def _calendar_parts(msg) -> list[bytes]:
|
||||
"""Every iCalendar payload in a mail, capped.
|
||||
|
||||
Senders disagree on the content type: a proper invitation is text/calendar,
|
||||
but plenty of clients attach it as application/ics or just a .ics file.
|
||||
"""
|
||||
out: list[bytes] = []
|
||||
for part in msg.walk():
|
||||
if len(out) >= MAX_CAL_PARTS:
|
||||
break
|
||||
ctype = (part.get_content_type() or "").lower()
|
||||
name = (part.get_filename() or "").lower()
|
||||
if ctype in ("text/calendar", "application/ics") or name.endswith(".ics"):
|
||||
try:
|
||||
payload = part.get_payload(decode=True)
|
||||
except Exception:
|
||||
continue
|
||||
if payload:
|
||||
out.append(payload)
|
||||
return out
|
||||
|
||||
|
||||
def _handle_message(db: Session, box: imaplib.IMAP4, uid: bytes) -> None:
|
||||
typ, size_data = box.uid("FETCH", uid, "(RFC822.SIZE)")
|
||||
if typ == "OK" and size_data and size_data[0]:
|
||||
m = re.search(rb"RFC822\.SIZE (\d+)", size_data[0])
|
||||
if m and int(m.group(1)) > MAX_MESSAGE_BYTES:
|
||||
logger.info("Mail %s zu groß (%s Bytes), übersprungen", uid, m.group(1))
|
||||
return
|
||||
|
||||
# PEEK: a plain FETCH sets \Seen on the server even if we crash afterwards,
|
||||
# which would lose the invitation entirely.
|
||||
typ, data = box.uid("FETCH", uid, "(BODY.PEEK[])")
|
||||
if typ != "OK" or not data or not data[0]:
|
||||
return
|
||||
msg = message_from_bytes(data[0][1])
|
||||
|
||||
for payload in _calendar_parts(msg):
|
||||
if ingest_ics(db, payload):
|
||||
return
|
||||
|
||||
|
||||
def fetch_invites(db: Session) -> None:
|
||||
"""Scheduler job: read new mail and store any invitations it holds."""
|
||||
if not is_configured():
|
||||
return
|
||||
box = _connect()
|
||||
try:
|
||||
box.select(os.environ.get("IMAP_FOLDER", "INBOX"))
|
||||
# UID commands, not sequence numbers: those shift when a message is
|
||||
# expunged mid-run and we would read the wrong mail.
|
||||
typ, data = box.uid("SEARCH", None, "UNSEEN")
|
||||
if typ != "OK":
|
||||
logger.warning("IMAP SEARCH fehlgeschlagen: %s", typ)
|
||||
return
|
||||
uids = (data[0] or b"").split()[:MAX_MESSAGES_PER_RUN]
|
||||
for uid in uids:
|
||||
try:
|
||||
_handle_message(db, box, uid)
|
||||
except Exception:
|
||||
logger.exception("Einladungsmail %s nicht verarbeitbar", uid)
|
||||
with contextlib.suppress(Exception):
|
||||
db.rollback()
|
||||
finally:
|
||||
# Advertising and broken mail get marked too, or every run
|
||||
# retries them forever. Nothing is ever deleted.
|
||||
with contextlib.suppress(Exception):
|
||||
box.uid("STORE", uid, "+FLAGS", "\\Seen")
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
box.logout()
|
||||
|
||||
|
||||
# ── Accepting ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def upsert_event(db: Session, inv, cal) -> models.LocalEvent:
|
||||
"""Create or update the event an acceptance produces.
|
||||
|
||||
The event gets a fresh UID. LocalEvent.uid is globally unique, so reusing
|
||||
the organiser's would let a mail collide with — and refuse to coexist
|
||||
alongside — an event that is already there. The organiser's UID goes to
|
||||
external_uid, which is what the rest of the app already uses for
|
||||
"this came from somewhere else".
|
||||
"""
|
||||
ev = None
|
||||
if inv.event_id:
|
||||
ev = db.query(models.LocalEvent).filter(models.LocalEvent.id == inv.event_id).first()
|
||||
if ev is None:
|
||||
ev = models.LocalEvent(uid=str(uuid.uuid4()),
|
||||
external_uid=f"invite:{inv.uid}",
|
||||
creator_id=inv.user_id)
|
||||
db.add(ev)
|
||||
ev.calendar_id = cal.id
|
||||
ev.title = inv.title
|
||||
ev.start = inv.start
|
||||
ev.end = inv.end
|
||||
ev.all_day = bool(inv.all_day)
|
||||
ev.location = inv.location
|
||||
ev.description = inv.description
|
||||
ev.rrule = inv.rrule
|
||||
ev.creator_name_external = inv.organizer_name
|
||||
ev.etag = dav_util.new_tag()
|
||||
dav_util.bump_dav(cal)
|
||||
db.flush()
|
||||
inv.event_id = ev.id
|
||||
return ev
|
||||
+2
-1
@@ -18,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import scheduler
|
||||
from database import Base, engine
|
||||
from routers import admin_router, attachments_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, oidc_router, profile_router, settings_router, users_router
|
||||
from routers import admin_router, attachments_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, invites_router, local_router, oidc_router, profile_router, settings_router, users_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@@ -460,6 +460,7 @@ app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
|
||||
app.include_router(invites_router.router, prefix="/api/invites", tags=["invites"])
|
||||
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
|
||||
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||
app.include_router(dav_router.router, tags=["dav"])
|
||||
|
||||
@@ -398,6 +398,74 @@ class HomeAssistantCalendar(Base):
|
||||
# ── Collaboration: sharing & groups (local calendars only) ────────────────
|
||||
|
||||
|
||||
class UserEmailAddress(Base):
|
||||
"""An address under which this user appears in an ATTENDEE list.
|
||||
|
||||
A list rather than a column: the mail arrives at a forwarding mailbox while
|
||||
the invitation still names the original address, and people hand out more
|
||||
than one. Globally unique so nobody can claim someone else's address and
|
||||
push events into their calendar.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_email_addresses"
|
||||
__table_args__ = (UniqueConstraint("address", name="uq_user_email_address"),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
# Always lowercase and without the "mailto:" prefix.
|
||||
address = Column(String(255), nullable=False)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class EventInvite(Base):
|
||||
"""An iTIP invitation (METHOD:REQUEST) that arrived by mail.
|
||||
|
||||
Raw *and* parsed. ``raw_ics`` is the only thing the organiser actually sent,
|
||||
and the reply is built from it — a rebuilt DTSTART is what strict
|
||||
counterparts reject. The parsed columns exist so clients can render a list
|
||||
without parsing iCalendar, and use the same formats as LocalEvent.
|
||||
|
||||
Nothing here is visible in any calendar read path: an invitation becomes an
|
||||
event only when a human accepts it.
|
||||
"""
|
||||
|
||||
__tablename__ = "event_invites"
|
||||
__table_args__ = (UniqueConstraint("user_id", "uid", name="uq_event_invite_user_uid"),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
# The organiser's UID. Only ever matched together with user_id, so two
|
||||
# users invited to the same meeting never collide.
|
||||
uid = Column(String(255), nullable=False)
|
||||
sequence = Column(Integer, default=0, nullable=False)
|
||||
# pending | accepted | declined | cancelled
|
||||
status = Column(String(12), default="pending", nullable=False)
|
||||
|
||||
title = Column(String(255), nullable=False)
|
||||
start = Column(String(50), nullable=False)
|
||||
end = Column(String(50), nullable=False)
|
||||
all_day = Column(Boolean, default=False)
|
||||
location = Column(String(500), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
rrule = Column(Text, nullable=True)
|
||||
|
||||
organizer_email = Column(String(255), nullable=True)
|
||||
organizer_name = Column(String(255), nullable=True)
|
||||
# The ATTENDEE address that matched this user. The reply must name exactly
|
||||
# this one or the organiser will not recognise the answer.
|
||||
attendee_email = Column(String(255), nullable=False)
|
||||
|
||||
raw_ics = Column(Text, nullable=False)
|
||||
# The event created on acceptance, so an update or a CANCEL can find it.
|
||||
event_id = Column(Integer, ForeignKey("local_events.id"), nullable=True)
|
||||
received_at = Column(DateTime, default=datetime.utcnow)
|
||||
# None | sent | failed | skipped
|
||||
reply_state = Column(String(12), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class CalendarShare(Base):
|
||||
"""A local calendar shared with another Calendarr user."""
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Invitations that arrived by mail: list them, accept or decline, dismiss.
|
||||
|
||||
The decision is the only thing that turns an invitation into a calendar entry.
|
||||
Everything a mail can do on its own stops at the event_invites table.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import mail_invites
|
||||
import models
|
||||
import permissions
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _invite_dict(inv: models.EventInvite) -> dict:
|
||||
return {
|
||||
"id": inv.id,
|
||||
"uid": inv.uid,
|
||||
"status": inv.status,
|
||||
"title": inv.title,
|
||||
"start": inv.start,
|
||||
"end": inv.end,
|
||||
"all_day": bool(inv.all_day),
|
||||
"location": inv.location,
|
||||
"description": inv.description,
|
||||
"rrule": inv.rrule,
|
||||
"organizer_email": inv.organizer_email,
|
||||
"organizer_name": inv.organizer_name,
|
||||
"attendee_email": inv.attendee_email,
|
||||
"reply_state": inv.reply_state,
|
||||
"received_at": inv.received_at.isoformat() if inv.received_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _own_invite(db: Session, user: models.User, invite_id: int) -> models.EventInvite:
|
||||
inv = (db.query(models.EventInvite)
|
||||
.filter(models.EventInvite.id == invite_id,
|
||||
models.EventInvite.user_id == user.id).first())
|
||||
if inv is None:
|
||||
raise HTTPException(404, "Einladung nicht gefunden")
|
||||
return inv
|
||||
|
||||
|
||||
def _default_invite_calendar(db: Session, user: models.User) -> models.LocalCalendar:
|
||||
"""Where an accepted invitation lands when the client names no calendar.
|
||||
|
||||
The user's own, non-birthday calendar with the lowest id — the same one they
|
||||
would have picked by hand in almost every case. A birthday calendar is
|
||||
excluded because the server injects reminders into those.
|
||||
"""
|
||||
cal = (db.query(models.LocalCalendar)
|
||||
.filter(models.LocalCalendar.user_id == user.id,
|
||||
models.LocalCalendar.is_birthday.isnot(True))
|
||||
.order_by(models.LocalCalendar.id).first())
|
||||
if cal:
|
||||
return cal
|
||||
cal = models.LocalCalendar(user_id=user.id, name="Einladungen", color="#4285f4")
|
||||
db.add(cal)
|
||||
db.flush()
|
||||
return cal
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_invites(db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user)):
|
||||
"""Everything still awaiting a decision, plus withdrawn ones.
|
||||
|
||||
Cancelled invitations stay visible on purpose: a withdrawal the user never
|
||||
sees is worse than one more row to dismiss.
|
||||
"""
|
||||
rows = (db.query(models.EventInvite)
|
||||
.filter(models.EventInvite.user_id == current_user.id,
|
||||
models.EventInvite.status.in_(("pending", "cancelled")))
|
||||
.order_by(models.EventInvite.start).all())
|
||||
return [_invite_dict(i) for i in rows]
|
||||
|
||||
|
||||
class InviteRespond(BaseModel):
|
||||
action: str # accept | decline
|
||||
calendar_id: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/{invite_id}/respond")
|
||||
def respond(invite_id: int, data: InviteRespond,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user)):
|
||||
inv = _own_invite(db, current_user, invite_id)
|
||||
if data.action not in ("accept", "decline"):
|
||||
raise HTTPException(422, "action muss accept oder decline sein")
|
||||
if inv.status == "cancelled":
|
||||
raise HTTPException(409, "Die Einladung wurde zurückgezogen.")
|
||||
|
||||
cal = None
|
||||
if data.action == "accept":
|
||||
# The target is checked server-side; a client cannot name a calendar it
|
||||
# may not write to.
|
||||
cal = (permissions.accessible_local_calendar(db, current_user, data.calendar_id,
|
||||
require_write=True)
|
||||
if data.calendar_id else _default_invite_calendar(db, current_user))
|
||||
mail_invites.upsert_event(db, inv, cal)
|
||||
inv.status = "accepted"
|
||||
else:
|
||||
mail_invites.drop_event(db, inv)
|
||||
inv.status = "declined"
|
||||
|
||||
# Committed before any mail is attempted: a broken SMTP must never roll back
|
||||
# a decision the user already made.
|
||||
db.commit()
|
||||
return {"ok": True, "status": inv.status, "reply_state": inv.reply_state,
|
||||
"calendar_id": cal.id if cal else None}
|
||||
|
||||
|
||||
@router.delete("/{invite_id}")
|
||||
def dismiss(invite_id: int, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user)):
|
||||
"""Clear an invitation from the list. An accepted event stays."""
|
||||
inv = _own_invite(db, current_user, invite_id)
|
||||
db.delete(inv)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status(current_user: models.User = Depends(get_current_user)):
|
||||
return {"imap_configured": mail_invites.is_configured()}
|
||||
|
||||
|
||||
@router.post("/poll")
|
||||
def poll_now(db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user)):
|
||||
"""Fetch right now instead of waiting for the scheduler.
|
||||
|
||||
Admin-only: it opens a connection to the shared mailbox, so it is not a
|
||||
per-user action.
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Nur für Administratoren")
|
||||
if not mail_invites.is_configured():
|
||||
raise HTTPException(400, "Kein Postfach konfiguriert")
|
||||
mail_invites.fetch_invites(db)
|
||||
return {"ok": True}
|
||||
@@ -133,11 +133,24 @@ def refresh_ical_subscriptions(db: Session) -> None:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Invitations arriving by mail.
|
||||
|
||||
The mailbox is never touched here — ingest_ics is fed directly, because what
|
||||
matters is what an iCalendar payload from a stranger is allowed to do, not
|
||||
whether imaplib works.
|
||||
"""
|
||||
|
||||
import mail_invites
|
||||
import models
|
||||
from conftest import auth, register_admin
|
||||
from database import SessionLocal
|
||||
|
||||
|
||||
def _ics(uid="meet-1", sequence=0, method="REQUEST", attendee="admin@example.com",
|
||||
summary="Projektbesprechung", start="20260601T090000Z", end="20260601T100000Z"):
|
||||
return (
|
||||
"BEGIN:VCALENDAR\r\n"
|
||||
"VERSION:2.0\r\n"
|
||||
"PRODID:-//Test//EN\r\n"
|
||||
f"METHOD:{method}\r\n"
|
||||
"BEGIN:VEVENT\r\n"
|
||||
f"UID:{uid}\r\n"
|
||||
f"SEQUENCE:{sequence}\r\n"
|
||||
f"SUMMARY:{summary}\r\n"
|
||||
f"DTSTART:{start}\r\n"
|
||||
f"DTEND:{end}\r\n"
|
||||
"ORGANIZER;CN=Chefin:mailto:chefin@example.org\r\n"
|
||||
f"ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{attendee}\r\n"
|
||||
"END:VEVENT\r\n"
|
||||
"END:VCALENDAR\r\n"
|
||||
).encode()
|
||||
|
||||
|
||||
def _admin_with_email(client, address="admin@example.com"):
|
||||
token = register_admin(client)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == "admin").first()
|
||||
user.email = address
|
||||
db.commit()
|
||||
return token, user.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _ingest(payload):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return mail_invites.ingest_ics(db, payload)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_parser_exposes_the_itip_fields():
|
||||
import ical_io
|
||||
|
||||
parsed = ical_io.parse_ics(_ics(sequence=3))
|
||||
assert parsed["method"] == "REQUEST"
|
||||
item = parsed["events"][0]
|
||||
assert item["sequence"] == 3
|
||||
assert item["organizer_email"] == "chefin@example.org"
|
||||
assert item["attendees"][0]["email"] == "admin@example.com"
|
||||
assert item["attendees"][0]["partstat"] == "NEEDS-ACTION"
|
||||
assert item["attendees"][0]["rsvp"] is True
|
||||
|
||||
|
||||
def test_invitation_lands_as_pending(client):
|
||||
token, _ = _admin_with_email(client)
|
||||
assert _ingest(_ics()) is True
|
||||
|
||||
rows = client.get("/api/invites", headers=auth(token)).json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["status"] == "pending"
|
||||
assert rows[0]["title"] == "Projektbesprechung"
|
||||
assert rows[0]["organizer_email"] == "chefin@example.org"
|
||||
|
||||
|
||||
def test_a_mail_alone_creates_no_event(client):
|
||||
"""The whole security model in one assertion."""
|
||||
token, _ = _admin_with_email(client)
|
||||
_ingest(_ics())
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.query(models.LocalEvent).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_unknown_attendee_is_ignored(client):
|
||||
register_admin(client) # no email set, so nothing matches
|
||||
assert _ingest(_ics(attendee="someone-else@example.net")) is False
|
||||
|
||||
|
||||
def test_from_header_cannot_route(client):
|
||||
"""Routing is by ATTENDEE only; a sender can claim anything."""
|
||||
token, _ = _admin_with_email(client)
|
||||
assert _ingest(_ics(attendee="stranger@example.net")) is False
|
||||
assert client.get("/api/invites", headers=auth(token)).json() == []
|
||||
|
||||
|
||||
def test_same_sequence_is_ignored(client):
|
||||
token, _ = _admin_with_email(client)
|
||||
_ingest(_ics(summary="Erste Fassung"))
|
||||
_ingest(_ics(summary="Zweite Fassung")) # forwarded twice
|
||||
|
||||
rows = client.get("/api/invites", headers=auth(token)).json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["title"] == "Erste Fassung"
|
||||
|
||||
|
||||
def test_higher_sequence_reopens_an_answered_invitation(client):
|
||||
token, _ = _admin_with_email(client)
|
||||
_ingest(_ics())
|
||||
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
|
||||
headers=auth(token))
|
||||
|
||||
_ingest(_ics(sequence=1, summary="Verschoben"))
|
||||
|
||||
rows = client.get("/api/invites", headers=auth(token)).json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["status"] == "pending"
|
||||
assert rows[0]["title"] == "Verschoben"
|
||||
|
||||
|
||||
def test_accepting_creates_the_event(client):
|
||||
token, _ = _admin_with_email(client)
|
||||
_ingest(_ics())
|
||||
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||
|
||||
res = client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
|
||||
headers=auth(token))
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "accepted"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev = db.query(models.LocalEvent).one()
|
||||
assert ev.title == "Projektbesprechung"
|
||||
assert ev.external_uid == "invite:meet-1"
|
||||
assert ev.uid != "meet-1" # never the organiser's UID
|
||||
finally:
|
||||
db.close()
|
||||
assert client.get("/api/invites", headers=auth(token)).json() == []
|
||||
|
||||
|
||||
def test_invitation_cannot_overwrite_an_existing_event(client):
|
||||
"""The test that matters: a colliding UID must not touch what is there."""
|
||||
token, user_id = _admin_with_email(client)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cal = models.LocalCalendar(user_id=user_id, name="Privat", color="#4285f4")
|
||||
db.add(cal)
|
||||
db.flush()
|
||||
db.add(models.LocalEvent(calendar_id=cal.id, uid="meet-1", title="Mein Termin",
|
||||
start="2026-06-01T09:00:00+00:00",
|
||||
end="2026-06-01T10:00:00+00:00"))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
_ingest(_ics(uid="meet-1", summary="Fremder Termin"))
|
||||
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
|
||||
headers=auth(token))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
mine = db.query(models.LocalEvent).filter(models.LocalEvent.uid == "meet-1").one()
|
||||
assert mine.title == "Mein Termin" # untouched
|
||||
assert db.query(models.LocalEvent).count() == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_declining_creates_nothing(client):
|
||||
token, _ = _admin_with_email(client)
|
||||
_ingest(_ics())
|
||||
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||
|
||||
client.post(f"/api/invites/{invite_id}/respond", json={"action": "decline"},
|
||||
headers=auth(token))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.query(models.LocalEvent).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_cancel_removes_an_accepted_event(client):
|
||||
token, _ = _admin_with_email(client)
|
||||
_ingest(_ics())
|
||||
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
|
||||
headers=auth(token))
|
||||
|
||||
_ingest(_ics(method="CANCEL", sequence=1))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.query(models.LocalEvent).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
rows = client.get("/api/invites", headers=auth(token)).json()
|
||||
assert rows[0]["status"] == "cancelled" # stays visible, not silently gone
|
||||
|
||||
|
||||
def test_reply_and_publish_are_not_invitations(client):
|
||||
_admin_with_email(client)
|
||||
assert _ingest(_ics(method="REPLY")) is False
|
||||
assert _ingest(_ics(method="PUBLISH")) is False
|
||||
|
||||
|
||||
def test_oversized_payload_is_refused(client):
|
||||
_admin_with_email(client)
|
||||
padded = _ics(summary="X" * (mail_invites.MAX_ICS_BYTES + 10))
|
||||
assert _ingest(padded) is False
|
||||
Reference in New Issue
Block a user