Skip to main content

just-book-my-cal

A small, self-hosted alternative to cal.com for one person: others book a meeting with you in the free times of your calendar. No accounts, no admin interface, no external services.

  • Reads your busy times from one or more public ICS feeds, including recurring events and moved or cancelled instances.
  • Offers only slots that fit your rules: working hours per weekday (with breaks), the meeting lengths you choose (e.g. 15/30/60 minutes), minimum notice, booking horizon and buffers before and after your meetings.
  • Books instantly. You and the booker both get a calendar invitation (ICS) by email, with a video link for each meeting (a room of its own, or a fixed address) and an optional reminder.
  • Both sides get a private link to reschedule or cancel. The booker can only move the meeting into slots that are free under your rules. You can move it anywhere, with a warning if that overlaps with something.
  • Visitors see the times in their own time zone (a few lines of JavaScript; without them, the times are shown in your time zone). Free times are listed one calendar week at a time, with arrows to the previous and next week.
  • Abuse protection: rate limiting, a honeypot field, CSRF protection and a cap on upcoming meetings per email address.

The only thing it stores is the bookings, in SQLite. Everything else is in one YAML file.

Quick start

You need uv and Python 3.10 or newer.

uv sync --no-dev
cp config.example.yaml config.yaml    # then edit it, at least the keys marked REQUIRED
uv run justbookmycal-cli --db upgrade # creates the database; run again after updates
uv run justbookmycal-cli --debug      # development server on http://127.0.0.1:5262

Before you publish the page, check that the app sees your calendar the way you expect:

export FLASK_APP='justbookmycal.wsgi:main()' CONFIG_FILE=config.yaml
uv run flask busy --days 7        # busy blocks found in your feeds
uv run flask slots --duration 30  # slots people could book

Configuration

All settings are explained in config.example.yaml, and validated against a JSON schema on start. Only five keys are required: SECRET_KEY, OWNER_NAME, OWNER_EMAIL, SYSTEM_EMAIL and ICS_FEEDS.

How your feed is read:

  • Events with TRANSP:TRANSPARENT ("free" in most calendar apps) and STATUS:CANCELLED never block time.
  • Everything else blocks, including all-day events. A missing TRANSP counts as busy, as the standard says. If birthdays or holidays in your feed block whole days, mark them as free or move them to another calendar.
  • Feeds are cached for FEED_CACHE_SECONDS. If a feed cannot be loaded, the last copy (up to 24 hours old) is used. If there is none, the page says that the calendar is unavailable and takes no bookings, rather than risking a double booking.
  • Bookings made through the app block their time immediately, even if your calendar app has not published the invitation in the feed yet.

Texts: EVENT_TITLE, EVENT_DESCRIPTION, EMAIL_BODY_BOOKER and EMAIL_BODY_OWNER are Jinja2 templates, so you can use the names of the participants, the message of the booker and conditions like {% if booker_message %}. The available variables are listed in the example configuration.

The invitation

The system address (SYSTEM_EMAIL, e.g. noreply@example.com) sends the mails and is the ORGANIZER of the event. Its display name is SYSTEM_NAME (default: your name). You and the booker are both ATTENDEEs. This is deliberate: an invitation that arrives in the mailbox of its own organizer is not handled as a normal invitation by Outlook, so with the system address as organizer both of you get an ordinary invitation, and updates and cancellations are recognised as such. A rescheduled meeting keeps its UID and gets a higher SEQUENCE, a cancellation is sent as METHOD:CANCEL.

Each mail contains a plain text part, the invitation as text/calendar; method=… (for the Accept/Decline buttons of mail clients) and the same ICS as an attachment. Times are sent in UTC. Each side receives its own private link in every mail (the first one and all updates and cancellations), never the link of the other side. The links are not stored: they are derived from SECRET_KEY and the booking, and only their SHA-256 hashes are in the database. Changing SECRET_KEY later breaks the links in mails sent afterwards for bookings that already exist (links sent before keep working), so avoid changing it while there are upcoming meetings.

If the mail server rejects the mails, the booking is kept and the booker is told. Because the owner's admin link would be lost, it is written to the log in this case.

