Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

calrs

Fast, self-hostable scheduling. Like Cal.com, but written in Rust.

calrs is an open-source scheduling platform. Connect your CalDAV calendar (Nextcloud, Fastmail, BlueMind, iCloud…), define bookable meeting types, and share a link. No Node.js, no PostgreSQL, no subscription.

Key features

  • CalDAV sync — pull events from any CalDAV server for free/busy computation, with multi-VEVENT support for recurring event modifications
  • CalDAV write-back — confirmed bookings are automatically pushed to your calendar
  • Availability engine — computes free slots from availability rules + calendar events
  • Recurring events — RRULE expansion (DAILY/WEEKLY/MONTHLY with INTERVAL, UNTIL, COUNT, BYDAY, EXDATE) blocks availability correctly
  • Event types — bookable meeting templates with duration, buffers, minimum notice
  • Booking flow — public slot picker, booking form, email confirmations with .ics invites
  • Email approve/decline — approve or decline pending bookings directly from the notification email
  • HTML emails — clean, responsive HTML notifications with plain text fallback
  • Teams — unified scheduling across team members with round-robin or collective modes
  • Timezone support — guest timezone picker with browser auto-detection; CalDAV events are converted from their original timezone to your host timezone, so availability is always accurate regardless of where your calendar events were created
  • Authentication — local accounts (Argon2) or OIDC/SSO (Keycloak, Authentik, etc.)
  • Web dashboard — manage event types, calendar sources, pending approvals, bookings
  • Dark/light theme — manual toggle (System/Light/Dark) on public pages and dashboard settings
  • Admin panel — user management, auth settings, OIDC config, SMTP status, impersonation
  • Structured loggingtracing + tower-http for production observability, configurable via RUST_LOG
  • Three-level visibility — public (listed on profile), internal (any team member generates invite links for external contacts), private (invite-only by owner)
  • Availability overrides — block specific dates or set custom hours per event type
  • Security hardening — CSRF protection, booking rate limiting, input validation, double-booking prevention
  • Availability troubleshoot — visual timeline showing why slots are blocked
  • SQLite storage — single-file WAL-mode database, zero ops
  • Markdown descriptions — bold, italic, links, and inline code in user bio, event type descriptions, and team descriptions. Formatting toolbar with live preview on all description fields
  • Onboarding — getting-started checklist and guided action cards on the dashboard overview
  • Single binary — no runtime dependencies

calrs dashboard

How it works

  1. Connect your CalDAV calendar (or multiple calendars)
  2. Sync events so calrs knows when you’re busy
  3. Create event types with your availability schedule
  4. Share your booking link (/u/yourname/meeting-slug)
  5. Guests pick a slot, fill in their details, and book
  6. Both parties get an email with a calendar invite
  7. The booking appears on your CalDAV calendar automatically

License

AGPL-3.0 — free to use, modify, and self-host.

Getting Started

Installation

See Deployment for Docker, systemd, and binary install options.

For development:

cargo build --release

First-time setup

  1. Start the server:
    calrs serve --port 3000
    
  2. Open http://localhost:3000 in your browser
  3. Register an account — the first user automatically becomes admin
  4. From the dashboard, add a CalDAV source and create your first event type

Option 2: CLI

# Create an admin user
calrs user create --email alice@example.com --name "Alice" --admin

# Connect your CalDAV calendar
calrs source add --url https://nextcloud.example.com/remote.php/dav \
                 --username alice --name "My Calendar"

# Pull events
calrs sync

# Create a bookable meeting type
calrs event-type create --title "30min intro call" --slug intro --duration 30

# Check available slots
calrs event-type slots intro

# Start the web server
calrs serve --port 3000

Environment variables

VariableDescriptionDefault
CALRS_DATA_DIRDirectory for the SQLite databasePlatform-specific (XDG)
CALRS_BASE_URLPublic URL (needed for OIDC callbacks and email action links)http://localhost:3000

Data directory

calrs stores everything in a single SQLite database (calrs.db) inside the data directory. By default this follows XDG conventions:

  • Linux: ~/.local/share/calrs/
  • macOS: ~/Library/Application Support/calrs/

Override with CALRS_DATA_DIR or --data-dir.

Quick test

After setup, your booking page is available at:

  • /u/yourname — your profile listing all event types
  • /u/yourname/intro — the slot picker for the “intro” event type

CalDAV Integration

calrs connects to any CalDAV server to read your calendar for free/busy computation and optionally write confirmed bookings back.

Connecting a calendar source

From the web dashboard

  1. Go to Dashboard > Calendar sources > + Add
  2. Select your provider (BlueMind, Nextcloud, Fastmail, etc.) — the URL is auto-filled
  3. Enter your username and password
  4. Click Add source

The connection is tested automatically before saving. Use “Skip connection test” if your server doesn’t respond to OPTIONS requests (e.g., BlueMind).

Add calendar source

From the CLI

calrs source add --url https://nextcloud.example.com/remote.php/dav \
                 --username alice --name "Work Calendar"

# Skip connection test if needed
calrs source add --url https://mail.company.com/dav/ \
                 --username alice --name "BlueMind" --no-test

Provider URLs

ProviderCalDAV URL
BlueMindhttps://mail.yourcompany.com/dav/
Nextcloudhttps://cloud.example.com/remote.php/dav
Fastmailhttps://caldav.fastmail.com/dav/calendars/user/you@fastmail.com/
iCloudhttps://caldav.icloud.com/
Zimbrahttps://mail.example.com/dav/
SOGohttps://mail.example.com/SOGo/dav/
Radicalehttps://cal.example.com/

Tip: Use app-specific passwords for Fastmail and iCloud.

Google Calendar is not currently supported

Google dropped Basic Auth for CalDAV in 2020 and now requires OAuth2. Google “app passwords” only work for IMAP/SMTP, not CalDAV, so they will fail with 401 loginRequired against the CalDAV endpoint. OAuth2 support for CalDAV sources is not implemented yet.

If you need Google Calendar availability in calrs, a working pattern is to bridge it through a CalDAV server that can subscribe to a Google calendar (for example, Nextcloud’s calendar app), and point calrs at that server.

Auto-discovery

calrs follows the CalDAV standard (RFC 4791) for discovery:

  1. PROPFIND on the base URL to find the current-user-principal
  2. PROPFIND on the principal to find the calendar-home-set
  3. PROPFIND on the calendar home to list all calendars
  4. Filters to actual calendar collections (skips inbox, outbox, tasks, etc.)

Syncing

# Sync all sources
calrs sync

# Full re-sync (ignore sync tokens)
calrs sync --full

From the dashboard, click Sync on any source to trigger a sync.

Sync pulls all VEVENT data from your calendars and stores it in the local SQLite database. Events are upserted by UID (and RECURRENCE-ID for modified instances), so re-syncing is safe.

Multi-VEVENT resources

Some CalDAV servers (notably BlueMind) bundle recurring events and their modified instances into a single CalDAV resource containing multiple VEVENTs. calrs splits these and stores each VEVENT as a separate row:

  • The parent event has the RRULE and is stored with its UID
  • Modified instances have a RECURRENCE-ID and are stored alongside the parent with a composite unique key (uid, recurrence_id)
  • This ensures modified occurrences correctly block (or free) availability

CalDAV write-back

When a booking is confirmed, calrs can automatically push it to your CalDAV calendar as a VEVENT. When a booking is cancelled, the event is deleted.

