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
+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}