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
@@ -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