Each meeting gets a video link rendered from the MEETING_URL template. The default creates a room like https://meet.jit.si/MaxMehl-JaneDoe-20261020-k7p2x: the random suffix makes it hard to guess, but the room name contains both names, so the operator of the video server can read them. Use your own Jitsi instance in the template if that matters to you, or a fixed address without variables to use one permanent room. You can change the link of a single meeting on its admin page, for example if the room needs to be different; the update is sent to both of you like a reschedule.

Deployment

Run the app with gunicorn behind a reverse proxy that terminates TLS:

uv run justbookmycal --app-config /path/to/config.yaml   # listens on 127.0.0.1:5262

The bundled gunicorn configuration uses a single worker on purpose: the rate limiter and the feed cache live in memory of the process. Pass your own with --gunicorn-config if you need something else.

An nginx location for it:

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

TRUSTED_PROXIES (default 1) tells the app how many proxies to believe when determining the client IP, which the rate limit depends on. Set 0 only if the app is reachable directly. With too high a value, visitors can fake their IP and escape the rate limit. Use a systemd service or a container to keep it running, and create the database with --db upgrade after every update.

Security notes

  • The booking page is public and has no login, by design. It exposes your free and busy pattern (not the content of your events), and anyone can use it to put a meeting into your calendar, within the limits set by the rate limit, the per-email cap and your rules.
  • The manage and admin links are capabilities: whoever has the link can change or cancel the meeting. They must not be forwarded. The pages are not cached and send no Referer. Only a SHA-256 hash of each link is stored.
  • The database contains names, email addresses and messages of the people who booked. Keep the file out of any web root, restrict its permissions and treat backups as sensitive.
  • The mail text is built from your templates and the input of visitors. Names are restricted to characters that are safe in mail headers and ICS files.
  • Anyone can enter any email address when booking. The invitation goes to that address, so the app can be used to send mails to third parties (one invitation per booking, limited by the rate limit and the cap). If this worries you, lower RATE_LIMIT_POST.

Known limitations

  • Your own copy of a booking can block neighbouring times. When you accept the invitation in your calendar app and that app publishes it in your feed, the event is counted twice (once from the database, once from the feed). This is harmless for new bookings. When a booker moves a meeting, the feed's copy of the old time keeps blocking until the feed shows the change. Calendar apps that keep the invitation's UID (e.g. Thunderbird, Nextcloud) are ignored correctly during rescheduling. Exchange/Outlook assigns its own UID, so the old copy stays in the way until it disappears from your feed.
  • Only one organiser, one language (English) and one set of rules.
  • Calendar clients differ in how they show invitations. The mails follow the common conventions for Outlook and Thunderbird, but check that they look right in your setup before you rely on them.

Development

uv sync                  # with dev tools
mise run test-all        # ruff, ty, reuse and pytest
mise run fix-all         # auto-fix formatting and lint issues

See AGENTS.md for the layout of the code. Please use conventional commits. Releases are created by release-please.

License

Apache-2.0 for the code, CC0-1.0 for configuration files, CC-BY-4.0 for documentation. See the REUSE headers and the LICENSES directory.

Metadata

Release files for justbookmycal 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for justbookmycal 0.1.0
File Size Uploaded Provenance
justbookmycal-0.1.0.tar.gz 51.3 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for justbookmycal 0.1.0
File Interpreter ABI Platform
justbookmycal-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 113.7 kB

Release files / justbookmycal-0.1.0.tar.gz

Download URL justbookmycal-0.1.0.tar.gz
Size 51.3 kB
Tags Source
SHA-256 checksum
How to use checksums
8b0510eb7285067d7bad41f1a733d7061c88f9dbbd983ffe516865f6d4890be2
BLAKE2b-256 checksum
How to use checksums
50eb4a3260d8b721b494cf1d0c7240c46be873d6ed26f5e293ff3bd642279e56
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.

Transparency log

Release files / justbookmycal-0.1.0-py3-none-any.whl

Download URL justbookmycal-0.1.0-py3-none-any.whl
Size 62.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
94f934473275458620baaf4e4f221a85314db9982f041af70d3d223eb081a726
BLAKE2b-256 checksum
How to use checksums
e74d268d40d4309162cbfb866a9b71953e32e52c03078abdd570f277e68bf1ed
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.

Transparency log

Security for justbookmycal 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for justbookmycal-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page