feat: emailed iCalendar invites on confirm / reschedule / cancel (#189) #190

Merged
claude-bot merged 3 commits from feat/189-email-invites into main 2026-07-17 22:26:09 +00:00
Contributor

Fixes #189. The replacement for #107 Phase B (Google Calendar OAuth push, which was dropped as poor value for a self-hosted tool).

Why

The #107 iCal feed gives subscription sync, but Google refreshes external feeds only every 8–24h. Emailed iCalendar invites are the near-instant, provider-agnostic, no-OAuth alternative: an invite lands in Gmail/Apple/Outlook and auto-adds the session; a reschedule updates it in place; a cancel removes it — via standard iTIP METHOD:REQUEST/CANCEL.

What changed

  • Opt-in: users.calendar_invite_opt_in (migration d1e2f3a4b5c6, down_revision c0d1e2f3a4b5), mirroring recap_email_opt_in, with a Profile toggle. Invites go only to opted-in members who have an email; a silent no-op if SMTP is unconfigured.
  • calendar_service.build_itip_invite(): a single-VEVENT iTIP VCALENDAR with METHOD:REQUEST/CANCEL, ORGANIZER, and one ATTENDEE. Reuses #107's stable UID + SEQUENCE, so a client updates the same event across reschedules rather than duplicating. Feed/download builders untouched.
  • email.send_calendar_invite(): multipart/mixedalternative[html, text/calendar; method=REQUEST] + an application/ics attachment — iMIP-correct so mail clients render "add to calendar".
  • Wiring: into send_session_confirmed (confirm and reschedule share this task) and send_session_cancelled. Best-effort — a calendar failure is logged and swallowed, never turning a good Discord/bot notification into a Celery retry.

Two notification-path changes — verified NOT to regress Discord

These were necessary (else invites wouldn't fire without Discord), and I reviewed each:

  1. Lifted the Discord-config early-returns (_send_confirmation_notice; cancel_session previously only queued when guild_id was set) so invites also fire for campaigns with no Discord integration.
  2. No early return after a successful bot publish — a bot_notified flag preserves the original "skip the webhook when the bot was notified" behaviour (no double-post), while letting the calendar step always run afterward. I confirmed the webhook still fires only when the bot wasn't notified.

Acceptance criteria

  • Confirmed session → each opted-in member with an email gets METHOD:REQUEST (correct UID/ATTENDEE/DTSTART).
  • Reschedule → re-sends with a higher SEQUENCE (same UID → updates in place).
  • Cancel → METHOD:CANCEL / STATUS:CANCELLED.
  • Non-opted-in / no-email members not emailed; SMTP-unconfigured is a silent no-op.
  • Migration applies and round-trips.

Verification

Backend (dev-host harness): migration applies from base and downgrade -1 → upgrade head round-trips (opt-in column drops/recreates); 434 passed (4 new); ruff check/format clean. Frontend (node:20): lint clean, 170 tests, build succeeds.

I also verified the iMIP wire format (calendar part Content-Type: text/calendar; method=REQUEST) and read the restructured send_session_confirmed/send_session_cancelled to confirm no webhook double-post.

Deploy note: migration d1e2f3a4b5c6 ships with the next deploy (backup-first), after v3.6.0's three.

🤖 Generated with Claude Code

Fixes #189. The **replacement for #107 Phase B** (Google Calendar OAuth push, which was dropped as poor value for a self-hosted tool). ## Why The #107 iCal feed gives subscription sync, but Google refreshes external feeds only every 8–24h. Emailed iCalendar invites are the near-instant, provider-agnostic, **no-OAuth** alternative: an invite lands in Gmail/Apple/Outlook and auto-adds the session; a reschedule updates it in place; a cancel removes it — via standard iTIP `METHOD:REQUEST`/`CANCEL`. ## What changed - **Opt-in**: `users.calendar_invite_opt_in` (migration `d1e2f3a4b5c6`, `down_revision c0d1e2f3a4b5`), mirroring `recap_email_opt_in`, with a Profile toggle. Invites go only to opted-in members who have an email; a silent no-op if SMTP is unconfigured. - **`calendar_service.build_itip_invite()`**: a single-VEVENT iTIP `VCALENDAR` with `METHOD:REQUEST`/`CANCEL`, `ORGANIZER`, and one `ATTENDEE`. Reuses #107's **stable `UID` + `SEQUENCE`**, so a client updates the *same* event across reschedules rather than duplicating. Feed/download builders untouched. - **`email.send_calendar_invite()`**: `multipart/mixed` → `alternative[html, text/calendar; method=REQUEST]` + an `application/ics` attachment — iMIP-correct so mail clients render "add to calendar". - **Wiring**: into `send_session_confirmed` (confirm *and* reschedule share this task) and `send_session_cancelled`. **Best-effort** — a calendar failure is logged and swallowed, never turning a good Discord/bot notification into a Celery retry. ## Two notification-path changes — verified NOT to regress Discord These were necessary (else invites wouldn't fire without Discord), and I reviewed each: 1. **Lifted the Discord-config early-returns** (`_send_confirmation_notice`; `cancel_session` previously only queued when `guild_id` was set) so invites also fire for campaigns with no Discord integration. 2. **No early `return` after a successful bot publish** — a `bot_notified` flag preserves the original "skip the webhook when the bot was notified" behaviour (**no double-post**), while letting the calendar step always run afterward. I confirmed the webhook still fires *only* when the bot wasn't notified. ## Acceptance criteria - [x] Confirmed session → each opted-in member with an email gets `METHOD:REQUEST` (correct `UID`/`ATTENDEE`/`DTSTART`). - [x] Reschedule → re-sends with a higher `SEQUENCE` (same UID → updates in place). - [x] Cancel → `METHOD:CANCEL` / `STATUS:CANCELLED`. - [x] Non-opted-in / no-email members not emailed; SMTP-unconfigured is a silent no-op. - [x] Migration applies and round-trips. ## Verification Backend (dev-host harness): migration applies from base **and** `downgrade -1 → upgrade head` round-trips (opt-in column drops/recreates); **434 passed** (4 new); `ruff check`/`format` clean. Frontend (node:20): lint clean, **170 tests**, build succeeds. I also verified the iMIP wire format (calendar part `Content-Type: text/calendar; method=REQUEST`) and read the restructured `send_session_confirmed`/`send_session_cancelled` to confirm no webhook double-post. **Deploy note:** migration `d1e2f3a4b5c6` ships with the next deploy (backup-first), after v3.6.0's three. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Replaces the dropped Google Calendar OAuth push (#107 Phase B). When a session
is confirmed, opted-in campaign members with an email get a real calendar invite
that auto-adds to Gmail/Apple/Outlook; reschedules update it in place and
cancellations remove it — near-instant, provider-agnostic, no OAuth.

- Migration d1e2f3a4b5c6 (down_revision c0d1e2f3a4b5): users.calendar_invite_opt_in
  (bool, default false), mirroring recap_email_opt_in; on UserResponse/UserUpdate
  and a Profile toggle.
- calendar_service.build_itip_invite(): a single-VEVENT iTIP VCALENDAR with
  METHOD:REQUEST/CANCEL, ORGANIZER, and one ATTENDEE, reusing #107's stable
  UID + SEQUENCE so a client updates the same event across reschedules. The feed
  and per-session download builders are untouched.
- email.send_calendar_invite(): multipart/mixed → alternative[html,
  text/calendar; method=REQUEST] + an application/ics attachment. iMIP-correct
  so mail clients render "add to calendar".
- Wired into send_session_confirmed (confirm AND reschedule share this task) and
  send_session_cancelled. Best-effort: a calendar failure is logged and swallowed,
  never turning a good Discord/bot notification into a Celery retry.

Two necessary changes to the notification paths, verified not to regress Discord:
- The Discord-config early-returns that used to gate notifications
  (_send_confirmation_notice; cancel_session only queuing when guild_id set) are
  lifted, so invites also fire for campaigns with no Discord integration.
- send_session_confirmed/cancelled no longer return immediately after a
  successful bot publish; a bot_notified flag preserves "skip the webhook on bot
  success" (no double-post) while letting the calendar step always run.

Verified via the dev-host harness: migration applies from base and downgrade -1
+ upgrade head round-trips; backend 434 passed (4 new), ruff clean; frontend
lint clean, 170 tests, build succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs: changelog for emailed calendar invites (#189)
Some checks failed
CI / Frontend tests, audit, and build (pull_request) Successful in 1m24s
CI / Docker image build (pull_request) Successful in 35s
CI / Backend migration, tests, and audit (pull_request) Successful in 3m57s
CI / Backend lint (ruff) (pull_request) Successful in 3m12s
CI / Bot tests and audit (pull_request) Has been cancelled
b98ad3360a
fix(security): HTML-escape campaign/session names in notification emails (#189)
All checks were successful
CI / Docker image build (pull_request) Successful in 25s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m36s
CI / Bot tests and audit (pull_request) Successful in 2m1s
CI / Backend lint (ruff) (pull_request) Successful in 2m41s
CI / Backend migration, tests, and audit (pull_request) Successful in 3m55s
5969b6bc74
A background security review flagged stored XSS via manual HTML building: the
calendar-invite email body (and the pre-existing session-confirmation and
reminder emails) interpolated the GM-controlled campaign name and session title
straight into HTML without escaping, so a campaign/session named e.g.
"<img src=x onerror=...>" injected markup into the email.

html.escape() the campaign name and session title before interpolation in
_send_calendar_invites_impl (reminder_tasks.py) and in
EmailNotificationBackend.send_confirmation / send_reminder (email.py — same
latent pattern, fixed while here). The ICS builder is unaffected (it uses
RFC 5545 escaping, not HTML, and iCal is not an HTML context).

Test: an invite for a campaign/session named with <script>/<img onerror> tags
has them escaped in the email's text/html part. Backend 435 passed, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Contributor

Pushed a security fix (5969b6b): the automated review flagged stored XSS via manual HTML building — the invite email body interpolated the GM-controlled campaign name and session title into HTML without escaping. Now html.escape()d in _send_calendar_invites_impl, and I fixed the same pre-existing pattern in EmailNotificationBackend.send_confirmation/send_reminder while there. The ICS builder is unaffected (RFC 5545 escaping, not an HTML context). Added a test asserting <script>/<img onerror> in a campaign/session name is escaped in the email's text/html part. Backend 435 passed, ruff clean.

Pushed a security fix (`5969b6b`): the automated review flagged **stored XSS via manual HTML building** — the invite email body interpolated the GM-controlled campaign name and session title into HTML without escaping. Now `html.escape()`d in `_send_calendar_invites_impl`, and I fixed the **same pre-existing pattern** in `EmailNotificationBackend.send_confirmation`/`send_reminder` while there. The ICS builder is unaffected (RFC 5545 escaping, not an HTML context). Added a test asserting `<script>`/`<img onerror>` in a campaign/session name is escaped in the email's `text/html` part. Backend **435 passed**, ruff clean.
claude-bot deleted branch feat/189-email-invites 2026-07-17 22:26:09 +00:00
Sign in to join this conversation.
No description provided.