Setup

  1. Sync your calendar source at least once (so calrs knows which calendars exist)
  2. On the dashboard, find your source under “Calendar sources”
  3. Use the “Write bookings to” dropdown to select which calendar should receive bookings
  4. Select “None” to disable write-back

How it works

  • On confirmation: calrs generates an ICS event and PUTs it to {calendar-href}/{booking-uid}.ics
  • On cancellation: calrs DELETEs the event from the same path
  • The booking tracks which calendar it was pushed to, so cancellation always targets the right calendar
  • If no write calendar is configured, write-back is silently skipped (emails still work)
  • Write-back works for individual bookings, group round-robin bookings, and pending-then-confirmed bookings

Managing sources

# List all sources
calrs source list

# Test a connection
calrs source test <id-prefix>

# Remove a source (cascade-deletes calendars and events)
calrs source remove <id-prefix>

From the dashboard: Sync, Test, and Remove buttons are available for each source.

Credentials

Passwords are hex-encoded and stored in the SQLite database. This is not encryption — it prevents accidental display in logs but does not protect against database access. Secure your data directory appropriately.

Event Types

Event types are bookable meeting templates. Each one defines the duration, availability schedule, and booking rules.

Meeting types overview

calrs supports seven distinct booking scenarios:

TypeWho books?How?Assigned toUse case
Personal (public)AnyoneListed on your profileYouFreelancer’s “30min intro call”
Personal (internal)Invited guestsAny colleague generates a linkYouSenior engineer: teammates share a “Code Review” link with external contributors
Personal (private)Invited guestsYou send an invite linkYouExecutive coaching for selected clients
Team (public)AnyoneListed on team pageRound-robinPublic support call page
Team (internal)Invited guestsAny employee generates a linkRound-robinCross-team: Sales shares Support links with customers
Team (private)Invited guestsOwner sends invite linksRound-robinDemo team sends links to qualified leads
Dynamic groupAnyone with the URLAd-hoc link: /u/alice+bob/slugEvent type ownerOne-off sales call needing engineering support

Personal vs team: Personal event types book time on your calendar only. Team event types show combined availability (any member free) and assign the booking to the least-busy member via round-robin.

Dynamic group links: Ad-hoc collective meetings without creating a team — see Dynamic group links below.

Multi-timezone teams: For teams spread across timezones, set a wide availability window (e.g., 06:00–23:00) and let each member’s synced CalDAV calendar handle the blocking. The slot picker naturally shows the union of all members’ real availability — see Teams > Multi-timezone teams for details.

Creating an event type

From the dashboard

Go to Dashboard > Event types > + New and fill in:

  • Title — display name (e.g., “30-minute intro call”)
  • Slug — URL path (e.g., intro gives /u/yourname/intro)
  • Duration — meeting length in minutes
  • Slot interval — how often slots start (optional; leave blank to match duration — see Slot interval below)
  • Buffer before/after — padding between meetings (prevents back-to-back bookings)
  • Minimum notice — how far in advance guests must book (in minutes)
  • Requires confirmation — if checked, bookings start as “pending” and you approve from the dashboard
  • Additional guests — allow guests to invite additional attendees (0, 1, 3, 5, or 10 max)
  • Location — video link, phone number, in-person address, or custom text
  • Availability schedule — which days and hours you’re available

Description fields support Markdown formatting (bold, italic, links) with a toolbar and live preview.

Event type edit form

From the CLI

calrs event-type create \
  --title "30min intro call" \
  --slug intro \
  --duration 30 \
  --buffer-before 5 \
  --buffer-after 5

Calendar selection

When you have multiple CalDAV calendars, you can choose which calendars block availability for each event type. For example, a “Work meeting” event type can check only the work calendar, while a “Personal chat” checks only the personal calendar.

From the dashboard form, select the calendars under the Calendars section. Only calendars marked as “busy” (is_busy=1) appear.

Default behavior: If no calendars are selected, all busy calendars are checked — same as before. This is fully backward-compatible.

Availability schedule

Each event type has its own availability rules. By default: Monday–Friday, 09:00–17:00.

From the dashboard form, you can set:

  • Which days of the week are available (checkboxes)
  • Start and end time for available hours

The availability engine intersects these rules with your synced calendar events (filtered by selected calendars) and existing bookings to compute free slots.

Booking limits

Control how slots are displayed and how often the event type can be booked.

One slot per day

Enable “One slot per day” to show only the earliest available time each day. The guest sees one slot per day instead of all available windows — useful for daily standups, check-ins, or any event where you want at most one booking per day.

Frequency limits

Enable “Limit booking frequency” to cap how many bookings can be made per time period. You can combine multiple limits — for example, max 2 per day AND 8 per week. Available periods: day, week, month, year. When a limit is reached, the booking form rejects new bookings for that period.

Both settings are configured via toggle switches in the Booking limits card of the event type form.

Calendar views

The guest slot picker supports three views, switchable via icons in the calendar header:

ViewDescription
Month (default)Month calendar grid with a slot list panel on the right
Week7-day columns with time slots listed under each day
ColumnDays listed as rows with all time slot pills inline

The guest’s chosen view is remembered in their browser. Hosts can set which view guests see by default from the Booking options card in the event type form.

Slot interval

By default, slot start times are spaced by the event’s duration — a 20-minute event produces slots at 9:00, 9:20, 9:40, and so on. The Slot interval field decouples start-time spacing from meeting length.

DurationSlot intervalSlot start times
20 min(blank — default)9:00, 9:20, 9:40, 10:00, …
20 min309:00, 9:30, 10:00, 10:30, … (10-minute gap between meetings)
45 min609:00, 10:00, 11:00, … (rounded hourly starts)
60 min309:00, 9:30, 10:00, 10:30, … (overlap-allowed cadence — slots still honour busy times and buffers)

Set this when you want “every half hour on the dot” or similar rounded start times regardless of meeting length. Leave blank to preserve the legacy back-to-back behaviour. Buffers and minimum notice still apply on top.

Slot computation

Available slots are computed by:

  1. Generating candidate slots from availability rules (day of week + time range)
  2. Filtering out slots that overlap with calendar events (from CalDAV sync)
  3. Filtering out slots that overlap with confirmed bookings
  4. Applying buffer times (before and after each slot)
  5. Removing slots that violate minimum notice (too close to now)
  6. If “one slot per day” is enabled, keeping only the earliest slot per day
# View available slots for the next 7 days
calrs event-type slots intro

# View slots for the next 14 days
calrs event-type slots intro --days 14

Location

Event types support four location types:

TypeDescription
linkVideo meeting URL (Zoom, Meet, etc.)
phonePhone number
in_personPhysical address
customFree-text description

The location is displayed on the public booking page, in confirmation emails, and in .ics calendar invites.

Enabling/disabling

Event types can be toggled on/off from the dashboard without deleting them. Disabled event types don’t show up on your public profile and return 404 on their booking page.

Visibility

Event types have three visibility levels, set from the Visibility dropdown in the event type form:

LevelAvailable forListed publicly?Who can create invite links?Badge
PublicPersonal + TeamYesN/A (no invite needed)(none)
InternalPersonal + TeamNoAny authenticated userblue “internal”
PrivatePersonal + TeamNoEvent type owner onlyindigo “private”

Internal event types

Internal visibility is designed for cross-team and cross-person booking within an organization. It is available for both personal and team event types.

