diff --git a/README.md b/README.md index 23d468b..7124516 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 diff --git a/backend/ical_io.py b/backend/ical_io.py index 694a530..48b04ed 100644 --- a/backend/ical_io.py +++ b/backend/ical_io.py @@ -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: diff --git a/backend/mail_invites.py b/backend/mail_invites.py new file mode 100644 index 0000000..50a7500 --- /dev/null +++ b/backend/mail_invites.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 262216e..d3a1369 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"]) diff --git a/backend/models.py b/backend/models.py index c63d20a..e037991 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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.""" diff --git a/backend/routers/invites_router.py b/backend/routers/invites_router.py new file mode 100644 index 0000000..031778c --- /dev/null +++ b/backend/routers/invites_router.py @@ -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} diff --git a/backend/scheduler.py b/backend/scheduler.py index 27f557a..6cbdcd6 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -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 diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py new file mode 100644 index 0000000..fc4ea27 --- /dev/null +++ b/backend/tests/test_invites.py @@ -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