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 |
|
| `SCHEDULER_ENABLED` | `1` | `0` disables all background jobs |
|
||||||
| `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up |
|
| `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up |
|
||||||
| `ICAL_REFRESH_MINUTES` | `15` | How often iCal subscriptions are checked in the background |
|
| `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)* |
|
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
|
||||||
|
|
||||||
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
|
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 |
|
| `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 |
|
| `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
|
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
|
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
|
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:
|
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.
|
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),
|
"rrule": _rrule_to_str(component),
|
||||||
"exdate": _exdate_to_csv(component),
|
"exdate": _exdate_to_csv(component),
|
||||||
"organizer": _organizer_name(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:
|
except Exception as exc:
|
||||||
logger.warning("Skipping malformed VEVENT: %s", exc)
|
logger.warning("Skipping malformed VEVENT: %s", exc)
|
||||||
errors.append(f"Fehlerhafter Eintrag übersprungen: {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:
|
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
|
import scheduler
|
||||||
from database import Base, engine
|
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)
|
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(google_router.router, prefix="/api/google", tags=["google"])
|
||||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
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(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
|
# 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.
|
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||||
app.include_router(dav_router.router, tags=["dav"])
|
app.include_router(dav_router.router, tags=["dav"])
|
||||||
|
|||||||
@@ -398,6 +398,74 @@ class HomeAssistantCalendar(Base):
|
|||||||
# ── Collaboration: sharing & groups (local calendars only) ────────────────
|
# ── 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):
|
class CalendarShare(Base):
|
||||||
"""A local calendar shared with another Calendarr user."""
|
"""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()
|
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:
|
def install_default_jobs() -> None:
|
||||||
sweep_hours = float(os.environ.get("ATTACHMENT_SWEEP_HOURS", "24"))
|
sweep_hours = float(os.environ.get("ATTACHMENT_SWEEP_HOURS", "24"))
|
||||||
ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15"))
|
ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15"))
|
||||||
register("attachments.sweep", sweep_hours * 3600, sweep_attachments)
|
register("attachments.sweep", sweep_hours * 3600, sweep_attachments)
|
||||||
register("ical.refresh", ical_minutes * 60, refresh_ical_subscriptions)
|
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
|
@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