Typical use case (team): A Support team creates an internal “Support Call” event type. When a Sales rep needs to put a customer in touch with Support, they go to the Invite Links page, click “Get link” next to “Support Call”, and paste the generated URL in a Slack message or email to the customer. The customer clicks the link, picks a slot, and books — the link expires after 7 days and can’t be reused.

Typical use case (personal): A senior engineer creates an internal “Code Review” event type. Any teammate can generate a one-time link from the Invite Links page and share it with an external contributor who needs a review session.

The Invite Links page (/dashboard/organization) lists all internal event types across the organization — both personal and team. Each event type has:

  • Get link — generates a single-use invite link (expires in 7 days) and copies it to clipboard
  • Invites — opens the full invite management page for custom expiry, multi-use links, and guest pre-fill

Internal vs private: Internal lets any colleague generate links on the fly — ideal for cross-org services like support, IT help desk, or personal event types that colleagues need to share on your behalf. Private restricts link distribution to the event type owner only — better when you want controlled access. See Teams > Private teams vs internal vs private event types for a detailed comparison.

Private event types

Private event types are hidden from public pages and only accessible via invite links sent by the event type owner or team admin.

Typical use case: A demo team creates a private team event type. Sales reps send personalized invites to qualified leads. The demo is automatically assigned to the least-busy team member via round-robin.

Both internal and private event types use booking invites to grant access:

  1. Go to Dashboard > Event Types (or Organization) and click Invite
  2. Fill in the guest’s name, email, and an optional personal message
  3. Choose an expiration (7, 14, or 30 days, or never) and whether to allow multiple bookings
  4. Click Send invite — the guest receives an email with a personalized booking link

The invite link takes the guest directly to the slot picker with the invite token embedded. Their name and email are pre-filled on the booking form. The token is validated at every step (expired, used-up, or invalid tokens are rejected).

Invite management

The invite management page (/dashboard/invites/{event_type_id}) shows:

  • A “Get link” button at the top for one-click link generation — generates a single-use invite URL and copies it to your clipboard. No email form needed
  • A form to send invites via email (with guest name, email, message, expiry, and usage options)
  • A list of sent invites with status badges:
    • Active — invite is valid and unused (or has remaining uses)
    • Expired — past the expiration date
    • Used — all uses consumed (for single-use invites)
  • Delete button to revoke an invite

Availability overrides

Block specific dates or set custom hours per event type — perfect for holidays, conferences, or one-off schedule changes.

Go to Dashboard > Event Types > Overrides and add:

  • Block entire day — no slots available on that date (e.g., company holiday)
  • Custom hours — replace the weekly rules with specific time windows for that date (e.g., 08:00–12:00 only)

Multiple custom hour windows can be added for the same date (e.g., morning + afternoon with a lunch break). Overrides are visible in the Troubleshoot view with a banner showing when they’re active.

Public URLs

Public profile page

  • Profile: /u/yourname — lists all enabled, non-private event types
  • Slot picker: /u/yourname/slug — shows available time slots
  • Booking form: /u/yourname/slug/book?date=...&time=... — booking form for a specific slot
  • Invite booking: same URLs with ?invite={token} — for private event types accessed via invite links
  • Dynamic group: /u/alice+bob+carol/slug — collective availability across multiple users (see below)

Dynamic group links let you create ad-hoc collective meetings by combining usernames in the URL — no team setup required.

How it works

Take any public event type URL and add other usernames with +:

/u/alice/intro                → individual booking (Alice only)
/u/alice+bob/intro            → collective booking (Alice & Bob)
/u/alice+bob+carol/intro      → collective booking (Alice, Bob & Carol)

The first username owns the event type. Their event type settings (duration, buffer, availability rules) define the meeting. All participants’ calendars are checked — only slots where everyone is free are shown.

Dynamic group slot picker

From the event type edit page, public personal event types show a Dynamic Group Link card at the bottom. Type a username to search — only users who have opted in are shown. Click to add them, and the URL is built live with a copy button.

Dynamic group link builder

Opt-out

Users can disable being included in dynamic group links from Profile & Settings. The checkbox “Allow others to include me in dynamic group links” is enabled by default. When disabled, the user won’t appear in the search dropdown and any URL containing their username will show an error.

CalDAV write-back

When a dynamic group booking is confirmed:

  • The event is written to the first user’s (event type owner’s) CalDAV calendar
  • Other participants are added as ATTENDEE in the ICS event
  • CalDAV servers that support scheduling (Nextcloud, SOGo, etc.) automatically propagate the invite to participants’ calendars

Constraints

  • Only works with public event types (not internal or private)
  • Requires at least two usernames in the URL
  • All users must have allow_dynamic_group enabled

Booking Flow

Guest experience

  1. Visit the booking page/u/host/meeting-slug (or via an invite link for private event types, or /u/host+other/slug for dynamic group links)
  2. Pick a timezone — auto-detected from the browser, changeable via dropdown
  3. Browse available slots — displayed as a week view, navigate with Previous/Next buttons
  4. Click a slot — opens the booking form
  5. Fill in details — name, email, optional notes (pre-filled from invite if applicable)
  6. Add guests — optionally invite additional attendees (if the event type allows it)
  7. Submit — booking is created
  8. Confirmation page — shows booking summary (including any additional attendees)
  9. Email — guest and any additional attendees receive a confirmation email with an .ics calendar invite attached

Available time slots

Booking form

Booking statuses

StatusDescription
confirmedBooking is active. Slot is blocked. Emails sent.
pendingAwaiting host approval (when requires_confirmation is on).
cancelledCancelled by host or guest. Slot is freed.
declinedDeclined by host (pending booking rejected).

Confirmation mode

When an event type has requires confirmation enabled:

  1. Guest submits booking → status is pending
  2. Guest receives a “pending” email (no .ics yet)
  3. Host receives an “approval request” email with Approve and Decline buttons
  4. Host can approve/decline in two ways:
    • From the email — click the Approve or Decline button (no login required, token-based)
    • From the dashboard — go to Pending approval section and click Confirm or Decline
  5. On confirm: status becomes confirmed, guest receives confirmation email with .ics, booking is pushed to CalDAV
  6. On decline: status becomes declined, guest receives a decline notification with optional reason

Note: The email action buttons require CALRS_BASE_URL to be set. Without it, the host must use the dashboard.

Cancellation

From the dashboard, click Cancel on an upcoming booking:

  1. Optionally enter a reason
  2. Confirm the cancellation
  3. Both guest and host receive cancellation emails with a METHOD:CANCEL .ics attachment
  4. If the booking was pushed to CalDAV, the event is deleted from the calendar

Guest self-cancellation

Guests can cancel their own bookings via a link in the confirmation email:

  1. Click the “Cancel booking” link in the email
  2. Optionally enter a reason
  3. Confirm the cancellation
  4. Both guest and host are notified

The cancellation email correctly attributes who cancelled (host vs guest).

Reschedule

Bookings can be rescheduled without cancelling and rebooking. Both guests and hosts can initiate a reschedule.

Guest reschedule

Guests can reschedule their booking via the reschedule link in the confirmation or pending email:

  1. Click the “Reschedule” button in the email
  2. Pick a new time slot from the slot picker (the current booking’s slot is freed so it remains available)
  3. Confirm the new time
  4. The booking moves to pending status — the host must approve via email or dashboard
  5. If the booking was previously pushed to CalDAV, the event is removed (re-pushed on approval)

