diff --git a/CHANGELOG.md b/CHANGELOG.md index 2681c281..df6061e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ 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. +- **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. 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 ### Added diff --git a/DEPLOY.md b/DEPLOY.md index 9956f710..0cddc97a 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -127,6 +127,32 @@ 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. + +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. 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. + --- ## 5. Google OAuth (sign-in + calendar) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77fcc2e0..8baf33cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,6 +546,41 @@ 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 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). + +**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 **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` 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..4f2172c3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -37,6 +37,13 @@ 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'; + /** 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; @@ -103,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 = { @@ -181,6 +190,14 @@ 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; + /** 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/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/event-types/[slug]/+page.svelte b/frontend/src/routes/event-types/[slug]/+page.svelte index d79fd07a..8243ff7c 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,42 @@ + +
+

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} +

+
+ {#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} +
+

Price

diff --git a/frontend/src/routes/settings/email/+page.svelte b/frontend/src/routes/settings/email/+page.svelte index f5fc2ea4..6a5ff3fe 100644 --- a/frontend/src/routes/settings/email/+page.svelte +++ b/frontend/src/routes/settings/email/+page.svelte @@ -27,6 +27,14 @@ // 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 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(''); @@ -53,9 +61,13 @@ smtpStartTLS = email.smtp_starttls; emailFrom = email.email_from; emailFromName = email.email_from_name || 'Calnode'; + rsvpAddress = email.rsvp_address ?? ''; }, '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, @@ -66,12 +78,44 @@ // 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(); + 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() { @@ -224,6 +268,109 @@
+ +
+
+
+

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} +
+ +

+ 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 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. +

