Repository navigation
feat(invites): record bookers' RSVPs through Resend inbound email - #111
Conversation
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
left a comment
There was a problem hiding this comment.
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.
c30ecdc to
7572ebb
Compare
|
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.
Both fail the test when the check is removed. Secondary notes:
Also new, from the author's side:
|
There was a problem hiding this comment.
✅ 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-typecalendar/calnode, copied onto each booking at creation and read from the booking through confirm, reschedule, cancel, reassign and reconcile.calnodecreates the host events guestless (calendarInvitee→""), always attaches Calnode's.icsto the booker, and organizes it by the instance sender (applyInviteDelivery/HideHostInInvite); host copies go outMETHOD:PUBLISHwith noATTENDEE(ICSWithoutAttendee). Validated on change only, so an already-calnodetype stays saveable if email is later removed. - RSVP capture. Per-booking
rsvp+<128-bit base32>@domainorganizer fixed onbookings.invite_organizerat first send (with anemail_frombackfill for pre-existing calnode bookings).POST /v1/email/inbound/resendverifies the Svix signature, routes by recipient to a confirmed, upcoming booking, fetches the raw message from Resend, and recordsbooking_attendees.rsvp_statusonly 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-webhookcreates, reuses or repairs the Resendemail.receivedwebhook and stores its secret; Settings → Email shows state and a manual fallback, bookings show the RSVP badge, andbooking.rsvp+rsvp_statusare 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.
opencode-go/deepseek-v4.1-flash | 𝕏

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
rsvp+<random token>@reply.yourdomain. Resend receives every address on that domain, so no mailbox needs to exist.POST /v1/email/inbound/resend. Calnode verifies the signature, fetches the message, reads the calendar reply and stores the answer on the booking.booking.rsvpwebhook event.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:
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:
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.