Host reschedule

Hosts can reschedule from the dashboard:

  1. Go to Dashboard > Bookings and click Reschedule on a booking
  2. Pick a new time slot
  3. Confirm the new time
  4. The booking stays confirmed — no approval needed
  5. The CalDAV event is updated in place (same UID)
  6. The guest receives a reschedule notification with the updated .ics invite

Token regeneration

After each reschedule, the reschedule_token, cancel_token, and confirm_token are regenerated. This invalidates any previous email links, ensuring only the latest links work.

Edge cases

  • Already cancelled/declined bookings cannot be rescheduled (error page shown)
  • Self-conflict is handled: the booking being rescheduled doesn’t block its own new slot
  • Group bookings keep the original assigned_user_id (no re-running round-robin)
  • Reminder state is reset: reminder_sent_at is cleared so a new reminder is sent for the updated time

Conflict detection

Before a booking is accepted, calrs checks for conflicts:

  • Calendar events — from synced CalDAV sources
  • Existing bookings — confirmed bookings on any event type
  • Buffer times — the buffer before/after is included in the conflict window
  • Minimum notice — slots too close to the current time are rejected

Additionally, a database-level unique index prevents two bookings from occupying the same slot, even if two guests submit simultaneously.

CalDAV write-back

When a booking is confirmed (either directly or via approval), calrs can push the event to the host’s CalDAV calendar. See CalDAV Integration > Write-back for setup.

Email notifications

If SMTP is configured, calrs sends emails at these moments:

EventGuest receivesHost receives
Booking confirmedConfirmation + .ics REQUESTNotification + .ics REQUEST
Booking pending“Awaiting confirmation” noticeApproval request with Approve/Decline buttons
Booking declinedDecline notice (with optional reason)
Booking cancelledCancellation + .ics CANCELCancellation + .ics CANCEL
Booking rescheduled (by host)Reschedule notification + updated .ics
Reschedule request (by guest)“Pending” notice with updated timeReschedule approval request with Approve/Decline buttons
Booking reminderReminder with cancel buttonReminder with details
Invite sentInvite email with booking link

All emails are sent as HTML with plain text fallback. They include event title, date, time, timezone, location, and notes. The HTML templates are responsive and support dark mode in email clients that honor prefers-color-scheme.

Timezone handling

  • Guest’s timezone is auto-detected via Intl.DateTimeFormat in the browser
  • A timezone dropdown lets the guest change it
  • Slots are displayed in the guest’s selected timezone
  • The booking is stored in the host’s timezone
  • The timezone is preserved across navigation (week picker, booking form)

CLI booking

calrs booking create intro \
  --date 2026-03-20 --time 14:00 \
  --name "Jane Doe" --email jane@example.com \
  --timezone Europe/Paris --notes "Let's discuss the project"

Teams

Teams allow multiple users to share booking pages with combined availability and automatic assignment.

Key concepts

Teams replace the old separate “Groups” and “Team Links” concepts into a single unified system.

FeatureDescription
VisibilityPublic (anyone can book) or Private (requires invite token)
Scheduling modeRound-robin (any member free, assigned to least-busy) or Collective (all members must be free)
Team adminManages event types and settings without needing global admin
OIDC syncOptionally link Keycloak groups — all group members become team members

Creating a team

From Dashboard > Teams > + New:

  1. Set name, slug, and description
  2. Choose visibility: public or private
  3. Pick members from all enabled users
  4. Optionally link OIDC groups (all group members become team members automatically)
  5. Click Create team

The creator becomes a team admin. Global admins can remove themselves from teams they created — they retain management access via the admin panel. This supports the IT admin use case of creating teams without being bookable.

Team settings

Any team admin can access settings from Dashboard > Teams > Settings:

  • Avatar upload — team profile image
  • Description — displayed on the public team page
  • Members — view members and their roles
  • OIDC group linking — from team settings, use the unified search bar to find and link OIDC groups. When a group is linked, all its members are automatically added to the team with source=‘group’. Members stay in sync on each OIDC login — new group members are added, removed members are cleaned up
  • Private teams — the invite link is shown with a copy button for sharing

Team event types

Team event types are created from Dashboard > Event Types > + New (select the team from the dropdown) or from Dashboard > Teams > team settings.

Unified event types page: Personal and team event types appear together in a single list on the Event Types dashboard page. Team event types are distinguished by a team name badge, so you can manage all your event types from one place.

They support the same options as personal event types:

  • Duration, buffer before/after, minimum notice
  • Availability schedule (days + hours)
  • Calendar selection, location, confirmation mode
  • Invite links (for private event types)

Additional team-specific options:

  • Scheduling mode — round-robin or collective (see below)
  • Member weights — admins can set priority per member via the Member Priority card, which appears during both creation and editing. Weights can be set globally on the team or overridden per event type. Weight 0 excludes a member from round-robin assignment for that event type — excluded members also don’t appear on the public booking page’s avatar list.

Public team pages

  • Public teams: /team/{slug} — shows team profile with avatar, description, members, and event types
  • Private teams: /team/{slug}?invite={token} — same page, but requires a valid invite token
  • Slot picker: /team/{slug}/{event-slug} — shows available slots based on the scheduling mode. The sidebar displays the team avatar and stacked member avatars (members excluded via weight 0 are hidden)
  • Legacy redirects: /g/{slug} redirects to /team/{slug}, /t/{token} redirects to /team/{slug}?invite={token}

Scheduling modes

Round-robin

A slot is available if any team member is free. The booking is assigned to the least-busy available member (fewest confirmed bookings).

When a booking is submitted:

  1. calrs finds all team members (with weight > 0)
  2. For each member, checks if the slot is free (no calendar events or bookings in the buffer window)
  3. Among available members, picks the one with the fewest confirmed bookings
  4. The booking is assigned to that member and pushed to their CalDAV calendar
  5. If no member is available, the booking is rejected

Best for: support queues, sales demos, intake calls — any scenario where the guest doesn’t care who they meet.

Collective

A slot is available only if all team members are free. The booking includes every member.

When a booking is submitted:

  1. calrs verifies all members are free for the slot
  2. The booking is created and pushed to every member’s CalDAV calendar
  3. Email notifications are sent to all members
  4. If any member has a conflict, the slot is not shown

Best for: panel interviews, group demos, team syncs with external guests.

Excluding members

On a collective event type, per-member exclusions can be set from the event type editor — useful when a team member is joining the team but shouldn’t (yet) be required for a specific event. Excluded members don’t gate the availability window, don’t receive notifications, and don’t appear on the public booking page’s avatar list.

Booking watchers

Designate a team as watchers on an event type to separate the “who gets booked” decision from the “who can pick up this booking” decision.

When a booking lands on a watched event type:

  1. Every watcher team member gets an email with a Claim this booking button
  2. The first watcher to click the button claims the booking — a short-lived token backs each button
  3. Subsequent clicks land on an “already claimed” page; no double-assignment
  4. Claimed bookings show up on the watcher’s dashboard with a “Claimed by you” badge

Typical setup: a customer self-serves through a public event type, and a rotating support team watches it. Whoever has bandwidth grabs the booking — no round-robin assumptions, no manual dispatch.

Configuration: in the event type editor, scroll to Booking watchers and pick one or more teams. Watchers can be set on any scheduling mode; they’re independent from the round-robin / collective assignment.

