Skip to content

feat(invites): record bookers' RSVPs through Resend inbound email - #111

Merged
shockalotti merged 4 commits into
Calnode:mainfrom
jeroenrinzema:feat/rsvp-inbound
Sep 29, 2026
Merged

shockalotti merged 4 commits into
Calnode:mainfrom
jeroenrinzema:feat/rsvp-inbound

Conversation

@jeroenrinzema

@jeroenrinzema jeroenrinzema commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #110. For the diff with only this PR's changes, see jeroenrinzema/calnode#2, which is based on #110's branch. It can't be stacked here directly because a fork can't push branches to this repo. A maintainer can push feat/calnode-sent-invites here and retarget this PR to it. Once #110 merges, this PR's diff shrinks to just its own changes.

Why

#110 lets Calnode send the calendar invite itself, so hosts don't have to expose their own email and a team can book under one name. The catch: when the booker clicks Yes / No / Maybe, their mail client sends the answer to the invite's organizer, and nothing reads it.

This PR closes that gap for setups that use Resend.

What

  • Private reply address per booking. With RSVP tracking on, each invite is organized by rsvp+<random token>@reply.yourdomain. Resend receives every address on that domain, so no mailbox needs to exist.
  • Resend inbound webhook, POST /v1/email/inbound/resend. Calnode verifies the signature, fetches the message, reads the calendar reply and stores the answer on the booking.
  • Visible where it matters. A "guest accepted / maybe / declined" badge in the bookings list, and a new booking.rsvp webhook event.
  • Setup in Settings → Email → RSVP tracking. Enter the address and save. Calnode then creates the webhook in your Resend account (or reuses one that already points at it) and stores the signing secret. If that can't work, for example on an http instance or with a sending-only key, the page says why and shows the manual steps.

It needs a full-access Resend API key. Resend only lets sending-only keys send email, so they can't read replies or create webhooks.

Safety

An answer is recorded only if all of these hold:

  1. Resend's webhook signature is valid.
  2. It's addressed to the reply address of a booking that is still confirmed and upcoming.
  3. Resend verified the sender (DKIM or DMARC pass) and the sender is that booking's booker. The reply's own content is written by whoever sends it, so it's never trusted alone.
  4. The reply is for this booking's event.

The invite's organizer is fixed per booking at its first send. Calendar apps match an update by organizer, so a cancellation must come from the same address as the original invite. Changing the sender or switching RSVP tracking on later therefore doesn't affect bookings already made. Calnode-invited bookings that exist before this migration are backfilled with the sender they went out with.

Not in this PR

Writing the answer onto the hosts' own calendar events. That needs a new "update event" operation for Google, Microsoft and CalDAV, so it's better as its own change.

Tests

  • Signature check: verified against Svix's published example, so it matches what Resend actually sends.

  • Reply parsing: Gmail-style and folded quoted-printable replies, plus non-replies that must be ignored.

  • Resend API: the webhook is created when missing, reused and repaired when one exists (never duplicated), a sending-only key gives a clear error, and the sender's authentication results are read correctly.

  • End to end: book, get a private address, reply, answer recorded. Each of these is rejected:

    • a forged webhook;
    • a forwarded invite answered by someone else;
    • the booker's answer sent from another mailbox;
    • an unverified sender;
    • a reply for another event;
    • a cancelled booking.

    Also covered: the organizer stays the same after the settings change, and setup stores Resend's secret (or explains why it can't).

  • Running instance: automatic setup on an http instance explains itself and opens the manual steps. Saving a pasted secret connects, and removing it turns tracking off.

A per-event-type setting, invite_delivery. 'calendar' (default) keeps today's
behaviour: the host's connected calendar invites the booker, from the host's
own address. 'calnode' writes the hosts' events without guests (so Google and
Microsoft email nobody) and attaches Calnode's .ics to the booker's emails,
organized by the instance sender from Settings -> Email.

For hosts who don't want their personal address on every invite, and for
teams booking under one name. The mode is stored on each booking so its
reschedule, cancel, reassign and reconcile follow the channel the invite
actually went out on.

