diff --git a/CHANGELOG.md b/CHANGELOG.md index 2681c281..8a7c2a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ All notable changes to Calnode are recorded here. The format follows exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrades. `1.0.0` will mark the point at which the API and schema are declared stable. +## [Unreleased] + +### Added +- **Calendar invites can come from Calnode instead of the host's calendar.** A new + per-event-type setting, *Calendar invite → Sent by*. The default is unchanged: the host's + connected calendar invites the booker, from the host's own address. Choose *Calnode* and + the booker's invite comes from your workspace's email sender (Settings → Email) instead, + so no host's personal address reaches them, and a team can book under one name. Hosts + still get the meeting, with its Meet/Teams link, on their own calendar. Needs email set + up. Existing bookings keep the way they were invited. + ## [0.10.1] - 2026-09-29 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77fcc2e0..8f7133b3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,6 +546,21 @@ 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). + **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..03e94520 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; 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/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/handler/booking_handler.go b/internal/handler/booking_handler.go index fcfb363f..1c5ec351 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -472,6 +472,9 @@ type bookableEventType struct { // so the page can grey them out. Read by the public slots endpoint only: the MCP // tool and the booking assistant must keep seeing bookable times and nothing else. ShowTakenSlots bool + // InviteDelivery is who sends the booker's invite (booking.InviteByCalendar/ByCalnode), + // copied onto each booking at creation. + InviteDelivery string } // loadBookableEventType loads an event type by slug for the booking-creation/slot paths. @@ -486,12 +489,14 @@ func (h *Handler) loadBookableEventType(ctx context.Context, slug string) (*book SELECT id, user_id, name, duration_minutes, slot_interval_minutes, location_type, location_value, allow_phone_call, routing_mode, rr_strategy, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_future_days, - is_active, is_public, show_taken_slots, max_active_bookings, price_cents, currency + is_active, is_public, show_taken_slots, max_active_bookings, price_cents, currency, + invite_delivery FROM event_types WHERE slug = ?`, slug). Scan(&et.ID, &et.UserID, &et.Name, &et.DurationMinutes, &et.SlotIntervalMinutes, &et.LocationType, &et.LocationValue, &et.AllowPhoneCall, &et.RoutingMode, &et.RRStrategy, &et.BufferBeforeMinutes, &et.BufferAfterMinutes, &et.MinNoticeMinutes, &et.MaxFutureDays, - &isActive, &isPublic, &showTaken, &et.MaxActiveBookings, &et.PriceCents, &et.Currency) + &isActive, &isPublic, &showTaken, &et.MaxActiveBookings, &et.PriceCents, &et.Currency, + &et.InviteDelivery) if err != nil || isActive == 0 || isPublic == 0 { return nil, errEventTypeNotFound } @@ -570,6 +575,7 @@ func (h *Handler) createBookingForSlug(ctx context.Context, slug string, startAt Answers: answers, MaxActivePerInvitee: et.MaxActiveBookings, MaxBookingsPerHour: maxBookingsPerEmailPerHour, + InviteDelivery: et.InviteDelivery, }) if err != nil { return nil, err @@ -900,6 +906,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) { Answers: answers, MaxActivePerInvitee: et.MaxActiveBookings, MaxBookingsPerHour: maxBookingsPerEmailPerHour, + InviteDelivery: et.InviteDelivery, }) if err != nil { if errors.Is(err, booking.ErrDoubleBooked) { @@ -1030,12 +1037,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 +1198,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 +1240,7 @@ func (h *Handler) createHostEventsAndNotify(ctx context.Context, b *booking.Book primaryPrefs = prefs } if prefs.NotifyHostBooking { - hd := h.hostBookingData(ctx, *bData, host, b.UpdatedAt) + hd := h.hostBookingData(ctx, *bData, host, b.InviteDelivery, b.UpdatedAt) if livekitHostURL != "" { hd.LocationValue = livekitHostURL // host email gets the controls-enabled link } @@ -1325,7 +1331,7 @@ func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConf // Attendee confirmation, once. "With:" names the primary host; gated on the // primary host's notification preference (matches prior behaviour). bData.HostName, bData.HostEmail = primaryHost(hosts).Name, primaryHost(hosts).Email - 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 { @@ -1764,13 +1770,13 @@ func (h *Handler) cancelSideEffects(b booking.Booking) { d.HostName, d.HostEmail = host.Name, host.Email // attendee "With:" = primary host, not owner } if prefs.NotifyHostCancel { - hd := h.hostBookingData(ctx, d, host, b.UpdatedAt) + hd := h.hostBookingData(ctx, d, host, b.InviteDelivery, b.UpdatedAt) if err := mailer.SendCancellationToHost(ctx, h.mailer, hd); err != nil { h.logger.Error("booking cancellation email (host)", "error", err, "booking_id", b.ID, "host", host.UserID) } } } - 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/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..8b1cb77f --- /dev/null +++ b/internal/handler/invite_delivery.go @@ -0,0 +1,94 @@ +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 instance sender, never the host. +// Calendar-sent: attach only when the primary host's calendar will not invite the booker +// itself (noConnectedDestination), with the host as organizer, exactly as before. +func (h *Handler) applyInviteDelivery(ctx context.Context, d *mailer.BookingData, mode, primaryHostID string) { + if mode == booking.InviteByCalnode { + d.AttachICS = true + d.HideHostInInvite = true + d.InviteOrganizerName, d.InviteOrganizerEmail = h.inviteSender(ctx) + return + } + d.AttachICS = h.noConnectedDestination(ctx, primaryHostID) +} + +// applyHostInvite turns booking email data into one host's own copy: organized by that +// host, attached only when their calendar does not already hold the event, and - for a +// Calnode-invited booking - without the booker as a guest (see ICSWithoutAttendee). +func (h *Handler) applyHostInvite(ctx context.Context, d *mailer.BookingData, mode, hostID string) { + d.HideHostInInvite = false + d.ICSWithoutAttendee = mode == booking.InviteByCalnode + d.AttachICS = h.noConnectedDestination(ctx, hostID) +} diff --git a/internal/handler/invite_delivery_internal_test.go b/internal/handler/invite_delivery_internal_test.go 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/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"