Multi-timezone teams

The availability window on a team event type (e.g., Mon-Fri 09:00-17:00) is defined once for the whole team and interpreted in the server’s timezone. For teams spread across timezones, this window may not cover everyone’s working hours.

Recommended setup: Set a wide availability window (e.g., 06:00-23:00 or even 00:00-23:59) and let each member’s CalDAV calendar handle the actual blocking. Because calrs syncs each member’s calendar independently and converts events from their original timezone, the slot picker naturally shows the correct availability:

  • Alice (Paris, 09:00-17:00 CET) — her calendar blocks evenings and weekends
  • Bob (New York, 09:00-17:00 EST) — his calendar blocks his mornings (CET afternoon/evening)
  • A guest sees slots from 09:00-23:00 CET, with Alice covering the morning and Bob covering the evening

This approach requires no per-member configuration — just sync your calendars and set a wide window.

OIDC group sync

Groups synced from your OIDC provider can be linked to teams, automatically adding group members as team members.

How it works

  1. User logs in via SSO
  2. calrs reads the groups claim from the JWT
  3. Groups are created if they don’t exist (leading / stripped from Keycloak paths)
  4. User is added to their groups and removed from groups they no longer belong to
  5. Groups linked to teams via the team_groups junction table sync membership automatically

OIDC-synced members get role='member', never admin. Manual team admin status is preserved across syncs.

Keycloak setup

In your Keycloak realm:

  1. Create groups under Groups (e.g., “Sales”, “Engineering”)
  2. Assign users to groups
  3. Add a groups mapper to your client:
    • Mapper type: Group Membership
    • Token claim name: groups
    • Add to ID token: ON
    • Full group path: ON (calrs strips the leading /)

Private teams vs internal vs private event types

There are three ways to restrict access to team bookings. They serve different use cases and can be combined:

MechanismWhat it gatesWho distributes linksUse case
Private teamThe entire team pageTeam admin shares one invite linkControlled distribution — only the team admin decides who books
Internal event typeA single event type (personal or team)Any authenticated employee via Invite Links page (under Shared Links in the sidebar)Self-serve — any Sales rep can generate a Support Call link for a customer
Private event typeA single event typeEvent type owner sends personalized invitesTargeted — send invites to specific guests with pre-filled info

When to use each

Private team — your team handles external meetings but you don’t want colleagues exposed to unsolicited bookings. The team admin shares the invite link only with approved contacts. Example: a consulting team where only the account manager shares the booking page with clients.

Internal event type — you (or your team) provide a cross-org service and you want any colleague to be a link distributor, without involving the owner each time. Works for both personal and team event types. Example: IT Help Desk, Support Calls, or a senior engineer’s “Code Review” slot — any colleague can generate a one-time link from the Invite Links page (under Shared Links in the sidebar) and paste it in a Slack message or support ticket. Links are single-use and expire after 7 days.

Private event type — you want to send personalized invites to specific guests with their name and email pre-filled. Example: demo team sends targeted invites to qualified leads with custom messages.

Combining them

  • A public team can have internal event types — the team page is public but some event types are only bookable via employee-generated links
  • A private team can have internal event types — guests need the team invite token first, then employees can generate per-event-type links
  • A public team can have private event types — the team page lists public event types, but private ones require their own invite
  • Personal internal event types work the same way — any colleague can generate links from the Invite Links page, but the booking is assigned to the event type owner (not round-robin)

Dashboard

The Teams page in the dashboard shows all teams you belong to:

  • Team avatar, name, and visibility badge (public/private)
  • Member count
  • Settings link (visible to team admins)
  • Global admins see all teams and can create new ones

Authentication

calrs supports two authentication methods: local accounts and OIDC (OpenID Connect) SSO.

Login page

Local accounts

Registration

  • The first user to register becomes admin
  • Registration can be enabled/disabled from the admin dashboard or CLI
  • Registration can be restricted to specific email domains
# Disable open registration
calrs config auth --registration false

# Restrict to a domain
calrs config auth --allowed-domains company.com

# Allow any domain
calrs config auth --allowed-domains any

Password hashing

Passwords are hashed with Argon2 (via the argon2 crate with password-hash). Plain-text passwords are never stored.

Sessions

  • Server-side sessions stored in SQLite
  • 30-day TTL
  • Session ID in an HttpOnly cookie (not accessible to JavaScript)
  • Sessions are invalidated on logout

User management (CLI)

calrs user create --email alice@example.com --name "Alice" --admin
calrs user list
calrs user set-password alice@example.com
calrs user promote alice@example.com    # → admin
calrs user demote alice@example.com     # → user
calrs user disable alice@example.com
calrs user enable alice@example.com

OIDC / SSO

calrs supports OpenID Connect for single sign-on, tested with Keycloak and compatible with any OIDC provider (Authentik, Auth0, etc.).

Features

  • Authorization code flow with PKCE — no client secret stored in the browser
  • Auto-discovery — reads .well-known/openid-configuration from the issuer URL
  • User linking by email — if a local user exists with the same email, the OIDC identity is linked
  • Auto-registration — new users are created on first OIDC login (if enabled)
  • Group sync — groups from the groups JWT claim are synced on each login and can be linked to teams

Configuration

calrs config oidc \
  --issuer-url https://keycloak.example.com/realms/your-realm \
  --client-id calrs \
  --client-secret YOUR_CLIENT_SECRET \
  --enabled true \
  --auto-register true

Or from the Admin dashboard > OIDC section.

Keycloak setup

  1. Create a new OpenID Connect client:
    • Client ID: calrs
    • Client authentication: ON (confidential)
    • Valid redirect URIs: https://your-calrs-host/auth/oidc/callback
    • Web origins: https://your-calrs-host
  2. Copy the Client secret from the Credentials tab
  3. Set CALRS_BASE_URL to your public URL before starting the server

The login page will show a “Sign in with SSO” button when OIDC is enabled.

User roles

RoleCapabilities
userManage own event types, calendar sources, bookings
team adminEverything above + manage team event types and team members
adminEverything above + user management, auth settings, OIDC config, SMTP config

The first registered user is automatically promoted to admin.

Email notifications (SMTP)

SMTP configuration is required for booking confirmation emails. Without it, bookings still work but no emails are sent.

calrs config smtp \
  --host smtp.example.com \
  --port 587 \
  --username calrs@example.com \
  --from-email calrs@example.com \
  --from-name "calrs"

# Test the configuration
calrs config smtp-test you@example.com

# View current config
calrs config show

Or configure from the Admin dashboard > SMTP section.

Admin Dashboard

The admin dashboard is available at /dashboard/admin for users with the admin role.

Admin dashboard

User management

Lists all registered users with:

  • Name, email, username
  • Role (admin/user)
  • Status (enabled/disabled)
  • Teams and groups

Actions per user:

  • Promote/Demote — toggle admin role
  • Enable/Disable — disabled users cannot log in or receive bookings
  • Impersonate — view the dashboard as that user (for troubleshooting)

Impersonation

Admins can impersonate any user to troubleshoot their view:

  1. Click Impersonate next to a user in the admin panel
  2. You are redirected to the dashboard, viewing it as that user
  3. A yellow banner at the top shows who you’re impersonating
  4. Click Stop impersonating to return to your own view

Impersonation uses a separate calrs_impersonate cookie (24-hour TTL). The real admin session is preserved.

Availability troubleshoot

