Fourth pre-release of 2.0, and the last planned before a release candidate: every item agreed for 2.0 is now in. The dashboard is warmer (#403) and laid out in cards on every page (#414, #417), long lists are paged and searched on the server (#415), and the public landing pages are redesigned (#406, #416): the host's usual hours, place and languages under their name, meetings grouped in sections the host orders, and lengths of time written in the reader's language on pages and in emails. Also new: booking answers through the REST API (#398), a dark theme logo (#404), background sync that scales to thousands of sources (#388), and a long list of correctness fixes: recurring events far from their start, BYSETPOS, YEARLY and RDATE rules (#376), clock changes (#377), invite limits, claimed bookings and collective exclusions (#373, #374, #375), team event types losing their settings on create (#400), and the Exchange client moved onto the shared XML parser (#369). Security: outbound calendar requests now connect only to addresses they checked (#363), X-Forwarded-For is believed only from trusted proxies, and the remaining items of the 2026 audit are closed (#85), with the HTTP client moved to reqwest 0.12. Eight migrations, one of which does nothing. Read the upgrade notes below, and back up your database before upgrading, as for any pre-release.
Docker: this version is published as :2.0.0-beta.4 and :next. :latest stays on the 1.23 line until 2.0 is final.
Thanks to @optimistic5 for the dark theme logo, and to everyone testing the betas. Please try this one on a copy of your database and tell us what breaks.
Added
- Booking question answers through the REST API (#169, #210) -
POST /api/v1/bookingstakes an optionalanswersobject keyed by each question's stablekey: a string for a text question, an option id for a single choice, an array of option ids for several choices,trueorfalsefor a checkbox. The answers go through the booking page's own parsing and limits and are stored the same way, as the guest would have read the questions, so the host emails, the host's calendar event (except questions kept out of it), the guest's confirmation email and booking webhooks show them as for a booking from the page. Whenanswersis sent, every required question must be answered and a required consent must betrue; an unknown key, a key given twice, an option the question does not offer, an answer of the wrong type or over its length is refused withinvalid_requestnaming the key, and a question left out stores nothing. A consent sent astrueis recorded on the answer row as attested by the caller (user, and personal access token id or OAuth client id;attested_byin reads and webhooks): hosts read "Agreed, recorded by" that host, the guest's emails say it was recorded on their behalf, never "You agreed". Migration20261108175740_booking_answer_attestation. Withoutanswers, nothing changes: no question is asked, as before.noteskeeps its behaviour either way.GET /api/v1/event-types/{id}now lists the event type'squestions(key, kind, options and their ids) to the hosts who may book it,nullfor read-only viewers. The body limit ofPOST /api/v1/bookingsis raised to 1.5 MiB so the longest answers fit, even JSON-escaped; other requests keep 16 KiB. TheIdempotency-Keycovers the answers like every other field. - Dark theme company logo (#356, by @optimistic5) - The Branding tab of the admin panel takes a second, optional logo for the dark theme. Public booking pages carry both images and the
html.darkclass, set before first paint, picks one, so the theme toggle swaps the logo with no flash. A light logo alone still shows in both themes, so existing instances see no change; a dark logo alone shows in the dark theme only, since a mark made for a dark background may not read on a light page./logois the light logo and the new/logo/darkthe dark one, falling back to the light one. Pages render an image only for a logo that exists, and the hidden one is lazy, so a page without a logo asks for none and the theme not shown is not fetched. The dark logo follows the light one's rules (PNG, JPEG, GIF or WebP, 2 MB, checked by its bytes, no SVG). The admin previews each logo on the page background of the instance theme, light and dark. A linked logo is now named after the site it opens, and a logo that fails to load hides its block.
Changed
- Exchange (EWS) replies are read with the shared XML parser (#369) - The EWS client read Exchange's SOAP answers with its own string search. It now uses the namespace-aware parser the CalDAV client got in #325, so the prefixes a server or a proxy writes (
s:,soap:,m:,t:, default namespaces) no longer matter, and entities and CDATA are decoded (an event title writtenR&Dnow readsR&D). SOAP faults are recognised in a 200 as well as a 500, in SOAP 1.1 and 1.2, with Exchange's language-independent code first (ErrorImpersonateUserDenied: ...), and every message of a batch is checked, not only the first (an item deleted between the listing and its fetch is still dropped from a GetItem batch). AWarningcarryingNoErrorreads as a success everywhere. A reply calrs cannot read is now an error instead of an empty answer: malformed or truncated XML, an HTML page, a FindItem without itsRootFolderorItems, an item or folder without its id, a paging flag that is not a boolean, or an item whose start or end is not a date-time. An item whose UID holds control characters (the organizer chooses it, and a line break there could add a second event marked free to the iCalendar calrs makes from it) is kept as busy time under a derived UID,ews-and a SHA-256 of the original. Before, several of these read as a calendar with no events, which shows the host as free. Answers from Exchange 2016 and 2019 read as before. Replies are parsed off the threads serving pages, and Autodiscover reads its answer, capped at 1 MB, with the same parser. The shared parser now reads attributes, so an XML attribute with broken syntax makes any document malformed, CalDAV answers included; no server calrs is used with writes one. - Background calendar sync scales to thousands of sources (#388) - The background loop synced one source per minute, the stalest, so with one managed Exchange source per user (#138) each source came round every N minutes for N sources, and past about 1,440 the daily full resync could no longer run daily. Calendar sync now has a task of its own, so reminders, request expiry and write-back retries never wait on a slow calendar server. Each minute it syncs enough sources for every one to come round about once an hour (one per minute up to 60 sources, as before), sources due their daily full resync first, which also gives busy hosts, whose booking pages keep their sources fresh, the full resync the old order never reached. Up to
CALRS_SYNC_CONCURRENCYsources sync at once (default 4, capped at 8), never two against the same server (managed Exchange sources all count against the global endpoint's server, since they share its service account; Microsoft 365 and Google sources, throttled per user, each count on their own). A slow server holds one slot only while the others carry on, and a sync still running after 10 minutes is stopped and counts as a failure. A source already being synced, by a booking page, the Sync button or the background sync, is never synced twice at once: a booking page never waits for another sync and shows the cached times, an approval waits for it (within its 10 second refresh limit), and the Sync button waits up to 20 seconds, then says the source is still being synced. The background sync restarts if it ever crashes.CALRS_SYNC_MIN_AGEkeeps its meaning, and a failing source is retried exactly as before. Migration20261108144516_background_full_attempt. - The SMS daily limit counts billed segments, not messages (#399) - Gateways charge per segment (a message of up to 160 characters is one, or up to 70 once the text holds a character outside the GSM alphabet; longer messages are billed one per 153 characters, or 67), and a calrs message can run to two, so a limit of N messages could bill about 2N segments. The admin card now labels the limit in segments per UTC day and shows today's segments next to today's messages. A message is sent only if its estimated segments fit what is left of the day. The estimate counts UTF-16 code units for text outside the GSM alphabet, as gateways bill it, so an emoji in an event title counts twice. Twilio and seven.io report the segments they billed, and the ledger row is corrected to that count; GatewayAPI and the webhook report none, so their rows keep the estimate.
- Dashboard overview (#403) - The overview opens with you: your face, a greeting by the time of day in your language and timezone ("Good evening, Olivier"), today's date and week, and one plain sentence about what is coming ("Tomorrow you meet Nina Rossi at 15:30. One request is waiting for your answer."). Up next shows the next meeting as the one large block: the time, a countdown in words ("tomorrow afternoon", "in 25 minutes", "Now, until 16:00"), the date, the place (the video service or the location), the guest and the Join button. Guests appear as faces: their initials on a warm colour that stays the same for the same person, mixed into the theme's own surface so it reads on every theme, on Up next, the requests waiting for your answer, the agenda and the week. The timezone notice, the default-hours note and the getting-started step fold into one quiet "N things to set up" line, each still actionable and dismissible. The week becomes a strip of seven days whose bars carry the guest's initials, open hours as a light dotted texture; a day with a confirmed booking opens that day on the bookings page. The four 30-day figures become one sentence ("8 meetings in the last 30 days, 4 h with guests"), with a comparison only when the 30 days before had meetings, above the 12-week bars; the day ruler is gone. Booking links become chips, your public page first. The sidebar and a few places where an icon carries meaning (Join, copy, set-up, time and place) get Phosphor icons (Phosphor Icons 2.1.1, MIT licence, vendored inline in
assets/phosphor/, no CDN). The default palette is warmer (a slightly warm paper and greys, a warm charcoal in dark mode), on every page; preset and custom themes keep their own colours. No migration. - Dashboard overview in cards (#414, #417) - The overview's blocks are now cards instead of sections under horizontal rules: each is a sheet on the page background with a hairline border and a softly tinted head band holding its title, count and link, and rows inside keep only faint hairlines. Up next keeps an accent tint. The page reads as three rows of two cards: Up next beside the requests waiting for your answer, Coming up beside This week, your booking links and favourite meetings beside the last 30 days and the meeting types; one column on narrow screens. The card is a shared component in
dashboard_base.html(.card,.card-head,.card-body,.card-part,.card.is-now), and the event types list, event type form, overrides and embed pages, calendar sources, booking directory, time off, profile and settings, webhooks, API tokens, connected apps, troubleshoot, API docs and host notes now use it too: each section is a card with its title (or the list's search) in the band, forms are grouped into cards by topic with the save bar outside them, and the event type form's collapsible sections are cards whose band opens and closes them. The bookings page (one card, each section a part of it, under a band holding the one search for every section), the Invite Links page and each event type's invites page, the teams list (which gains a search by name or link), the team form and team settings (its members' time off included), every tab of the admin panel (the overview's blocks, the users and groups lists with their search and filters in the band, and each settings form) and the two-factor settings page, which moves into the dashboard, follow it too, and a test fails when a page of cards draws a ruled section again. Pressing / focuses the first search on screen, so on the admin panel it finds the open tab's. A card's content now sits centred in it: one space above the body, between the band and the body, and below it, and a list that opens or closes a card drops its outer row padding (before, a one-line card had about twice as much room below its content as above). A list row's buttons centre on its text (a tall record such as a booking, a webhook or a token keeps them on its first line), and a page's buttons sit on the line of its title instead of under its description. No migration. - Logo caching (#356) - Pages link the logos with a version (
/logo?v=...) that changes with each upload, so a new logo shows at once, and/logoand/logo/darkare cached for five minutes then revalidated with anETaginstead of cached for an hour. Uploads are written to a temporary file of their own, flushed to disk and renamed into place. - Public landing pages redesigned (#406, #416) - The user page (
/u/{username}) and the team page (/team/{slug}) drop the gradient banner, the striped cards and the arrows, and help guests choose a meeting by its purpose. The host is set like a business card on a tint of the brand accent, in one band: the photo, then the name, title and bio (a team shows its members as faces with their first names), and beside them, on wide screens, a compact block with only what is set: the host's usual hours as a small timetable under a heading that names the timezone once ("Usually available, Paris time", then one row per schedule, "Mon to Thu 09:00 to 17:30", "Fri 09:00 to 16:00", from the weekly hours of the meetings listed; more than four schedules say the hours vary by day; shown only when the meetings listed share one timezone), a "My time" button that shows the same rows in the visitor's timezone when it differs (the rows follow the visitor's 12- or 24-hour setting), and on one line where the host is based and the languages they speak (two new optional fields in Profile & Settings, the languages named in the visitor's language). The page is wider on large screens and the tenant logo larger (still on the left); on narrow ones the block goes under the bio. The meetings sit in cards, the title in a tinted band: one "Meetings" card with the count, or, when the host gives meetings a section (a new optional field of the event type form, with suggestions from the host's existing sections), one card per section and the others last under "Other meetings". The host sets the order of the sections and of the meetings with up and down buttons on a new page,/dashboard/event-types/order(linked from the section field), and team admins the order of their team page at/dashboard/teams/{id}/order. Each meeting shows its length drawn as small stopwatch faces (one per hour, the last one tinted in proportion) in a colour that follows the length, how the guest meets (video, phone or in person) and "Host confirms" when each request is confirmed by hand, each an icon and a label, and for team meetings who the guest meets ("with one of" or "with everyone" and their faces). A line above the cards says how booking works when there is more than one meeting. Migration20261109074443_landing_sections_and_profile_facts. The slot page's broken emoji icons (duration, location, timezone, time format) and the footer crab are now drawn as images, so they show on systems without an emoji font. - Lengths of time are written in the reader's language (#416) - One formatter writes every length calrs shows on pages and in emails: the length of a meeting on the landing pages, the slot page, the booking form, the event types and invite links pages, the booking directory, the dashboard and the troubleshoot view ("45 min", "1 h 30", "2 Std."), and the notice periods in sentences, with the language's plurals and whole days as days ("at least 1 day of notice" instead of "at least 1440 minutes notice").
- Long lists are paged on the server (#415) - Lists that grow with an instance now show one page at a time, 50 rows by default, with Previous and Next and "1 to 50 of N" under them, instead of rendering everything or stopping at a hidden limit. The bookings page pages each of its five sections on its own (
pp,cp,bp,rpandxpin the URL, each link keeping the other sections' pages and scrolling back to its section): Upcoming and Available to claim used to stop silently at 50, Waiting for your decision had no limit, and Last 30 days (30) and Cancelled and declined (50) could not be paged; their counts and the page's summary are now totals. Cancelled and declined now reads from 30 days ago onwards, so far-future cancellations sit on its last pages. A day link of the overview's week opens the page of Upcoming that shows the day. The search boxes of the bookings page, the event types page and the Invite Links page now search every page on the server (press Enter; typing still narrows the rows shown at once), and the page links keep the search. Cancelling, approving or asking a guest to reschedule from a page of the bookings list returns to that page and search, and changing a group member's weight returns to that group on its page of the admin Groups list; the page to return to must be a dashboard path of this site. Also paged: the invites of an event type, the Invite Links page, the teams list, the event types page (your own event types first, then the team ones, counted and paged as one list), and the members of a team's settings page (the save still keeps every member, and leaving the page with unsaved changes asks first). The admin panel's Users list is always searched, filtered, sorted and paged on the server (it did so past 100 users only), and its Groups list moves to the server too: search by name, 25 groups a page, ten members a group with their own pages; the two lists keep each other's place. Public team pages and team slot pages load and show a capped set of faces (12 on the team, 8 per meeting, 6 on the slot page) and "+N" for the rest, and screen readers hear "and N more" instead of every name. A user in two teams watching the same event type now sees each claimable booking once. The REST API and MCP keep their cursors. No migration.
Fixed
- Long-running recurring events block their current occurrences (#376) - Recurring events were expanded from their first occurrence with a cap of 2000 steps, so a series started years ago (a daily stand-up since 2021, a weekday meeting since 2019) ran out before today and its current occurrences stopped blocking slots, on the booking pages, at the booking check, for shared resource feeds, in the troubleshoot view, the CLI and MCP busy times. The expansion now starts at the requested window however old the series is, with a cap per window, and
calrs booking createchecks recurring events too. Rules that read as free before are now expanded: yearly series (birthdays, "last Sunday of March"),BYMONTHDAY(the 1st and 15th, the last day of the month),DAILYwithBYDAY(which blocked every day before),WKST,BYSETPOS("the last weekday of the month", as Thunderbird and Apple write it, whoseCOUNTused to run out early), a bare monthlyBYDAY=MO(every Monday), positions such as-2MO, and rules written in lower case. ACOUNTkeeps counting cancelled occurrences, as RFC 5545 says (a daily series with anEXDATEused to end one day late), weekly days are walked in calendar order whatever orderBYDAYlists them in, a UTCUNTILis read in the event's own zone (east of UTC the last occurrence was dropped), a series in another zone than the host's is read in the host's day, and DTSTART always blocks as the series' first instance, even when the rule itself would skip it. Extra dates a series adds withRDATE(in any form: date, date and time in UTC, under a timezone or floating, period) now block too, with or without anRRULE, and do not use up itsCOUNT; anEXDATEor a moved occurrence takes them out as usual (migration20261108175519_event_rdateflags the series already cached; on Exchange impersonation sources their dates arrive with the next sync). A rule calrs cannot expand now blocks instead of reading as free: forBYYEARDAY,BYWEEKNO,RSCALE,SKIPor any other part calrs does not implement, the event's own time every day from its start; for an hourly rule orBYHOUR, all the time from its start; in both cases within itsUNTIL(orCOUNTwhen known). The troubleshoot view labels it, sync logs it once, and the overview's week marks its time apart (the busy band with a dashed outline) with a tooltip saying why and a link to the troubleshoot view of that day. - Bookings at a clock change block only their own time (#377) - Around a backward clock change, a booking's busy time was widened by the length of the change on both sides, so a booking that merely spanned the change also blocked the hour before it and the hour after it. Its busy time is now the exact range of wall-clock times it covers, as for time off (#366): an end moves out only when it falls inside the repeated hour, so both occurrences of that hour stay blocked when the booking is in it, and nothing outside it is.
- SMS limit could be overshot by concurrent bookings (#399) - The limit was read before each send and the message recorded after it, so bookings arriving together all passed the check. Each message now reserves its segments in the usage ledger in one atomic statement before calrs calls the gateway, and gives them back if the gateway refuses it. A send that may have reached the gateway without a usable answer (a timeout once the request was sent, a dropped connection, a 5xx, an unreadable answer, including a seven.io HTTP 200 whose body cannot be read), which the gateway may have delivered and billed anyway, keeps its reservation and is shown on the admin card as having no answer from the gateway: the limit errs toward over-counting. A request that never left calrs (connection refused, a DNS or TLS failure, or the Twilio trial-mode account check failing) gives it back. A ledger that cannot be written sends nothing. Migration
20261108200223_sms_usage_status. - SMS test messages were not counted (#399) - Test gateway with a phone number sent a message that appeared nowhere in today's count or cost and ignored the daily limit. Test messages are now recorded (event
test), counted in today's figures and against the limit, and refused once it is reached, with a message saying so. The free credential check (empty recipient) records nothing. - Twilio trial mode on a full account texted guests the template name (#399) - With
CALRS_SMS_TWILIO_TRIALleft set after the Twilio account was upgraded, only the admin's credential check noticed; every guest was texted the literalsms_appointment_remindersat full price. calrs now reads the account type before the first trial-mode send (again every hour, or when the configuration changes) and refuses to send to a full account, logging an error, and checks it once whencalrs servestarts. Bookings and emails are unaffected. - Creating a team event type keeps its scheduling mode (#400) - A team event type created as collective was stored as round robin, from the event types form with a team picked and from the team form alike; only saving it again from its edit page made it collective. The team form also dropped the visibility, the reminder, the additional guest limit and the member priorities or exclusions it showed, and offered no member card, while the event types form offered no watcher teams. Both create forms now store every setting their edit page stores, a test holds them to it, a scheduling mode or visibility outside the known values is stored as the default instead of as posted, and the watcher teams saved are only those the form offers its user (every team for a global admin, otherwise the teams they belong to); a watcher team already attached that the editor is not in is shown as a read-only row on the edit form (named "A private team" when it is private) and kept whatever the form posts, where toggling any other team used to drop it. Member weights and watchers are now saved in the same transaction as the event type, created weights are kept to the 0 to 3 the priority buttons give, and a personal event type stays round robin on edit too. Present since team event types gained the collective mode.
- Rate limits no longer put every client in one bucket without a reverse proxy (#85) - With no
X-Forwarded-Forheader, every client was counted as "unknown", so on an instance exposed directly one person mistyping a password ten times locked everyone out of signing in for 15 minutes, and the same went for two-factor codes, bookings and OAuth. calrs now counts each connection's own address, unless it comes from a trusted proxy (see Security). - A limited invite link can no longer be used more times than allowed (#373) - A booking through an invite link checked the remaining uses and counted its own use in two separate steps, so guests submitting at the same moment on a single-use link could all get a booking. The use is now counted in the booking's own database write, only while one is left: the extra submissions are refused (as used, or as expired or invalid when the link expired or was deleted meanwhile), and a booking that fails does not spend a use.
- A booking you claimed as a watcher now makes you busy (#374) - After claiming a booking on another member's event type, you were still offered as free at that time on your own event types (unless the copy written to your calendar had been synced back). Confirmed bookings you claimed now block your slots, the booking-time checks, reschedules and approvals, and the availability troubleshooter shows them as "(claimed booking)". Moving or approving a booking you claimed also checks that you are free at the new time, since your claim stays on it.
- Members excluded from a collective event type are no longer made busy by its bookings (#375) - A member set to weight 0 on a collective event type is not one of its hosts, yet each of its bookings still blocked that member's other event types. Excluded members are now free during those bookings; they already did not gate its availability. The event type's creator is busy for them only while a member of the team and not excluded, and the dashboard overview counts them the same way.
Security
- HTTP client upgraded to reqwest 0.12 (#85, FINDING-19). reqwest 0.11 is end of life. calrs now uses 0.12 (still rustls, no OpenSSL), the version the OpenID Connect client already used, so the build carries one HTTP client stack instead of two. Behaviour is unchanged: HTTP/2 is still offered and response text is still decoded by its charset, redirects are followed the same number of times, and the outbound URL guard (#363) keeps checking and pinning every address. reqwest 0.12 accepts a proxy setting of any scheme, so calrs now checks itself that
HTTP_PROXY,HTTPS_PROXYandALL_PROXYname anhttp://orhttps://proxy and ignores the rest (SOCKS), as 0.11 did. A proxy written without a scheme (localhost:3128) is now recognised as the proxy's host, so the resolver exempts it and URLs naming it are refused, as withhttp://localhost:3128. The clients outside the private-host policy (Google, Microsoft and Graph, SMS gateway, captcha, meeting webhook, OIDC) now read proxies the same way, where 0.12 on its own would have sent their plain-HTTP requests to a SOCKS proxy as if it were an HTTP one. The Google token, userinfo and Meet clients no longer follow redirects (Google answers them directly, and 0.12's default would have followed one more than before). Reading one CalDAV event (Google Meet updates) is now capped like every other CalDAV response. calrs resource proberefuses private addresses before its feed request (#363) - Its ICS feed request went to a private or reserved IP literal, or to an outbound proxy's own host, without the check the server's feed fetch applies. It now refuses those unless they are on the private-host allowlist, like its CalDAV discovery already did.- Outbound calendar requests connect only to addresses they checked (#363) - The private-host check on CalDAV, EWS, resource feed and webhook URLs looked the host up once, and the request then looked it up again, so the two answers could differ. These requests now go through one shared client (
src/net_guard.rs) that resolves the host at each connection, refuses it when any address is private or reserved unless the host is on the private-host allowlist, and connects only to the addresses it checked. It covers CalDAV and EWS sources (including the organization-wide Exchange endpoint and Autodiscover), resource feeds and resource calendars, booking webhooks andcalrs resource probe, and also the hosts a server names on its own: a CalDAV href on another host and a redirect go through the same check. Private IP literals, which need no lookup, are checked against the allowlist at every request, the saved URL included. A refused request stores a generic error (no address) on the sources page and in the owner's alert email, and logs the address at warn level; the save-time check no longer shows the address a host name resolved to either. Allowlist entries that are IP addresses now match any spelling of the address (fd00:0::1andfd00::1). The meeting webhook and the captcha verification no longer follow redirects, which could re-send the signed body or the captcha secret to another host (the SMS client already did not). Calendar requests still honourHTTP_PROXY,HTTPS_PROXY,ALL_PROXYandNO_PROXY; a request that goes through a proxy cannot be pinned (the proxy resolves the name), so for those, filtering private addresses is the proxy's job, which calrs logs when it starts (any command) along with any proxy setting it could not use (SOCKS: those requests go direct and are checked); hosts inNO_PROXYget the full pinning, and a URL, server href or redirect that names a proxy's own host is refused unless that host is allowlisted. Booking webhooks still use no proxy. The fixed Google and Microsoft endpoints are unchanged, as are the admin-configured captcha server, meeting webhook, SMS gateway and OIDC issuer, which were never under the private-host policy. X-Forwarded-Foris believed only from a trusted proxy (#85) - The rate limits took the client's address fromX-Forwarded-Forwhoever sent it, so a client talking to calrs directly could send a new made-up address with every sign-in attempt, two-factor code, booking or OAuth request and never be limited. The header is now read only when the connection comes from a trusted proxy, every line of it in order, right to left past the trusted hops, with ports dropped; otherwise the connection's address is the client. A hop that is not an address puts the request in a bucket of its own, never the proxy's.X-Real-IPandForwardedare ignored. The list isCALRS_TRUSTED_PROXIES(addresses and CIDR ranges, ornone), else the new Trusted proxies field in the admin System settings, else loopback, private, unique local and link-local ranges; a range trusting everyone (0.0.0.0/0,::/0) is refused, and System settings check every field before saving any, so a refused list saves nothing else from the form either. IPv6 clients now count by their /64. Upgrade note: Caddy or Nginx on the same host or Docker network keeps working unchanged. A proxy on a public address (load balancer, CDN) must be listed, or every client counts as the proxy. An instance exposed directly on a LAN trusts LAN peers by default: setCALRS_TRUSTED_PROXIES=nonethere; behind a proxy with LAN users, list only the proxy's address. Behind Docker's userland proxy (IPv6 published ports, hairpin), every client looks like the bridge gateway and shares one bucket; see the security docs. Migration20261108203003_trusted_proxies.- Availability overrides are addressed by event type id (#85, FINDING-15) - The overrides pages looked an event type up by its slug alone, so when two users both had an
introevent type an administrator could view or add overrides on the wrong one, and a team admin could do the same with two teams sharing a slug. The pages now live at/dashboard/event-types/{id}/overridesand every link uses that. An old slug URL still works when the slug names exactly one event type you may manage; otherwise it answers "Not found" instead of guessing. - The booking rate limit survives a restart (#85, FINDING-10) - The per-address limit on booking submissions (10 per 5 minutes) was kept in memory only, so restarting calrs gave every address a fresh budget. Its counts are now also kept in the database and read back at startup; only accepted submissions write, so a flood of refused ones never reaches the database, and a failed write keeps the decision made in memory. The MCP invite-link limit gets a bucket of its own. Migration
20261108230754_rate_limit_counters. - The secret key and decrypted credentials are wiped from memory (#85, FINDING-14) - The encryption key (from
CALRS_SECRET_KEYorsecret.key) and its long-lived copies, the plaintext of every decryption, the CalDAV, EWS, SMTP and SMS credentials in calrs's own structures, OAuth access tokens, and the captcha, meeting webhook and EWS service-account secrets the server keeps in memory are now cleared when released (zeroize), as is the AES key schedule. Copies made inside libraries (HTTP and SMTP clients, the OpenID Connect client) and the process environment are outside its reach.
Upgrade notes
Back up your database before upgrading. Behaviour changes to know about:
- Calendar sources on a private address are checked at every request (#363) - 2.0.0-beta.3 and 1.23.1 said existing CalDAV sources keep syncing after the IPv6 range fix, because their URLs were only checked when saved. That is no longer true: a CalDAV or EWS source, the organization-wide Exchange endpoint or a resource calendar whose URL names a private or reserved IP literal (for instance
http://[fd12::1]/, accepted before the fix), or whose host name now resolves to one, stops syncing until it is on the private-host allowlist (CALRS_ALLOW_PRIVATE_HOSTSor Admin panel, System). List an IP literal as the address itself, without brackets:CALRS_ALLOW_PRIVATE_HOSTS=fd12::1,10.0.0.5. Behind an outbound proxy, make sure the proxy refuses internal destinations, or list internal calendar hosts inNO_PROXYso calrs checks them itself. The CLI now reads the allowlist (and base URL) stored in the database for every command, not onlyserve. - Availability overrides are addressed by event type id (#85) - The pages moved to
/dashboard/event-types/{id}/overrides. A bookmarked slug URL redirects there when the slug names exactly one event type you may manage, and answers "Not found" otherwise. - Team event type create forms save every field (#400) - Both create forms now store the scheduling mode, visibility, reminder, additional guest limit, member priorities and watchers they show, so a collective team event type is created collective. A scheduling mode or visibility outside the known values is stored as the default (round robin, public) instead of as posted.
- Watcher teams are limited to the editor's own teams (#400) - An editor can add as watchers only the teams they belong to; a global admin, any team. A watcher team already attached that the editor is not in stays, shown read-only on the edit form, a private one without its name.
- Recurrence rules calrs cannot expand now block time (#376) - They used to read as free. A series with an hourly rule or
BYHOURnow blocks all the time from its start, one withBYYEARDAY,BYWEEKNO,RSCALE,SKIPor another part calrs does not implement its own time every day, both within itsUNTIL(orCOUNT). Hosts with such events lose that availability; the troubleshoot view names the event and the overview marks the time. - An unreadable Exchange reply is a sync failure (#369) - Malformed or truncated XML, an HTML page, or an answer missing its items used to read as a calendar with no events. It now fails the source's sync (the cached busy times stay in use), so an owner whose Exchange server or proxy answers that way can get the source-failure email.
- An existing SMS daily limit now counts segments (#399) - A limit of 100 used to allow 100 messages; it now allows 100 segments, which is 100 short messages but only 50 that run to two segments (often the case in languages with accented letters, where one character outside the GSM alphabet halves the segment size). Raise the limit if you sized it in messages.
CALRS_SMS_DAILY_CAPchanges meaning the same way. Test messages, and sends whose outcome is unknown, also count against it now. One migration (20261108200223_sms_usage_status). - Rate limits trust
X-Forwarded-Foronly from trusted proxies (#85) - Caddy or Nginx on the same host or Docker network needs nothing. A proxy on a public address must be listed inCALRS_TRUSTED_PROXIESor the admin System settings; an instance reached directly on a LAN should setnone. See Security above and the security docs.