Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-domain>/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)
Expand Down
35 changes: 35 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/routes/bookings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,13 @@
{:else if b.payment_status === 'pending'}
<Badge class="border-amber-200 bg-amber-50 text-amber-700">unpaid</Badge>
{/if}
{#if b.status === 'confirmed' && b.rsvp_status === 'accepted'}
<Badge class="border-green-200 bg-green-50 text-green-700">guest accepted</Badge>
{:else if b.status === 'confirmed' && b.rsvp_status === 'tentative'}
<Badge class="border-amber-200 bg-amber-50 text-amber-700">guest maybe</Badge>
{:else if b.status === 'confirmed' && b.rsvp_status === 'declined'}
<Badge class="border-transparent bg-destructive/10 text-destructive">guest declined</Badge>
{/if}
</div>
</td>
<td class="px-4 py-3">
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/routes/event-types/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventType | null>(null);
const TABS = [
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -707,6 +715,42 @@
</div>
</div>

<!-- Calendar invites -->
<div class="mt-6 border-t pt-5">
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Calendar invite</p>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="et-invite">Sent by</Label>
<Select.Root type="single" bind:value={form.invite_delivery}>
<Select.Trigger id="et-invite" class="w-full">
{INVITE_DELIVERY.find((o) => o.value === form.invite_delivery)?.label ?? 'Select…'}
</Select.Trigger>
<Select.Content>
{#each INVITE_DELIVERY as o}
<Select.Item value={o.value} label={o.label}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<p class="self-end text-xs text-muted-foreground">
{#if form.invite_delivery === 'calnode'}
Invitees get the invite from your workspace's email sender
(<a href="{base}/settings/email" class="underline">Settings → Email</a>), 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}
</p>
</div>
{#if form.invite_delivery === 'calnode' && et?.invite_sender_ready === false}
<p class="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Email isn't set up, so bookers won't receive an invite. Add a sender in
<a href="{base}/settings/email" class="underline">Settings → Email</a>.
</p>
{/if}
</div>

<!-- Price -->
<div class="mt-6 border-t pt-5">
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Price</p>
Expand Down
Loading
Loading