@shockalotti shockalotti left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed commit c30ecdc. The webhook auth is genuinely solid (constant-time compare, Svix 5-min tolerance both ways, rotation support — and I recomputed your test vector independently, it matches). Organizer pinning, backfill, and secret handling all check out.

One required fix before this can land:

The sender-is-booker gate reads attacker-controlled data. The check compares the REPLY body's ATTENDEE line to the booker email, but both that line and the message itself are attacker-controlled — Resend signs whatever arrives at the reply address. Anyone holding a forwarded .ics (which contains the token address, the UID, and the booker email) can craft a raw MIME REPLY with ATTENDEE:mailto: PARTSTAT:DECLINED, and it will record with a valid signature. The existing forwarded-invite test only covers the honest-client case, not a forged ATTENDEE line. Fix: require the envelope sender (ev.Data.From) and/or the MIME From to match the booker address before recording.

Secondary notes (non-blocking):

  • Reply tokens are stored plaintext and never expire; recordRSVP has no booking-status guard, so replies to cancelled/completed bookings still record + fire booking.rsvp.
  • booking.rsvp adds rsvp_status to defaultFields — confirm every existing default-field webhook should now carry the (usually empty) key.
  • No UI affordance to clear the webhook secret (API key has one); unconfigured endpoint 404s pre-auth (minor config oracle).

Also noting #110 is still a draft — this stacks on it, so both need to land in order.

- Host copies of a Calnode-invited booking no longer list the booker and go
  out as METHOD:PUBLISH, so no host calendar holds them as a re-invitable guest.
- The invite mode travels on booking.Booking instead of a per-call lookup that
  failed open to 'calendar'.
- inviteSenderReady defers the transport decision to the live mailer.
- The API treats an empty invite_delivery as 'calendar', like the service.
- The event type editor warns when Calnode mode is on but email is not.
- Regression tests for reschedule, reassign and the reconciler.
For event types whose invite Calnode sends: each invite is organized by a
private per-booking reply address (rsvp+<token>@<receiving domain>). The
booker's Yes/No/Maybe reply reaches Resend, which posts email.received to
POST /v1/email/inbound/resend. Calnode verifies the Svix signature, fetches
the message, reads the iTIP REPLY and records the answer on the booking,
firing a booking.rsvp webhook when it changes.

An answer counts only when the signature, the booking's UID and the booker's
address all match. The organizer is fixed per booking at its first send so
later updates keep matching, and existing Calnode-invited bookings are
backfilled with the sender they went out with.
…omatically

- An RSVP is recorded only when Resend verified the From domain (DKIM or DMARC
  pass) and that From is the booker. The REPLY body is sender-written, so a
  forged ATTENDEE line from any other mailbox no longer counts.
- Only confirmed, upcoming bookings take answers.
- The inbound endpoint answers 401 whether unconfigured or wrongly signed.
- Settings -> Email -> RSVP tracking creates (or reuses) the email.received
  webhook in Resend and stores its signing secret. When it cannot - an http
  instance, a sending-only key - it says why and shows the manual steps.
- RSVP tracking needs a full-access Resend key; the page, DEPLOY.md and the
  errors now say so. The webhook secret can be removed from the UI.
@jeroenrinzema

Copy link
Copy Markdown
Contributor Author

Thanks, good catch on the required fix. Addressed in 7572ebb; the branch is also rebased onto the updated #110.

Required: forged REPLY. An answer now counts only if Resend verified the message's From domain (DKIM or DMARC pass) and that From is the booker. FetchReceived reads Resend's own SPF/DKIM/DMARC verdicts from the Received emails API, which the sender can't forge. The REPLY body, including its ATTENDEE line, is never trusted on its own. New cases in TestRSVP_rejectsWhatIsNotTheBookersAnswer:

  • the booker's DECLINED sent from an attacker's own verified mailbox (your scenario);
  • the booker's address typed into an unverified From.

Both fail the test when the check is removed.