For each event type, the dashboard offers a Troubleshoot link that opens a visual timeline at /dashboard/troubleshoot/{event_type_id}:

  • Shows candidate slots for the next 7 days
  • Displays why each slot is blocked (calendar event name, existing booking, buffer overlap)
  • Helps debug availability issues when users report incorrect free/busy status

Availability troubleshoot

Authentication settings

  • Registration — toggle open registration on/off
  • Allowed domains — restrict registration to specific email domains (comma-separated) or allow any

OIDC configuration

  • Enabled — toggle SSO login on/off
  • Issuer URL — your OIDC provider’s base URL
  • Client ID — the client ID registered with your provider
  • Client secret — update the secret (current value is never displayed)
  • Auto-register — automatically create users on first OIDC login

SMTP status

Shows whether SMTP is configured and the current sender address. SMTP is configured via CLI (calrs config smtp) or by editing the database directly.

Deployment

Pre-built images are available on GitHub Container Registry for amd64 and arm64:

docker run -d --name calrs \
  -p 3000:3000 \
  -v calrs-data:/var/lib/calrs \
  -e CALRS_BASE_URL=https://cal.example.com \
  ghcr.io/olivierlambert/calrs:latest

Podman works as a drop-in replacement — just use podman instead of docker in all commands. The Containerfile (Dockerfile) is compatible with both runtimes.

To pin to a specific version: ghcr.io/olivierlambert/calrs:0.14.0

The image uses a multi-stage build:

  • Builder: rust:slim-trixie — compiles the release binary
  • Runtime: debian:trixie-slim — minimal image with only ca-certificates
  • Runs as unprivileged calrs user
  • Data stored in /var/lib/calrs
  • Templates bundled at /opt/calrs/templates/

To build from source instead: docker build -t calrs .

Docker Compose / Podman Compose

services:
  calrs:
    image: ghcr.io/olivierlambert/calrs:latest
    ports:
      - "3000:3000"
    volumes:
      - calrs-data:/var/lib/calrs
    environment:
      - CALRS_BASE_URL=https://cal.example.com
    restart: unless-stopped

volumes:
  calrs-data:

Works with both docker compose and podman-compose.

Binary + systemd

# Build from source
cargo build --release

# Install binary and templates
sudo cp target/release/calrs /usr/local/bin/
sudo cp -r templates /var/lib/calrs/templates

# Create a system user
sudo useradd -r -s /bin/false -m -d /var/lib/calrs calrs

# Install the service
sudo cp calrs.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now calrs

Edit /etc/systemd/system/calrs.service to set CALRS_BASE_URL.

systemd service

The included calrs.service has security hardening:

  • NoNewPrivileges=true
  • ProtectSystem=strict
  • ProtectHome=true
  • ReadWritePaths=/var/lib/calrs
  • PrivateTmp=true
  • ProtectKernelTunables=true
  • ProtectControlGroups=true
  • Restart=on-failure with 5-second delay

From source (development)

cargo build --release
calrs serve --port 3000

Then register at http://localhost:3000 — the first user becomes admin.

Reverse proxy

calrs listens on port 3000 by default. Put nginx or caddy in front for TLS.

nginx example

