Minimal, self-hosted appointment request service. Reads existing calendars (CalDAV, ICS feeds) to compute availability and presents free slots on a web page. Customers pick a slot and submit their contact details — you receive an email with an .ics draft event to import, edit, and use for the calendar invitation.
The application never writes to any calendar. You stay in control of every meeting request.
- A background task periodically reads your calendars — CalDAV, ICS feeds, or both (read-only). The default refresh interval is 15 minutes and can be changed with
rules.refresh_interval. - Free slots are computed based on your working hours, buffer, and other rules.
- A static HTML page is generated and served by Caddy.
- When a customer requests a slot, a small FastAPI endpoint sends you an email with an
.icsattachment. - You import the
.icsdraft, add final meeting details or meeting links, and send the actual invitation from your calendar client.
The attached .ics files are drafts for the owner. Importing them into the owner's calendar does not send invitations by itself; customer invitations are sent separately from your calendar client after you have reviewed and edited the event.
All-day calendar events are treated as busy for the full local day. Multi-day all-day events, such as vacations, block every working-hour slot they cover.
Internet → [Traefik] → Caddy (static files + reverse proxy)
↓ POST /book, GET /api/free-slots
Python App (FastAPI)
↓ read-only
CalDAV / ICS Calendars
Two containers in production:
- Caddy — serves static files, proxies
/bookand/api/*to the app. No credentials, no Python. - Python App — reads calendars, computes slots, generates HTML, sends booking emails. Internal-only, not exposed to the internet.
Zeitfenster is designed around a small public surface and a secret-free frontend container.
- Read-only calendars: the app reads CalDAV and ICS sources, but never writes to calendars. Meeting requests are sent as draft
.icsemail attachments for manual import. - Secret isolation: Caddy serves static files and proxies selected requests, but does not receive CalDAV or SMTP credentials. Those stay in the internal Python app container.
- No database or sessions: state is derived from configuration, calendar reads, generated static files, and in-memory availability.
- Bounded booking input: booking form fields are normalized and size-limited before use. Names reject control characters, and customer email addresses are checked for a valid basic shape with a dotted domain. The generated form mirrors key limits with native browser validation.
- Booking slot validation:
POST /bookdoes not trust submitted hidden form fields by themselves. The backend parses timezone-aware datetimes, requiresslot_end > slot_start, checks that the posted duration is configured and matches the submitted range, and requires(duration, slot_start, slot_end)to exactly match a currently advertised slot in memory. - Request abuse controls: accepted meeting requests pass through an in-memory global rate limit before email delivery. Request-triggered availability regeneration is coalesced so repeated posts cannot create unlimited concurrent calendar refresh tasks. Caddy also caps
/bookrequest bodies. - Browser hardening: booking-page JavaScript is served as a static asset, with no inline event handlers. Caddy sends a Content Security Policy,
X-Content-Type-Options: nosniff, andReferrer-Policy. - User-facing validation errors: booking form submissions are intercepted by the static JavaScript. Backend validation failures are mapped back to native browser validation messages instead of exposing raw JSON error responses to normal users.
- Fail-before-side-effects: invalid, forged, stale, malformed, or timezone-naive meeting requests are rejected before
.icsgeneration, SMTP delivery, or availability regeneration. - Federation privacy boundary:
/api/free-slotsexposes computed free slots only. Federation members do not receive raw busy intervals or calendar event details.
make upThis starts a full demo environment with:
- Booking page at
http://localhost:8080 - Mailpit (email viewer) at
http://localhost:8025 - Radicale (CalDAV server) with sample recurring events
src/zeitfenster/
├── app.py FastAPI app (POST /book, GET /api/free-slots, scheduler)
├── availability.py Free slot computation, intersection, orchestrator
├── caldav_client.py CalDAV read wrapper
├── ics_client.py ICS URL feed reader
├── zeitfenster_client.py Federation client (fetches remote free slots)
├── config.py Pydantic config (YAML + env vars)
├── email.py SMTP email with .ics attachment (multi-recipient)
├── generator.py Static site generator (Jinja2 → HTML)
├── ics.py .ics file builder (owner-side draft VEVENT)
├── parsing.py Duration and time range parsing
├── templates/ Jinja2 templates (base, index, thankyou, placeholder)
└── static/ Pico CSS + custom styles
tests/ Unit and integration tests
demo/ Demo environment (Radicale config, sample calendars, .env)
Dockerfile Multi-stage build
Caddyfile Caddy reverse proxy config
compose.yaml Demo environment (Caddy + app + Radicale + Mailpit)
compose.prod.yaml Production override (Traefik labels, no demo services)
pod.yaml Podman kube reference (production)
config.example.yaml Example configuration
Multiple zeitfenster instances can be federated so customers only see slots when all team members are free. Each instance exposes its computed free slots via GET /api/free-slots. A federation instance fetches those and intersects them — it never learns individual busy times.
availability:
zeitfenster_urls:
- url: https://alice.example.com
- url: https://bob.example.com
token_env: BOB_ZEITFENSTER_TOKEN
federation:
free_slots_token_env: MY_FREE_SLOTS_TOKEN
email:
owner:
- alice@example.com
- bob@example.comRequirements:
- All member instances and the federation must use the same
slot_durationsand compatible timezones. - If any member instance is unreachable, the federation shows no slots (fail-closed to prevent double-bookings).
- A pure federation instance (no own calendars) works naturally — working-hour candidates are generated locally, then narrowed by intersection.
- Federation authentication is optional on both sides. If
federation.free_slots_token_envis set, this instance requiresAuthorization: Bearer ...forGET /api/free-slots. If azeitfenster_urlsmember hastoken_env, the federation client sends that token to the member. If no inbound token is configured,/api/free-slotsis public and computed free slots are scrapeable. - If a federation token environment variable is configured but missing or empty, startup/fetching fails instead of silently falling back to unauthenticated access.
- On startup, the app logs whether
/api/free-slotsauthentication is enabled.
Copy config.example.yaml and adjust to your setup. Secrets (CalDAV passwords, SMTP credentials, federation tokens) are referenced by environment variable name, not stored in the config file.
See config.example.yaml for all available options including working hours, slot durations, buffer, minimum notice, horizon, refresh interval, branding, and owner-side booking event text/location.
The public page title is configured separately from generated calendar event text. Use branding.title for the booking page heading, and booking.owner_name plus booking.summary_template for the .ics event subject.
Owner notification emails use email.from_name as the display name for the SMTP sender, defaulting to Zeitfenster <SMTP_USER>.
On startup, Zeitfenster generates a placeholder page and retries availability generation if calendar sources are not ready yet. Startup regeneration retries use exponential backoff and can be tuned with ZEITFENSTER_STARTUP_REGEN_MAX_ATTEMPTS and ZEITFENSTER_STARTUP_REGEN_INITIAL_DELAY_SECONDS.
Frontend colors are configured under branding.colors. Every setting has a safe default, so you can override only the values needed for your corporate identity.
branding:
title: "Book a Meeting"
logo: "/static/logo.png"
colors:
background: "#ffffff"
text: "#373c44"
muted_text: "#646b79"
primary: "#2563eb"
primary_hover: "#1d4ed8"
primary_focus: "rgba(37, 99, 235, 0.25)"
primary_inverse: "#ffffff"
surface: "#ffffff"
surface_border: "#e7eaef"
surface_section: "#fbfbfc"
form_background: "#fbfbfc"
form_border: "#cfd5e2"
form_active_background: "#ffffff"
slot_colors:
- "#4a90d9"
- "#5ba870"
- "#d4833e"
slot_backgrounds:
- "#dbeafe"
- "#d1fae5"
- "#ffedd5"primary* values style buttons, links, focus rings, and active controls. surface* values style cards and day sections. form* values style text inputs. slot_colors and slot_backgrounds style duration tabs and slot buttons; if there are more durations than configured colors, the lists are reused cyclically.
Deployment-owned static assets can be mounted with ZEITFENSTER_CUSTOM_STATIC_DIR. Zeitfenster copies that directory into /site/static/custom during site generation, so Caddy serves it with the rest of the generated static site. Symlinks in the custom static directory are rejected.
Example production mount:
services:
app:
volumes:
- /etc/zeitfenster/static:/etc/zeitfenster/static:ro
environment:
ZEITFENSTER_CUSTOM_STATIC_DIR: /etc/zeitfenster/staticWith /etc/zeitfenster/static/logo.svg mounted, configure the logo as:
branding:
logo: "/static/custom/logo.svg"The Makefile includes Podman targets for building and pushing an OCI image:
make build
make pushThe default image is registry.example.com/zeitfenster/zeitfenster:1.0.0. Override IMAGE or TAG when needed:
make build TAG=1.0.1
make push IMAGE=registry.example.com/zeitfenster/zeitfenster TAG=1.0.1uv sync # install dependencies
uv run pytest # run tests
uv run pre-commit run --all-files # lint + format + type check