From 42565860f4e1bde8006e9bf6b8a7e5d4fb750295 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Tue, 29 Sep 2026 23:04:11 +0200 Subject: [PATCH 1/4] feat(invites): let Calnode send the booker's calendar invite 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. --- CHANGELOG.md | 11 + docs/ARCHITECTURE.md | 13 + frontend/src/lib/api.ts | 4 + .../routes/event-types/[slug]/+page.svelte | 38 +++ internal/booking/booking.go | 17 + internal/booking/service.go | 12 +- .../db/migrations/00068_invite_delivery.sql | 25 ++ internal/handler/booking_handler.go | 23 +- internal/handler/calendar_reconcile.go | 8 +- internal/handler/event_type.go | 52 ++- internal/handler/event_type_duplicate.go | 4 +- internal/handler/event_type_duplicate_test.go | 1 + internal/handler/invite_delivery.go | 93 +++++ .../handler/invite_delivery_internal_test.go | 320 ++++++++++++++++++ internal/handler/manage_handler.go | 9 +- internal/handler/reassign.go | 16 +- internal/mailer/booking.go | 15 +- internal/mailer/ics.go | 14 +- internal/mailer/ics_test.go | 18 + 19 files changed, 658 insertions(+), 35 deletions(-) create mode 100644 internal/db/migrations/00068_invite_delivery.sql create mode 100644 internal/handler/invite_delivery.go create mode 100644 internal/handler/invite_delivery_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2681c281..8a7c2a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ All notable changes to Calnode are recorded here. The format follows exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrades. `1.0.0` will mark the point at which the API and schema are declared stable. +## [Unreleased] + +### Added +- **Calendar invites can come from Calnode instead of the host's calendar.** A new + per-event-type setting, *Calendar invite → Sent by*. The default is unchanged: the host's + connected calendar invites the booker, from the host's own address. Choose *Calnode* and + the booker's invite comes from your workspace's email sender (Settings → Email) instead, + so no host's personal address reaches them, and a team can book under one name. Hosts + still get the meeting, with its Meet/Teams link, on their own calendar. Needs email set + up. Existing bookings keep the way they were invited. + ## [0.10.1] - 2026-09-29 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77fcc2e0..fb11c298 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,6 +546,19 @@ CalDAV without iTIP scheduling (RFC 6638) does **not** auto-invite, so a future provider would want the `.ics` — the rule is "no destination whose provider auto-delivers invites," not "no Google." +**Who sends the invite: `invite_delivery` (migration 00068).** Per event type, copied onto +each booking at creation. `calendar` (default) is everything above: the booker is a guest +on each host's event and the provider invites them, from the host's own address. +`calnode` writes the hosts' events **without guests** (`calendarInvitee` returns "", which +every provider reads as "no attendee", so nobody is emailed from a host's account) and +always attaches Calnode's `.ics` to the booker's emails, with `ORGANIZER` set to the +instance sender from Settings → Email (`applyInviteDelivery`, `BookingData.HideHostInInvite`). +No sender address means no `ORGANIZER` line, never a fallback to the host. Host copies keep +the host as organizer. Reschedule, cancel, reassign and the reconciler read the mode from +the **booking**, not the event type, so a booking is always updated through the channel +its invite went out on. Switching an event type to `calnode` requires email to be set up +(validated on change). + **Adding another provider (Apple/iCloud, CalDAV):** implement `calendar.Provider`, register it in `internal/server`, set `InvitesGuests()` correctly (drives the `.ics` gate above), and extend `providerMintsPlatform`/`CanAutoGenerate` if it offers a diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b8f1f973..ad9d128e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -37,6 +37,10 @@ export type EventType = { * them. Off by default: the slots endpoint is public, so this makes the host's * booked hours visible to anyone with the link. */ show_taken_slots: boolean; + /** Who sends the booker's calendar invite. 'calendar': each host's connected calendar, + * from the host's own address. 'calnode': Calnode's own invite, from the email sender + * in Settings → Email, so no host's personal address reaches the booker. */ + invite_delivery: 'calendar' | 'calnode'; location_type: string; location_value?: string; buffer_before_minutes: number; diff --git a/frontend/src/routes/event-types/[slug]/+page.svelte b/frontend/src/routes/event-types/[slug]/+page.svelte index d79fd07a..595501dc 100644 --- a/frontend/src/routes/event-types/[slug]/+page.svelte +++ b/frontend/src/routes/event-types/[slug]/+page.svelte @@ -40,6 +40,11 @@ phone: '+1 555 123 4567 (required)', in_person: 'Address (optional)', }; + const INVITE_DELIVERY = [ + { value: 'calendar', label: "The host's calendar" }, + { value: 'calnode', label: 'Calnode, from your workspace' }, + ]; + // ── Event type ─────────────────────────────────────────────────────────────── let et = $state(null); const TABS = [ @@ -65,6 +70,7 @@ let form = $state({ name: '', slug: '', description: '', duration_minutes: 30, slot_interval_minutes: 30, is_active: true, is_public: true, show_taken_slots: false, + invite_delivery: 'calendar' as EventType['invite_delivery'], location_type: 'link', location_value: '', buffer_before_minutes: 0, buffer_after_minutes: 0, min_notice_minutes: 0, max_future_days: 60, @@ -285,6 +291,7 @@ is_active: et.is_active, is_public: et.is_public, show_taken_slots: et.show_taken_slots ?? false, + invite_delivery: et.invite_delivery ?? 'calendar', location_type: et.location_type, location_value: et.location_value ?? '', buffer_before_minutes: et.buffer_before_minutes, @@ -343,6 +350,7 @@ is_active: form.is_active, is_public: form.is_public, show_taken_slots: form.show_taken_slots, + invite_delivery: form.invite_delivery, allow_phone_call: allowPhoneCall, location_type: form.location_type, location_value: form.location_value.trim() || null, @@ -707,6 +715,36 @@ + +
+

Calendar invite