Secondary notes:

  • Cancelled or finished bookings: routing now only matches confirmed bookings that haven't ended, so replies to cancelled or past meetings are ignored. That also limits how long a leaked reply address is worth anything (TestRSVP_cancelledBookingTakesNoAnswer). I left the token unhashed. It's a routing handle that appears in plaintext in the invite anyway, and on its own it's no longer enough to record anything.
  • rsvp_status in defaultFields: it's in the omit-if-empty set, so every event other than booking.rsvp keeps exactly its old payload. Without it in the defaults, a booking.rsvp webhook with the default field set wouldn't say what the answer was.
  • Clearing the secret: the UI can now remove it, like the API key.
  • Config oracle: the endpoint answers 401 whether it's unconfigured or wrongly signed.

Also new, from the author's side:

  • Automatic webhook setup. Settings → Email → RSVP tracking creates the email.received webhook in Resend, or reuses one that already points at the instance, and stores its signing secret. When it can't (an http instance, a sending-only key) it says why and shows the manual steps with the endpoint prefilled.
  • Full-access key required. Resend returns 401 restricted_api_key for sending-only keys on everything except sending, including reading received emails. The settings page, DEPLOY.md and the error messages now say so.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — the full 35-file diff at 7572ebb, which carries this PR's RSVP work plus the stacked #110 invite-delivery feature. I also re-verified the one required fix from the earlier human review.

  • Invite delivery mode (invite_delivery). New per-event-type calendar/calnode, copied onto each booking at creation and read from the booking through confirm, reschedule, cancel, reassign and reconcile. calnode creates the host events guestless (calendarInvitee → ""), always attaches Calnode's .ics to the booker, and organizes it by the instance sender (applyInviteDelivery/HideHostInInvite); host copies go out METHOD:PUBLISH with no ATTENDEE (ICSWithoutAttendee). Validated on change only, so an already-calnode type stays saveable if email is later removed.
  • RSVP capture. Per-booking rsvp+<128-bit base32>@domain organizer fixed on bookings.invite_organizer at first send (with an email_from backfill for pre-existing calnode bookings). POST /v1/email/inbound/resend verifies the Svix signature, routes by recipient to a confirmed, upcoming booking, fetches the raw message from Resend, and records booking_attendees.rsvp_status only when Resend reports DMARC or an aligned DKIM pass, the From is the booker, and the REPLY UID and ATTENDEE are the booking's.
  • Webhook auto-setup and UI. POST /v1/settings/email/rsvp-webhook creates, reuses or repairs the Resend email.received webhook and stores its secret; Settings → Email shows state and a manual fallback, bookings show the RSVP badge, and booking.rsvp + rsvp_status are wired through the webhook payload.
  • Migrations 00068 / 00069. Invite mode + organizer + RSVP columns, CHECK-constrained, with symmetric downs.

I independently confirmed the two load-bearing external contracts: Resend's authentication.dkim only reports pass for a signature aligned with From (so DMARC == "pass" || DKIM == "pass" is not spoofable by signing an unrelated domain), and GET /webhooks/{id} returns signing_secret while GET /webhooks is unpaginated by default (so the reuse path is sound).

ℹ️ Sender authentication is deliberately strict, and rejections are silent

The gate accepts an answer only when Resend reports an aligned DKIM or DMARC pass, so a booker whose From domain has neither (SPF-only, no DKIM signature) has a legitimate RSVP ignored with a 200 and no log. That is the documented safety model and I am not asking to relax it — just noting that an operator debugging "bookers say they accepted but nothing shows" has no signal anywhere. A debug-level log on the ignore paths would make that diagnosable without changing behavior or spamming the endpoint.

ℹ️ Scope note: this PR is stacked on #110

The head branch carries #110's commits while the base is main, so the diff under review includes #110's invite-delivery feature. As the PR body says, both need to land in order (or a maintainer needs to push feat/calnode-sent-invites here and retarget). Nothing to change in the code; flagging only so the reviewed scope is explicit.

Pullfrog  | View workflow run | Using opencode-go/deepseek-v4.1-flash | 𝕏

@shockalotti
shockalotti merged commit 91e9932 into Calnode:main Sep 29, 2026
2 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants