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:
Scarriffle
2026-10-29 17:16:25 +01:00
co-authored by Claude Opus 5
parent aa795fde76
commit 9bf7d3797d
8 changed files with 840 additions and 3 deletions
+23
View File
@@ -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
View File
@@ -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:
+312
View File
@@ -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
View File
@@ -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"])
+68
View File
@@ -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."""
+151
View File
@@ -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}
+13
View File
@@ -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
+219
View File
@@ -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