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