+
+
+ + + + {INVITE_DELIVERY.find((o) => o.value === form.invite_delivery)?.label ?? 'Select…'} + + + {#each INVITE_DELIVERY as o} + {o.label} + {/each} + + +
+

+ {#if form.invite_delivery === 'calnode'} + Invitees get the invite from your workspace's email sender + (Settings → Email), never from a host's + own address. Hosts still see the meeting on their own calendar, with the meeting link. + {:else} + The host's connected calendar invites the booker, so the invite shows the host's own + email address. Choose Calnode to keep hosts' addresses private or to book as a team. + {/if} +

+
+
+

Price

diff --git a/internal/booking/booking.go b/internal/booking/booking.go index 85ac6321..bcdc46b2 100644 --- a/internal/booking/booking.go +++ b/internal/booking/booking.go @@ -14,6 +14,17 @@ var ( ErrEmailThrottled = errors.New("booking: too many bookings from this email address") ) +// Who sends a booking's calendar invite to the booker (event_types/bookings.invite_delivery, +// migration 00068). +const ( + // InviteByCalendar: the booker is a guest on each host's calendar event, and the host's + // provider (Google, Microsoft) emails the invite from the host's own account. + InviteByCalendar = "calendar" + // InviteByCalnode: the hosts' events are created without guests, and Calnode sends the + // invite itself, organized by the instance's sender identity instead of any one host. + InviteByCalnode = "calnode" +) + // Booking is a confirmed or cancelled appointment. type Booking struct { ID string @@ -88,4 +99,10 @@ type CreateParams struct { // creation transaction, next to the active cap, so concurrent submissions // can't both slip past a read-then-write check. MaxBookingsPerHour int + // InviteDelivery records who sends the booker's calendar invite for this booking: + // InviteByCalendar (the hosts' connected calendars; the default when empty) or + // InviteByCalnode (Calnode's own .ics, with the hosts' events created guestless). + // Stored on the booking so its reschedule and cancel follow the mode it was created + // with, not whatever the event type is set to by then. + InviteDelivery string } diff --git a/internal/booking/service.go b/internal/booking/service.go index 419b49de..60945577 100644 --- a/internal/booking/service.go +++ b/internal/booking/service.go @@ -152,12 +152,18 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error) } bookingID := uid.New() + inviteDelivery := p.InviteDelivery + if inviteDelivery == "" { + inviteDelivery = InviteByCalendar + } _, err = tx.ExecContext(ctx, ` INSERT INTO bookings - (id, event_type_id, host_id, start_at, end_at, status, location_value, location_type, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'confirmed', ?, ?, ?, ?)`, - bookingID, p.EventTypeID, chosenHost, startStr, endStr, p.LocationValue, p.LocationType, now, now) + (id, event_type_id, host_id, start_at, end_at, status, location_value, location_type, + invite_delivery, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'confirmed', ?, ?, ?, ?, ?)`, + bookingID, p.EventTypeID, chosenHost, startStr, endStr, p.LocationValue, p.LocationType, + inviteDelivery, now, now) if err != nil { if db.IsUniqueViolation(err) { return nil, ErrDoubleBooked diff --git a/internal/db/migrations/00068_invite_delivery.sql b/internal/db/migrations/00068_invite_delivery.sql new file mode 100644 index 00000000..b8c75110 --- /dev/null +++ b/internal/db/migrations/00068_invite_delivery.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Who sends the booker's calendar invite. +-- +-- 'calendar' (default, today's behaviour): the event is created on each host's connected +-- calendar with the booker as a guest, and Google/Microsoft email the invite. The invite +-- comes from the host's own account, so the booker sees the host's personal address. +-- +-- 'calnode': the host events are still created (they block time and mint the Meet/Teams +-- link) but WITHOUT the booker as a guest, so the provider emails nobody. Calnode sends +-- the invite itself as an .ics on the confirmation email, organized by the instance's +-- sender identity (Settings -> Email). For teams booking under one name, or hosts who +-- do not want their personal address on every invite. +-- +-- Stored on the booking as well as the event type: the mode a booking was CREATED with +-- decides how its reschedule and cancel are delivered. Reading the event type's current +-- mode instead would, after the setting changes, cancel a Google-invited booking with an +-- .ics nobody has (or leave a Calnode-invited one with no cancellation at all). +ALTER TABLE event_types ADD COLUMN invite_delivery TEXT NOT NULL DEFAULT 'calendar' + CHECK (invite_delivery IN ('calendar', 'calnode')); +ALTER TABLE bookings ADD COLUMN invite_delivery TEXT NOT NULL DEFAULT 'calendar' + CHECK (invite_delivery IN ('calendar', 'calnode')); + +-- +goose Down +ALTER TABLE bookings DROP COLUMN invite_delivery; +ALTER TABLE event_types DROP COLUMN invite_delivery; diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index fcfb363f..3253edf3 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -472,6 +472,9 @@ type bookableEventType struct { // so the page can grey them out. Read by the public slots endpoint only: the MCP // tool and the booking assistant must keep seeing bookable times and nothing else. ShowTakenSlots bool + // InviteDelivery is who sends the booker's invite (booking.InviteByCalendar/ByCalnode), + // copied onto each booking at creation. + InviteDelivery string } // loadBookableEventType loads an event type by slug for the booking-creation/slot paths. @@ -486,12 +489,14 @@ func (h *Handler) loadBookableEventType(ctx context.Context, slug string) (*book SELECT id, user_id, name, duration_minutes, slot_interval_minutes, location_type, location_value, allow_phone_call, routing_mode, rr_strategy, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_future_days, - is_active, is_public, show_taken_slots, max_active_bookings, price_cents, currency + is_active, is_public, show_taken_slots, max_active_bookings, price_cents, currency, + invite_delivery FROM event_types WHERE slug = ?`, slug). Scan(&et.ID, &et.UserID, &et.Name, &et.DurationMinutes, &et.SlotIntervalMinutes, &et.LocationType, &et.LocationValue, &et.AllowPhoneCall, &et.RoutingMode, &et.RRStrategy, &et.BufferBeforeMinutes, &et.BufferAfterMinutes, &et.MinNoticeMinutes, &et.MaxFutureDays, - &isActive, &isPublic, &showTaken, &et.MaxActiveBookings, &et.PriceCents, &et.Currency) + &isActive, &isPublic, &showTaken, &et.MaxActiveBookings, &et.PriceCents, &et.Currency, + &et.InviteDelivery) if err != nil || isActive == 0 || isPublic == 0 { return nil, errEventTypeNotFound } @@ -570,6 +575,7 @@ func (h *Handler) createBookingForSlug(ctx context.Context, slug string, startAt Answers: answers, MaxActivePerInvitee: et.MaxActiveBookings, MaxBookingsPerHour: maxBookingsPerEmailPerHour, + InviteDelivery: et.InviteDelivery, }) if err != nil { return nil, err @@ -900,6 +906,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) { Answers: answers, MaxActivePerInvitee: et.MaxActiveBookings, MaxBookingsPerHour: maxBookingsPerEmailPerHour, + InviteDelivery: et.InviteDelivery, }) if err != nil { if errors.Is(err, booking.ErrDoubleBooked) { @@ -1012,6 +1019,9 @@ type bookingConfirmationInput struct { // OrganizerLocale is the attendee's resolved locale code (e.g. "es") — same value // stored on booking_attendees.locale, threaded into the confirmation email. OrganizerLocale string + // InviteDelivery is the booking's stored invite mode; dispatchBookingConfirmation + // fills it from the booking row, so callers need not. + InviteDelivery string } // hostPrefsOrDefault loads a host's notification prefs, defaulting to allOnPrefs and @@ -1037,6 +1047,8 @@ func (h *Handler) hostBookingData(ctx context.Context, base mailer.BookingData, hd.HostName, hd.HostEmail = host.Name, host.Email hd.AttachICS = h.noConnectedDestination(ctx, host.UserID) hd.ICSSequence = int(updatedAt.Unix()) + // The host's own copy is organized by the host, whichever way the booker was invited. + hd.HideHostInInvite = false return hd } @@ -1192,7 +1204,7 @@ func (h *Handler) createHostEventsAndNotify(ctx context.Context, b *booking.Book Start: b.StartAt, End: b.EndAt, OrganizerName: in.OrganizerName, - OrganizerEmail: in.OrganizerEmail, + OrganizerEmail: calendarInvitee(in.InviteDelivery, in.OrganizerEmail), AddMeet: autoGenMeet && host.IsPrimary, }) if err != nil { @@ -1280,6 +1292,7 @@ func sendWithRetry(ctx context.Context, logger *slog.Logger, bookingID, who stri func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConfirmationInput) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + in.InviteDelivery = h.bookingInviteDelivery(ctx, b.ID) bData := mailer.BookingData{ BookingID: b.ID, EventTypeName: in.EventTypeName, @@ -1325,7 +1338,7 @@ func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConf // Attendee confirmation, once. "With:" names the primary host; gated on the // primary host's notification preference (matches prior behaviour). bData.HostName, bData.HostEmail = primaryHost(hosts).Name, primaryHost(hosts).Email - bData.AttachICS = h.noConnectedDestination(ctx, b.HostID) + h.applyInviteDelivery(ctx, &bData, in.InviteDelivery, b.HostID) bData.ICSSequence = int(b.UpdatedAt.Unix()) confirmFailed := hostFailed if primaryPrefs.NotifyConfirmation { @@ -1770,7 +1783,7 @@ func (h *Handler) cancelSideEffects(b booking.Booking) { } } } - d.AttachICS = h.noConnectedDestination(ctx, b.HostID) + h.applyInviteDelivery(ctx, &d, h.bookingInviteDelivery(ctx, b.ID), b.HostID) d.ICSSequence = int(b.UpdatedAt.Unix()) if primaryPrefs.NotifyCancellation { if err := mailer.SendCancellationToAttendee(ctx, h.mailer, d); err != nil { diff --git a/internal/handler/calendar_reconcile.go b/internal/handler/calendar_reconcile.go index 7194d04f..b012cb32 100644 --- a/internal/handler/calendar_reconcile.go +++ b/internal/handler/calendar_reconcile.go @@ -187,13 +187,13 @@ func (h *Handler) reconcileCreations(ctx context.Context, gc *calendar.Service) cutoff := nowT.Add(-5 * time.Minute).Format(time.RFC3339) type missing struct { bookingID, userID, etName, orgName, orgEmail, orgLocale, startStr, endStr string - locationType, bookingLoc string + locationType, bookingLoc, inviteMode string isPrimary bool } var items []missing rows, err := h.db.QueryContext(ctx, ` SELECT bh.booking_id, bh.user_id, bh.is_primary, et.name, COALESCE(NULLIF(b.location_type, ''), et.location_type), - COALESCE(b.location_value, ''), + COALESCE(b.location_value, ''), b.invite_delivery, COALESCE(o.name, ''), COALESCE(o.email, ''), COALESCE(o.locale, ''), b.start_at, b.end_at FROM booking_hosts bh JOIN bookings b ON b.id = bh.booking_id @@ -209,7 +209,7 @@ func (h *Handler) reconcileCreations(ctx context.Context, gc *calendar.Service) var m missing var primary int if err := rows.Scan(&m.bookingID, &m.userID, &primary, &m.etName, &m.locationType, - &m.bookingLoc, &m.orgName, &m.orgEmail, &m.orgLocale, &m.startStr, &m.endStr); err == nil { + &m.bookingLoc, &m.inviteMode, &m.orgName, &m.orgEmail, &m.orgLocale, &m.startStr, &m.endStr); err == nil { m.isPrimary = primary != 0 items = append(items, m) } @@ -248,7 +248,7 @@ func (h *Handler) reconcileCreations(ctx context.Context, gc *calendar.Service) Start: start, End: end, OrganizerName: m.orgName, - OrganizerEmail: m.orgEmail, + OrganizerEmail: calendarInvitee(m.inviteMode, m.orgEmail), AddMeet: autoGenMeet, }) if err != nil { diff --git a/internal/handler/event_type.go b/internal/handler/event_type.go index a49413a9..0f7e63cd 100644 --- a/internal/handler/event_type.go +++ b/internal/handler/event_type.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -39,7 +40,11 @@ type eventTypeJSON struct { // ShowTakenSlots renders already-booked times greyed out on the booking page // instead of omitting them. Off by default: the slots endpoint is public, so this // makes the host's booked hours legible to anyone with the link (#19). - ShowTakenSlots bool `json:"show_taken_slots"` + ShowTakenSlots bool `json:"show_taken_slots"` + // InviteDelivery is who sends the booker's calendar invite: "calendar" (each host's + // connected calendar, from the host's own address) or "calnode" (Calnode's own .ics, + // from the instance sender, so no host address reaches the booker). See migration 00068. + InviteDelivery string `json:"invite_delivery"` IsPublic bool `json:"is_public"` CreatedAt string `json:"created_at"` MsgConfirmation *string `json:"msg_confirmation"` @@ -95,7 +100,7 @@ func scanEventTypeRow(s rowScanner, trailing ...any) (*eventTypeJSON, error) { &isActive, &isPublic, &showTaken, &et.CreatedAt, &msgConf, &msgCancel, &msgResched, &msgRemind, &msgGreeting, &subjConf, &subjCancel, &subjResched, &subjRemind, - &et.PriceCents, &et.Currency, + &et.PriceCents, &et.Currency, &et.InviteDelivery, } dests = append(dests, trailing...) err := s.Scan(dests...) @@ -154,7 +159,7 @@ const etColumns = `id, slug, name, description, is_active, is_public, show_taken_slots, created_at, msg_confirmation, msg_cancellation, msg_reschedule, msg_reminder, msg_greeting, subj_confirmation, subj_cancellation, subj_reschedule, subj_reminder, - price_cents, currency` + price_cents, currency, invite_delivery` // selectETCols fetches a single owner-scoped event type (no `owned` column). const selectETCols = "SELECT " + etColumns + " FROM event_types" @@ -221,6 +226,7 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { MaxActiveBookings *int `json:"max_active_bookings"` AllowPhoneCall *bool `json:"allow_phone_call"` ShowTakenSlots *bool `json:"show_taken_slots"` + InviteDelivery *string `json:"invite_delivery"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid JSON") @@ -295,6 +301,18 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { if req.ShowTakenSlots != nil && *req.ShowTakenSlots { showTaken = 1 } + inviteDelivery := booking.InviteByCalendar + if req.InviteDelivery != nil { + if !validInviteDelivery(*req.InviteDelivery) { + h.writeError(w, http.StatusBadRequest, "invite_delivery must be 'calendar' or 'calnode'") + return + } + if *req.InviteDelivery == booking.InviteByCalnode && !h.inviteSenderReady(r.Context()) { + h.writeError(w, http.StatusBadRequest, errInviteSenderMissing.Error()) + return + } + inviteDelivery = *req.InviteDelivery + } id := uid.New() tx, err := h.db.BeginTx(r.Context(), nil) @@ -310,12 +328,12 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { (id, user_id, slug, name, description, duration_minutes, slot_interval_minutes, location_type, location_value, allow_phone_call, routing_mode, buffer_before_minutes, buffer_after_minutes, - min_notice_minutes, max_future_days, max_active_bookings, show_taken_slots, + min_notice_minutes, max_future_days, max_active_bookings, show_taken_slots, invite_delivery, msg_confirmation, msg_cancellation, msg_reschedule, msg_reminder) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, user.ID, req.Slug, req.Name, req.Description, req.DurationMinutes, slotInterval, locType, req.LocationValue, req.AllowPhoneCall != nil && *req.AllowPhoneCall, - routingMode, bufBefore, bufAfter, minNotice, maxFuture, maxActive, showTaken, + routingMode, bufBefore, bufAfter, minNotice, maxFuture, maxActive, showTaken, inviteDelivery, defaultMsgConfirmation, defaultMsgCancellation, defaultMsgReschedule, defaultMsgReminder) if err != nil { if db.IsUniqueViolation(err) { @@ -448,6 +466,7 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { IsPublic *bool `json:"is_public"` AllowPhoneCall *bool `json:"allow_phone_call"` ShowTakenSlots *bool `json:"show_taken_slots"` + InviteDelivery *string `json:"invite_delivery"` Archived *bool `json:"archived"` MsgConfirmation *string `json:"msg_confirmation"` MsgCancellation *string `json:"msg_cancellation"` @@ -602,6 +621,10 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { } set("show_taken_slots", v) } + if req.InviteDelivery != nil && !validInviteDelivery(*req.InviteDelivery) { + h.writeError(w, http.StatusBadRequest, "invite_delivery must be 'calendar' or 'calnode'") + return + } if req.Archived != nil { if *req.Archived { // strftime literal (no bound value) — matches the DB's timestamp format; @@ -670,10 +693,10 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { // Look up the event type ID + current location (needed for reminder upsert, to // verify ownership, and to validate the effective online-meeting location below). - var etID, curLocType, curLocVal string + var etID, curLocType, curLocVal, curInviteDelivery string if err := h.db.QueryRowContext(r.Context(), - `SELECT id, location_type, COALESCE(location_value, '') FROM event_types WHERE slug = ? AND user_id = ?`, slug, user.ID). - Scan(&etID, &curLocType, &curLocVal); err != nil { + `SELECT id, location_type, COALESCE(location_value, ''), invite_delivery FROM event_types WHERE slug = ? AND user_id = ?`, slug, user.ID). + Scan(&etID, &curLocType, &curLocVal, &curInviteDelivery); err != nil { if err == sql.ErrNoRows { h.writeError(w, http.StatusNotFound, "event type not found") return @@ -714,6 +737,17 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { } } + // Same on-change rule for invite delivery: switching to Calnode-sent invites needs a + // working email sender (the invite IS an email), but an event type already set that + // way stays saveable if email is later removed - that is fixed in Settings → Email. + if req.InviteDelivery != nil && *req.InviteDelivery != curInviteDelivery { + if *req.InviteDelivery == booking.InviteByCalnode && !h.inviteSenderReady(r.Context()) { + h.writeError(w, http.StatusBadRequest, errInviteSenderMissing.Error()) + return + } + set("invite_delivery", *req.InviteDelivery) + } + // The slug is the public booking URL, so renaming one that is already in circulation // breaks every link to it: an invitation in somebody's inbox, a page embedding the // widget, a QR code on a card. It is allowed only while the event type has no diff --git a/internal/handler/event_type_duplicate.go b/internal/handler/event_type_duplicate.go index e24ed8dd..e56a5d97 100644 --- a/internal/handler/event_type_duplicate.go +++ b/internal/handler/event_type_duplicate.go @@ -141,7 +141,7 @@ func (h *Handler) DuplicateEventType(w http.ResponseWriter, r *http.Request) { is_active, is_public, show_taken_slots, archived_at, msg_confirmation, msg_cancellation, msg_reschedule, msg_reminder, msg_greeting, subj_confirmation, subj_cancellation, subj_reschedule, subj_reminder, - price_cents, currency) + price_cents, currency, invite_delivery) SELECT ?, user_id, team_id, ?, name, description, duration_minutes, slot_interval_minutes, @@ -152,7 +152,7 @@ func (h *Handler) DuplicateEventType(w http.ResponseWriter, r *http.Request) { 0, is_public, show_taken_slots, NULL, msg_confirmation, msg_cancellation, msg_reschedule, msg_reminder, msg_greeting, subj_confirmation, subj_cancellation, subj_reschedule, subj_reminder, - price_cents, currency + price_cents, currency, invite_delivery FROM event_types WHERE id = ?`, newID, newSlug, srcID); err != nil { h.logger.ErrorContext(r.Context(), "duplicate event type: copy row", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") diff --git a/internal/handler/event_type_duplicate_test.go b/internal/handler/event_type_duplicate_test.go index ecb7b465..44ae4bbb 100644 --- a/internal/handler/event_type_duplicate_test.go +++ b/internal/handler/event_type_duplicate_test.go @@ -604,6 +604,7 @@ func TestDuplicateEventType_handlesEveryEventTypeColumn(t *testing.T) { "subj_confirmation": true, "subj_cancellation": true, "subj_reschedule": true, "subj_reminder": true, "price_cents": true, "currency": true, + "invite_delivery": true, } // Deliberately not inherited, with the reason. notInherited := map[string]string{ diff --git a/internal/handler/invite_delivery.go b/internal/handler/invite_delivery.go new file mode 100644 index 00000000..479be451 --- /dev/null +++ b/internal/handler/invite_delivery.go @@ -0,0 +1,93 @@ +package handler + +import ( + "context" + "errors" + + "github.com/calnode/calnode/internal/booking" + "github.com/calnode/calnode/internal/mailer" +) + +// Invite delivery decides who sends the booker's calendar invite (migration 00068): +// +// - booking.InviteByCalendar: each host's connected calendar invites the booker, so +// Google/Microsoft email the invite from that host's own account. The default. +// - booking.InviteByCalnode: the hosts' events are still written (they block time and +// mint the Meet/Teams link) but with no guests, so the provider emails nobody. +// Calnode sends the invite itself on the confirmation email, organized by the +// instance's sender identity (Settings → Email). No host's address reaches the booker, +// and a team books under one name. +// +// The mode is copied onto the booking when it is created and read from there afterwards, +// so a booking's reschedule and cancel follow the channel its invite actually went out on. + +var errInviteSenderMissing = errors.New( + "set up email in Settings → Email before letting Calnode send invites: the invite is delivered by email, from that sender") + +func validInviteDelivery(v string) bool { + return v == booking.InviteByCalendar || v == booking.InviteByCalnode +} + +// bookingInviteDelivery returns the invite delivery a booking was created with. A lookup +// failure reads as InviteByCalendar, the behaviour every booking had before the setting +// existed. +func (h *Handler) bookingInviteDelivery(ctx context.Context, bookingID string) string { + var mode string + if err := h.db.QueryRowContext(ctx, + `SELECT invite_delivery FROM bookings WHERE id = ?`, bookingID).Scan(&mode); err != nil { + h.logger.ErrorContext(ctx, "load booking invite delivery", "error", err, "booking_id", bookingID) + return booking.InviteByCalendar + } + return mode +} + +// calendarInvitee is the guest to put on a host's calendar event: the booker when the +// hosts' calendars send the invite, nobody when Calnode does. An empty address is what +// every provider (Google, Microsoft, CalDAV) reads as "no attendee", so the provider has +// no one to email and the event stays private to the host. +func calendarInvitee(mode, bookerEmail string) string { + if mode == booking.InviteByCalnode { + return "" + } + return bookerEmail +} + +// inviteSender returns the instance's sender identity, the From name and address in +// Settings → Email. It organizes invites Calnode sends, which puts replies (RSVPs) where +// the operator already reads mail rather than in any one host's inbox. +func (h *Handler) inviteSender(ctx context.Context) (name, email string) { + if err := h.db.QueryRowContext(ctx, + `SELECT email_from_name, email_from FROM server_settings WHERE id = 1`).Scan(&name, &email); err != nil { + h.logger.ErrorContext(ctx, "load invite sender", "error", err) + return "", "" + } + return name, email +} + +// inviteSenderReady reports whether Calnode can deliver invites itself: a sender address +// and a transport (SMTP host or Resend API key) are configured. Checked when an event type +// switches to InviteByCalnode, since with no email there is no invite at all. +func (h *Handler) inviteSenderReady(ctx context.Context) bool { + var from, host, resendKey string + if err := h.db.QueryRowContext(ctx, + `SELECT email_from, smtp_host, resend_api_key_enc FROM server_settings WHERE id = 1`). + Scan(&from, &host, &resendKey); err != nil { + h.logger.ErrorContext(ctx, "check invite sender", "error", err) + return false + } + return from != "" && (host != "" || resendKey != "") +} + +// applyInviteDelivery prepares an attendee-facing email's .ics for the booking's invite +// mode. Calnode-sent: always attach, organized by the instance sender, never the host. +// Calendar-sent: attach only when the primary host's calendar will not invite the booker +// itself (noConnectedDestination), with the host as organizer, exactly as before. +func (h *Handler) applyInviteDelivery(ctx context.Context, d *mailer.BookingData, mode, primaryHostID string) { + if mode == booking.InviteByCalnode { + d.AttachICS = true + d.HideHostInInvite = true + d.InviteOrganizerName, d.InviteOrganizerEmail = h.inviteSender(ctx) + return + } + d.AttachICS = h.noConnectedDestination(ctx, primaryHostID) +} diff --git a/internal/handler/invite_delivery_internal_test.go b/internal/handler/invite_delivery_internal_test.go new file mode 100644 index 00000000..72b3edd4 --- /dev/null +++ b/internal/handler/invite_delivery_internal_test.go @@ -0,0 +1,320 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/calnode/calnode/internal/booking" + "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/mailer" + "github.com/calnode/calnode/internal/slots" +) + +// inviteProvider is a Google-like destination (it emails guests itself) that records the +// events Calnode asks it to create, so a test can see who would have been invited. +type inviteProvider struct { + mu sync.Mutex + created []calendar.CreateEventParams +} + +func (p *inviteProvider) Name() string { return "google" } +func (p *inviteProvider) InvitesGuests() bool { return true } +func (p *inviteProvider) AuthURL(string) string { return "" } +func (p *inviteProvider) EncryptState(string) (string, error) { return "", nil } +func (p *inviteProvider) DecryptState(string) (string, error) { return "", nil } +func (p *inviteProvider) Exchange(context.Context, string, string, string) error { return nil } +func (p *inviteProvider) Connected(context.Context, string) (bool, error) { return true, nil } +func (p *inviteProvider) Disconnect(context.Context, string) error { return nil } +func (p *inviteProvider) HasDestination(context.Context, string) (bool, error) { return true, nil } +func (p *inviteProvider) ListCalendars(context.Context, string, string) ([]calendar.CalendarInfo, error) { + return nil, nil +} +func (p *inviteProvider) FreeBusy(context.Context, string, time.Time, time.Time) ([]slots.Interval, error) { + return nil, nil +} +func (p *inviteProvider) CreateEvent(_ context.Context, _ string, params calendar.CreateEventParams) (string, string, string, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.created = append(p.created, params) + return fmt.Sprintf("evt-%d", len(p.created)), "", "primary", nil +} +func (p *inviteProvider) UpdateEvent(context.Context, string, string, string, time.Time, time.Time) error { + return nil +} +func (p *inviteProvider) CancelEvent(context.Context, string, string, string) error { return nil } + +func (p *inviteProvider) events() []calendar.CreateEventParams { + p.mu.Lock() + defer p.mu.Unlock() + return append([]calendar.CreateEventParams(nil), p.created...) +} + +type inviteFixture struct { + h *Handler + mail *captureMailer + cal *inviteProvider + apiKey string +} + +const ( + inviteHostEmail = "jeroen@host.example" + inviteBookerEmail = "guest@booker.example" + inviteSenderEmail = "bookings@team.example" +) + +// newInviteFixture boots a workspace whose host has a Google-like destination calendar. +// withSender configures Settings → Email, which Calnode-sent invites require. +func newInviteFixture(t *testing.T, withSender bool) *inviteFixture { + t.Helper() + database, err := db.Open("sqlite://:memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + + h := New(database, slog.Default()) + f := &inviteFixture{h: h, mail: &captureMailer{}, cal: &inviteProvider{}} + h.SetMailer(f.mail, "https://book.team.example") + svc := calendar.NewService(database) + svc.Register(f.cal) + h.SetCalendar(svc) + + rec := httptest.NewRecorder() + h.Setup(rec, httptest.NewRequest(http.MethodPost, "/v1/setup", + strings.NewReader(`{"name":"Jeroen Host","email":"`+inviteHostEmail+`","timezone":"UTC"}`))) + if rec.Code != http.StatusCreated { + t.Fatalf("setup: %d — %s", rec.Code, rec.Body.String()) + } + var setup struct { + APIKey string `json:"api_key"` + UserID string `json:"user_id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &setup); err != nil { + t.Fatalf("decode setup: %v", err) + } + f.apiKey = setup.APIKey + + if _, err := database.Exec(` + INSERT INTO calendar_connections + (id, user_id, provider, access_token_enc, calendar_id, check_conflicts, is_destination, created_at) + VALUES ('conn-1', ?, 'google', 'e', 'primary', 1, 1, '2026-01-01T00:00:00Z')`, setup.UserID); err != nil { + t.Fatalf("seed calendar connection: %v", err) + } + if withSender { + if _, err := database.Exec(`UPDATE server_settings + SET smtp_host = 'smtp.team.example', email_from = ?, email_from_name = 'Palamond Team' + WHERE id = 1`, inviteSenderEmail); err != nil { + t.Fatalf("seed email settings: %v", err) + } + } + for day := 0; day < 7; day++ { + rec := f.do(t, h.CreateAvailabilityRule, http.MethodPost, "/v1/availability-rules", + fmt.Sprintf(`{"day_of_week":%d,"start_time":"00:00","end_time":"23:59"}`, day)) + if rec.Code != http.StatusCreated { + t.Fatalf("seed availability: %d — %s", rec.Code, rec.Body.String()) + } + } + return f +} + +func (f *inviteFixture) do(t *testing.T, fn http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", f.apiKey) + if rest, ok := strings.CutPrefix(path, "/v1/event-types/"); ok { + req.SetPathValue("slug", rest) + } + rec := httptest.NewRecorder() + f.h.RequireAuth(fn)(rec, req) + return rec +} + +func (f *inviteFixture) createEventType(t *testing.T, slug, delivery string) *httptest.ResponseRecorder { + t.Helper() + return f.do(t, f.h.CreateEventType, http.MethodPost, "/v1/event-types", fmt.Sprintf( + `{"slug":%q,"name":"Intro","duration_minutes":30,"location_type":"phone","location_value":"+31 20 000 0000",`+ + `"max_active_bookings":0,"max_future_days":0,"invite_delivery":%q}`, slug, delivery)) +} + +// book creates a booking through the public endpoint and waits for the booker's +// confirmation, which the handler sends from a background goroutine. +func (f *inviteFixture) book(t *testing.T, slug string) (string, *mailer.Message) { + t.Helper() + start := time.Now().UTC().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) + req := httptest.NewRequest(http.MethodPost, "/v1/bookings", strings.NewReader(fmt.Sprintf( + `{"event_type_slug":%q,"start_at":%q,"name":"Guest","email":%q,"timezone":"UTC"}`, + slug, start.Format(time.RFC3339), inviteBookerEmail))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + f.h.CreateBooking(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create booking: %d — %s", rec.Code, rec.Body.String()) + } + var b struct { + ID string `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil { + t.Fatalf("decode booking: %v", err) + } + return b.ID, f.waitFor(t, inviteBookerEmail, 1) +} + +// waitFor returns the n-th message sent to addr, polling while background sends land. +func (f *inviteFixture) waitFor(t *testing.T, addr string, n int) *mailer.Message { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + f.mail.mu.Lock() + var seen []mailer.Message + for _, m := range f.mail.msgs { + if len(m.To) > 0 && m.To[0] == addr { + seen = append(seen, m) + } + } + f.mail.mu.Unlock() + if len(seen) >= n { + return &seen[n-1] + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("message %d to %s never arrived; sent to: %v", n, addr, f.mail.recipients()) + return nil +} + +func icsOf(m *mailer.Message) string { + for _, a := range m.Attachments { + if strings.HasPrefix(a.ContentType, "text/calendar") { + return string(a.Content) + } + } + return "" +} + +// The point of Calnode-sent invites: nothing the booker receives, and nothing the host's +// calendar provider is asked to send, carries the host's own address. +func TestInviteDelivery_calnodeKeepsHostAddressFromBooker(t *testing.T) { + f := newInviteFixture(t, true) + if rec := f.createEventType(t, "intro", booking.InviteByCalnode); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + _, msg := f.book(t, "intro") + + events := f.cal.events() + if len(events) != 1 { + t.Fatalf("host calendar events: got %d; want 1 (the host still needs the meeting on their calendar)", len(events)) + } + if events[0].OrganizerEmail != "" { + t.Errorf("host event invites %q; want no guest, so the provider emails nobody from the host's account", + events[0].OrganizerEmail) + } + + ics := icsOf(msg) + if ics == "" { + t.Fatal("booker confirmation has no .ics: with the provider silenced, it is the only invite") + } + if !strings.Contains(ics, `ORGANIZER;CN="Palamond Team":mailto:`+inviteSenderEmail) { + t.Errorf("invite ORGANIZER is not the workspace sender:\n%s", ics) + } + if !strings.Contains(ics, "ATTENDEE") || !strings.Contains(ics, inviteBookerEmail) { + t.Errorf("invite does not list the booker as attendee:\n%s", ics) + } + for part, body := range map[string]string{"ics": ics, "text": msg.Text, "html": msg.HTML, "subject": msg.Subject} { + if strings.Contains(body, inviteHostEmail) { + t.Errorf("booker's %s contains the host's address %s", part, inviteHostEmail) + } + } + + // The host's own notification still names the host as organizer: it is their meeting. + hostMsg := f.waitFor(t, inviteHostEmail, 1) + if hostICS := icsOf(hostMsg); strings.Contains(hostICS, inviteSenderEmail) { + t.Errorf("host notification's invite is organized by the workspace sender:\n%s", hostICS) + } +} + +// The default stays exactly as before: the host's calendar invites the booker, and +// Calnode adds no second invite on top. +func TestInviteDelivery_calendarInvitesThroughHostCalendar(t *testing.T) { + f := newInviteFixture(t, true) + if rec := f.createEventType(t, "intro", booking.InviteByCalendar); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + _, msg := f.book(t, "intro") + + events := f.cal.events() + if len(events) != 1 || events[0].OrganizerEmail != inviteBookerEmail { + t.Fatalf("host calendar event guests = %+v; want the booker invited by the host's calendar", events) + } + if ics := icsOf(msg); ics != "" { + t.Errorf("booker got Calnode's .ics as well as the calendar's own invite (a duplicate):\n%s", ics) + } +} + +// A booking keeps the channel its invite went out on. Switching the event type back to +// calendar-sent must still cancel a Calnode-invited booking with Calnode's .ics, since the +// host's calendar never invited that booker and has no cancellation to send. +func TestInviteDelivery_cancelFollowsTheBookingNotTheEventType(t *testing.T) { + f := newInviteFixture(t, true) + if rec := f.createEventType(t, "intro", booking.InviteByCalnode); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + id, _ := f.book(t, "intro") + + if rec := f.do(t, f.h.PatchEventType, http.MethodPatch, "/v1/event-types/intro", + `{"invite_delivery":"calendar"}`); rec.Code != http.StatusOK { + t.Fatalf("switch back to calendar: %d — %s", rec.Code, rec.Body.String()) + } + + ctx := context.Background() + if err := f.h.bookingSvc.CancelByID(ctx, id, "changed plans"); err != nil { + t.Fatalf("cancel: %v", err) + } + b, err := f.h.bookingSvc.Get(ctx, id) + if err != nil { + t.Fatalf("get booking: %v", err) + } + f.h.cancelSideEffects(*b) + + ics := icsOf(f.waitFor(t, inviteBookerEmail, 2)) + if !strings.Contains(ics, "METHOD:CANCEL") { + t.Fatalf("cancellation carries no .ics CANCEL; the booker's calendar keeps a dead meeting:\n%s", ics) + } + if strings.Contains(ics, inviteHostEmail) || !strings.Contains(ics, inviteSenderEmail) { + t.Errorf("cancellation is not organized by the workspace sender:\n%s", ics) + } +} + +// Calnode-sent invites ARE email, so turning them on without a sender would silently +// leave bookers with no invite at all. +func TestInviteDelivery_requiresEmailSender(t *testing.T) { + f := newInviteFixture(t, false) + rec := f.createEventType(t, "intro", booking.InviteByCalnode) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Settings → Email") { + t.Errorf("create with calnode invites and no email: %d — %s; want 400 pointing at Settings → Email", + rec.Code, rec.Body.String()) + } + + if rec := f.createEventType(t, "intro", booking.InviteByCalendar); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + rec = f.do(t, f.h.PatchEventType, http.MethodPatch, "/v1/event-types/intro", `{"invite_delivery":"calnode"}`) + if rec.Code != http.StatusBadRequest { + t.Errorf("switch to calnode invites with no email: %d — %s; want 400", rec.Code, rec.Body.String()) + } + rec = f.do(t, f.h.PatchEventType, http.MethodPatch, "/v1/event-types/intro", `{"invite_delivery":"carrier-pigeon"}`) + if rec.Code != http.StatusBadRequest { + t.Errorf("unknown invite_delivery: %d; want 400", rec.Code) + } +} diff --git a/internal/handler/manage_handler.go b/internal/handler/manage_handler.go index 191ef1ad..a884de22 100644 --- a/internal/handler/manage_handler.go +++ b/internal/handler/manage_handler.go @@ -314,7 +314,7 @@ func (h *Handler) rescheduleSideEffects(bCopy booking.Booking, capturedEtID stri if tok, err := h.bookingSvc.RotateManageToken(ctx, bCopy.ID); err == nil { d.ManageURL = h.publicURL() + "/manage/" + tok } - d.AttachICS = h.noConnectedDestination(ctx, bCopy.HostID) + h.applyInviteDelivery(ctx, &d, h.bookingInviteDelivery(ctx, bCopy.ID), bCopy.HostID) d.ICSSequence = int(bCopy.UpdatedAt.Unix()) prefs := h.hostPrefsOrDefault(ctx, bCopy.ID, bCopy.HostID) @@ -333,7 +333,12 @@ func (h *Handler) rescheduleSideEffects(bCopy booking.Booking, capturedEtID stri } } if prefs.NotifyHostReschedule { - if err := mailer.SendRescheduleToHost(ctx, h.mailer, d); err != nil { + // The host's copy keeps the host as organizer and attaches only when the host has + // no calendar of their own holding the event, whoever invited the booker. + hd := d + hd.HideHostInInvite = false + hd.AttachICS = h.noConnectedDestination(ctx, bCopy.HostID) + if err := mailer.SendRescheduleToHost(ctx, h.mailer, hd); err != nil { h.logger.Error("reschedule email (host)", "error", err, "booking_id", bCopy.ID) } } diff --git a/internal/handler/reassign.go b/internal/handler/reassign.go index 27ad9bb8..8def7608 100644 --- a/internal/handler/reassign.go +++ b/internal/handler/reassign.go @@ -158,6 +158,7 @@ func (h *Handler) ReassignBooking(w http.ResponseWriter, r *http.Request) { // Move the Google Calendar event: remove from the old host, recreate on // the new host, and persist the new event ID (clearing it if recreation // produced nothing, e.g. the new host has no destination calendar). + inviteMode := h.bookingInviteDelivery(ctx, bCopy.ID) if gc := h.getCal(); gc != nil { if extEventID != "" { // Reassignment cancels on the OLD host's calendar. Their stamped provider @@ -174,7 +175,7 @@ func (h *Handler) ReassignBooking(w http.ResponseWriter, r *http.Request) { Start: bCopy.StartAt, End: bCopy.EndAt, OrganizerName: orgName, - OrganizerEmail: orgEmail, + OrganizerEmail: calendarInvitee(inviteMode, orgEmail), }) if err != nil { h.logger.Error("reassign: create new calendar event", "error", err, "booking_id", bCopy.ID) @@ -205,7 +206,13 @@ func (h *Handler) ReassignBooking(w http.ResponseWriter, r *http.Request) { if err := h.loadHostIntoData(ctx, newHostID, &d); err != nil { h.logger.Error("reassign: load new host", "error", err, "booking_id", bCopy.ID) } - + // A Calnode-sent invite is re-issued (same UID, newer SEQUENCE) so the booker's + // calendar entry follows the change. A calendar-sent one needs nothing here: the + // new host's calendar invited the booker when the event was recreated above. + if inviteMode == booking.InviteByCalnode { + h.applyInviteDelivery(ctx, &d, inviteMode, newHostID) + d.ICSSequence = int(time.Now().Unix()) + } prefs := h.hostPrefsOrDefault(ctx, bCopy.ID, newHostID) if prefs.NotifyConfirmation { if err := mailer.SendConfirmationToAttendee(ctx, h.mailer, d); err != nil { @@ -213,7 +220,10 @@ func (h *Handler) ReassignBooking(w http.ResponseWriter, r *http.Request) { } } if prefs.NotifyHostBooking { - if err := mailer.SendConfirmationToHost(ctx, h.mailer, d); err != nil { + hd := d + hd.HideHostInInvite = false + hd.AttachICS = false + if err := mailer.SendConfirmationToHost(ctx, h.mailer, hd); err != nil { h.logger.Error("reassign: email new host", "error", err, "booking_id", bCopy.ID) } } diff --git a/internal/mailer/booking.go b/internal/mailer/booking.go index c41c63ea..6f64df48 100644 --- a/internal/mailer/booking.go +++ b/internal/mailer/booking.go @@ -33,11 +33,20 @@ type BookingData struct { CustomNote string // optional host-configured note appended to the email body SubjectOverride string // optional per-event-type custom subject; falls back to the default when empty // AttachICS attaches an iCalendar invite to the attendee's email — set by the - // handler only when the host has no Google destination calendar (so Google - // isn't already inviting the attendee, which would duplicate). ICSSequence must - // be non-decreasing across a booking's confirm→reschedule→cancel lifecycle. + // handler when the host has no Google destination calendar (so Google isn't already + // inviting the attendee, which would duplicate), or when the booking's invites are + // sent by Calnode (see HideHostInInvite). ICSSequence must be non-decreasing across + // a booking's confirm→reschedule→cancel lifecycle. AttachICS bool ICSSequence int + // HideHostInInvite makes the instance's sender identity (InviteOrganizerName / + // InviteOrganizerEmail) the .ics ORGANIZER instead of the host, so the booker's + // calendar never carries a host's personal address. Set on attendee sends of bookings + // whose invites Calnode delivers; host copies clear it. With no organizer address the + // ORGANIZER line is omitted rather than falling back to the host. + HideHostInInvite bool + InviteOrganizerName string + InviteOrganizerEmail string // Branding — instance-wide, threaded in by the handler. BrandName is the // wordmark/footer name (falls back to "Calnode" when empty); LogoURL is an // optional absolute https image shown in the HTML email header. diff --git a/internal/mailer/ics.go b/internal/mailer/ics.go index df478ab8..6530ecc1 100644 --- a/internal/mailer/ics.go +++ b/internal/mailer/ics.go @@ -12,8 +12,10 @@ import ( // reschedule update, and the eventual CANCEL to the same event; d.ICSSequence must // not decrease across that lifecycle (the handlers feed it updated_at's unix time). // -// This is only used when the host has no Google destination calendar — otherwise -// Google already invites the attendee and a second invite would duplicate it. +// Attached when the host has no Google destination calendar (otherwise Google already +// invites the attendee and a second invite would duplicate it), and always when the +// booking's invites are sent by Calnode — then d.HideHostInInvite swaps the host out of +// ORGANIZER for the instance's sender identity. func BuildICS(d BookingData, method string) []byte { status := "CONFIRMED" if method == "CANCEL" { @@ -42,8 +44,12 @@ func BuildICS(d BookingData, method string) []byte { if d.LocationValue != "" { writeICSLine(&b, "LOCATION:"+escapeICSText(d.LocationValue)) } - if d.HostEmail != "" { - writeICSLine(&b, "ORGANIZER;CN="+escapeICSParam(d.HostName)+":mailto:"+d.HostEmail) + orgName, orgEmail := d.HostName, d.HostEmail + if d.HideHostInInvite { + orgName, orgEmail = d.InviteOrganizerName, d.InviteOrganizerEmail + } + if orgEmail != "" { + writeICSLine(&b, "ORGANIZER;CN="+escapeICSParam(orgName)+":mailto:"+orgEmail) } if d.OrganizerEmail != "" { writeICSLine(&b, "ATTENDEE;CN="+escapeICSParam(d.OrganizerName)+";RSVP=TRUE:mailto:"+d.OrganizerEmail) diff --git a/internal/mailer/ics_test.go b/internal/mailer/ics_test.go index c0faa023..0d90d002 100644 --- a/internal/mailer/ics_test.go +++ b/internal/mailer/ics_test.go @@ -40,6 +40,24 @@ func TestBuildICS_cancel(t *testing.T) { } } +// A Calnode-sent invite exists to keep the host's address off the booker's calendar, so a +// missing sender identity must drop ORGANIZER, never quietly fall back to the host. +func TestBuildICS_hideHostNeverFallsBackToHost(t *testing.T) { + d := testBookingData() + d.HideHostInInvite = true + d.InviteOrganizerName, d.InviteOrganizerEmail = "Team", "bookings@team.example" + got := string(BuildICS(d, "REQUEST")) + if !strings.Contains(got, `ORGANIZER;CN="Team":mailto:bookings@team.example`) { + t.Errorf("ORGANIZER is not the instance sender:\n%s", got) + } + + d.InviteOrganizerName, d.InviteOrganizerEmail = "", "" + got = string(BuildICS(d, "REQUEST")) + if strings.Contains(got, "ORGANIZER") || strings.Contains(got, d.HostEmail) { + t.Errorf("with no sender identity the invite fell back to the host:\n%s", got) + } +} + func TestBuildICS_escapesText(t *testing.T) { d := testBookingData() d.EventTypeName = "Strategy, Planning; Q3" From 4cc5c06295ade2d22caea0977980aa847851ba91 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Wed, 30 Sep 2026 00:05:32 +0200 Subject: [PATCH 2/4] fix(invites): address review on Calnode-sent invites - 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. --- docs/ARCHITECTURE.md | 4 +- frontend/src/lib/api.ts | 3 + .../routes/event-types/[slug]/+page.svelte | 6 + internal/booking/booking.go | 3 + internal/booking/service.go | 25 ++-- internal/handler/booking_handler.go | 23 ++-- internal/handler/event_type.go | 30 +++-- internal/handler/invite_delivery.go | 67 ++++++----- .../handler/invite_delivery_internal_test.go | 113 +++++++++++++++++- internal/handler/manage_handler.go | 7 +- internal/handler/reassign.go | 6 +- internal/mailer/booking.go | 5 + internal/mailer/ics.go | 15 ++- internal/mailer/ics_test.go | 20 ++++ 14 files changed, 246 insertions(+), 81 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fb11c298..8f7133b3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -554,7 +554,9 @@ every provider reads as "no attendee", so nobody is emailed from a host's accoun always attaches Calnode's `.ics` to the booker's emails, with `ORGANIZER` set to the instance sender from Settings → Email (`applyInviteDelivery`, `BookingData.HideHostInInvite`). No sender address means no `ORGANIZER` line, never a fallback to the host. Host copies keep -the host as organizer. Reschedule, cancel, reassign and the reconciler read the mode from +the host as organizer and, for a `calnode` booking, leave the booker out entirely and go out as +`METHOD:PUBLISH` (`applyHostInvite`, `ICSWithoutAttendee`), so no host calendar holds them as +a guest it could re-invite. Reschedule, cancel, reassign and the reconciler read the mode from the **booking**, not the event type, so a booking is always updated through the channel its invite went out on. Switching an event type to `calnode` requires email to be set up (validated on change). diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ad9d128e..03e94520 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -41,6 +41,9 @@ export type EventType = { * from the host's own address. 'calnode': Calnode's own invite, from the email sender * in Settings → Email, so no host's personal address reaches the booker. */ invite_delivery: 'calendar' | 'calnode'; + /** Whether Calnode can currently send invites (a sender and working email). Only + * returned on the single event type GET. */ + invite_sender_ready?: boolean; location_type: string; location_value?: string; buffer_before_minutes: number; diff --git a/frontend/src/routes/event-types/[slug]/+page.svelte b/frontend/src/routes/event-types/[slug]/+page.svelte index 595501dc..8243ff7c 100644 --- a/frontend/src/routes/event-types/[slug]/+page.svelte +++ b/frontend/src/routes/event-types/[slug]/+page.svelte @@ -743,6 +743,12 @@ {/if}

+ {#if form.invite_delivery === 'calnode' && et?.invite_sender_ready === false} +

+ Email isn't set up, so bookers won't receive an invite. Add a sender in + Settings → Email. +

+ {/if} diff --git a/internal/booking/booking.go b/internal/booking/booking.go index bcdc46b2..380fe499 100644 --- a/internal/booking/booking.go +++ b/internal/booking/booking.go @@ -44,6 +44,9 @@ type Booking struct { // ConfirmFailed reports whether the initial confirmation email failed (after // retry). Operator-visible via the booking JSON; see migration 00064. ConfirmFailed bool + // InviteDelivery is who sent this booking's invite (InviteByCalendar/InviteByCalnode), + // fixed at creation; reschedule, cancel and reassign follow it. + InviteDelivery string } // Attendee is a participant in a booking (the person who made the booking). diff --git a/internal/booking/service.go b/internal/booking/service.go index 60945577..29e7aa43 100644 --- a/internal/booking/service.go +++ b/internal/booking/service.go @@ -217,16 +217,17 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error) nowT, _ := time.Parse(time.RFC3339Nano, now) return &Booking{ - ID: bookingID, - EventTypeID: p.EventTypeID, - HostID: chosenHost, - StartAt: p.StartAt.UTC(), - EndAt: p.EndAt.UTC(), - Status: "confirmed", - LocationValue: p.LocationValue, - LocationType: p.LocationType, - CreatedAt: nowT, - UpdatedAt: nowT, + ID: bookingID, + EventTypeID: p.EventTypeID, + HostID: chosenHost, + StartAt: p.StartAt.UTC(), + EndAt: p.EndAt.UTC(), + Status: "confirmed", + LocationValue: p.LocationValue, + LocationType: p.LocationType, + InviteDelivery: inviteDelivery, + CreatedAt: nowT, + UpdatedAt: nowT, }, nil } @@ -306,7 +307,7 @@ const bookingColumns = `id, event_type_id, host_id, start_at, end_at, status, COALESCE(cancellation_reason, ''), COALESCE(location_value, ''), created_at, updated_at, payment_status, amount_paid_cents, amount_paid_currency, location_type, - confirm_failed` + confirm_failed, invite_delivery` // hostBusy reports whether hostID has any non-cancelled booking overlapping // [start, end) — the double-booking invariant every write path (Create, Reschedule, @@ -675,7 +676,7 @@ func scanBooking(s scanner) (*Booking, error) { &b.CancellationReason, &b.LocationValue, &createdStr, &updatedStr, &b.PaymentStatus, &b.AmountPaidCents, &b.AmountPaidCurrency, &b.LocationType, - &confirmFailed, + &confirmFailed, &b.InviteDelivery, ) if err == sql.ErrNoRows { return nil, ErrNotFound diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index 3253edf3..1c5ec351 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -1019,9 +1019,6 @@ type bookingConfirmationInput struct { // OrganizerLocale is the attendee's resolved locale code (e.g. "es") — same value // stored on booking_attendees.locale, threaded into the confirmation email. OrganizerLocale string - // InviteDelivery is the booking's stored invite mode; dispatchBookingConfirmation - // fills it from the booking row, so callers need not. - InviteDelivery string } // hostPrefsOrDefault loads a host's notification prefs, defaulting to allOnPrefs and @@ -1040,15 +1037,12 @@ func (h *Handler) hostPrefsOrDefault(ctx context.Context, bookingID, userID stri // hostBookingData returns a per-host copy of base with the fields every host // notification email needs filled in: the host's own name/email (so "with " -// reads correctly), whether to attach an ICS (only when that host has no connected -// destination calendar of their own), and the ICS sequence number. -func (h *Handler) hostBookingData(ctx context.Context, base mailer.BookingData, host assignedHost, updatedAt time.Time) mailer.BookingData { +// reads correctly), the host's own invite (applyHostInvite), and the ICS sequence number. +func (h *Handler) hostBookingData(ctx context.Context, base mailer.BookingData, host assignedHost, inviteMode string, updatedAt time.Time) mailer.BookingData { hd := base hd.HostName, hd.HostEmail = host.Name, host.Email - hd.AttachICS = h.noConnectedDestination(ctx, host.UserID) + h.applyHostInvite(ctx, &hd, inviteMode, host.UserID) hd.ICSSequence = int(updatedAt.Unix()) - // The host's own copy is organized by the host, whichever way the booker was invited. - hd.HideHostInInvite = false return hd } @@ -1204,7 +1198,7 @@ func (h *Handler) createHostEventsAndNotify(ctx context.Context, b *booking.Book Start: b.StartAt, End: b.EndAt, OrganizerName: in.OrganizerName, - OrganizerEmail: calendarInvitee(in.InviteDelivery, in.OrganizerEmail), + OrganizerEmail: calendarInvitee(b.InviteDelivery, in.OrganizerEmail), AddMeet: autoGenMeet && host.IsPrimary, }) if err != nil { @@ -1246,7 +1240,7 @@ func (h *Handler) createHostEventsAndNotify(ctx context.Context, b *booking.Book primaryPrefs = prefs } if prefs.NotifyHostBooking { - hd := h.hostBookingData(ctx, *bData, host, b.UpdatedAt) + hd := h.hostBookingData(ctx, *bData, host, b.InviteDelivery, b.UpdatedAt) if livekitHostURL != "" { hd.LocationValue = livekitHostURL // host email gets the controls-enabled link } @@ -1292,7 +1286,6 @@ func sendWithRetry(ctx context.Context, logger *slog.Logger, bookingID, who stri func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConfirmationInput) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - in.InviteDelivery = h.bookingInviteDelivery(ctx, b.ID) bData := mailer.BookingData{ BookingID: b.ID, EventTypeName: in.EventTypeName, @@ -1338,7 +1331,7 @@ func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConf // Attendee confirmation, once. "With:" names the primary host; gated on the // primary host's notification preference (matches prior behaviour). bData.HostName, bData.HostEmail = primaryHost(hosts).Name, primaryHost(hosts).Email - h.applyInviteDelivery(ctx, &bData, in.InviteDelivery, b.HostID) + h.applyInviteDelivery(ctx, &bData, b.InviteDelivery, b.HostID) bData.ICSSequence = int(b.UpdatedAt.Unix()) confirmFailed := hostFailed if primaryPrefs.NotifyConfirmation { @@ -1777,13 +1770,13 @@ func (h *Handler) cancelSideEffects(b booking.Booking) { d.HostName, d.HostEmail = host.Name, host.Email // attendee "With:" = primary host, not owner } if prefs.NotifyHostCancel { - hd := h.hostBookingData(ctx, d, host, b.UpdatedAt) + hd := h.hostBookingData(ctx, d, host, b.InviteDelivery, b.UpdatedAt) if err := mailer.SendCancellationToHost(ctx, h.mailer, hd); err != nil { h.logger.Error("booking cancellation email (host)", "error", err, "booking_id", b.ID, "host", host.UserID) } } } - h.applyInviteDelivery(ctx, &d, h.bookingInviteDelivery(ctx, b.ID), b.HostID) + h.applyInviteDelivery(ctx, &d, b.InviteDelivery, b.HostID) d.ICSSequence = int(b.UpdatedAt.Unix()) if primaryPrefs.NotifyCancellation { if err := mailer.SendCancellationToAttendee(ctx, h.mailer, d); err != nil { diff --git a/internal/handler/event_type.go b/internal/handler/event_type.go index 0f7e63cd..c0bf93ba 100644 --- a/internal/handler/event_type.go +++ b/internal/handler/event_type.go @@ -72,6 +72,11 @@ type eventTypeJSON struct { // contact for changes. Populated only for the host (read-only) GET case. OwnerName string `json:"owner_name,omitempty"` OwnerEmail string `json:"owner_email,omitempty"` + // InviteSenderReady reports, on the single-event-type GET, whether Calnode can + // currently deliver the invites it is set to send. False means email was removed + // after the event type switched to Calnode-sent invites: bookers get no invite until + // Settings → Email is fixed, so the editor warns. + InviteSenderReady bool `json:"invite_sender_ready"` } type rowScanner interface { @@ -303,15 +308,16 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { } inviteDelivery := booking.InviteByCalendar if req.InviteDelivery != nil { - if !validInviteDelivery(*req.InviteDelivery) { + mode, ok := normalizeInviteDelivery(*req.InviteDelivery) + if !ok { h.writeError(w, http.StatusBadRequest, "invite_delivery must be 'calendar' or 'calnode'") return } - if *req.InviteDelivery == booking.InviteByCalnode && !h.inviteSenderReady(r.Context()) { + if mode == booking.InviteByCalnode && !h.inviteSenderReady(r.Context()) { h.writeError(w, http.StatusBadRequest, errInviteSenderMissing.Error()) return } - inviteDelivery = *req.InviteDelivery + inviteDelivery = mode } id := uid.New() @@ -433,6 +439,7 @@ func (h *Handler) GetEventType(w http.ResponseWriter, r *http.Request) { et.OwnerName = ownerName et.OwnerEmail = ownerEmail } + et.InviteSenderReady = h.inviteSenderReady(r.Context()) if err := h.loadReminders(r.Context(), et.ID, et); err != nil { h.logger.ErrorContext(r.Context(), "get event type: load reminders", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") @@ -621,9 +628,14 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { } set("show_taken_slots", v) } - if req.InviteDelivery != nil && !validInviteDelivery(*req.InviteDelivery) { - h.writeError(w, http.StatusBadRequest, "invite_delivery must be 'calendar' or 'calnode'") - return + var newInviteDelivery string + if req.InviteDelivery != nil { + mode, ok := normalizeInviteDelivery(*req.InviteDelivery) + if !ok { + h.writeError(w, http.StatusBadRequest, "invite_delivery must be 'calendar' or 'calnode'") + return + } + newInviteDelivery = mode } if req.Archived != nil { if *req.Archived { @@ -740,12 +752,12 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { // Same on-change rule for invite delivery: switching to Calnode-sent invites needs a // working email sender (the invite IS an email), but an event type already set that // way stays saveable if email is later removed - that is fixed in Settings → Email. - if req.InviteDelivery != nil && *req.InviteDelivery != curInviteDelivery { - if *req.InviteDelivery == booking.InviteByCalnode && !h.inviteSenderReady(r.Context()) { + if newInviteDelivery != "" && newInviteDelivery != curInviteDelivery { + if newInviteDelivery == booking.InviteByCalnode && !h.inviteSenderReady(r.Context()) { h.writeError(w, http.StatusBadRequest, errInviteSenderMissing.Error()) return } - set("invite_delivery", *req.InviteDelivery) + set("invite_delivery", newInviteDelivery) } // The slug is the public booking URL, so renaming one that is already in circulation diff --git a/internal/handler/invite_delivery.go b/internal/handler/invite_delivery.go index 479be451..8b1cb77f 100644 --- a/internal/handler/invite_delivery.go +++ b/internal/handler/invite_delivery.go @@ -18,33 +18,30 @@ import ( // instance's sender identity (Settings → Email). No host's address reaches the booker, // and a team books under one name. // -// The mode is copied onto the booking when it is created and read from there afterwards, -// so a booking's reschedule and cancel follow the channel its invite actually went out on. +// The mode is copied onto the booking when it is created (booking.Booking.InviteDelivery) +// and read from there afterwards, so a booking's reschedule and cancel follow the channel +// its invite actually went out on. var errInviteSenderMissing = errors.New( "set up email in Settings → Email before letting Calnode send invites: the invite is delivered by email, from that sender") -func validInviteDelivery(v string) bool { - return v == booking.InviteByCalendar || v == booking.InviteByCalnode -} - -// bookingInviteDelivery returns the invite delivery a booking was created with. A lookup -// failure reads as InviteByCalendar, the behaviour every booking had before the setting -// existed. -func (h *Handler) bookingInviteDelivery(ctx context.Context, bookingID string) string { - var mode string - if err := h.db.QueryRowContext(ctx, - `SELECT invite_delivery FROM bookings WHERE id = ?`, bookingID).Scan(&mode); err != nil { - h.logger.ErrorContext(ctx, "load booking invite delivery", "error", err, "booking_id", bookingID) - return booking.InviteByCalendar +// normalizeInviteDelivery maps the API value to a stored mode. Empty means the default, +// matching booking.CreateParams; anything else unknown is rejected. +func normalizeInviteDelivery(v string) (string, bool) { + switch v { + case "", booking.InviteByCalendar: + return booking.InviteByCalendar, true + case booking.InviteByCalnode: + return booking.InviteByCalnode, true } - return mode + return "", false } -// calendarInvitee is the guest to put on a host's calendar event: the booker when the -// hosts' calendars send the invite, nobody when Calnode does. An empty address is what -// every provider (Google, Microsoft, CalDAV) reads as "no attendee", so the provider has -// no one to email and the event stays private to the host. +// calendarInvitee is the address passed as CreateEventParams.OrganizerEmail for a host's +// calendar event: the booker when the hosts' calendars send the invite, "" when Calnode +// does. Google and Microsoft add that address as the event's attendee, so "" leaves them +// no one to email and the event private to the host. CalDAV never sends invites; there it +// only decides whether the stored event names the booker at all. func calendarInvitee(mode, bookerEmail string) string { if mode == booking.InviteByCalnode { return "" @@ -64,22 +61,17 @@ func (h *Handler) inviteSender(ctx context.Context) (name, email string) { return name, email } -// inviteSenderReady reports whether Calnode can deliver invites itself: a sender address -// and a transport (SMTP host or Resend API key) are configured. Checked when an event type -// switches to InviteByCalnode, since with no email there is no invite at all. +// inviteSenderReady reports whether Calnode can deliver invites itself: a sender address, +// and a mailer that actually sends (the transport choice stays BuildMailer's alone). +// Checked when an event type switches to InviteByCalnode, since with no email there is +// no invite at all. func (h *Handler) inviteSenderReady(ctx context.Context) bool { - var from, host, resendKey string - if err := h.db.QueryRowContext(ctx, - `SELECT email_from, smtp_host, resend_api_key_enc FROM server_settings WHERE id = 1`). - Scan(&from, &host, &resendKey); err != nil { - h.logger.ErrorContext(ctx, "check invite sender", "error", err) - return false - } - return from != "" && (host != "" || resendKey != "") + _, from := h.inviteSender(ctx) + return from != "" && h.isEmailEnabled() } -// applyInviteDelivery prepares an attendee-facing email's .ics for the booking's invite -// mode. Calnode-sent: always attach, organized by the instance sender, never the host. +// applyInviteDelivery prepares the booker's email .ics for the booking's invite mode. +// Calnode-sent: always attach, organized by the instance sender, never the host. // Calendar-sent: attach only when the primary host's calendar will not invite the booker // itself (noConnectedDestination), with the host as organizer, exactly as before. func (h *Handler) applyInviteDelivery(ctx context.Context, d *mailer.BookingData, mode, primaryHostID string) { @@ -91,3 +83,12 @@ func (h *Handler) applyInviteDelivery(ctx context.Context, d *mailer.BookingData } d.AttachICS = h.noConnectedDestination(ctx, primaryHostID) } + +// applyHostInvite turns booking email data into one host's own copy: organized by that +// host, attached only when their calendar does not already hold the event, and - for a +// Calnode-invited booking - without the booker as a guest (see ICSWithoutAttendee). +func (h *Handler) applyHostInvite(ctx context.Context, d *mailer.BookingData, mode, hostID string) { + d.HideHostInInvite = false + d.ICSWithoutAttendee = mode == booking.InviteByCalnode + d.AttachICS = h.noConnectedDestination(ctx, hostID) +} diff --git a/internal/handler/invite_delivery_internal_test.go b/internal/handler/invite_delivery_internal_test.go index 72b3edd4..f5ecc4e8 100644 --- a/internal/handler/invite_delivery_internal_test.go +++ b/internal/handler/invite_delivery_internal_test.go @@ -197,7 +197,8 @@ func (f *inviteFixture) waitFor(t *testing.T, addr string, n int) *mailer.Messag func icsOf(m *mailer.Message) string { for _, a := range m.Attachments { if strings.HasPrefix(a.ContentType, "text/calendar") { - return string(a.Content) + // Unfold RFC 5545 continuation lines so assertions see whole properties. + return strings.ReplaceAll(string(a.Content), "\r\n ", "") } } return "" @@ -318,3 +319,113 @@ func TestInviteDelivery_requiresEmailSender(t *testing.T) { t.Errorf("unknown invite_delivery: %d; want 400", rec.Code) } } + +// assertWorkspaceInvite fails unless ics is a Calnode-organized invite of the given method +// that carries none of the listed host addresses. +func assertWorkspaceInvite(t *testing.T, what, ics, method string, hostAddrs ...string) { + t.Helper() + if !strings.Contains(ics, "METHOD:"+method) { + t.Fatalf("%s: no %s invite; with the host's calendar silenced it is the booker's only update:\n%s", what, method, ics) + } + if !strings.Contains(ics, "mailto:"+inviteSenderEmail) { + t.Errorf("%s: invite not organized by the workspace sender:\n%s", what, ics) + } + for _, a := range hostAddrs { + if strings.Contains(ics, a) { + t.Errorf("%s: invite carries host address %s:\n%s", what, a, ics) + } + } +} + +func (f *inviteFixture) calnodeBooking(t *testing.T) (*booking.Booking, string) { + t.Helper() + if rec := f.createEventType(t, "intro", booking.InviteByCalnode); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + id, _ := f.book(t, "intro") + b, err := f.h.bookingSvc.Get(context.Background(), id) + if err != nil { + t.Fatalf("get booking: %v", err) + } + if b.InviteDelivery != booking.InviteByCalnode { + t.Fatalf("booking invite_delivery = %q; want calnode copied from the event type", b.InviteDelivery) + } + return b, b.EventTypeID +} + +// A reschedule must reach the booker as Calnode's updated invite, since the host's +// calendar event (moved silently) never listed them. +func TestInviteDelivery_rescheduleSendsWorkspaceInvite(t *testing.T) { + f := newInviteFixture(t, true) + b, etID := f.calnodeBooking(t) + + newStart := b.StartAt.Add(24 * time.Hour) + updated, err := f.h.bookingSvc.Reschedule(context.Background(), b.ID, newStart, newStart.Add(30*time.Minute)) + if err != nil { + t.Fatalf("reschedule: %v", err) + } + f.h.rescheduleSideEffects(*updated, etID, b.StartAt, b.EndAt) + + ics := icsOf(f.waitFor(t, inviteBookerEmail, 2)) + assertWorkspaceInvite(t, "reschedule", ics, "REQUEST", inviteHostEmail) + if !strings.Contains(ics, "DTSTART:"+newStart.UTC().Format("20060102T150405Z")) { + t.Errorf("reschedule invite does not carry the new time:\n%s", ics) + } +} + +// Reassigning moves the event to the new host's calendar without inviting the booker +// there, and re-issues Calnode's invite: neither host's address reaches the booker. +func TestInviteDelivery_reassignKeepsBothHostsPrivate(t *testing.T) { + f := newInviteFixture(t, true) + b, _ := f.calnodeBooking(t) + + const newHostEmail = "colleague@host.example" + if _, err := f.h.db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) + VALUES ('host-2', ?, 'Colleague', 'UTC', 0, '2026-01-01T00:00:00Z')`, newHostEmail); err != nil { + t.Fatalf("seed second host: %v", err) + } + if _, err := f.h.db.Exec(`INSERT INTO calendar_connections + (id, user_id, provider, access_token_enc, calendar_id, check_conflicts, is_destination, created_at) + VALUES ('conn-2', 'host-2', 'google', 'e', 'primary', 1, 1, '2026-01-01T00:00:00Z')`); err != nil { + t.Fatalf("seed second host calendar: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/bookings/"+b.ID+"/reassign", strings.NewReader(`{"host_id":"host-2"}`)) + req.SetPathValue("id", b.ID) + req.Header.Set("X-API-Key", f.apiKey) + rec := httptest.NewRecorder() + f.h.RequireAuth(f.h.ReassignBooking)(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("reassign: %d — %s", rec.Code, rec.Body.String()) + } + + assertWorkspaceInvite(t, "reassign", icsOf(f.waitFor(t, inviteBookerEmail, 2)), "REQUEST", + inviteHostEmail, newHostEmail) + events := f.cal.events() + if len(events) != 2 || events[1].OrganizerEmail != "" { + t.Errorf("new host's calendar events = %+v; want the moved event created without the booker", events) + } +} + +// The reconciler re-creates a host event whose inline create failed; for a Calnode +// booking it must not add the booker, or the provider would invite them after all. +func TestInviteDelivery_reconcilerHealsWithoutInvitingBooker(t *testing.T) { + f := newInviteFixture(t, true) + b, _ := f.calnodeBooking(t) + + if _, err := f.h.db.Exec(`UPDATE booking_hosts SET external_event_id = NULL WHERE booking_id = ?`, b.ID); err != nil { + t.Fatal(err) + } + if _, err := f.h.db.Exec(`UPDATE bookings SET created_at = ? WHERE id = ?`, + time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), b.ID); err != nil { + t.Fatal(err) + } + f.h.reconcileCreations(context.Background(), f.h.getCal()) + + events := f.cal.events() + if len(events) != 2 { + t.Fatalf("host calendar events = %d; want the missing one re-created", len(events)) + } + if events[1].OrganizerEmail != "" { + t.Errorf("healed event invites %q; want no guest", events[1].OrganizerEmail) + } +} diff --git a/internal/handler/manage_handler.go b/internal/handler/manage_handler.go index a884de22..c6adaabe 100644 --- a/internal/handler/manage_handler.go +++ b/internal/handler/manage_handler.go @@ -314,7 +314,7 @@ func (h *Handler) rescheduleSideEffects(bCopy booking.Booking, capturedEtID stri if tok, err := h.bookingSvc.RotateManageToken(ctx, bCopy.ID); err == nil { d.ManageURL = h.publicURL() + "/manage/" + tok } - h.applyInviteDelivery(ctx, &d, h.bookingInviteDelivery(ctx, bCopy.ID), bCopy.HostID) + h.applyInviteDelivery(ctx, &d, bCopy.InviteDelivery, bCopy.HostID) d.ICSSequence = int(bCopy.UpdatedAt.Unix()) prefs := h.hostPrefsOrDefault(ctx, bCopy.ID, bCopy.HostID) @@ -333,11 +333,8 @@ func (h *Handler) rescheduleSideEffects(bCopy booking.Booking, capturedEtID stri } } if prefs.NotifyHostReschedule { - // The host's copy keeps the host as organizer and attaches only when the host has - // no calendar of their own holding the event, whoever invited the booker. hd := d - hd.HideHostInInvite = false - hd.AttachICS = h.noConnectedDestination(ctx, bCopy.HostID) + h.applyHostInvite(ctx, &hd, bCopy.InviteDelivery, bCopy.HostID) if err := mailer.SendRescheduleToHost(ctx, h.mailer, hd); err != nil { h.logger.Error("reschedule email (host)", "error", err, "booking_id", bCopy.ID) } diff --git a/internal/handler/reassign.go b/internal/handler/reassign.go index 8def7608..2a7b893b 100644 --- a/internal/handler/reassign.go +++ b/internal/handler/reassign.go @@ -158,7 +158,7 @@ func (h *Handler) ReassignBooking(w http.ResponseWriter, r *http.Request) { // Move the Google Calendar event: remove from the old host, recreate on // the new host, and persist the new event ID (clearing it if recreation // produced nothing, e.g. the new host has no destination calendar). - inviteMode := h.bookingInviteDelivery(ctx, bCopy.ID) + inviteMode := bCopy.InviteDelivery if gc := h.getCal(); gc != nil { if extEventID != "" { // Reassignment cancels on the OLD host's calendar. Their stamped provider @@ -221,8 +221,8 @@ func (h *Handler) ReassignBooking(w http.ResponseWriter, r *http.Request) { } if prefs.NotifyHostBooking { hd := d - hd.HideHostInInvite = false - hd.AttachICS = false + h.applyHostInvite(ctx, &hd, inviteMode, newHostID) + hd.ICSSequence = int(time.Now().Unix()) if err := mailer.SendConfirmationToHost(ctx, h.mailer, hd); err != nil { h.logger.Error("reassign: email new host", "error", err, "booking_id", bCopy.ID) } diff --git a/internal/mailer/booking.go b/internal/mailer/booking.go index 6f64df48..7791b310 100644 --- a/internal/mailer/booking.go +++ b/internal/mailer/booking.go @@ -47,6 +47,11 @@ type BookingData struct { HideHostInInvite bool InviteOrganizerName string InviteOrganizerEmail string + // ICSWithoutAttendee leaves the booker out of the .ics and sends it as PUBLISH: a + // host's own copy of a Calnode-invited booking. The host's calendar client must never + // hold the booker as a guest, or editing the entry there could re-invite them from the + // host's personal account - the very thing Calnode-sent invites avoid. + ICSWithoutAttendee bool // Branding — instance-wide, threaded in by the handler. BrandName is the // wordmark/footer name (falls back to "Calnode" when empty); LogoURL is an // optional absolute https image shown in the HTML email header. diff --git a/internal/mailer/ics.go b/internal/mailer/ics.go index 6530ecc1..c788405f 100644 --- a/internal/mailer/ics.go +++ b/internal/mailer/ics.go @@ -21,6 +21,7 @@ func BuildICS(d BookingData, method string) []byte { if method == "CANCEL" { status = "CANCELLED" } + method = d.icsMethod(method) stamp := time.Now().UTC().Format(icsTimeLayout) var b strings.Builder @@ -51,7 +52,7 @@ func BuildICS(d BookingData, method string) []byte { if orgEmail != "" { writeICSLine(&b, "ORGANIZER;CN="+escapeICSParam(orgName)+":mailto:"+orgEmail) } - if d.OrganizerEmail != "" { + if d.OrganizerEmail != "" && !d.ICSWithoutAttendee { writeICSLine(&b, "ATTENDEE;CN="+escapeICSParam(d.OrganizerName)+";RSVP=TRUE:mailto:"+d.OrganizerEmail) } writeICSLine(&b, "STATUS:"+status) @@ -67,11 +68,21 @@ const icsTimeLayout = "20060102T150405Z" func icsAttachment(d BookingData, method string) Attachment { return Attachment{ Filename: "invite.ics", - ContentType: "text/calendar; charset=utf-8; method=" + method, + ContentType: "text/calendar; charset=utf-8; method=" + d.icsMethod(method), Content: BuildICS(d, method), } } +// icsMethod is the iTIP method actually sent. A copy without the attendee is a plain +// calendar entry, which is PUBLISH (RFC 5546 §3.2.1): REQUEST and CANCEL are addressed to +// attendees, and a client handed a REQUEST with none may try to invite them itself. +func (d BookingData) icsMethod(method string) string { + if d.ICSWithoutAttendee { + return "PUBLISH" + } + return method +} + // escapeICSText escapes a value for an iCalendar TEXT field (RFC 5545 §3.3.11). func escapeICSText(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) diff --git a/internal/mailer/ics_test.go b/internal/mailer/ics_test.go index 0d90d002..bc863325 100644 --- a/internal/mailer/ics_test.go +++ b/internal/mailer/ics_test.go @@ -58,6 +58,26 @@ func TestBuildICS_hideHostNeverFallsBackToHost(t *testing.T) { } } +// A host's own copy of a Calnode-invited booking must never list the booker: a calendar +// client holding them as a guest could re-invite them from the host's own account. +func TestICSAttachment_withoutAttendeeIsPublish(t *testing.T) { + d := testBookingData() + d.ICSWithoutAttendee = true + for _, method := range []string{"REQUEST", "CANCEL"} { + a := icsAttachment(d, method) + got := string(a.Content) + if strings.Contains(got, "ATTENDEE") || strings.Contains(got, d.OrganizerEmail) { + t.Errorf("%s: host copy lists the booker:\n%s", method, got) + } + if !strings.Contains(got, "METHOD:PUBLISH") || !strings.HasSuffix(a.ContentType, "method=PUBLISH") { + t.Errorf("%s: attendee-less copy not sent as PUBLISH (content type %q):\n%s", method, a.ContentType, got) + } + } + if got := string(BuildICS(d, "CANCEL")); !strings.Contains(got, "STATUS:CANCELLED") { + t.Errorf("a cancelled host copy lost STATUS:CANCELLED:\n%s", got) + } +} + func TestBuildICS_escapesText(t *testing.T) { d := testBookingData() d.EventTypeName = "Strategy, Planning; Q3" From dd014f69962b9cf70906c13bf94f16a0786118fe Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Tue, 29 Sep 2026 23:23:02 +0200 Subject: [PATCH 3/4] feat(invites): record bookers' RSVPs through Resend inbound email For event types whose invite Calnode sends: each invite is organized by a private per-booking reply address (rsvp+@). 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. --- CHANGELOG.md | 4 + DEPLOY.md | 16 ++ docs/ARCHITECTURE.md | 16 ++ frontend/src/lib/api.ts | 8 + frontend/src/routes/bookings/+page.svelte | 7 + .../src/routes/settings/email/+page.svelte | 64 +++++ frontend/src/routes/webhooks/+page.svelte | 2 + internal/db/migrations/00069_invite_rsvp.sql | 40 +++ internal/handler/booking_handler.go | 21 +- internal/handler/email_settings.go | 56 +++- internal/handler/invite_delivery.go | 48 +++- internal/handler/rsvp_inbound.go | 255 ++++++++++++++++++ .../handler/rsvp_inbound_internal_test.go | 192 +++++++++++++ internal/handler/webhook_handler.go | 2 +- internal/mailer/itip.go | 210 +++++++++++++++ internal/mailer/resend_inbound.go | 138 ++++++++++ internal/mailer/resend_inbound_test.go | 129 +++++++++ internal/server/server.go | 3 + internal/webhook/webhook.go | 9 +- 19 files changed, 1206 insertions(+), 14 deletions(-) create mode 100644 internal/db/migrations/00069_invite_rsvp.sql create mode 100644 internal/handler/rsvp_inbound.go create mode 100644 internal/handler/rsvp_inbound_internal_test.go create mode 100644 internal/mailer/itip.go create mode 100644 internal/mailer/resend_inbound.go create mode 100644 internal/mailer/resend_inbound_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7c2a02..39d45e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad so no host's personal address reaches them, and a team can book under one name. Hosts still get the meeting, with its Meet/Teams link, on their own calendar. Needs email set up. Existing bookings keep the way they were invited. +- **RSVP tracking for invites Calnode sends.** With Resend receiving set up (Settings → + Email → RSVP tracking), a booker's Yes / No / Maybe is read from their calendar reply, + shown on the booking, and sent as a new `booking.rsvp` webhook event. Each invite gets a + private reply address, and an answer only counts when it comes from that booking's booker. ## [0.10.1] - 2026-09-29 diff --git a/DEPLOY.md b/DEPLOY.md index 9956f710..6d59f380 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -127,6 +127,22 @@ API key takes precedence over the SMTP fields; clearing it ("Remove key") switch > Email settings are stored **per instance** in that instance's DB — staging/prod/local each need their own. +### RSVP tracking (optional, Resend only) + +For event types whose calendar invite is **sent by Calnode** (event type → Calendar invite), +Calnode can record whether the booker accepted, declined or said maybe. The answer shows on +the booking and fires a `booking.rsvp` webhook. + +1. In Resend, set up **receiving** on a subdomain, e.g. `reply.yourdomain` (one MX record). + Use a subdomain: the MX record would take over your main domain's mail. +2. In Resend → Webhooks, add `https:///v1/email/inbound/resend` for the + `email.received` event and copy its signing secret (`whsec_…`). +3. Settings → Email → RSVP tracking: enter an address on that subdomain (e.g. + `rsvp@reply.yourdomain`) and the signing secret. It also needs the Resend API key above. + +Each invite is organized by its own private variant of that address, so no mailbox has to +exist. Bookings made before you switch it on keep the organizer they were invited with. + --- ## 5. Google OAuth (sign-in + calendar) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f7133b3..31f306c7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -561,6 +561,22 @@ the **booking**, not the event type, so a booking is always updated through the its invite went out on. Switching an event type to `calnode` requires email to be set up (validated on change). +**RSVPs to Calnode-sent invites (migration 00069, `rsvp_inbound.go`).** A booker's +Yes/No/Maybe is an iTIP `REPLY` their mail client emails to the invite's `ORGANIZER`. Each +booking's organizer address is fixed at its first send (`bookings.invite_organizer`, +`bookingInviteOrganizer`), because clients match later updates by UID *and* organizer. With +RSVP tracking set up (Settings → Email: an address on a Resend receiving domain, the +webhook signing secret, and a Resend API key) that address is private per booking, +`rsvp+<128-bit token>@domain`. Resend posts `email.received` to +`POST /v1/email/inbound/resend`; the handler verifies the Svix signature +(`mailer.VerifyResendWebhook`), routes by recipient to the booking, downloads the raw +message (`mailer.FetchReceivedRaw`), parses the `REPLY` (`mailer.ParseICSReply`), and +records the answer on `booking_attendees.rsvp_status` only if the UID is that booking's +and the answering attendee is its booker. A changed answer fires `booking.rsvp`. It +answers 200 to anything it will not act on and 5xx only when the fetch failed, so Resend +retries exactly what a retry can fix. Hosts' calendar events are not updated with the +answer: no provider has an "edit event" write op yet. + **Adding another provider (Apple/iCloud, CalDAV):** implement `calendar.Provider`, register it in `internal/server`, set `InvitesGuests()` correctly (drives the `.ics` gate above), and extend `providerMintsPlatform`/`CanAutoGenerate` if it offers a diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 03e94520..962fc57a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -110,6 +110,8 @@ export type Booking = { payment_status?: 'pending' | 'paid' | 'refunded'; amount_paid_cents?: number; amount_paid_currency?: string; + /** The booker's RSVP to a Calnode-sent invite; absent until an answer arrives. */ + rsvp_status?: 'accepted' | 'declined' | 'tentative'; }; export type APIKey = { @@ -188,6 +190,12 @@ export type EmailSettings = { // being delivered over SMTP" can differ, so the server reports the live answer. transport: 'none' | 'smtp' | 'resend_api'; enabled: boolean; + /** RSVP tracking for invites Calnode sends: the Resend inbound address RSVPs are routed + * through (e.g. rsvp@reply.example.com). */ + rsvp_address: string; + resend_webhook_secret_set: boolean; // never returned directly + /** True when address, webhook secret and Resend API key are all present. */ + rsvp_tracking: boolean; }; export type GoogleSettings = { diff --git a/frontend/src/routes/bookings/+page.svelte b/frontend/src/routes/bookings/+page.svelte index 099b3785..35debba5 100644 --- a/frontend/src/routes/bookings/+page.svelte +++ b/frontend/src/routes/bookings/+page.svelte @@ -443,6 +443,13 @@ {:else if b.payment_status === 'pending'} unpaid {/if} + {#if b.status === 'confirmed' && b.rsvp_status === 'accepted'} + guest accepted + {:else if b.status === 'confirmed' && b.rsvp_status === 'tentative'} + guest maybe + {:else if b.status === 'confirmed' && b.rsvp_status === 'declined'} + guest declined + {/if} diff --git a/frontend/src/routes/settings/email/+page.svelte b/frontend/src/routes/settings/email/+page.svelte index f5fc2ea4..0a66b4cb 100644 --- a/frontend/src/routes/settings/email/+page.svelte +++ b/frontend/src/routes/settings/email/+page.svelte @@ -27,6 +27,10 @@ // Distinct from "the field is blank": blank means keep the stored key, this means // deliberately remove it and go back to SMTP. let clearResendKey = $state(false); + // RSVP tracking (Resend inbound) for invites Calnode sends itself. + let rsvpAddress = $state(''); + let webhookSecret = $state(''); + let webhookUrl = $state(''); let userEmail = $state(''); @@ -53,6 +57,8 @@ smtpStartTLS = email.smtp_starttls; emailFrom = email.email_from; emailFromName = email.email_from_name || 'Calnode'; + rsvpAddress = email.rsvp_address ?? ''; + webhookUrl = `${window.location.origin}/v1/email/inbound/resend`; }, 'Could not load email settings')); async function save() { @@ -66,9 +72,13 @@ // Omit the key entirely to keep the stored one; send "" only to clear it. if (resendApiKey) body.resend_api_key = resendApiKey; else if (clearResendKey) body.resend_api_key = ''; + body.rsvp_address = rsvpAddress.trim(); + // Like the API key: omit to keep the stored secret. + if (webhookSecret) body.resend_webhook_secret = webhookSecret.trim(); emailSettings = await api.patch('/v1/settings/email', body); smtpPass = ''; resendApiKey = ''; + webhookSecret = ''; clearResendKey = false; toast.success('Email settings saved'); }, 'Could not save email settings'); @@ -224,6 +234,60 @@ + +
+
+
+

RSVP tracking

+

+ For event types whose invites Calnode sends. Bookers' Yes / No / Maybe comes back + through Resend and shows on the booking. +

+
+ {#if emailSettings !== null} + + + {emailSettings.rsvp_tracking ? 'On' : 'Off'} + + {/if} +
+ + {#if !emailSettings?.resend_api_key_set} +

+ Needs a Resend API key above: Calnode uses it to read the replies. +

+ {/if} + +
+
+ + +

+ An address on a domain Resend receives email for. Each invite gets its own private + variant of it, so answers find their booking. +

+
+
+ + +

+ Add this in Resend → Webhooks for the email.received event. +

+
+
+ + +
+
+ +
+ +
+
{/if} diff --git a/frontend/src/routes/webhooks/+page.svelte b/frontend/src/routes/webhooks/+page.svelte index ed4eb83b..1dc67e78 100644 --- a/frontend/src/routes/webhooks/+page.svelte +++ b/frontend/src/routes/webhooks/+page.svelte @@ -18,6 +18,7 @@ 'booking.created', 'booking.cancelled', 'booking.rescheduled', + 'booking.rsvp', 'recording.completed', 'transcript.ready', 'notes.ready' @@ -37,6 +38,7 @@ { key: 'cancellation_reason', label: 'Cancellation reason' }, { key: 'previous_start_at', label: 'Previous start (reschedule)' }, { key: 'previous_end_at', label: 'Previous end (reschedule)' }, + { key: 'rsvp_status', label: 'RSVP answer (booking.rsvp)' }, ] }, { group: 'Payment', fields: [ { key: 'payment_status', label: 'Payment status' }, diff --git a/internal/db/migrations/00069_invite_rsvp.sql b/internal/db/migrations/00069_invite_rsvp.sql new file mode 100644 index 00000000..b4f599df --- /dev/null +++ b/internal/db/migrations/00069_invite_rsvp.sql @@ -0,0 +1,40 @@ +-- +goose Up +-- RSVPs for invites Calnode sends itself (invite_delivery = 'calnode', migration 00068). +-- +-- A booker's Yes/No/Maybe is an iTIP REPLY that their mail client emails to the invite's +-- ORGANIZER. With Resend's inbound email configured, that organizer is a per-booking reply +-- address (rsvp+@); Resend receives the reply, calls +-- POST /v1/email/inbound/resend, and Calnode records the answer on the booking. + +-- The inbound address RSVPs are routed through (e.g. rsvp@reply.example.com). Empty = RSVP +-- tracking off: Calnode-sent invites are organized by the plain email sender, as before. +ALTER TABLE server_settings ADD COLUMN rsvp_address TEXT NOT NULL DEFAULT ''; +-- Signing secret of the Resend webhook ("whsec_..."), encrypted like the other email secrets. +ALTER TABLE server_settings ADD COLUMN resend_webhook_secret_enc TEXT NOT NULL DEFAULT ''; + +-- The ORGANIZER address a booking's invite went out with, fixed at the first send. Calendar +-- clients match a later reschedule or cancellation by UID and organizer, so it must never +-- change mid-lifecycle - not when the sender address is edited, and not when RSVP tracking +-- is switched on after the booking was made. A per-booking reply address is also how an +-- incoming RSVP finds its booking. Empty for bookings whose invite Calnode never sent. +ALTER TABLE bookings ADD COLUMN invite_organizer TEXT NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS idx_bookings_invite_organizer ON bookings (invite_organizer) + WHERE invite_organizer != ''; +-- Calnode-invited bookings made before this migration already went out organized by the +-- sender address, so that is their fixed organizer. Without this their next update would +-- mint a reply address and arrive from an organizer the booker's calendar has never seen. +UPDATE bookings SET invite_organizer = (SELECT email_from FROM server_settings WHERE id = 1) +WHERE invite_delivery = 'calnode'; + +-- The booker's latest answer, iCalendar PARTSTAT values in lower case. +ALTER TABLE booking_attendees ADD COLUMN rsvp_status TEXT NOT NULL DEFAULT 'needs-action' + CHECK (rsvp_status IN ('needs-action', 'accepted', 'declined', 'tentative')); +ALTER TABLE booking_attendees ADD COLUMN rsvp_at TEXT; + +-- +goose Down +DROP INDEX IF EXISTS idx_bookings_invite_organizer; +ALTER TABLE booking_attendees DROP COLUMN rsvp_at; +ALTER TABLE booking_attendees DROP COLUMN rsvp_status; +ALTER TABLE bookings DROP COLUMN invite_organizer; +ALTER TABLE server_settings DROP COLUMN resend_webhook_secret_enc; +ALTER TABLE server_settings DROP COLUMN rsvp_address; diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index 1c5ec351..02a7ef99 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -616,9 +616,12 @@ type bookingJSON struct { AmountPaidCurrency string `json:"amount_paid_currency,omitempty" jsonschema:"ISO 4217 currency of the charge (lowercase)"` // ConfirmFailed flags a booking whose initial confirmation email failed after // retry — operator-visible so a lost confirmation can be followed up manually. - ConfirmFailed bool `json:"confirm_failed,omitempty"` - Attendees []attendeeJSON `json:"attendees,omitempty"` - Hosts []hostBrief `json:"hosts,omitempty"` // assigned host(s) for display; set on the public create response + ConfirmFailed bool `json:"confirm_failed,omitempty"` + // RSVPStatus is the booker's answer to a Calnode-sent invite ("accepted", "declined", + // "tentative"), absent until one arrives. Admin list views only. + RSVPStatus string `json:"rsvp_status,omitempty"` + Attendees []attendeeJSON `json:"attendees,omitempty"` + Hosts []hostBrief `json:"hosts,omitempty"` // assigned host(s) for display; set on the public create response } // hostBrief is an assigned host's identity for the booking-confirmation screen. @@ -1579,6 +1582,15 @@ func (h *Handler) enrichBookings(ctx context.Context, bookings []booking.Booking } } + // The booker's RSVP to a Calnode-sent invite, once one has arrived. + if err := h.scanPairs(ctx, bookingRSVPQuery, ids, func(bid, val string) { + if i, ok := idxByID[bid]; ok { + items[i].RSVPStatus = val + } + }); err != nil { + return nil, err + } + // Organizer attendee. Three columns, so it doesn't fit scanPairs; ph is a generated // run of "?" and every id is bound. rows, err := h.db.QueryContext(ctx, // #nosec G701 -- ph is placeholders(), a generated run of "?"; every value is bound via ids..., never concatenated into the SQL text @@ -1618,6 +1630,9 @@ const ( bookingHostNamesQuery pairQuery = `SELECT b.id, COALESCE(u.name, '') FROM bookings b LEFT JOIN users u ON u.id = b.host_id WHERE b.id IN (%s)` + + bookingRSVPQuery pairQuery = `SELECT a.booking_id, a.rsvp_status FROM booking_attendees a + WHERE a.is_organizer = 1 AND a.rsvp_status != 'needs-action' AND a.booking_id IN (%s)` ) // placeholders returns "?,?,?" for n, for an IN clause. n must be positive. diff --git a/internal/handler/email_settings.go b/internal/handler/email_settings.go index 80a13bc7..08f407db 100644 --- a/internal/handler/email_settings.go +++ b/internal/handler/email_settings.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "strconv" + "strings" "time" "github.com/calnode/calnode/internal/mailer" @@ -139,14 +140,15 @@ func (h *Handler) GetEmailSettings(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireAdmin(w, r); !ok { return } - var host, port, user, passEnc, from, fromName, resendEnc string + var host, port, user, passEnc, from, fromName, resendEnc, rsvpAddr, webhookSecretEnc string var smtpTLS, startTLS int err := h.db.QueryRowContext(r.Context(), ` SELECT smtp_host, smtp_port, smtp_user, smtp_pass_enc, smtp_tls, smtp_starttls, email_from, email_from_name, - resend_api_key_enc + resend_api_key_enc, rsvp_address, resend_webhook_secret_enc FROM server_settings WHERE id = 1`). - Scan(&host, &port, &user, &passEnc, &smtpTLS, &startTLS, &from, &fromName, &resendEnc) + Scan(&host, &port, &user, &passEnc, &smtpTLS, &startTLS, &from, &fromName, &resendEnc, + &rsvpAddr, &webhookSecretEnc) if err != nil && err != sql.ErrNoRows { h.logger.ErrorContext(r.Context(), "email settings: query", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") @@ -167,6 +169,11 @@ func (h *Handler) GetEmailSettings(w http.ResponseWriter, r *http.Request) { "resend_api_key_set": resendEnc != "", "transport": string(transport), "enabled": h.isEmailEnabled(), + // RSVP tracking for Calnode-sent invites (Resend inbound). Active only with all + // three of the address, the webhook secret and a Resend API key. + "rsvp_address": rsvpAddr, + "resend_webhook_secret_set": webhookSecretEnc != "", + "rsvp_tracking": rsvpAddr != "" && webhookSecretEnc != "" && resendEnc != "", }) } @@ -179,11 +186,15 @@ type emailSecret int const ( secretSMTPPass emailSecret = iota secretResendAPIKey + secretResendWebhook ) func (s emailSecret) String() string { - if s == secretResendAPIKey { + switch s { + case secretResendAPIKey: return "resend_api_key_enc" + case secretResendWebhook: + return "resend_webhook_secret_enc" } return "smtp_pass_enc" } @@ -211,6 +222,8 @@ func (h *Handler) storeEmailSecret(ctx context.Context, db execer, which emailSe switch which { case secretResendAPIKey: q = `UPDATE server_settings SET resend_api_key_enc = ?, updated_at = datetime('now') WHERE id = 1` + case secretResendWebhook: + q = `UPDATE server_settings SET resend_webhook_secret_enc = ?, updated_at = datetime('now') WHERE id = 1` case secretSMTPPass: q = `UPDATE server_settings SET smtp_pass_enc = ?, updated_at = datetime('now') WHERE id = 1` default: @@ -248,6 +261,9 @@ func (h *Handler) PatchEmailSettings(w http.ResponseWriter, r *http.Request) { // Optional; omit to keep the existing key, send "" explicitly to clear it and // fall back to SMTP. ResendAPIKey *string `json:"resend_api_key"` + // RSVP tracking (Resend inbound). Pointers for the same keep/clear distinction. + RSVPAddress *string `json:"rsvp_address"` + ResendWebhookSecret *string `json:"resend_webhook_secret"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid JSON") @@ -265,6 +281,22 @@ func (h *Handler) PatchEmailSettings(w http.ResponseWriter, r *http.Request) { if req.EmailFromName == "" { req.EmailFromName = "Calnode" } + if req.RSVPAddress != nil { + *req.RSVPAddress = strings.TrimSpace(*req.RSVPAddress) + if *req.RSVPAddress != "" && !validRSVPAddress(*req.RSVPAddress) { + h.writeError(w, http.StatusBadRequest, + "rsvp_address must be a plain address like rsvp@reply.example.com, without a +tag") + return + } + } + if req.ResendWebhookSecret != nil { + *req.ResendWebhookSecret = strings.TrimSpace(*req.ResendWebhookSecret) + if *req.ResendWebhookSecret != "" && !strings.HasPrefix(*req.ResendWebhookSecret, "whsec_") { + h.writeError(w, http.StatusBadRequest, + "resend_webhook_secret must be the webhook's signing secret, starting with whsec_") + return + } + } boolToInt := func(b bool) int { if b { @@ -316,6 +348,22 @@ func (h *Handler) PatchEmailSettings(w http.ResponseWriter, r *http.Request) { return } } + if req.RSVPAddress != nil { + if _, err := tx.ExecContext(r.Context(), + `UPDATE server_settings SET rsvp_address = ?, updated_at = datetime('now') WHERE id = 1`, + *req.RSVPAddress); err != nil { + h.logger.ErrorContext(r.Context(), "email settings: rsvp address", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + } + if req.ResendWebhookSecret != nil { + if err := h.storeEmailSecret(r.Context(), tx, secretResendWebhook, *req.ResendWebhookSecret); err != nil { + h.logger.ErrorContext(r.Context(), "email settings: resend webhook secret", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + } if err := tx.Commit(); err != nil { h.logger.ErrorContext(r.Context(), "email settings: commit", "error", err) diff --git a/internal/handler/invite_delivery.go b/internal/handler/invite_delivery.go index 8b1cb77f..81164fd0 100644 --- a/internal/handler/invite_delivery.go +++ b/internal/handler/invite_delivery.go @@ -71,14 +71,14 @@ func (h *Handler) inviteSenderReady(ctx context.Context) bool { } // applyInviteDelivery prepares the booker's email .ics for the booking's invite mode. -// Calnode-sent: always attach, organized by the instance sender, never the host. -// Calendar-sent: attach only when the primary host's calendar will not invite the booker -// itself (noConnectedDestination), with the host as organizer, exactly as before. +// Calnode-sent: always attach, organized by the booking's fixed invite organizer, never +// the host. Calendar-sent: attach only when the primary host's calendar will not invite +// the booker itself (noConnectedDestination), with the host as organizer, exactly as before. func (h *Handler) applyInviteDelivery(ctx context.Context, d *mailer.BookingData, mode, primaryHostID string) { if mode == booking.InviteByCalnode { d.AttachICS = true d.HideHostInInvite = true - d.InviteOrganizerName, d.InviteOrganizerEmail = h.inviteSender(ctx) + d.InviteOrganizerName, d.InviteOrganizerEmail = h.bookingInviteOrganizer(ctx, d.BookingID) return } d.AttachICS = h.noConnectedDestination(ctx, primaryHostID) @@ -92,3 +92,43 @@ func (h *Handler) applyHostInvite(ctx context.Context, d *mailer.BookingData, mo d.ICSWithoutAttendee = mode == booking.InviteByCalnode d.AttachICS = h.noConnectedDestination(ctx, hostID) } + +// bookingInviteOrganizer returns the name and address a Calnode-sent invite is organized +// by. The address is fixed on the booking at its first send (bookings.invite_organizer): +// calendar clients match a reschedule or cancellation by UID and organizer, so editing the +// sender or switching RSVP tracking on later must not change it for invites already out. +// New bookings get a private reply address when RSVP tracking is on, else the sender. +func (h *Handler) bookingInviteOrganizer(ctx context.Context, bookingID string) (name, email string) { + name, sender := h.inviteSender(ctx) + var stored string + if err := h.db.QueryRowContext(ctx, + `SELECT invite_organizer FROM bookings WHERE id = ?`, bookingID).Scan(&stored); err != nil { + h.logger.ErrorContext(ctx, "load invite organizer", "error", err, "booking_id", bookingID) + return name, sender + } + if stored != "" { + return name, stored + } + addr := sender + if base := h.rsvpAddress(ctx); base != "" { + if reply, err := newReplyAddress(base); err == nil { + addr = reply + } else { + h.logger.ErrorContext(ctx, "mint rsvp reply address", "error", err, "booking_id", bookingID) + } + } + if addr == "" { + return name, "" + } + // Only the first send writes; a concurrent first send reads the winner back. + if _, err := h.db.ExecContext(ctx, + `UPDATE bookings SET invite_organizer = ? WHERE id = ? AND invite_organizer = ''`, addr, bookingID); err != nil { + h.logger.ErrorContext(ctx, "fix invite organizer", "error", err, "booking_id", bookingID) + return name, addr + } + if err := h.db.QueryRowContext(ctx, + `SELECT invite_organizer FROM bookings WHERE id = ?`, bookingID).Scan(&stored); err != nil || stored == "" { + return name, addr + } + return name, stored +} diff --git a/internal/handler/rsvp_inbound.go b/internal/handler/rsvp_inbound.go new file mode 100644 index 00000000..fb61a9f1 --- /dev/null +++ b/internal/handler/rsvp_inbound.go @@ -0,0 +1,255 @@ +package handler + +import ( + "context" + "crypto/rand" + "encoding/base32" + "encoding/json" + "errors" + "io" + "net/http" + "net/mail" + "strings" + "time" + + "github.com/calnode/calnode/internal/mailer" + "github.com/calnode/calnode/internal/secret" + "github.com/calnode/calnode/internal/webhook" +) + +// RSVP tracking for Calnode-sent invites (migration 00069). +// +// A booker's Yes/No/Maybe is an iTIP REPLY their mail client emails to the invite's +// ORGANIZER. When RSVP tracking is set up, that organizer is a private per-booking address +// on a domain Resend receives for (rsvp+@reply.example.com). Resend calls +// POST /v1/email/inbound/resend with the message's metadata; Calnode checks the signature, +// fetches the message, reads the REPLY and records the answer on the booking. +// +// Three checks stand between a stranger and a forged RSVP: the Resend signature, the +// unguessable per-booking address, and the REPLY's attendee having to be that booking's +// booker. A forwarded invite answered by someone else is therefore ignored. + +// fetchReceivedRaw downloads a received message from Resend; a var so tests can stand in +// for Resend. +var fetchReceivedRaw = mailer.FetchReceivedRaw + +// rsvpStatuses are the PARTSTAT answers recorded; anything else in a REPLY is ignored. +var rsvpStatuses = map[string]bool{"accepted": true, "declined": true, "tentative": true} + +// rsvpAddress returns the configured RSVP inbox (e.g. rsvp@reply.example.com) when RSVP +// tracking can actually work end to end: an address, the Resend webhook secret to trust +// deliveries, and a Resend API key to fetch them. "" otherwise, and invites fall back to +// the plain sender, which is what they would get anyway. +func (h *Handler) rsvpAddress(ctx context.Context) string { + var addr, secretEnc, apiKeyEnc string + if err := h.db.QueryRowContext(ctx, + `SELECT rsvp_address, resend_webhook_secret_enc, resend_api_key_enc FROM server_settings WHERE id = 1`). + Scan(&addr, &secretEnc, &apiKeyEnc); err != nil { + h.logger.ErrorContext(ctx, "load rsvp settings", "error", err) + return "" + } + if addr == "" || secretEnc == "" || apiKeyEnc == "" { + return "" + } + return addr +} + +// replyToken is 128 bits of randomness, lower-case base32 so it survives case-folding +// mail systems and stays well inside a 64-octet local part. +var replyToken = base32.StdEncoding.WithPadding(base32.NoPadding) + +// newReplyAddress derives a private per-booking address from the RSVP inbox: +// rsvp@reply.example.com → rsvp+@reply.example.com. Resend receives every local +// part at its domain, so no mailbox has to exist for it. +func newReplyAddress(base string) (string, error) { + local, domain, ok := strings.Cut(base, "@") + if !ok || local == "" || domain == "" { + return "", errors.New("rsvp address is not local@domain") + } + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return local + "+" + strings.ToLower(replyToken.EncodeToString(b)) + "@" + domain, nil +} + +// isReplyAddressFor reports whether addr is a per-booking address minted from base. Only +// those can route to a booking: a plain email to the sender or the inbox itself never does. +func isReplyAddressFor(addr, base string) bool { + local, domain, ok := strings.Cut(strings.ToLower(addr), "@") + baseLocal, baseDomain, bok := strings.Cut(strings.ToLower(base), "@") + if !ok || !bok || domain != baseDomain { + return false + } + token, found := strings.CutPrefix(local, baseLocal+"+") + return found && token != "" +} + +// validRSVPAddress accepts a bare local@domain with no plus tag of its own (the per-booking +// tag is appended to it) and no display name. +func validRSVPAddress(v string) bool { + a, err := mail.ParseAddress(v) + if err != nil || a.Name != "" || a.Address != v { + return false + } + local, _, _ := strings.Cut(v, "@") + return !strings.Contains(local, "+") +} + +// InboundEmailResend handles POST /v1/email/inbound/resend, Resend's email.received webhook. +// Public, authenticated by the webhook signature. Answers 200 for anything that is not an +// RSVP to act on, so Resend does not retry it, and 5xx only when fetching the message +// failed and a retry could succeed. +func (h *Handler) InboundEmailResend(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) + if err != nil { + h.writeError(w, http.StatusRequestEntityTooLarge, "payload too large") + return + } + + var addr, secretEnc, apiKeyEnc string + if err := h.db.QueryRowContext(ctx, + `SELECT rsvp_address, resend_webhook_secret_enc, resend_api_key_enc FROM server_settings WHERE id = 1`). + Scan(&addr, &secretEnc, &apiKeyEnc); err != nil || secretEnc == "" { + h.writeError(w, http.StatusNotFound, "inbound email is not configured") + return + } + whSecret, err := secret.Decrypt(h.encKey, secretEnc) + if err != nil { + h.logger.ErrorContext(ctx, "inbound email: decrypt webhook secret", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + if err := mailer.VerifyResendWebhook(whSecret, r.Header, body, time.Now()); err != nil { + h.logger.WarnContext(ctx, "inbound email: rejected webhook", "error", err) + h.writeError(w, http.StatusUnauthorized, "invalid signature") + return + } + + var ev mailer.ResendInboundEvent + if err := json.Unmarshal(body, &ev); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + ignore := func(reason string) { + h.writeJSON(w, http.StatusOK, map[string]string{"ignored": reason}) + } + if ev.Type != "email.received" { + ignore("not an email.received event") + return + } + bookingID, bookerEmail := h.bookingForReplyAddress(ctx, ev.Recipients(), addr) + if bookingID == "" { + ignore("not addressed to a booking's reply address") + return + } + + apiKey, err := secret.Decrypt(h.encKey, apiKeyEnc) + if err != nil || apiKey == "" { + h.logger.ErrorContext(ctx, "inbound email: resend api key unavailable", "error", err) + h.writeError(w, http.StatusServiceUnavailable, "resend api key not configured") + return + } + raw, err := fetchReceivedRaw(ctx, apiKey, ev.Data.EmailID) + if err != nil { + h.logger.ErrorContext(ctx, "inbound email: fetch message", "error", err, "booking_id", bookingID) + h.writeError(w, http.StatusBadGateway, "could not fetch the received email") + return + } + reply, err := mailer.ParseICSReply(raw) + if err != nil { + ignore("no calendar reply in message") + return + } + if !strings.EqualFold(reply.UID, bookingID+"@calnode") { + ignore("reply is for a different event") + return + } + status := "" + for _, a := range reply.Attendees { + if strings.EqualFold(a.Email, bookerEmail) { + status = a.PartStat + break + } + } + if !rsvpStatuses[status] { + ignore("no answer from this booking's booker") + return + } + + changed, err := h.recordRSVP(ctx, bookingID, status) + if err != nil { + h.logger.ErrorContext(ctx, "inbound email: record rsvp", "error", err, "booking_id", bookingID) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + h.writeJSON(w, http.StatusOK, map[string]string{"rsvp_status": status}) + if changed { + h.enqueueRSVPWebhook(ctx, bookingID, status) + } +} + +// bookingForReplyAddress finds the booking one of the recipients was minted for, and that +// booking's booker address. +func (h *Handler) bookingForReplyAddress(ctx context.Context, recipients []string, base string) (bookingID, bookerEmail string) { + if base == "" { + return "", "" + } + for _, rcpt := range recipients { + a, err := mail.ParseAddress(rcpt) + if err != nil || !isReplyAddressFor(a.Address, base) { + continue + } + err = h.db.QueryRowContext(ctx, ` + SELECT b.id, COALESCE(o.email, '') + FROM bookings b + LEFT JOIN booking_attendees o ON o.booking_id = b.id AND o.is_organizer = 1 + WHERE b.invite_organizer = ? COLLATE NOCASE`, a.Address).Scan(&bookingID, &bookerEmail) + if err == nil { + return bookingID, bookerEmail + } + } + return "", "" +} + +// recordRSVP stores the booker's answer, reporting whether it changed. Resend retries and +// clients re-send the same answer, so an unchanged answer is a no-op, not a new event. +func (h *Handler) recordRSVP(ctx context.Context, bookingID, status string) (bool, error) { + res, err := h.db.ExecContext(ctx, ` + UPDATE booking_attendees SET rsvp_status = ?, rsvp_at = ? + WHERE booking_id = ? AND is_organizer = 1 AND rsvp_status != ?`, + status, time.Now().UTC().Format(time.RFC3339), bookingID, status) + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n > 0, err +} + +// enqueueRSVPWebhook fires booking.rsvp for the booking's hosts' subscribed webhooks. +func (h *Handler) enqueueRSVPWebhook(ctx context.Context, bookingID, status string) { + if h.webhookSvc == nil { + return + } + b, err := h.bookingSvc.Get(ctx, bookingID) + if err != nil { + h.logger.ErrorContext(ctx, "rsvp webhook: load booking", "error", err, "booking_id", bookingID) + return + } + var slug string + _ = h.db.QueryRowContext(ctx, `SELECT slug FROM event_types WHERE id = ?`, b.EventTypeID).Scan(&slug) + if err := h.webhookSvc.Enqueue(ctx, "booking.rsvp", webhook.BookingPayload{ + ID: b.ID, + EventTypeSlug: slug, + HostID: b.HostID, + StartAt: b.StartAt.UTC().Format(time.RFC3339), + EndAt: b.EndAt.UTC().Format(time.RFC3339), + Status: b.Status, + LocationValue: b.LocationValue, + CreatedAt: b.CreatedAt.UTC().Format(time.RFC3339), + RSVPStatus: status, + }); err != nil { + h.logger.ErrorContext(ctx, "enqueue booking.rsvp webhook", "error", err, "booking_id", bookingID) + } +} diff --git a/internal/handler/rsvp_inbound_internal_test.go b/internal/handler/rsvp_inbound_internal_test.go new file mode 100644 index 00000000..e6f8db9e --- /dev/null +++ b/internal/handler/rsvp_inbound_internal_test.go @@ -0,0 +1,192 @@ +package handler + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/calnode/calnode/internal/booking" + "github.com/calnode/calnode/internal/secret" +) + +const ( + rsvpInbox = "rsvp@reply.team.example" + rsvpSecretRaw = "calnode-test-webhook-signing-key" +) + +var rsvpSecret = "whsec_" + base64.StdEncoding.EncodeToString([]byte(rsvpSecretRaw)) + +// newRSVPFixture is an invite fixture with RSVP tracking fully configured. +func newRSVPFixture(t *testing.T) *inviteFixture { + t.Helper() + f := newInviteFixture(t, true) + whEnc, err := secret.Encrypt(f.h.encKey, rsvpSecret) + if err != nil { + t.Fatal(err) + } + keyEnc, err := secret.Encrypt(f.h.encKey, "re_test_key") + if err != nil { + t.Fatal(err) + } + if _, err := f.h.db.Exec(`UPDATE server_settings + SET rsvp_address = ?, resend_webhook_secret_enc = ?, resend_api_key_enc = ? WHERE id = 1`, + rsvpInbox, whEnc, keyEnc); err != nil { + t.Fatalf("seed rsvp settings: %v", err) + } + return f +} + +// stubResend stands in for Resend's Received emails API, serving raw for any email id. +func stubResend(t *testing.T, raw []byte) { + t.Helper() + prev := fetchReceivedRaw + fetchReceivedRaw = func(_ context.Context, apiKey, _ string) ([]byte, error) { + if apiKey != "re_test_key" { + return nil, errors.New("wrong api key") + } + return raw, nil + } + t.Cleanup(func() { fetchReceivedRaw = prev }) +} + +func replyMessage(uid, attendee, partstat string) []byte { + return []byte("From: " + attendee + "\r\nSubject: Accepted\r\n" + + "Content-Type: text/calendar; charset=UTF-8; method=REPLY\r\n\r\n" + + "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nBEGIN:VEVENT\r\nUID:" + uid + "\r\n" + + "ATTENDEE;PARTSTAT=" + partstat + ":mailto:" + attendee + "\r\n" + + "END:VEVENT\r\nEND:VCALENDAR\r\n") +} + +// postInbound delivers an email.received webhook for a message to `to`, signed with sign. +func (f *inviteFixture) postInbound(t *testing.T, to, sign string) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "type": "email.received", + "data": map[string]any{"email_id": "em_1", "from": inviteBookerEmail, "to": []string{to}}, + }) + ts := strconv.FormatInt(time.Now().Unix(), 10) + key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(sign, "whsec_")) + mac := hmac.New(sha256.New, key) + mac.Write([]byte("msg_1." + ts + ".")) + mac.Write(body) + req := httptest.NewRequest(http.MethodPost, "/v1/email/inbound/resend", strings.NewReader(string(body))) + req.Header.Set("svix-id", "msg_1") + req.Header.Set("svix-timestamp", ts) + req.Header.Set("svix-signature", "v1,"+base64.StdEncoding.EncodeToString(mac.Sum(nil))) + rec := httptest.NewRecorder() + f.h.InboundEmailResend(rec, req) + return rec +} + +func (f *inviteFixture) rsvpStatus(t *testing.T, bookingID string) string { + t.Helper() + var s string + if err := f.h.db.QueryRow(`SELECT rsvp_status FROM booking_attendees WHERE booking_id = ? AND is_organizer = 1`, + bookingID).Scan(&s); err != nil { + t.Fatalf("read rsvp: %v", err) + } + return s +} + +var organizerLine = regexp.MustCompile(`ORGANIZER;CN="[^"]*":mailto:(\S+)`) + +// The whole loop: the booker's invite is organized by a private reply address, their +// "Yes" comes back through Resend, and Calnode records it on the booking. +func TestRSVP_bookerAnswerIsRecorded(t *testing.T) { + f := newRSVPFixture(t) + if rec := f.createEventType(t, "intro", booking.InviteByCalnode); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + id, msg := f.book(t, "intro") + + m := organizerLine.FindStringSubmatch(icsOf(msg)) + if m == nil || !isReplyAddressFor(m[1], rsvpInbox) { + t.Fatalf("invite is not organized by a per-booking reply address:\n%s", icsOf(msg)) + } + replyAddr := m[1] + stubResend(t, replyMessage(id+"@calnode", inviteBookerEmail, "ACCEPTED")) + + rec := f.postInbound(t, replyAddr, rsvpSecret) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "accepted") { + t.Fatalf("inbound RSVP: %d — %s", rec.Code, rec.Body.String()) + } + if got := f.rsvpStatus(t, id); got != "accepted" { + t.Errorf("rsvp_status = %q; want accepted", got) + } +} + +// The signature, the event UID and the booker check must each, on their own, stop an +// answer from being recorded. +func TestRSVP_rejectsWhatIsNotTheBookersAnswer(t *testing.T) { + f := newRSVPFixture(t) + if rec := f.createEventType(t, "intro", booking.InviteByCalnode); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + id, msg := f.book(t, "intro") + replyAddr := organizerLine.FindStringSubmatch(icsOf(msg))[1] + if !isReplyAddressFor(replyAddr, rsvpInbox) { + t.Fatalf("invite organizer %q is not a reply address; the checks below would pass vacuously", replyAddr) + } + + t.Run("forged webhook", func(t *testing.T) { + stubResend(t, replyMessage(id+"@calnode", inviteBookerEmail, "DECLINED")) + other := "whsec_" + base64.StdEncoding.EncodeToString([]byte("not-the-secret")) + if rec := f.postInbound(t, replyAddr, other); rec.Code != http.StatusUnauthorized { + t.Errorf("unsigned-by-Resend webhook: %d; want 401", rec.Code) + } + }) + t.Run("forwarded invite answered by someone else", func(t *testing.T) { + stubResend(t, replyMessage(id+"@calnode", "colleague@elsewhere.example", "DECLINED")) + f.postInbound(t, replyAddr, rsvpSecret) + }) + t.Run("reply for another event", func(t *testing.T) { + stubResend(t, replyMessage("someone-else@calnode", inviteBookerEmail, "DECLINED")) + f.postInbound(t, replyAddr, rsvpSecret) + }) + if got := f.rsvpStatus(t, id); got != "needs-action" { + t.Errorf("rsvp_status = %q after only invalid answers; want needs-action", got) + } +} + +// Calendar clients match a cancellation to the invite by UID and organizer. Changing the +// RSVP inbox after a booking was made must not change the organizer its cancellation uses. +func TestRSVP_organizerStaysFixedForTheBookingsLifetime(t *testing.T) { + f := newRSVPFixture(t) + if rec := f.createEventType(t, "intro", booking.InviteByCalnode); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + id, msg := f.book(t, "intro") + first := organizerLine.FindStringSubmatch(icsOf(msg))[1] + if !isReplyAddressFor(first, rsvpInbox) { + t.Fatalf("invite organizer %q is not a reply address", first) + } + + if _, err := f.h.db.Exec(`UPDATE server_settings SET rsvp_address = 'answers@other.example', email_from = 'new@team.example' WHERE id = 1`); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := f.h.bookingSvc.CancelByID(ctx, id, ""); err != nil { + t.Fatal(err) + } + b, err := f.h.bookingSvc.Get(ctx, id) + if err != nil { + t.Fatal(err) + } + f.h.cancelSideEffects(*b) + + cancelICS := icsOf(f.waitFor(t, inviteBookerEmail, 2)) + m := organizerLine.FindStringSubmatch(cancelICS) + if m == nil || m[1] != first { + t.Errorf("cancellation organizer = %v; want the invite's original %s:\n%s", m, first, cancelICS) + } +} diff --git a/internal/handler/webhook_handler.go b/internal/handler/webhook_handler.go index 2691c4d1..829cbaeb 100644 --- a/internal/handler/webhook_handler.go +++ b/internal/handler/webhook_handler.go @@ -15,7 +15,7 @@ import ( ) var validWebhookEvents = []string{ - "booking.created", "booking.cancelled", "booking.rescheduled", + "booking.created", "booking.cancelled", "booking.rescheduled", "booking.rsvp", "recording.completed", "transcript.ready", "notes.ready", } diff --git a/internal/mailer/itip.go b/internal/mailer/itip.go new file mode 100644 index 00000000..0c63a3d7 --- /dev/null +++ b/internal/mailer/itip.go @@ -0,0 +1,210 @@ +package mailer + +import ( + "bufio" + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strings" +) + +// ErrNoReply means a received email carries no iCalendar REPLY: an ordinary reply typed by +// a person, an auto-responder, or a forwarded invite. Not an error to retry. +var ErrNoReply = errors.New("itip: no calendar REPLY in message") + +// ICSReply is the part of an iTIP REPLY (RFC 5546 §3.2.3) that says who answered what. +type ICSReply struct { + UID string + Attendees []ICSReplyAttendee +} + +// ICSReplyAttendee is one ATTENDEE line of a REPLY. PartStat is lower-cased +// ("accepted", "declined", "tentative", "needs-action", ...). +type ICSReplyAttendee struct { + Email string + PartStat string +} + +// maxICSPart bounds how much of one calendar part is read; a REPLY is a few hundred bytes. +const maxICSPart = 256 << 10 + +// ParseICSReply finds the METHOD:REPLY calendar object in a raw RFC 5322 message. Mail +// clients put it in a text/calendar body part (Gmail, Outlook, Apple Mail), often with a +// second copy as an .ics attachment; the first REPLY found wins. +func ParseICSReply(raw []byte) (ICSReply, error) { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return ICSReply{}, fmt.Errorf("itip: read message: %w", err) + } + var found ICSReply + done, walkErr := walkParts(msg.Header, msg.Body, 0, func(body []byte) bool { + r, ok := parseReplyCalendar(body) + if ok { + found = r + } + return ok + }) + if done { + return found, nil + } + if walkErr != nil { + return ICSReply{}, walkErr + } + return ICSReply{}, ErrNoReply +} + +type partHeader interface{ Get(string) string } + +// walkParts visits every calendar-looking leaf part, depth-first, and stops as soon as +// visit reports it found what it wanted (done). +func walkParts(h partHeader, body io.Reader, depth int, visit func([]byte) bool) (done bool, err error) { + if depth > 8 { + return false, errors.New("itip: MIME nesting too deep") + } + mediaType, params, err := mime.ParseMediaType(h.Get("Content-Type")) + if err != nil { + mediaType = "text/plain" + } + if strings.HasPrefix(mediaType, "multipart/") { + mr := multipart.NewReader(body, params["boundary"]) + for { + p, err := mr.NextRawPart() + if err == io.EOF { + return false, nil + } + if err != nil { + return false, fmt.Errorf("itip: read part: %w", err) + } + if done, err := walkParts(p.Header, p, depth+1, visit); done || err != nil { + return done, err + } + } + } + if !isCalendarPart(mediaType, h) { + return false, nil + } + data, err := io.ReadAll(io.LimitReader(decodeTransfer(h.Get("Content-Transfer-Encoding"), body), maxICSPart)) + if err != nil { + return false, nil // a malformed part is skipped; another copy may still be readable + } + return visit(data), nil +} + +func isCalendarPart(mediaType string, h partHeader) bool { + if mediaType == "text/calendar" || mediaType == "application/ics" { + return true + } + if _, params, err := mime.ParseMediaType(h.Get("Content-Disposition")); err == nil { + return strings.HasSuffix(strings.ToLower(params["filename"]), ".ics") + } + return false +} + +// decodeTransfer undoes a part's Content-Transfer-Encoding. The base64 decoder already +// skips the CR/LF that MIME wraps bodies with. +func decodeTransfer(enc string, r io.Reader) io.Reader { + switch strings.ToLower(strings.TrimSpace(enc)) { + case "base64": + return base64.NewDecoder(base64.StdEncoding, r) + case "quoted-printable": + return quotedprintable.NewReader(r) + default: + return r + } +} + +// parseReplyCalendar extracts the REPLY from one iCalendar object; ok is false when the +// object is not a REPLY (an echoed REQUEST, a CANCEL) or has no VEVENT UID. +func parseReplyCalendar(data []byte) (ICSReply, bool) { + var ( + reply ICSReply + isReply bool + inEvent bool + ) + for _, line := range unfoldICS(data) { + name, params, value := splitICSLine(line) + switch { + case name == "METHOD": + isReply = strings.EqualFold(value, "REPLY") + case name == "BEGIN" && strings.EqualFold(value, "VEVENT"): + inEvent = true + case name == "END" && strings.EqualFold(value, "VEVENT"): + inEvent = false + case inEvent && name == "UID" && reply.UID == "": + reply.UID = value + case inEvent && name == "ATTENDEE": + addr := value + if len(addr) > 7 && strings.EqualFold(addr[:7], "mailto:") { + addr = addr[7:] + } + stat := strings.ToLower(params["PARTSTAT"]) + if stat == "" { + stat = "needs-action" + } + reply.Attendees = append(reply.Attendees, ICSReplyAttendee{Email: strings.TrimSpace(addr), PartStat: stat}) + } + } + return reply, isReply && reply.UID != "" && len(reply.Attendees) > 0 +} + +// unfoldICS splits content lines and joins RFC 5545 §3.1 continuations (a line starting +// with a space or tab continues the previous one). +func unfoldICS(data []byte) []string { + var lines []string + sc := bufio.NewScanner(bytes.NewReader(data)) + sc.Buffer(make([]byte, 0, 4096), maxICSPart) + for sc.Scan() { + l := strings.TrimRight(sc.Text(), "\r") + if (strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t")) && len(lines) > 0 { + lines[len(lines)-1] += l[1:] + continue + } + lines = append(lines, l) + } + return lines +} + +// splitICSLine splits `NAME;P1=V1;P2="V;2":value` into the upper-cased name, its +// parameters (names upper-cased, quotes removed) and the value. Colons and semicolons +// inside quoted parameter values do not split. +func splitICSLine(line string) (string, map[string]string, string) { + inQuote := false + colon := -1 + for i, c := range line { + if c == '"' { + inQuote = !inQuote + } else if c == ':' && !inQuote { + colon = i + break + } + } + if colon < 0 { + return "", nil, "" + } + head, value := line[:colon], line[colon+1:] + var segs []string + inQuote = false + start := 0 + for i, c := range head { + if c == '"' { + inQuote = !inQuote + } else if c == ';' && !inQuote { + segs = append(segs, head[start:i]) + start = i + 1 + } + } + segs = append(segs, head[start:]) + params := make(map[string]string, len(segs)-1) + for _, s := range segs[1:] { + if k, v, ok := strings.Cut(s, "="); ok { + params[strings.ToUpper(k)] = strings.Trim(v, `"`) + } + } + return strings.ToUpper(segs[0]), params, value +} diff --git a/internal/mailer/resend_inbound.go b/internal/mailer/resend_inbound.go new file mode 100644 index 00000000..ba95717e --- /dev/null +++ b/internal/mailer/resend_inbound.go @@ -0,0 +1,138 @@ +package mailer + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Resend delivers received email ("inbound") as an email.received webhook, signed the Svix +// way. The webhook carries metadata only; the message itself is fetched from the +// Received emails API, whose raw.download_url serves the original RFC 5322 bytes. + +// ErrWebhookSignature means a webhook request is not provably from Resend: a missing or +// wrong signature, or a timestamp outside the replay window. +var ErrWebhookSignature = errors.New("resend webhook: signature verification failed") + +// webhookTolerance is the replay window Svix itself uses. +const webhookTolerance = 5 * time.Minute + +// VerifyResendWebhook checks a Resend (Svix) webhook signature against the endpoint's +// signing secret ("whsec_"). body must be the raw request body, unparsed. +func VerifyResendWebhook(secret string, header http.Header, body []byte, now time.Time) error { + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + if err != nil || len(key) == 0 { + return fmt.Errorf("%w: unusable signing secret", ErrWebhookSignature) + } + id, ts, sigs := header.Get("svix-id"), header.Get("svix-timestamp"), header.Get("svix-signature") + if id == "" || ts == "" || sigs == "" { + return fmt.Errorf("%w: missing svix headers", ErrWebhookSignature) + } + sec, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return fmt.Errorf("%w: bad timestamp", ErrWebhookSignature) + } + if d := now.Sub(time.Unix(sec, 0)); d > webhookTolerance || d < -webhookTolerance { + return fmt.Errorf("%w: timestamp outside tolerance", ErrWebhookSignature) + } + + mac := hmac.New(sha256.New, key) + mac.Write([]byte(id + "." + ts + ".")) + mac.Write(body) + want := mac.Sum(nil) + // The header lists one or more "v1," signatures (several during secret rotation). + for _, s := range strings.Fields(sigs) { + version, sig, ok := strings.Cut(s, ",") + if !ok || version != "v1" { + continue + } + got, err := base64.StdEncoding.DecodeString(sig) + if err == nil && hmac.Equal(got, want) { + return nil + } + } + return ErrWebhookSignature +} + +// ResendInboundEvent is the part of an email.received webhook Calnode reads. +type ResendInboundEvent struct { + Type string `json:"type"` + Data struct { + EmailID string `json:"email_id"` + From string `json:"from"` + To []string `json:"to"` + CC []string `json:"cc"` + ReceivedFor []string `json:"received_for"` + } `json:"data"` +} + +// Recipients returns every address the message was delivered to. +func (e ResendInboundEvent) Recipients() []string { + out := make([]string, 0, len(e.Data.To)+len(e.Data.CC)+len(e.Data.ReceivedFor)) + out = append(out, e.Data.To...) + out = append(out, e.Data.CC...) + return append(out, e.Data.ReceivedFor...) +} + +// resendReceivingEndpoint is the Received emails API. A var so tests can stub it. +var resendReceivingEndpoint = "https://api.resend.com/emails/receiving/" + +// maxRawEmail bounds a downloaded message. An RSVP is a few KB; this only stops a +// pathological message from being read into memory whole. +const maxRawEmail = 5 << 20 + +// FetchReceivedRaw downloads the original bytes of a received email: one API call for the +// short-lived signed URL, one GET for the message. +func FetchReceivedRaw(ctx context.Context, apiKey, emailID string) ([]byte, error) { + if apiKey == "" { + return nil, errors.New("resend inbound: api key not configured") + } + client := &http.Client{Timeout: resendTimeout} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resendReceivingEndpoint+url.PathEscape(emailID), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("resend inbound: retrieve email: %w", err) + } + var meta struct { + Raw *struct { + DownloadURL string `json:"download_url"` + } `json:"raw"` + } + decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&meta) + resp.Body.Close() // #nosec G104 -- body fully consumed above; a close error changes nothing + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("resend inbound: retrieve email: status %d", resp.StatusCode) + } + if decodeErr != nil || meta.Raw == nil || meta.Raw.DownloadURL == "" { + return nil, errors.New("resend inbound: retrieve email: no raw download url") + } + + req, err = http.NewRequestWithContext(ctx, http.MethodGet, meta.Raw.DownloadURL, nil) + if err != nil { + return nil, err + } + resp, err = client.Do(req) + if err != nil { + return nil, fmt.Errorf("resend inbound: download raw email: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("resend inbound: download raw email: status %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, maxRawEmail)) +} diff --git a/internal/mailer/resend_inbound_test.go b/internal/mailer/resend_inbound_test.go new file mode 100644 index 00000000..b6da8cb0 --- /dev/null +++ b/internal/mailer/resend_inbound_test.go @@ -0,0 +1,129 @@ +package mailer + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + "testing" + "time" +) + +// Svix's published verification example (docs.svix.com, "Verifying payloads manually"), +// the scheme Resend signs webhooks with. Matching it proves wire compatibility rather than +// agreement with our own signer. +func svixExample() (string, http.Header, []byte, time.Time) { + h := http.Header{} + h.Set("svix-id", "msg_p5jXN8AQM9LWM0D4loKWxJek") + h.Set("svix-timestamp", "1614265330") + h.Set("svix-signature", "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=") + return "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", h, []byte(`{"test": 2432232314}`), time.Unix(1614265330, 0) +} + +func TestVerifyResendWebhook_acceptsSvixExample(t *testing.T) { + secret, h, body, now := svixExample() + if err := VerifyResendWebhook(secret, h, body, now); err != nil { + t.Fatalf("valid Svix signature rejected: %v", err) + } + // Secret rotation sends several signatures; any one matching is enough. + h.Set("svix-signature", "v1,bm90LXRoZS1zaWduYXR1cmU= "+h.Get("svix-signature")) + if err := VerifyResendWebhook(secret, h, body, now); err != nil { + t.Errorf("valid signature among several rejected: %v", err) + } +} + +func TestVerifyResendWebhook_rejectsForgeries(t *testing.T) { + secret, h, body, now := svixExample() + cases := map[string]func() (string, http.Header, []byte, time.Time){ + "tampered body": func() (string, http.Header, []byte, time.Time) { + return secret, h, []byte(`{"test": 1}`), now + }, + "other secret": func() (string, http.Header, []byte, time.Time) { + return "whsec_" + base64.StdEncoding.EncodeToString([]byte("another-secret")), h, body, now + }, + "replayed later": func() (string, http.Header, []byte, time.Time) { + return secret, h, body, now.Add(6 * time.Minute) + }, + "unsigned": func() (string, http.Header, []byte, time.Time) { + return secret, http.Header{}, body, now + }, + } + for name, c := range cases { + s, hdr, b, at := c() + if err := VerifyResendWebhook(s, hdr, b, at); !errors.Is(err, ErrWebhookSignature) { + t.Errorf("%s: got %v; want ErrWebhookSignature", name, err) + } + } +} + +// gmailStyleReply mirrors how Gmail answers an invite: multipart/mixed holding a +// multipart/alternative whose text/calendar part is the REPLY (base64), plus the same +// calendar again as an invite.ics attachment. +func gmailStyleReply(uid, attendee, partstat string) []byte { + ics := "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nBEGIN:VEVENT\r\n" + + "ATTENDEE;PARTSTAT=" + partstat + ";CN=\"Guest; the: one\":mailto:" + attendee + "\r\n" + + "UID:" + uid + "\r\nSEQUENCE:0\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + b64 := base64.StdEncoding.EncodeToString([]byte(ics)) + var wrapped strings.Builder + for len(b64) > 76 { + wrapped.WriteString(b64[:76] + "\r\n") + b64 = b64[76:] + } + wrapped.WriteString(b64 + "\r\n") + return []byte("From: Guest <" + attendee + ">\r\n" + + "To: rsvp+tok@reply.example.com\r\n" + + "Subject: Accepted: Intro\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n" + + "--outer\r\n" + + "Content-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n" + + "--inner\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nGuest has accepted.\r\n" + + "--inner\r\nContent-Type: text/calendar; charset=UTF-8; method=REPLY\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + wrapped.String() + + "--inner--\r\n" + + "--outer\r\nContent-Type: application/ics; name=\"invite.ics\"\r\n" + + "Content-Disposition: attachment; filename=\"invite.ics\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + wrapped.String() + + "--outer--\r\n") +} + +func TestParseICSReply_gmailStyle(t *testing.T) { + got, err := ParseICSReply(gmailStyleReply("b-1@calnode", "guest@booker.example", "ACCEPTED")) + if err != nil { + t.Fatalf("ParseICSReply: %v", err) + } + if got.UID != "b-1@calnode" { + t.Errorf("UID = %q", got.UID) + } + if len(got.Attendees) != 1 || got.Attendees[0].Email != "guest@booker.example" || got.Attendees[0].PartStat != "accepted" { + t.Errorf("attendees = %+v; want guest@booker.example accepted (a quoted CN with ; and : must not split)", got.Attendees) + } +} + +func TestParseICSReply_foldedQuotedPrintable(t *testing.T) { + raw := "From: g@x.example\r\nContent-Type: text/calendar; method=REPLY\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + + "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nBEGIN:VEVENT\r\nUID:b-2@calnode\r\n" + + "ATTENDEE;CN=3DGuest;PARTSTAT=3DDECLINED:MAILTO:guest@booker.exa\r\n mple\r\n" + + "END:VEVENT\r\nEND:VCALENDAR\r\n" + got, err := ParseICSReply([]byte(raw)) + if err != nil { + t.Fatalf("ParseICSReply: %v", err) + } + if len(got.Attendees) != 1 || got.Attendees[0].Email != "guest@booker.example" || got.Attendees[0].PartStat != "declined" { + t.Errorf("attendees = %+v; want the folded MAILTO unfolded and declined", got.Attendees) + } +} + +// Only a REPLY is an answer: an echoed invite or a typed reply must not read as one. +func TestParseICSReply_ignoresNonReplies(t *testing.T) { + request := "From: g@x.example\r\nContent-Type: text/calendar; method=REQUEST\r\n\r\n" + + "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:b-3@calnode\r\n" + + "ATTENDEE;PARTSTAT=ACCEPTED:mailto:g@x.example\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + typed := "From: g@x.example\r\nContent-Type: text/plain\r\n\r\nSee you then!\r\n" + for name, raw := range map[string]string{"request": request, "typed reply": typed} { + if _, err := ParseICSReply([]byte(raw)); !errors.Is(err, ErrNoReply) { + t.Errorf("%s: got %v; want ErrNoReply", name, err) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 871667f9..b7810a78 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -517,6 +517,9 @@ func New(ctx context.Context, cfg *config.Config, db *sql.DB, logger *slog.Logge // Stripe payment webhook — public, authenticated by the signing secret (no session // cookie, so the CSRF check doesn't apply). Must receive the raw body. mux.HandleFunc("POST /v1/stripe/webhook", h.StripeWebhook) + // Resend inbound email (email.received): RSVPs to Calnode-sent invites. Public, + // authenticated by the webhook signature. + mux.HandleFunc("POST /v1/email/inbound/resend", h.InboundEmailResend) // API keys mux.HandleFunc("GET /v1/api-keys", h.RequireAuth(h.ListAPIKeys)) diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index d266df8e..0e0948ab 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -55,6 +55,7 @@ const ( FieldPaymentStatus = "payment_status" FieldAmountPaid = "amount_paid_cents" FieldCurrency = "amount_paid_currency" + FieldRSVPStatus = "rsvp_status" ) // AllFields is every selectable field, in payload order. Used to validate config @@ -65,7 +66,7 @@ var AllFields = []string{ FieldEventTypeSlug, FieldEventTypeName, FieldHostID, FieldHostName, FieldHostEmail, FieldAttendeeName, FieldAttendeeEmail, FieldAttendeeTZ, FieldAnswers, - FieldPaymentStatus, FieldAmountPaid, FieldCurrency, + FieldPaymentStatus, FieldAmountPaid, FieldCurrency, FieldRSVPStatus, } // defaultFields reproduces the original payload (no PII, no answers) so a webhook with no @@ -75,7 +76,7 @@ var defaultFields = []string{ FieldID, FieldEventTypeSlug, FieldHostID, FieldStartAt, FieldEndAt, FieldStatus, FieldLocation, FieldCancelReason, FieldCreatedAt, FieldPreviousStartAt, FieldPreviousEndAt, - FieldPaymentStatus, FieldAmountPaid, FieldCurrency, + FieldPaymentStatus, FieldAmountPaid, FieldCurrency, FieldRSVPStatus, } var validField = func() map[string]bool { @@ -125,6 +126,9 @@ type BookingPayload struct { PaymentStatus string `json:"payment_status,omitempty"` AmountPaidCents int `json:"amount_paid_cents,omitempty"` AmountPaidCurrency string `json:"amount_paid_currency,omitempty"` + // RSVPStatus is the booker's answer to a Calnode-sent invite (accepted, declined, + // tentative); set on booking.rsvp only. + RSVPStatus string `json:"rsvp_status,omitempty"` } type Service struct { @@ -346,6 +350,7 @@ func buildData(bd enrichedBooking, fields []string) map[string]any { FieldPreviousEndAt: bd.core.PreviousEndAt, FieldPaymentStatus: bd.core.PaymentStatus, FieldCurrency: bd.core.AmountPaidCurrency, + FieldRSVPStatus: bd.core.RSVPStatus, } out := make(map[string]any, len(fields)) for _, f := range fields { From 7572ebbd60d3a9f0939583ea1c9c12599f96de93 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Wed, 30 Sep 2026 00:16:43 +0200 Subject: [PATCH 4/4] fix(rsvp): trust only verified senders; set up the Resend webhook automatically - 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. --- CHANGELOG.md | 6 +- DEPLOY.md | 24 ++- docs/ARCHITECTURE.md | 24 +-- frontend/src/lib/api.ts | 2 + .../src/routes/settings/email/+page.svelte | 125 +++++++++++--- internal/handler/email_settings.go | 1 + internal/handler/rsvp_inbound.go | 108 +++++++++++-- .../handler/rsvp_inbound_internal_test.go | 128 +++++++++++++-- internal/mailer/resend_inbound.go | 77 +++++---- internal/mailer/resend_webhooks.go | 129 +++++++++++++++ internal/mailer/resend_webhooks_test.go | 152 ++++++++++++++++++ internal/server/server.go | 1 + 12 files changed, 681 insertions(+), 96 deletions(-) create mode 100644 internal/mailer/resend_webhooks.go create mode 100644 internal/mailer/resend_webhooks_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d45e84..df6061e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,10 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad up. Existing bookings keep the way they were invited. - **RSVP tracking for invites Calnode sends.** With Resend receiving set up (Settings → Email → RSVP tracking), a booker's Yes / No / Maybe is read from their calendar reply, - shown on the booking, and sent as a new `booking.rsvp` webhook event. Each invite gets a - private reply address, and an answer only counts when it comes from that booking's booker. + shown on the booking, and sent as a new `booking.rsvp` webhook event. Calnode sets up the + Resend webhook itself (manual steps as a fallback) and needs a full-access Resend API + key. Each invite gets a private reply address, and an answer only counts when Resend + verified it came from that booking's booker. ## [0.10.1] - 2026-09-29 diff --git a/DEPLOY.md b/DEPLOY.md index 6d59f380..0cddc97a 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -133,15 +133,25 @@ For event types whose calendar invite is **sent by Calnode** (event type → Cal Calnode can record whether the booker accepted, declined or said maybe. The answer shows on the booking and fires a `booking.rsvp` webhook. -1. In Resend, set up **receiving** on a subdomain, e.g. `reply.yourdomain` (one MX record). - Use a subdomain: the MX record would take over your main domain's mail. -2. In Resend → Webhooks, add `https:///v1/email/inbound/resend` for the - `email.received` event and copy its signing secret (`whsec_…`). -3. Settings → Email → RSVP tracking: enter an address on that subdomain (e.g. - `rsvp@reply.yourdomain`) and the signing secret. It also needs the Resend API key above. +It needs a **Full access** Resend API key (Settings → Email): Calnode uses it to set up the +webhook and to read the replies. A sending-only key can do neither. + +1. In Resend → Domains, enable **receiving** on a subdomain, e.g. `reply.yourdomain` (one MX + record). Use a subdomain: the MX record would take over your main domain's mail. +2. Settings → Email → RSVP tracking: enter an address on that subdomain (e.g. + `rsvp@reply.yourdomain`) and save. +3. Calnode then creates the `email.received` webhook in your Resend account and stores its + signing secret (or reuses the webhook if one already points at this instance). It needs + the instance to be reachable at a public `https://` `BASE_URL`. + +If automatic setup fails, the page says why and shows the manual steps: in Resend → Webhooks, +add `https:///v1/email/inbound/resend` for `email.received`, then paste its +signing secret (`whsec_…`) into the RSVP tracking section. Each invite is organized by its own private variant of that address, so no mailbox has to -exist. Bookings made before you switch it on keep the organizer they were invited with. +exist. An answer only counts when Resend verified the sender (DKIM or DMARC pass) and the +sender is that booking's booker. Bookings made before you switch it on keep the organizer +they were invited with. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31f306c7..8baf33cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -566,16 +566,20 @@ Yes/No/Maybe is an iTIP `REPLY` their mail client emails to the invite's `ORGANI booking's organizer address is fixed at its first send (`bookings.invite_organizer`, `bookingInviteOrganizer`), because clients match later updates by UID *and* organizer. With RSVP tracking set up (Settings → Email: an address on a Resend receiving domain, the -webhook signing secret, and a Resend API key) that address is private per booking, -`rsvp+<128-bit token>@domain`. Resend posts `email.received` to -`POST /v1/email/inbound/resend`; the handler verifies the Svix signature -(`mailer.VerifyResendWebhook`), routes by recipient to the booking, downloads the raw -message (`mailer.FetchReceivedRaw`), parses the `REPLY` (`mailer.ParseICSReply`), and -records the answer on `booking_attendees.rsvp_status` only if the UID is that booking's -and the answering attendee is its booker. A changed answer fires `booking.rsvp`. It -answers 200 to anything it will not act on and 5xx only when the fetch failed, so Resend -retries exactly what a retry can fix. Hosts' calendar events are not updated with the -answer: no provider has an "edit event" write op yet. +webhook signing secret, and a **full-access** Resend API key) that address is private per +booking, `rsvp+<128-bit token>@domain`. `POST /v1/settings/email/rsvp-webhook` +(`mailer.EnsureResendInboundWebhook`) creates or reuses the Resend webhook and stores its +secret; the page falls back to manual steps when it cannot. Resend posts `email.received` +to `POST /v1/email/inbound/resend`; the handler verifies the Svix signature +(`mailer.VerifyResendWebhook`), routes by recipient to a confirmed, upcoming booking, +fetches the message with Resend's own SPF/DKIM/DMARC verdicts (`mailer.FetchReceived`), +and records the answer on `booking_attendees.rsvp_status` only if Resend verified the From +domain (DKIM or DMARC pass), the From is the booker, the UID is the booking's, and the +`REPLY` answers for the booker. The body is sender-written, so its `ATTENDEE` line is never +trusted on its own. A changed answer fires `booking.rsvp`. It answers 200 to anything it +will not act on and 5xx only when the fetch failed, so Resend retries exactly what a retry +can fix. Hosts' calendar events are not updated with the answer: no provider has an "edit +event" write op yet. **Adding another provider (Apple/iCloud, CalDAV):** implement `calendar.Provider`, register it in `internal/server`, set `InvitesGuests()` correctly (drives the `.ics` diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 962fc57a..4f2172c3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -196,6 +196,8 @@ export type EmailSettings = { resend_webhook_secret_set: boolean; // never returned directly /** True when address, webhook secret and Resend API key are all present. */ rsvp_tracking: boolean; + /** Where Resend must deliver email.received: this instance's BASE_URL + the inbound path. */ + rsvp_webhook_url: string; }; export type GoogleSettings = { diff --git a/frontend/src/routes/settings/email/+page.svelte b/frontend/src/routes/settings/email/+page.svelte index 0a66b4cb..6a5ff3fe 100644 --- a/frontend/src/routes/settings/email/+page.svelte +++ b/frontend/src/routes/settings/email/+page.svelte @@ -30,7 +30,11 @@ // RSVP tracking (Resend inbound) for invites Calnode sends itself. let rsvpAddress = $state(''); let webhookSecret = $state(''); - let webhookUrl = $state(''); + let clearWebhookSecret = $state(false); + // Automatic webhook setup in Resend; its error, when it fails, opens the manual steps. + let settingUpWebhook = $state(false); + let webhookSetupError = $state(''); + let showManualWebhook = $state(false); let userEmail = $state(''); @@ -58,10 +62,12 @@ emailFrom = email.email_from; emailFromName = email.email_from_name || 'Calnode'; rsvpAddress = email.rsvp_address ?? ''; - webhookUrl = `${window.location.origin}/v1/email/inbound/resend`; }, 'Could not load email settings')); async function save() { + // A save that removes the secret is the admin turning RSVP tracking off; it must not + // immediately set the webhook back up below. + const removingSecret = clearWebhookSecret; await savingFlag.run(async () => { const body: Record = { smtp_host: smtpHost, smtp_port: smtpPort, smtp_user: smtpUser, @@ -75,13 +81,41 @@ body.rsvp_address = rsvpAddress.trim(); // Like the API key: omit to keep the stored secret. if (webhookSecret) body.resend_webhook_secret = webhookSecret.trim(); + else if (clearWebhookSecret) body.resend_webhook_secret = ''; emailSettings = await api.patch('/v1/settings/email', body); smtpPass = ''; resendApiKey = ''; webhookSecret = ''; clearResendKey = false; + clearWebhookSecret = false; + if (emailSettings.resend_webhook_secret_set) { + webhookSetupError = ''; + showManualWebhook = false; + } toast.success('Email settings saved'); }, 'Could not save email settings'); + // First time RSVP tracking has everything but the webhook: try to set that up too, + // so the admin only falls back to the manual steps when Resend says no. + const s = emailSettings; + if (!removingSecret && s?.rsvp_address && s.resend_api_key_set && !s.resend_webhook_secret_set) { + await setupWebhook(); + } + } + + async function setupWebhook() { + settingUpWebhook = true; + webhookSetupError = ''; + try { + const res = await api.post<{ created: boolean }>('/v1/settings/email/rsvp-webhook'); + emailSettings = await api.get('/v1/settings/email'); + showManualWebhook = false; + toast.success(res.created ? 'Webhook created in Resend' : 'Connected to your existing Resend webhook'); + } catch (e: any) { + webhookSetupError = e.message || 'Could not set up the webhook in Resend.'; + showManualWebhook = true; + } finally { + settingUpWebhook = false; + } } async function test() { @@ -252,33 +286,82 @@ {/if} - {#if !emailSettings?.resend_api_key_set} -

- Needs a Resend API key above: Calnode uses it to read the replies. -

- {/if} +

+ Needs a Full access Resend API key above: Calnode uses it to set up the + webhook and to read replies. A sending-only key can't do either. +

- An address on a domain Resend receives email for. Each invite gets its own private - variant of it, so answers find their booking. + An address on a subdomain you receive email on in Resend (Resend → Domains → enable + receiving, then add its MX record). Each invite gets its own private variant of it, + so answers find their booking.

-
- - -

- Add this in Resend → Webhooks for the email.received event. -

-
-
- - + +
+

Resend webhook

+ {#if emailSettings?.resend_webhook_secret_set && !clearWebhookSecret} +

+ Connected: Resend delivers replies to {emailSettings.rsvp_webhook_url}. +

+
+ + +
+ {:else} + {#if clearWebhookSecret} +
+

The stored secret will be removed on save; RSVP tracking turns off.

+ +
+ {:else} +

+ Calnode can create the webhook in your Resend account and store its signing secret. +

+
+ + +
+ {/if} + {/if} + + {#if webhookSetupError} +

+ {webhookSetupError} +

+ {/if} + + {#if showManualWebhook && !clearWebhookSecret} +
    +
  1. In Resend, open Webhooks and choose Add webhook.
  2. +
  3. + Endpoint URL: + +
  4. +
  5. Event: email.received.
  6. +
  7. + Copy the webhook's signing secret, paste it here and save: + +
  8. +
+ {/if}
diff --git a/internal/handler/email_settings.go b/internal/handler/email_settings.go index 08f407db..0b75e026 100644 --- a/internal/handler/email_settings.go +++ b/internal/handler/email_settings.go @@ -174,6 +174,7 @@ func (h *Handler) GetEmailSettings(w http.ResponseWriter, r *http.Request) { "rsvp_address": rsvpAddr, "resend_webhook_secret_set": webhookSecretEnc != "", "rsvp_tracking": rsvpAddr != "" && webhookSecretEnc != "" && resendEnc != "", + "rsvp_webhook_url": h.rsvpWebhookURL(), }) } diff --git a/internal/handler/rsvp_inbound.go b/internal/handler/rsvp_inbound.go index fb61a9f1..9f5cc594 100644 --- a/internal/handler/rsvp_inbound.go +++ b/internal/handler/rsvp_inbound.go @@ -25,13 +25,18 @@ import ( // POST /v1/email/inbound/resend with the message's metadata; Calnode checks the signature, // fetches the message, reads the REPLY and records the answer on the booking. // -// Three checks stand between a stranger and a forged RSVP: the Resend signature, the -// unguessable per-booking address, and the REPLY's attendee having to be that booking's -// booker. A forwarded invite answered by someone else is therefore ignored. +// An answer is recorded only when all of these hold: +// - the webhook carries a valid Resend signature; +// - it is addressed to the unguessable per-booking reply address of a booking that is +// still confirmed and upcoming; +// - Resend authenticated the message's From domain (DMARC or DKIM pass) and that From is +// the booking's booker. The REPLY body itself is sender-controlled, so its ATTENDEE +// line alone proves nothing: anyone holding a forwarded invite could write one; +// - the REPLY is for this booking's event, and answers for the booker. -// fetchReceivedRaw downloads a received message from Resend; a var so tests can stand in +// fetchReceived retrieves a received message from Resend; a var so tests can stand in // for Resend. -var fetchReceivedRaw = mailer.FetchReceivedRaw +var fetchReceived = mailer.FetchReceived // rsvpStatuses are the PARTSTAT answers recorded; anything else in a REPLY is ignored. var rsvpStatuses = map[string]bool{"accepted": true, "declined": true, "tentative": true} @@ -108,11 +113,13 @@ func (h *Handler) InboundEmailResend(w http.ResponseWriter, r *http.Request) { return } + // Unconfigured and wrongly signed answer the same, so the endpoint does not reveal + // whether RSVP tracking is set up to someone probing it. var addr, secretEnc, apiKeyEnc string if err := h.db.QueryRowContext(ctx, `SELECT rsvp_address, resend_webhook_secret_enc, resend_api_key_enc FROM server_settings WHERE id = 1`). Scan(&addr, &secretEnc, &apiKeyEnc); err != nil || secretEnc == "" { - h.writeError(w, http.StatusNotFound, "inbound email is not configured") + h.writeError(w, http.StatusUnauthorized, "invalid signature") return } whSecret, err := secret.Decrypt(h.encKey, secretEnc) @@ -151,13 +158,24 @@ func (h *Handler) InboundEmailResend(w http.ResponseWriter, r *http.Request) { h.writeError(w, http.StatusServiceUnavailable, "resend api key not configured") return } - raw, err := fetchReceivedRaw(ctx, apiKey, ev.Data.EmailID) + msg, err := fetchReceived(ctx, apiKey, ev.Data.EmailID) if err != nil { - h.logger.ErrorContext(ctx, "inbound email: fetch message", "error", err, "booking_id", bookingID) + if errors.Is(err, mailer.ErrResendRestrictedKey) { + h.logger.ErrorContext(ctx, "inbound email: the Resend API key is sending-only; RSVP tracking needs a full-access key (Settings → Email)", + "booking_id", bookingID) + } else { + h.logger.ErrorContext(ctx, "inbound email: fetch message", "error", err, "booking_id", bookingID) + } + // 5xx so Resend retries: once the key or network is fixed, the answer still lands. h.writeError(w, http.StatusBadGateway, "could not fetch the received email") return } - reply, err := mailer.ParseICSReply(raw) + from, err := mail.ParseAddress(msg.From) + if err != nil || !strings.EqualFold(from.Address, bookerEmail) || !msg.SenderAuthenticated() { + ignore("not sent, verifiably, by this booking's booker") + return + } + reply, err := mailer.ParseICSReply(msg.Raw) if err != nil { ignore("no calendar reply in message") return @@ -191,11 +209,14 @@ func (h *Handler) InboundEmailResend(w http.ResponseWriter, r *http.Request) { } // bookingForReplyAddress finds the booking one of the recipients was minted for, and that -// booking's booker address. +// booking's booker address. Only confirmed bookings that have not ended take answers: a +// reply to a cancelled or past meeting says nothing anyone can act on, and it bounds how +// long a leaked reply address is worth anything. func (h *Handler) bookingForReplyAddress(ctx context.Context, recipients []string, base string) (bookingID, bookerEmail string) { if base == "" { return "", "" } + now := time.Now().UTC().Format(time.RFC3339) for _, rcpt := range recipients { a, err := mail.ParseAddress(rcpt) if err != nil || !isReplyAddressFor(a.Address, base) { @@ -205,7 +226,8 @@ func (h *Handler) bookingForReplyAddress(ctx context.Context, recipients []strin SELECT b.id, COALESCE(o.email, '') FROM bookings b LEFT JOIN booking_attendees o ON o.booking_id = b.id AND o.is_organizer = 1 - WHERE b.invite_organizer = ? COLLATE NOCASE`, a.Address).Scan(&bookingID, &bookerEmail) + WHERE b.invite_organizer = ? COLLATE NOCASE + AND b.status = 'confirmed' AND b.end_at > ?`, a.Address, now).Scan(&bookingID, &bookerEmail) if err == nil { return bookingID, bookerEmail } @@ -253,3 +275,67 @@ func (h *Handler) enqueueRSVPWebhook(ctx context.Context, bookingID, status stri h.logger.ErrorContext(ctx, "enqueue booking.rsvp webhook", "error", err, "booking_id", bookingID) } } + +// ensureInboundWebhook creates or reuses the Resend webhook; a var so tests can stand in +// for Resend. +var ensureInboundWebhook = mailer.EnsureResendInboundWebhook + +// rsvpWebhookURL is where Resend has to deliver email.received for this instance. +func (h *Handler) rsvpWebhookURL() string { + return strings.TrimRight(h.baseURL, "/") + "/v1/email/inbound/resend" +} + +// SetupRSVPWebhook handles POST /v1/settings/email/rsvp-webhook (admin). It points a +// Resend webhook for email.received at this instance - reusing one that already targets +// it - and stores its signing secret, so the operator never copies a secret by hand. +// When that cannot work (no public https address, a sending-only key) the error says +// why, and the settings page falls back to the manual steps. +func (h *Handler) SetupRSVPWebhook(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAdmin(w, r); !ok { + return + } + if h.demoMode { + h.writeError(w, http.StatusServiceUnavailable, "not available in the demo") + return + } + ctx := r.Context() + endpoint := h.rsvpWebhookURL() + if !strings.HasPrefix(endpoint, "https://") { + h.writeError(w, http.StatusUnprocessableEntity, + "Resend can only deliver to a public https address, and this instance is at "+h.baseURL+ + ". Set the webhook up by hand once it is reachable over https.") + return + } + var keyEnc string + if err := h.db.QueryRowContext(ctx, + `SELECT resend_api_key_enc FROM server_settings WHERE id = 1`).Scan(&keyEnc); err != nil || keyEnc == "" { + h.writeError(w, http.StatusUnprocessableEntity, "Add a Resend API key above first.") + return + } + apiKey, err := secret.Decrypt(h.encKey, keyEnc) + if err != nil { + h.logger.ErrorContext(ctx, "rsvp webhook setup: decrypt resend key", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + whSecret, created, err := ensureInboundWebhook(ctx, apiKey, endpoint) + switch { + case errors.Is(err, mailer.ErrResendRestrictedKey): + h.writeError(w, http.StatusUnprocessableEntity, + "Your Resend API key can only send email. RSVP tracking needs a Full access key "+ + "(Resend → API Keys) to set up the webhook and read replies. Replace the key above, "+ + "or set the webhook up by hand.") + return + case err != nil: + h.logger.ErrorContext(ctx, "rsvp webhook setup", "error", err) + h.writeError(w, http.StatusBadGateway, "Resend could not set up the webhook: "+err.Error()) + return + } + if err := h.storeEmailSecret(ctx, h.db, secretResendWebhook, whSecret); err != nil { + h.logger.ErrorContext(ctx, "rsvp webhook setup: store secret", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + h.writeJSON(w, http.StatusOK, map[string]any{"created": created, "webhook_url": endpoint}) +} diff --git a/internal/handler/rsvp_inbound_internal_test.go b/internal/handler/rsvp_inbound_internal_test.go index e6f8db9e..a102c93c 100644 --- a/internal/handler/rsvp_inbound_internal_test.go +++ b/internal/handler/rsvp_inbound_internal_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/calnode/calnode/internal/booking" + "github.com/calnode/calnode/internal/mailer" "github.com/calnode/calnode/internal/secret" ) @@ -46,17 +47,22 @@ func newRSVPFixture(t *testing.T) *inviteFixture { return f } -// stubResend stands in for Resend's Received emails API, serving raw for any email id. -func stubResend(t *testing.T, raw []byte) { +// stubResend stands in for Resend's Received emails API, serving msg for any email id. +func stubResend(t *testing.T, msg mailer.ReceivedEmail) { t.Helper() - prev := fetchReceivedRaw - fetchReceivedRaw = func(_ context.Context, apiKey, _ string) ([]byte, error) { + prev := fetchReceived + fetchReceived = func(_ context.Context, apiKey, _ string) (mailer.ReceivedEmail, error) { if apiKey != "re_test_key" { - return nil, errors.New("wrong api key") + return mailer.ReceivedEmail{}, errors.New("wrong api key") } - return raw, nil + return msg, nil } - t.Cleanup(func() { fetchReceivedRaw = prev }) + t.Cleanup(func() { fetchReceived = prev }) +} + +// sentBy is a received message whose From Resend verified (DKIM and DMARC pass). +func sentBy(from string, raw []byte) mailer.ReceivedEmail { + return mailer.ReceivedEmail{Raw: raw, From: "Someone <" + from + ">", DKIM: "pass", DMARC: "pass"} } func replyMessage(uid, attendee, partstat string) []byte { @@ -114,7 +120,7 @@ func TestRSVP_bookerAnswerIsRecorded(t *testing.T) { t.Fatalf("invite is not organized by a per-booking reply address:\n%s", icsOf(msg)) } replyAddr := m[1] - stubResend(t, replyMessage(id+"@calnode", inviteBookerEmail, "ACCEPTED")) + stubResend(t, sentBy(inviteBookerEmail, replyMessage(id+"@calnode", inviteBookerEmail, "ACCEPTED"))) rec := f.postInbound(t, replyAddr, rsvpSecret) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "accepted") { @@ -125,8 +131,9 @@ func TestRSVP_bookerAnswerIsRecorded(t *testing.T) { } } -// The signature, the event UID and the booker check must each, on their own, stop an -// answer from being recorded. +// Each check must, on its own, stop an answer from being recorded: the Resend signature, +// a From that Resend verified and that is the booker, and the event UID. The REPLY body is +// written by whoever sends it, so its ATTENDEE line alone is never trusted. func TestRSVP_rejectsWhatIsNotTheBookersAnswer(t *testing.T) { f := newRSVPFixture(t) if rec := f.createEventType(t, "intro", booking.InviteByCalnode); rec.Code != http.StatusCreated { @@ -137,20 +144,32 @@ func TestRSVP_rejectsWhatIsNotTheBookersAnswer(t *testing.T) { if !isReplyAddressFor(replyAddr, rsvpInbox) { t.Fatalf("invite organizer %q is not a reply address; the checks below would pass vacuously", replyAddr) } + bookersDecline := replyMessage(id+"@calnode", inviteBookerEmail, "DECLINED") t.Run("forged webhook", func(t *testing.T) { - stubResend(t, replyMessage(id+"@calnode", inviteBookerEmail, "DECLINED")) + stubResend(t, sentBy(inviteBookerEmail, bookersDecline)) other := "whsec_" + base64.StdEncoding.EncodeToString([]byte("not-the-secret")) if rec := f.postInbound(t, replyAddr, other); rec.Code != http.StatusUnauthorized { t.Errorf("unsigned-by-Resend webhook: %d; want 401", rec.Code) } }) t.Run("forwarded invite answered by someone else", func(t *testing.T) { - stubResend(t, replyMessage(id+"@calnode", "colleague@elsewhere.example", "DECLINED")) + stubResend(t, sentBy("colleague@elsewhere.example", + replyMessage(id+"@calnode", "colleague@elsewhere.example", "DECLINED"))) + f.postInbound(t, replyAddr, rsvpSecret) + }) + t.Run("booker's answer forged from another mailbox", func(t *testing.T) { + stubResend(t, sentBy("attacker@evil.example", bookersDecline)) + f.postInbound(t, replyAddr, rsvpSecret) + }) + t.Run("booker's address typed into an unverified From", func(t *testing.T) { + spoofed := sentBy(inviteBookerEmail, bookersDecline) + spoofed.DKIM, spoofed.DMARC = "fail", "fail" + stubResend(t, spoofed) f.postInbound(t, replyAddr, rsvpSecret) }) t.Run("reply for another event", func(t *testing.T) { - stubResend(t, replyMessage("someone-else@calnode", inviteBookerEmail, "DECLINED")) + stubResend(t, sentBy(inviteBookerEmail, replyMessage("someone-else@calnode", inviteBookerEmail, "DECLINED"))) f.postInbound(t, replyAddr, rsvpSecret) }) if got := f.rsvpStatus(t, id); got != "needs-action" { @@ -158,6 +177,25 @@ func TestRSVP_rejectsWhatIsNotTheBookersAnswer(t *testing.T) { } } +// A cancelled booking takes no answers: nothing can act on them, and it ends the reply +// address's usefulness. +func TestRSVP_cancelledBookingTakesNoAnswer(t *testing.T) { + f := newRSVPFixture(t) + if rec := f.createEventType(t, "intro", booking.InviteByCalnode); rec.Code != http.StatusCreated { + t.Fatalf("create event type: %d — %s", rec.Code, rec.Body.String()) + } + id, msg := f.book(t, "intro") + replyAddr := organizerLine.FindStringSubmatch(icsOf(msg))[1] + if err := f.h.bookingSvc.CancelByID(context.Background(), id, ""); err != nil { + t.Fatal(err) + } + stubResend(t, sentBy(inviteBookerEmail, replyMessage(id+"@calnode", inviteBookerEmail, "ACCEPTED"))) + f.postInbound(t, replyAddr, rsvpSecret) + if got := f.rsvpStatus(t, id); got != "needs-action" { + t.Errorf("rsvp_status = %q on a cancelled booking; want needs-action", got) + } +} + // Calendar clients match a cancellation to the invite by UID and organizer. Changing the // RSVP inbox after a booking was made must not change the organizer its cancellation uses. func TestRSVP_organizerStaysFixedForTheBookingsLifetime(t *testing.T) { @@ -190,3 +228,67 @@ func TestRSVP_organizerStaysFixedForTheBookingsLifetime(t *testing.T) { t.Errorf("cancellation organizer = %v; want the invite's original %s:\n%s", m, first, cancelICS) } } + +func (f *inviteFixture) setupWebhook(t *testing.T) *httptest.ResponseRecorder { + t.Helper() + return f.do(t, f.h.SetupRSVPWebhook, http.MethodPost, "/v1/settings/email/rsvp-webhook", "") +} + +func stubEnsureWebhook(t *testing.T, fn func(ctx context.Context, apiKey, endpoint string) (string, bool, error)) { + t.Helper() + prev := ensureInboundWebhook + ensureInboundWebhook = fn + t.Cleanup(func() { ensureInboundWebhook = prev }) +} + +// The one-click path: Calnode points Resend at itself and stores the secret, and RSVP +// tracking is on without the admin ever handling the secret. +func TestRSVPWebhookSetup_storesSecretFromResend(t *testing.T) { + f := newRSVPFixture(t) + if _, err := f.h.db.Exec(`UPDATE server_settings SET resend_webhook_secret_enc = '' WHERE id = 1`); err != nil { + t.Fatal(err) + } + var gotEndpoint string + stubEnsureWebhook(t, func(_ context.Context, apiKey, endpoint string) (string, bool, error) { + gotEndpoint = endpoint + return rsvpSecret, true, nil + }) + + if rec := f.setupWebhook(t); rec.Code != http.StatusOK { + t.Fatalf("setup: %d — %s", rec.Code, rec.Body.String()) + } + if gotEndpoint != "https://book.team.example/v1/email/inbound/resend" { + t.Errorf("webhook endpoint = %q; want this instance's inbound URL", gotEndpoint) + } + if f.h.rsvpAddress(context.Background()) != rsvpInbox { + t.Error("RSVP tracking is still off after the webhook secret was stored") + } + // The stored secret is the one Resend signs with: a signed delivery now verifies. + if rec := f.postInbound(t, rsvpInbox, rsvpSecret); rec.Code != http.StatusOK { + t.Errorf("delivery signed with the stored secret: %d — %s", rec.Code, rec.Body.String()) + } +} + +// When automatic setup cannot work, the admin is told why in words they can act on. +func TestRSVPWebhookSetup_explainsWhyItCannot(t *testing.T) { + f := newRSVPFixture(t) + stubEnsureWebhook(t, func(context.Context, string, string) (string, bool, error) { + return "", false, mailer.ErrResendRestrictedKey + }) + rec := f.setupWebhook(t) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Full access") { + t.Errorf("sending-only key: %d — %s; want 422 asking for a Full access key", rec.Code, rec.Body.String()) + } + + called := false + stubEnsureWebhook(t, func(context.Context, string, string) (string, bool, error) { + called = true + return "", false, nil + }) + f.h.SetMailer(f.mail, "http://localhost:3000") + rec = f.setupWebhook(t) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "https") || called { + t.Errorf("non-https instance: %d — %s (Resend called: %v); want 422 before calling Resend", + rec.Code, rec.Body.String(), called) + } +} diff --git a/internal/mailer/resend_inbound.go b/internal/mailer/resend_inbound.go index ba95717e..3c4cef99 100644 --- a/internal/mailer/resend_inbound.go +++ b/internal/mailer/resend_inbound.go @@ -5,7 +5,6 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" - "encoding/json" "errors" "fmt" "io" @@ -84,55 +83,69 @@ func (e ResendInboundEvent) Recipients() []string { return append(out, e.Data.ReceivedFor...) } -// resendReceivingEndpoint is the Received emails API. A var so tests can stub it. -var resendReceivingEndpoint = "https://api.resend.com/emails/receiving/" - // maxRawEmail bounds a downloaded message. An RSVP is a few KB; this only stops a // pathological message from being read into memory whole. const maxRawEmail = 5 << 20 -// FetchReceivedRaw downloads the original bytes of a received email: one API call for the -// short-lived signed URL, one GET for the message. -func FetchReceivedRaw(ctx context.Context, apiKey, emailID string) ([]byte, error) { - if apiKey == "" { - return nil, errors.New("resend inbound: api key not configured") - } - client := &http.Client{Timeout: resendTimeout} +// ReceivedEmail is a message Resend received: its original bytes plus what Resend itself +// established about the sender. From is the header From address; the authentication +// results are computed by Resend's receiving server, so the sender cannot forge them. +type ReceivedEmail struct { + Raw []byte + From string + SPF string // "pass", "fail", "gray", ...; "" when Resend reported none + DKIM string + // DMARC "pass" means SPF or DKIM passed aligned with the From domain. + DMARC string +} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, resendReceivingEndpoint+url.PathEscape(emailID), nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+apiKey) - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("resend inbound: retrieve email: %w", err) - } +// SenderAuthenticated reports whether Resend verified the From domain: DMARC passed, or +// DKIM passed (Resend's DKIM pass requires the signing domain to match From). A header +// From that nothing vouches for is just text anyone can type. +func (e ReceivedEmail) SenderAuthenticated() bool { + return e.DMARC == "pass" || e.DKIM == "pass" +} + +// FetchReceived retrieves a received email: one API call for its metadata and the +// short-lived raw download URL, one GET for the message. Needs a full-access API key; +// a sending-only key fails with ErrResendRestrictedKey. +func FetchReceived(ctx context.Context, apiKey, emailID string) (ReceivedEmail, error) { var meta struct { + From string `json:"from"` + Authentication *struct { + SPF string `json:"spf"` + DKIM string `json:"dkim"` + DMARC string `json:"dmarc"` + } `json:"authentication"` Raw *struct { DownloadURL string `json:"download_url"` } `json:"raw"` } - decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&meta) - resp.Body.Close() // #nosec G104 -- body fully consumed above; a close error changes nothing - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("resend inbound: retrieve email: status %d", resp.StatusCode) + if err := resendAPI(ctx, apiKey, http.MethodGet, "/emails/receiving/"+url.PathEscape(emailID), nil, &meta); err != nil { + return ReceivedEmail{}, fmt.Errorf("resend inbound: retrieve email: %w", err) + } + if meta.Raw == nil || meta.Raw.DownloadURL == "" { + return ReceivedEmail{}, errors.New("resend inbound: retrieve email: no raw download url") } - if decodeErr != nil || meta.Raw == nil || meta.Raw.DownloadURL == "" { - return nil, errors.New("resend inbound: retrieve email: no raw download url") + got := ReceivedEmail{From: meta.From} + if a := meta.Authentication; a != nil { + got.SPF, got.DKIM, got.DMARC = a.SPF, a.DKIM, a.DMARC } - req, err = http.NewRequestWithContext(ctx, http.MethodGet, meta.Raw.DownloadURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, meta.Raw.DownloadURL, nil) if err != nil { - return nil, err + return ReceivedEmail{}, err } - resp, err = client.Do(req) + resp, err := (&http.Client{Timeout: resendTimeout}).Do(req) if err != nil { - return nil, fmt.Errorf("resend inbound: download raw email: %w", err) + return ReceivedEmail{}, fmt.Errorf("resend inbound: download raw email: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("resend inbound: download raw email: status %d", resp.StatusCode) + return ReceivedEmail{}, fmt.Errorf("resend inbound: download raw email: status %d", resp.StatusCode) + } + if got.Raw, err = io.ReadAll(io.LimitReader(resp.Body, maxRawEmail)); err != nil { + return ReceivedEmail{}, fmt.Errorf("resend inbound: download raw email: %w", err) } - return io.ReadAll(io.LimitReader(resp.Body, maxRawEmail)) + return got, nil } diff --git a/internal/mailer/resend_webhooks.go b/internal/mailer/resend_webhooks.go new file mode 100644 index 00000000..4e96598f --- /dev/null +++ b/internal/mailer/resend_webhooks.go @@ -0,0 +1,129 @@ +package mailer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" +) + +// resendAPIBase is Resend's REST API. A var so tests can point it at a stub. +var resendAPIBase = "https://api.resend.com" + +// ErrResendRestrictedKey means the API key is a sending-only key. RSVP tracking reads +// received emails and manages webhooks, which Resend reserves for full-access keys. +var ErrResendRestrictedKey = errors.New("resend: this API key can only send email; RSVP tracking needs a full-access key") + +// resendAPI makes one authenticated JSON call to the Resend API, decoding the response +// into out (when non-nil). Non-2xx answers become errors carrying Resend's own message; +// a sending-only key is reported as ErrResendRestrictedKey so callers can explain it. +func resendAPI(ctx context.Context, apiKey, method, path string, body, out any) error { + if apiKey == "" { + return errors.New("resend: api key not configured") + } + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + reqBody = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, resendAPIBase+path, reqBody) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := (&http.Client{Timeout: resendTimeout}).Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnreachable, err) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + var apiErr struct { + Name string `json:"name"` + Message string `json:"message"` + } + _ = json.Unmarshal(data, &apiErr) + if apiErr.Name == "restricted_api_key" && resp.StatusCode == http.StatusUnauthorized { + return ErrResendRestrictedKey + } + if apiErr.Message != "" { + return fmt.Errorf("resend api %d: %s", resp.StatusCode, apiErr.Message) + } + return fmt.Errorf("resend api %d", resp.StatusCode) + } + if out == nil { + return nil + } + return json.Unmarshal(data, out) +} + +// inboundWebhookEvent is the only event RSVP tracking subscribes to. +const inboundWebhookEvent = "email.received" + +// EnsureResendInboundWebhook makes sure Resend delivers email.received events to endpoint +// and returns that webhook's signing secret. An existing webhook for the same endpoint is +// reused (subscribed to email.received and enabled if it was not), so running this again +// never piles up duplicates or rotates a secret that is already stored. +func EnsureResendInboundWebhook(ctx context.Context, apiKey, endpoint string) (secret string, created bool, err error) { + var list struct { + Data []struct { + ID string `json:"id"` + Endpoint string `json:"endpoint"` + Status string `json:"status"` + Events []string `json:"events"` + } `json:"data"` + } + if err := resendAPI(ctx, apiKey, http.MethodGet, "/webhooks", nil, &list); err != nil { + return "", false, fmt.Errorf("list webhooks: %w", err) + } + + var got struct { + ID string `json:"id"` + SigningSecret string `json:"signing_secret"` + } + for _, wh := range list.Data { + if wh.Endpoint != endpoint { + continue + } + if !slices.Contains(wh.Events, inboundWebhookEvent) || wh.Status != "enabled" { + events := wh.Events + if !slices.Contains(events, inboundWebhookEvent) { + events = append(slices.Clone(events), inboundWebhookEvent) + } + update := map[string]any{"events": events, "status": "enabled"} + if err := resendAPI(ctx, apiKey, http.MethodPatch, "/webhooks/"+url.PathEscape(wh.ID), update, nil); err != nil { + return "", false, fmt.Errorf("update webhook: %w", err) + } + } + if err := resendAPI(ctx, apiKey, http.MethodGet, "/webhooks/"+url.PathEscape(wh.ID), nil, &got); err != nil { + return "", false, fmt.Errorf("retrieve webhook: %w", err) + } + if got.SigningSecret == "" { + return "", false, errors.New("retrieve webhook: no signing secret in response") + } + return got.SigningSecret, false, nil + } + + create := map[string]any{"endpoint": endpoint, "events": []string{inboundWebhookEvent}} + if err := resendAPI(ctx, apiKey, http.MethodPost, "/webhooks", create, &got); err != nil { + return "", false, fmt.Errorf("create webhook: %w", err) + } + if got.SigningSecret == "" { + return "", false, errors.New("create webhook: no signing secret in response") + } + return got.SigningSecret, true, nil +} diff --git a/internal/mailer/resend_webhooks_test.go b/internal/mailer/resend_webhooks_test.go new file mode 100644 index 00000000..e89bf314 --- /dev/null +++ b/internal/mailer/resend_webhooks_test.go @@ -0,0 +1,152 @@ +package mailer + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "slices" + "sync" + "testing" +) + +// fakeResend is a stand-in for the parts of Resend's API that RSVP tracking uses. It +// records every call as "METHOD /path". +type fakeResend struct { + mu sync.Mutex + calls []string + webhooks []map[string]any + patched map[string]any + created map[string]any +} + +func (f *fakeResend) start(t *testing.T, apiKey string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/raw/em_1" { // the signed download URL carries no API key + _, _ = w.Write([]byte("From: guest@booker.example\r\n\r\nhello")) + return + } + if r.Header.Get("Authorization") != "Bearer "+apiKey { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"statusCode":401,"name":"restricted_api_key","message":"This API key is restricted to only send emails."}`)) + return + } + switch { + case r.Method == http.MethodGet && r.URL.Path == "/webhooks": + _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": f.webhooks}) + case r.Method == http.MethodPost && r.URL.Path == "/webhooks": + _ = json.NewDecoder(r.Body).Decode(&f.created) + _, _ = w.Write([]byte(`{"object":"webhook","id":"wh_new","signing_secret":"whsec_new"}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/webhooks/wh_old": + _ = json.NewDecoder(r.Body).Decode(&f.patched) + _, _ = w.Write([]byte(`{"object":"webhook","id":"wh_old"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/webhooks/wh_old": + _, _ = w.Write([]byte(`{"object":"webhook","id":"wh_old","signing_secret":"whsec_old"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/emails/receiving/em_1": + _ = json.NewEncoder(w).Encode(map[string]any{ + "object": "email", "id": "em_1", "from": "Guest ", + "authentication": map[string]string{"spf": "pass", "dkim": "pass", "dmarc": "gray"}, + "raw": map[string]string{"download_url": "http://" + r.Host + "/raw/em_1"}, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + prev := resendAPIBase + resendAPIBase = srv.URL + t.Cleanup(func() { resendAPIBase = prev }) +} + +const inboundURL = "https://book.example.com/v1/email/inbound/resend" + +func TestEnsureResendInboundWebhook_createsWhenMissing(t *testing.T) { + f := &fakeResend{webhooks: []map[string]any{ + {"id": "wh_other", "endpoint": "https://other.example.com/hook", "status": "enabled", "events": []string{"email.sent"}}, + }} + f.start(t, "re_full") + + secret, created, err := EnsureResendInboundWebhook(context.Background(), "re_full", inboundURL) + if err != nil { + t.Fatalf("ensure: %v", err) + } + if secret != "whsec_new" || !created { + t.Errorf("got secret %q created %v; want the new webhook's secret", secret, created) + } + if f.created["endpoint"] != inboundURL || !slices.Equal(toStrings(f.created["events"]), []string{"email.received"}) { + t.Errorf("created webhook = %v; want our endpoint subscribed to email.received", f.created) + } +} + +// Running setup again, or after someone added the webhook by hand, must reuse it: a +// second webhook would deliver every reply twice. +func TestEnsureResendInboundWebhook_reusesAndRepairsExisting(t *testing.T) { + f := &fakeResend{webhooks: []map[string]any{ + {"id": "wh_old", "endpoint": inboundURL, "status": "disabled", "events": []string{"email.bounced"}}, + }} + f.start(t, "re_full") + + secret, created, err := EnsureResendInboundWebhook(context.Background(), "re_full", inboundURL) + if err != nil { + t.Fatalf("ensure: %v", err) + } + if secret != "whsec_old" || created { + t.Errorf("got secret %q created %v; want the existing webhook's secret, nothing created", secret, created) + } + if slices.Contains(f.calls, "POST /webhooks") { + t.Errorf("created a duplicate webhook: calls %v", f.calls) + } + events := toStrings(f.patched["events"]) + if f.patched["status"] != "enabled" || !slices.Contains(events, "email.received") || !slices.Contains(events, "email.bounced") { + t.Errorf("patch = %v; want it enabled with email.received added and existing events kept", f.patched) + } +} + +// A sending-only key is the common mistake: it must come back as the error the settings +// page can explain, not as a generic 401. +func TestEnsureResendInboundWebhook_sendingOnlyKey(t *testing.T) { + f := &fakeResend{} + f.start(t, "re_full") + _, _, err := EnsureResendInboundWebhook(context.Background(), "re_sending_only", inboundURL) + if !errors.Is(err, ErrResendRestrictedKey) { + t.Errorf("got %v; want ErrResendRestrictedKey", err) + } +} + +// The sender check that guards RSVPs reads Resend's authentication results; misreading +// them would silently drop every answer (or, worse, trust unverified ones). +func TestFetchReceived_returnsSenderAndAuthentication(t *testing.T) { + f := &fakeResend{} + f.start(t, "re_full") + got, err := FetchReceived(context.Background(), "re_full", "em_1") + if err != nil { + t.Fatalf("FetchReceived: %v", err) + } + if got.From != "Guest " || string(got.Raw) != "From: guest@booker.example\r\n\r\nhello" { + t.Errorf("got From %q raw %q", got.From, got.Raw) + } + if got.DKIM != "pass" || got.DMARC != "gray" || !got.SenderAuthenticated() { + t.Errorf("authentication = %+v; want DKIM pass to authenticate the sender", got) + } + if _, err := FetchReceived(context.Background(), "re_sending_only", "em_1"); !errors.Is(err, ErrResendRestrictedKey) { + t.Errorf("sending-only key: got %v; want ErrResendRestrictedKey", err) + } +} + +func toStrings(v any) []string { + var out []string + if list, ok := v.([]any); ok { + for _, s := range list { + if str, ok := s.(string); ok { + out = append(out, str) + } + } + } + return out +} diff --git a/internal/server/server.go b/internal/server/server.go index b7810a78..6c0c07ca 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -365,6 +365,7 @@ func New(ctx context.Context, cfg *config.Config, db *sql.DB, logger *slog.Logge mux.HandleFunc("GET /v1/settings/email", h.RequireAuth(h.GetEmailSettings)) mux.HandleFunc("PATCH /v1/settings/email", settingsRL(h.RequireAuth(h.PatchEmailSettings))) mux.HandleFunc("POST /v1/settings/email/test", settingsRL(h.RequireAuth(h.TestEmailConnection))) + mux.HandleFunc("POST /v1/settings/email/rsvp-webhook", settingsRL(h.RequireAuth(h.SetupRSVPWebhook))) mux.HandleFunc("GET /v1/settings/google", h.RequireAuth(h.GetGoogleSettings)) mux.HandleFunc("PATCH /v1/settings/google", settingsRL(h.RequireAuth(h.PatchGoogleSettings))) mux.HandleFunc("GET /v1/settings/zoom", h.RequireAuth(h.GetZoomSettings))