+
+ +
+

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} +
+
+ +
+ +
+
{/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/booking/booking.go b/internal/booking/booking.go index 85ac6321..380fe499 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 @@ -33,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). @@ -88,4 +102,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..29e7aa43 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 @@ -211,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 } @@ -300,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, @@ -669,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/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/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 fcfb363f..02a7ef99 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 @@ -610,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. @@ -900,6 +909,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) { @@ -1030,12 +1040,11 @@ 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()) return hd } @@ -1192,7 +1201,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(b.InviteDelivery, in.OrganizerEmail), AddMeet: autoGenMeet && host.IsPrimary, }) if err != nil { @@ -1234,7 +1243,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 } @@ -1325,7 +1334,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, b.InviteDelivery, b.HostID) bData.ICSSequence = int(b.UpdatedAt.Unix()) confirmFailed := hostFailed if primaryPrefs.NotifyConfirmation { @@ -1573,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 @@ -1612,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. @@ -1764,13 +1785,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) } } } - d.AttachICS = h.noConnectedDestination(ctx, 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/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/email_settings.go b/internal/handler/email_settings.go index 80a13bc7..0b75e026 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,12 @@ 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 != "", + "rsvp_webhook_url": h.rsvpWebhookURL(), }) } @@ -179,11 +187,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 +223,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 +262,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 +282,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 +349,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/event_type.go b/internal/handler/event_type.go index a49413a9..c0bf93ba 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"` @@ -67,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 { @@ -95,7 +105,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 +164,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 +231,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 +306,19 @@ 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 { + mode, ok := normalizeInviteDelivery(*req.InviteDelivery) + if !ok { + h.writeError(w, http.StatusBadRequest, "invite_delivery must be 'calendar' or 'calnode'") + return + } + if mode == booking.InviteByCalnode && !h.inviteSenderReady(r.Context()) { + h.writeError(w, http.StatusBadRequest, errInviteSenderMissing.Error()) + return + } + inviteDelivery = mode + } id := uid.New() tx, err := h.db.BeginTx(r.Context(), nil) @@ -310,12 +334,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) { @@ -415,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") @@ -448,6 +473,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 +628,15 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { } set("show_taken_slots", v) } + 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 { // strftime literal (no bound value) — matches the DB's timestamp format; @@ -670,10 +705,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 +749,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 newInviteDelivery != "" && newInviteDelivery != curInviteDelivery { + if newInviteDelivery == booking.InviteByCalnode && !h.inviteSenderReady(r.Context()) { + h.writeError(w, http.StatusBadRequest, errInviteSenderMissing.Error()) + return + } + set("invite_delivery", newInviteDelivery) + } + // 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..81164fd0 --- /dev/null +++ b/internal/handler/invite_delivery.go @@ -0,0 +1,134 @@ +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 (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") + +// 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 "", false +} + +// 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 "" + } + 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 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 { + _, from := h.inviteSender(ctx) + return from != "" && h.isEmailEnabled() +} + +// applyInviteDelivery prepares the booker's email .ics for the booking's invite mode. +// 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.bookingInviteOrganizer(ctx, d.BookingID) + return + } + 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) +} + +// 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/invite_delivery_internal_test.go b/internal/handler/invite_delivery_internal_test.go new file mode 100644 index 00000000..f5ecc4e8 --- /dev/null +++ b/internal/handler/invite_delivery_internal_test.go @@ -0,0 +1,431 @@ +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") { + // Unfold RFC 5545 continuation lines so assertions see whole properties. + return strings.ReplaceAll(string(a.Content), "\r\n ", "") + } + } + 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) + } +} + +// 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 191ef1ad..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 } - d.AttachICS = h.noConnectedDestination(ctx, 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,7 +333,9 @@ func (h *Handler) rescheduleSideEffects(bCopy booking.Booking, capturedEtID stri } } if prefs.NotifyHostReschedule { - if err := mailer.SendRescheduleToHost(ctx, h.mailer, d); err != nil { + hd := d + 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 27ad9bb8..2a7b893b 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 := bCopy.InviteDelivery 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 + 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/handler/rsvp_inbound.go b/internal/handler/rsvp_inbound.go new file mode 100644 index 00000000..9f5cc594 --- /dev/null +++ b/internal/handler/rsvp_inbound.go @@ -0,0 +1,341 @@ +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. +// +// 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. + +// fetchReceived retrieves a received message from Resend; a var so tests can stand in +// for Resend. +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} + +// 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 + } + + // 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.StatusUnauthorized, "invalid signature") + 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 + } + msg, err := fetchReceived(ctx, apiKey, ev.Data.EmailID) + if err != nil { + 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 + } + 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 + } + 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. 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) { + 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 + AND b.status = 'confirmed' AND b.end_at > ?`, a.Address, now).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) + } +} + +// 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 new file mode 100644 index 00000000..a102c93c --- /dev/null +++ b/internal/handler/rsvp_inbound_internal_test.go @@ -0,0 +1,294 @@ +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/mailer" + "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 msg for any email id. +func stubResend(t *testing.T, msg mailer.ReceivedEmail) { + t.Helper() + prev := fetchReceived + fetchReceived = func(_ context.Context, apiKey, _ string) (mailer.ReceivedEmail, error) { + if apiKey != "re_test_key" { + return mailer.ReceivedEmail{}, errors.New("wrong api key") + } + return msg, nil + } + 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 { + 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, sentBy(inviteBookerEmail, 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) + } +} + +// 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 { + 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) + } + bookersDecline := replyMessage(id+"@calnode", inviteBookerEmail, "DECLINED") + + t.Run("forged webhook", func(t *testing.T) { + 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, 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, sentBy(inviteBookerEmail, 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) + } +} + +// 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) { + 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) + } +} + +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/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/booking.go b/internal/mailer/booking.go index c41c63ea..7791b310 100644 --- a/internal/mailer/booking.go +++ b/internal/mailer/booking.go @@ -33,11 +33,25 @@ 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 + // 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 df478ab8..c788405f 100644 --- a/internal/mailer/ics.go +++ b/internal/mailer/ics.go @@ -12,13 +12,16 @@ 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" { status = "CANCELLED" } + method = d.icsMethod(method) stamp := time.Now().UTC().Format(icsTimeLayout) var b strings.Builder @@ -42,10 +45,14 @@ 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 d.OrganizerEmail != "" { + if orgEmail != "" { + writeICSLine(&b, "ORGANIZER;CN="+escapeICSParam(orgName)+":mailto:"+orgEmail) + } + if d.OrganizerEmail != "" && !d.ICSWithoutAttendee { writeICSLine(&b, "ATTENDEE;CN="+escapeICSParam(d.OrganizerName)+";RSVP=TRUE:mailto:"+d.OrganizerEmail) } writeICSLine(&b, "STATUS:"+status) @@ -61,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 c0faa023..bc863325 100644 --- a/internal/mailer/ics_test.go +++ b/internal/mailer/ics_test.go @@ -40,6 +40,44 @@ 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) + } +} + +// 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" 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..3c4cef99 --- /dev/null +++ b/internal/mailer/resend_inbound.go @@ -0,0 +1,151 @@ +package mailer + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "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...) +} + +// 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 + +// 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 +} + +// 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"` + } + 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") + } + 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) + if err != nil { + return ReceivedEmail{}, err + } + resp, err := (&http.Client{Timeout: resendTimeout}).Do(req) + if err != nil { + return ReceivedEmail{}, fmt.Errorf("resend inbound: download raw email: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + 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 got, nil +} 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/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 871667f9..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)) @@ -517,6 +518,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 {