server {
    listen 443 ssl http2;
    server_name cal.example.com;

    ssl_certificate /etc/letsencrypt/live/cal.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cal.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Caddy example

cal.example.com {
    reverse_proxy localhost:3000
}

Environment variables

VariableDescriptionDefault
CALRS_DATA_DIRSQLite database directory/var/lib/calrs (Docker/systemd) or XDG (dev)
CALRS_BASE_URLPublic URL (required for OIDC callbacks and email action links)http://localhost:3000
RUST_LOGLog level filtercalrs=info,tower_http=info

Observability

calrs uses structured logging via the tracing crate. All log output goes to stderr, captured by systemd journal or Docker logs.

Log levels

# Default (recommended)
RUST_LOG=calrs=info,tower_http=info

# Verbose (includes per-request details)
RUST_LOG=calrs=debug,tower_http=debug

# Errors only
RUST_LOG=calrs=error

What’s logged

CategoryLevelEvents
Authinfo/warnLogin success/failure, registration, logout, OIDC login
BookingsinfoCreated, cancelled, approved, declined, reminder sent
CalDAVinfo/errorSync completed, write-back/delete failures, source added/removed
Admininfo/warnRole changes, user toggle, config updates, impersonation
Emaildebug/errorDelivery success/failure
HTTPinfoEvery request (method, path, status, latency)
DatabaseinfoMigrations applied on startup

Viewing logs

# systemd
journalctl -u calrs -f

# Docker
docker logs -f calrs

Backup

The entire state is in a single SQLite file (calrs.db). To back up:

sqlite3 /var/lib/calrs/calrs.db ".backup /path/to/backup.db"

Or simply copy the file when the server is stopped.

CLI Reference

Global options

--data-dir <PATH>    Custom data directory (env: CALRS_DATA_DIR)

Commands

calrs source

Manage CalDAV calendar sources.

calrs source add [OPTIONS]
    --url <URL>           CalDAV server URL
    --username <USERNAME> CalDAV username
    --name <NAME>         Display name for this source
    --no-test             Skip the connection test

calrs source list

calrs source test <ID>    Test a connection (ID prefix match)

calrs source remove <ID>  Remove a source and all its data (ID prefix match)

calrs sync

Pull latest events from all CalDAV sources.

calrs sync [OPTIONS]
    --full    Full re-sync (ignore sync tokens)

calrs calendar

View synced calendar events.

calrs calendar show [OPTIONS]
    --from <DATE>    Start date (YYYY-MM-DD)
    --to <DATE>      End date (YYYY-MM-DD)

calrs event-type

Manage bookable event types.

calrs event-type create [OPTIONS]
    --title <TITLE>              Event type title (required)
    --slug <SLUG>                URL slug (required)
    --duration <MINUTES>         Duration in minutes (required)
    --description <DESC>         Description
    --buffer-before <MINUTES>    Buffer before (default: 0)
    --buffer-after <MINUTES>     Buffer after (default: 0)

calrs event-type list

calrs event-type slots <SLUG> [OPTIONS]
    --days <DAYS>    Number of days to show (default: 7)

calrs booking

Manage bookings.

calrs booking create <SLUG> [OPTIONS]
    --date <DATE>          Booking date (YYYY-MM-DD)
    --time <TIME>          Start time (HH:MM)
    --name <NAME>          Guest name
    --email <EMAIL>        Guest email
    --timezone <TZ>        Guest timezone (default: UTC)
    --notes <NOTES>        Optional notes

calrs booking list [OPTIONS]
    --upcoming    Show only upcoming bookings

calrs booking cancel <ID>    Cancel a booking (ID prefix match)

calrs config

Configure SMTP, authentication, and OIDC.

calrs config smtp [OPTIONS]
    --host <HOST>           SMTP server hostname
    --port <PORT>           SMTP port (default: 587)
    --username <USERNAME>   SMTP username
    --from-email <EMAIL>    Sender email address
    --from-name <NAME>      Sender display name

calrs config show           Display current configuration

calrs config smtp-test <EMAIL>   Send a test email

calrs config auth [OPTIONS]
    --registration <BOOL>        Enable/disable registration
    --allowed-domains <DOMAINS>  Comma-separated domains or "any"

calrs config oidc [OPTIONS]
    --issuer-url <URL>        OIDC issuer URL
    --client-id <ID>          Client ID
    --client-secret <SECRET>  Client secret
    --enabled <BOOL>          Enable/disable OIDC
    --auto-register <BOOL>    Auto-create users on first login

calrs user

Manage users (admin operations).

calrs user create [OPTIONS]
    --email <EMAIL>    User email
    --name <NAME>      User display name
    --admin            Grant admin role

calrs user list

calrs user set-password <EMAIL>

calrs user promote <EMAIL>     Promote to admin

calrs user demote <EMAIL>      Demote to regular user

calrs user disable <EMAIL>     Disable user account

calrs user enable <EMAIL>      Enable user account

calrs serve

Start the web server.

calrs serve [OPTIONS]
    --port <PORT>    Port to listen on (default: 3000)

Architecture

Project structure

calrs/
├── Cargo.toml              Package manifest
├── Dockerfile              Multi-stage Docker build
├── calrs.service           systemd unit file
├── migrations/             SQLite schema (35 incremental migrations, see migrations/ dir)
├── templates/              Minijinja HTML templates
│   ├── base.html           Base layout + CSS (light/dark mode)
│   ├── auth/               Login, registration
│   ├── dashboard_base.html Sidebar layout (all dashboard pages extend this)
│   ├── dashboard_overview.html   Overview with stats
│   ├── dashboard_event_types.html Event types listing
│   ├── dashboard_bookings.html   Bookings listing
│   ├── dashboard_sources.html    Calendar sources
│   ├── dashboard_teams.html      Teams listing
│   ├── dashboard_internal.html   Internal/organization event types
│   ├── admin.html          Admin panel
│   ├── settings.html       Profile & settings (avatar, title, bio)
│   ├── event_type_form.html  Create/edit event types
│   ├── invite_form.html    Invite management for private event types
│   ├── source_form.html    Add CalDAV source
│   ├── source_test.html    Connection test / sync results
│   ├── source_write_setup.html Write-back calendar selection
│   ├── team_form.html      Create/edit team
│   ├── team_settings.html  Team settings (members, groups, danger zone)
│   ├── overrides.html      Date overrides per event type
│   ├── troubleshoot.html   Availability troubleshoot timeline
│   ├── profile.html        Public user profile
│   ├── team_profile.html   Public team page
│   ├── slots.html          Slot picker (timezone-aware)
│   ├── book.html           Booking form
│   ├── confirmed.html      Confirmation / pending page
│   ├── booking_approved.html     Token-based approve success
│   ├── booking_decline_form.html Token-based decline form
│   ├── booking_declined.html     Token-based decline success
│   ├── booking_cancel_form.html  Guest self-cancel form
│   ├── booking_cancelled_guest.html Guest self-cancel success
│   ├── booking_host_reschedule.html Host-initiated reschedule
│   ├── booking_reschedule_confirm.html Reschedule confirmation
│   └── booking_action_error.html Invalid/expired token error
├── docs/                   mdBook documentation
└── src/
    ├── main.rs             CLI entry point (clap)
    ├── db.rs               SQLite connection + migrations
    ├── models.rs           Domain types
    ├── auth.rs             Authentication (local + OIDC)
    ├── email.rs            SMTP email with .ics invites + HTML templates
    ├── rrule.rs            RRULE expansion (DAILY/WEEKLY/MONTHLY)
    ├── utils.rs            Shared utilities (iCal splitting/parsing)
    ├── caldav/mod.rs       CalDAV client (RFC 4791) + write-back
    ├── web/mod.rs          Axum web server + handlers
    └── commands/           CLI subcommands
        ├── source.rs
        ├── sync.rs
        ├── calendar.rs
        ├── event_type.rs
        ├── booking.rs
        ├── config.rs
        └── user.rs

Database

SQLite in WAL mode. Single file, zero ops. Foreign keys with ON DELETE CASCADE.

Core tables

TablePurpose
accountsUser profiles (name, email, timezone)
usersAuthentication (password hash, role, username)
sessionsServer-side sessions
caldav_sourcesCalDAV server connections
calendarsDiscovered calendars
eventsSynced calendar events (unique on uid + recurrence_id)
event_typesBookable meeting templates
availability_rulesPer-event-type availability (day + time range)
availability_overridesDate-specific exceptions (blocked days, custom hours)
bookingsGuest bookings
booking_invitesTokenized invite links for private/internal event types
booking_attendeesAdditional attendees per booking
event_type_calendarsPer-event-type calendar selection (junction table)
event_type_member_weightsPer-event-type round-robin priority weights
smtp_configSMTP settings
auth_configRegistration, OIDC, theme settings
groupsOIDC groups (identity sync from Keycloak)
user_groupsGroup membership
teamsUnified teams (name, slug, visibility, invite_token)
team_membersTeam membership (role: admin/member, source: direct/group)
team_groupsLinks teams to OIDC groups for automatic member sync

Web server

Axum 0.8 with Arc<AppState> shared state containing the SqlitePool and minijinja::Environment.

Route structure

RouteHandler
/auth/login, /auth/registerAuthentication (redirects to dashboard if already logged in)
/auth/oidc/login, /auth/oidc/callbackOIDC flow
/dashboardOverview with stats
/dashboard/adminAdmin panel + impersonation
/dashboard/event-types/*Event type CRUD
/dashboard/sources/*CalDAV source management
/dashboard/bookings/*Booking actions (confirm, cancel)
/dashboard/teams/*Team CRUD
/dashboard/teams/{id}/settingsTeam settings (members, OIDC groups, danger zone)
/dashboard/organizationInternal event types + invite link generation
/dashboard/invites/{event_type_id}Invite management for private event types
/dashboard/troubleshoot/{id}Availability troubleshoot timeline
/booking/approve/{token}Token-based booking approval (from email)
/booking/decline/{token}Token-based booking decline (from email)
/booking/cancel/{token}Guest self-cancellation
/u/{username}Public user profile
/u/{username}/{slug}Public slot picker
/u/{username}/{slug}/bookBooking form + submit
/team/{slug}Public team page
/team/{slug}/{event-slug}Team event type booking
/g/{group-slug}Redirects to /team/{slug} (legacy)

Middleware

LayerPurpose
TraceLayerLogs every HTTP request (method, path, status, latency)
csrf_cookie_middlewareSets calrs_csrf cookie on responses for CSRF protection

CalDAV client

Minimal RFC 4791 implementation:

  • PROPFIND — principal discovery, calendar-home-set, calendar listing
  • REPORT — event fetch (calendar-query)
  • PUT — write events to calendar
  • DELETE — remove events from calendar
  • OPTIONS — connection test

Handles absolute and relative hrefs, BlueMind/Apple namespace prefixes, tags with attributes.

Templates

Minijinja 2 with file-based loader. Templates extend base.html which provides:

  • CSS custom properties for theming
  • Dark mode via prefers-color-scheme
  • Responsive layout
  • No JavaScript framework — vanilla JS only where needed (timezone detection, provider presets, CSRF token injection)

Email

Lettre for SMTP with STARTTLS. All emails are HTML with plain text fallback (multipart/alternative). ICS generation is hand-crafted (no icalendar crate dependency for generation):

  • METHOD:REQUEST for confirmations
  • METHOD:PUBLISH for guest confirmations (avoids mail server re-invites)
  • METHOD:CANCEL for cancellations
  • Events include ORGANIZER, ATTENDEE, LOCATION, STATUS

The approval request email includes Approve and Decline action buttons (table-based layout for email client compatibility). These link to token-based public endpoints that don’t require authentication.

Authentication flow

Local

  1. Registration/login form → POST with email + password
  2. Password verified with Argon2
  3. Session created in SQLite → session ID in HttpOnly cookie
  4. Extractors (AuthUser, AdminUser) validate session on each request

OIDC

  1. User clicks “Sign in with SSO”
  2. Redirect to OIDC provider with PKCE challenge
  3. Provider redirects back with authorization code
  4. calrs exchanges code for tokens
  5. Extracts email, name, groups from ID token
  6. Links to existing user by email or creates new user
  7. Session created as with local auth

Testing

calrs has an automated test suite with 219 tests, run on every push and pull request via GitHub Actions.

What’s tested:

AreaExamples
RRULE expansionDAILY/WEEKLY/MONTHLY recurrence, INTERVAL, UNTIL, COUNT, BYDAY, EXDATE
iCal parsingMulti-VEVENT splitting, field extraction, RECURRENCE-ID handling
Timezone conversionTZID extraction, floating times, UTC suffix, all-day events
Email renderingHTML/plain text output, cancellation attribution (host vs guest), .ics attachments
Availability engineFree/busy computation, buffer times, minimum notice, conflict detection
Web serverRate limiter (allow/block/reset/per-IP isolation)
AuthenticationArgon2 password hashing roundtrip, hash uniqueness
Input validationBooking name/email/notes/date validation, CSRF token verification
ICS regressionUTC timezone suffix, location field integrity, convert_to_utc
# Run the full suite
cargo test

# Check formatting and lint
cargo fmt --check
cargo clippy -- -D warnings

Dependencies

Key crates:

CratePurpose
clapCLI argument parsing
axumWeb framework
sqlxAsync SQLite
reqwestHTTP client (CalDAV)
minijinjaHTML templating
lettreSMTP email
chrono + chrono-tzTime and timezone handling
argon2Password hashing
openidconnectOIDC client
icalendarICS parsing
tracing + tracing-subscriberStructured logging
tower-httpHTTP request tracing (TraceLayer)

Security

This page documents calrs’s security measures and known limitations.

Authentication

  • Password hashing — Argon2 with random salt (via the argon2 + password-hash crates). Passwords are never stored in plaintext.
  • Sessions — 32-byte random tokens (cryptographically secure via OsRng), stored server-side in SQLite with 30-day TTL.
  • Cookie flags — All session cookies use HttpOnly; Secure; SameSite=Lax. The Secure flag ensures cookies are only sent over HTTPS.
  • OIDC — Authorization code flow with PKCE, state validation, and nonce verification. Tested with Keycloak.

Rate limiting

Login attempts are rate-limited per IP address:

  • 10 attempts per 15-minute window
  • After the limit, further attempts return an error without checking credentials
  • The client IP is read from the X-Forwarded-For header (set by your reverse proxy)

Important: Make sure your reverse proxy sets X-Forwarded-For correctly. Without it, rate limiting falls back to a single “unknown” bucket and won’t be effective.

Nginx

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Caddy

Caddy sets X-Forwarded-For automatically.

Booking endpoints

Booking submissions are rate-limited per IP address:

  • 10 attempts per 5-minute window
  • Applies to all booking handlers (user, team, and legacy)

CSRF protection

All POST forms are protected against cross-site request forgery using the double-submit cookie pattern:

  • A calrs_csrf cookie is set automatically on every response (via middleware)
  • Client-side JavaScript reads the cookie and injects a hidden _csrf field into all POST forms
  • On submission, the server verifies that the cookie value matches the form field
  • Mismatches return a 403 Forbidden response

This protects all 31 POST endpoints including booking submissions, settings changes, admin actions, and authentication forms. Multipart forms (avatar/logo upload) pass the token via query parameter.

The cookie uses SameSite=Lax and is intentionally NOT HttpOnly so the client-side script can read it.

Input validation

All user-submitted data is validated server-side:

  • Booking forms — name (1–255 chars), email (format + length), notes (max 5,000 chars), date (max 365 days in the future)
  • Registration — name (1–255 chars), email format and length validation
  • Settings — name length, booking email format validation
  • Avatar upload — strict content-type whitelist (JPEG, PNG, GIF, WebP only)
  • HTML templatesmaxlength attributes on form inputs (defense in depth)

ICS injection protection

User-supplied values (guest name, email, event title, location, notes) are sanitized before being inserted into .ics calendar invites:

  • Carriage returns (\r) and newlines (\n) are stripped to prevent ICS field injection
  • Semicolons and commas are escaped per RFC 5545

This prevents attackers from injecting arbitrary iCalendar properties (e.g., extra attendees, recurrence rules) through booking form fields.

SQL injection

All database queries use parameterized bindings via sqlx. No SQL is constructed through string concatenation.

XSS (cross-site scripting)

All HTML output is rendered through Minijinja, which auto-escapes all template variables by default. No |safe or |raw filters are used.

Double-booking prevention

A SQLite partial unique index prevents two bookings for the same event type and time slot:

CREATE UNIQUE INDEX idx_bookings_no_overlap
ON bookings(event_type_id, start_at)
WHERE status IN ('confirmed', 'pending');

Additionally, all booking handlers wrap the availability check and INSERT in a database transaction (BEGIN IMMEDIATE), preventing race conditions between concurrent requests.

Error handling

Web handlers use explicit error handling instead of panics. Template rendering failures, date parsing errors, and database errors return user-friendly HTTP error responses rather than crashing the server process.

Token-based actions

Certain actions can be performed without authentication, using single-use-like tokens:

  • Cancel token — allows guests to cancel their booking via a link in the confirmation email
  • Confirm token — allows hosts to approve or decline pending bookings via links in the approval request email

Tokens are UUID v4 (128-bit random), stored with unique indexes in the database. They are not invalidated after use (the booking status check prevents replay — a token for an already-confirmed booking shows “already approved”). These links should be treated as sensitive — anyone with the link can perform the action.

Known limitations

CalDAV credential storage

CalDAV and SMTP passwords are encrypted at rest using AES-256-GCM. The encryption key is auto-generated at $DATA_DIR/secret.key on first run, or can be provided via the CALRS_SECRET_KEY environment variable. Legacy hex-encoded passwords (from pre-v0.10.0) are auto-migrated to encrypted format on startup. Protect your secret.key file with filesystem permissions.

No brute-force account lockout

Rate limiting is per-IP, not per-account. A distributed attack from many IPs would not be rate-limited. Consider using fail2ban or your reverse proxy’s rate limiting for additional protection.

SSRF (server-side request forgery)

CalDAV source URLs are user-supplied. A malicious user could point a CalDAV source at an internal IP (e.g., http://127.0.0.1:8080/) to probe internal services. In a trusted multi-user deployment (e.g., behind OIDC), this is low risk. For public-registration instances, consider restricting network access at the firewall level.

Recommendations for production

  1. Always use HTTPS — the Secure cookie flag requires it
  2. Set CALRS_BASE_URL to your public HTTPS URL
  3. Configure your reverse proxy to set X-Forwarded-For correctly
  4. Restrict filesystem access to the data directory (contains the SQLite database with credentials)
  5. Disable registration if using OIDC (calrs config auth --registration false)
  6. Keep calrs updated for security patches