Releases: olivierlambert/calrs
v1.12.1
Patch release fixing wall-clock display in host-targeted emails.
A Paris organizer reading a cancellation email for a Los Angeles guest's 07:00 booking would see "07:00" with no timezone label and naturally read it as Paris time (the correct Paris-local time is 16:00). All host-facing emails now convert times to the host's timezone and append a (TZ) suffix; guest cancellation and decline emails gained the TZ label they were missing.
A latent bug in the reminder loop and reschedule "Previous" times — where host-tz-stored values were fed straight into guest-tz-labeled struct fields — would have caused a 16:00 Paris booking to render as "01:00 next day" under the new conversion path; both paths now route through booking_strings_in_guest_tz first, also fixing a pre-existing guest-reminder mislabeling.
No schema changes. 7 new unit tests, 677 total.
Full changelog: https://github.com/olivierlambert/calrs/blob/main/CHANGELOG.md#1121---2026-05-26
v1.12.0: source editing + SMTP env vars + port-465 support
A modernization pass on operator-facing surfaces: source connection details are now editable instead of delete-and-readd; SMTP gains env-var configuration and proper port-465 support; the From: mailbox stops mangling addresses without display names; and team event-type management permissions are tightened so management routes can't be reached via the read-only availability surface.
Added
- Edit CalDAV sources (closes #72, PR #73) —
GET /dashboard/sources/{id}/edit(form pre-filled, password field reads "Leave blank to keep existing") andPOST /dashboard/sources/{id}/edit(apply), both scoped byuser_id. CLI mirror:calrs source update <id-prefix> [--name ...] [--url ...] [--username ...] [--password].--passwordis a flag that prompts viarpasswordfor scripted rotation. Empty password on either surface preserves the stored encrypted blob untouched. URL changes passvalidate_caldav_urlfor SSRF parity between web and CLI - SMTP via environment variables with TLS mode support (PR #56) —
CALRS_SMTP_HOST,CALRS_SMTP_USERNAME,CALRS_SMTP_PASSWORD,CALRS_SMTP_FROM_EMAILrequired;CALRS_SMTP_PORT(default 587),CALRS_SMTP_TLS_MODE(starttls/tls),CALRS_SMTP_FROM_NAMEoptional. Env vars take priority over the DB; partial config errors loudly. NewSmtpTlsModeenum branchessend_emailonrelay()(implicit TLS) vsstarttls_relay()to fix the port-465 hang. Applies to DB-configured SMTP too via newtls_modecolumn (migration 052, defaults to'starttls');calrs config smtpprompt asks for the mode. Admin panel surfaces env-sourced status.SmtpConfig'sDebugimpl now redacts the password so it can't leak through tracing or test output
Fixed
- From: mailbox handles missing display name and special characters (PR #104) —
calrs config smtp-testproducedError: Invalid inputwheneverfrom_namewasNone: the old fallback yielded a string likeyou@example.com <you@example.com>whose two@'s failed RFC 5322 parsing. All 17 send sites now build From through a newSmtpConfig::mailbox_from()helper usingMailbox::new(Option<String>, Address), which also handles display names containing commas and other characters that need quoting. Unit test locks in both theNonecase and theSome("Name, With Comma")case - Team event type management gated through a single capability check (PR #55) — personal
/dashboard/event-types/{slug}/*mutation routes now requireet.team_id IS NULL; team-event mutation requires team admin; slug-collision resolution made deterministic via subquery +ORDER BY (team_id IS NULL) DESC. Behaviour change worth noting:delete_invitenow strictly requirescan_manage_event_type, so a non-admin team member who created an internal-event invite via the "any authenticated user" path can no longer delete that invite (let it expire or hit max-uses; owners, team admins, and global admins are unaffected). NewOptionalAuthUserextractor with sharedresolve_session_userlets public surfaces selectively widen access for logged-in viewers. Team members and global admins can now bypass the team-level invite token on public events of private teams;team_profile_pagelists private/internal event-type titles + slugs for logged-in team members (booking stays invite-gated). 8 new regression tests cover the full manageability matrix
Internal
- 671 tests total (up from 650 in 1.11.0), all green on pre-commit
- Migration 052 (
smtp_config.tls_mode)
v1.11.0: sync-robustness follow-ups + booking frequency limits (capped slots + per-member)
Two themes: closing the 1.10.2 sync-robustness hotfix loop (the three follow-ups filed against #105 / #106 / #107) and turning the booking-frequency-limits surface from a half-wired feature into a real one — first by hiding capped slots in the picker, then by adding per-team-member caps.
Added
- Hide booking slots when a frequency cap is reached (closes #115, PR #116) —
compute_slotsnow runsapply_frequency_limit_filterafter slot generation: for each configured(max, period)it counts existing confirmed+pending bookings per containing host-local period, then drops slots that fall in any capped period. The submit-time check (would_exceed_frequency_limit) stays as a race-condition backstop, but the picker no longer shows times the submit-time check would reject. Limit-reached page also got proper styling (template render instead of bareHtml("...")) - Per-member booking frequency limits (closes #117, PR #118) — new
booking_frequency_limits.per_memberflag (migration 051) so a host can express "1 demo/day per team member" instead of "1 demo/day team-wide". Threaded through three sites:would_exceed_frequency_limittakesOption<&str> assigned_user_idand scopes the count by assignee;pick_group_memberexcludes candidates already at their per-member cap so the picker doesn't route to a doomed user;apply_frequency_limit_filterhides a slot only when every eligible team member is at cap. UI gets a "Per team member" checkbox on each limit row, hidden on personal event types
Fixed
- CalDAV resource is HEAD-checked before cancelling a booking (closes #105, PR #108) — sync-collection's "deleted" entries are now treated as a hint, not a verdict: before cancelling a confirmed booking we HEAD the resource href and only act if the server confirms 404. Two new regression tests (one for the BlueMind phantom-deletion case, one for the legitimate deletion case)
cancel_orphaned_bookingis scoped to its own source/account (closes #106, PR #111) — previously did a global UID-only lookup against the bookings table, so a sync running on source A could cancel a booking whose CalDAV event lived under source B (different calendar, different account). Now joins throughevent_types → accounts → caldav_sourcesand only acts on rows whose source matches the one currently syncing- Property-level 404s inside
<d:propstat>are ignored (closes #107, PR #109) — the sync-collection parser previously treated any 404 status code inside a<d:response>as a deletion, including the per-property 404 some servers emit when one of the requested DAV properties is absent on an otherwise-live resource. Parser now distinguishes resource-level status from propstat-level status and only routes resource-level 404s into the deletion handler - Team event type frequency limits actually persist (PR #114) —
edit_group_event_type_formnever queriedbooking_frequency_limits, andcreate_group_event_type/update_group_event_typenever wrote to it. Toggling the cap on a team event type was a silent no-op. Three-way fix mirrors the working personal-event-type flow
Internal
- 650 tests total (up from 634 in 1.10.2), all green on pre-commit
- Migration 051 (
booking_frequency_limits.per_member) render_claim_errorrenamed torender_booking_action_errorsince the template (booking_action_error.html) isn't claim-specific- Coverage CI:
under_tarpaulin()now usescfg!(tarpaulin)(the previousCARGO_TARPAULIN_VERSIONenv-var check never fired, so the racy tracing-capture tests were leaking through the supposed-to-skip guard);Cargo.tomlregisterscfg(tarpaulin)via theunexpected_cfgslint config
v1.10.2: hotfix for phantom sync-collection booking cancellations
Hotfix release for a production incident where CalDAV sync was wrongly cancelling live customer bookings (visible to host and guest as a cancellation email with the reason "The calendar event was deleted by the host"). No schema changes, no behaviour changes outside the orphan-cancellation surface. Upgrade is recommended for any deployment running 1.10.x against BlueMind or any CalDAV server with quirky sync-collection deletion reporting.
Fixed
- Stop cancelling bookings on phantom sync-collection deletions —
delete_events_by_hrefinsrc/commands/sync.rscancelled any booking whose UID matched an href the server reported as deleted, regardless of whether the localeventstable actually had a matching row. In production, BlueMind's sync-collection emitted a "deleted" entry for an href whose corresponding event lived on a different calendar (the booking's write calendar); the global UID-onlycancel_orphaned_bookinglookup still found the confirmed booking, cancelled it, and emailed both host and guest. The cancellation is now gated onrows_affected > 0— if we never had a local event for that calendar/href, that's a server-side false positive and we log a warning instead of cancelling.
Internal
- 634 tests total (up from 632 in 1.10.1), all green on pre-commit
- New regression tests in
src/commands/sync.rs:delete_events_by_href_skips_cancellation_when_no_local_event— captures the exact prod failure modedelete_events_by_href_cancels_when_local_event_existed— guards the legitimate deletion case from regressing
Follow-ups for the next release
Three further hardening items are tracked separately:
- #105 — confirm-before-cancel via HEAD/PROPFIND on the resource href before any orphan path cancels a booking
- #106 — scope
cancel_orphaned_bookingby source/account, not by UID alone - #107 — propstat-aware sync-collection XML parser (currently misreads a
<d:status>404</d:status>inside a<d:propstat>as a resource-level deletion)
v1.10.1: booking time timezone display fixes
Patch release fixing booking-time timezone display across the dashboard, the post-booking emails, and the ICS attachments they carry. No schema changes, no behaviour changes outside the timezone-display surface.
Fixed
- Dashboard booking times now render in the host's profile timezone (closes #100) — listings previously used the raw naive datetime stored against the event-type tz plus the server's local clock for the "Today"/"Tomorrow" label, with no tz suffix. With an event type configured in
America/New_Yorkand a host inEurope/Paris, the dashboard showed10:00with no qualifier and let the host believe the meeting was at 10:00 Paris time. The dashboard now readset.timezone(with fallback tousers.timezone) as the stored tz, converts to the host's profile tz, and appends a tz abbreviation. When the guest's selected tz differs, a muted secondary line shows the guest's view too so the host can see both - Confirmation, decline, and cancellation emails (and their ICS attachments) now use the guest's timezone (closes #101) — five post-booking action handlers (dashboard "Approve", email-link "Approve", dashboard "Cancel"/"Decline", email-link "Decline", guest self-cancel link) fed the event-type-local stored datetime straight into
BookingDetails/CancellationDetails. Becauseemail::generate_ics/generate_cancel_icsboth callconvert_to_utc(date, start_time, end_time, guest_timezone), the email body, the rendered post-action page, and the ICS UTC came out wrong: a booking made at 16:00 Paris on a New York event type arrived in the guest's inbox as 10:00, and the CalDAV write-back landed at a different absolute time than the host saw on the dashboard. Newbooking_strings_in_guest_tz()helper converts stored times through the event-type tz to the guest tz before populating either struct. Regression tests cover both the approve and the cancel surface
Internal
- 632 tests total (up from 624 in 1.10.0), all green on pre-commit
v1.10.0: security audit round 3 + guest cancel/reschedule notice
Security audit round 3 (one High, seven Mediums) plus a guest-side cancel/reschedule notice window. Also bundles two months of translation work merged from the long-lived i18n branch and a few self-hoster fixes that landed since 1.9.0.
Security
All eight items below were originally reported by @marcotama in a third-party audit; the High was fixed by the audit author themselves, the seven Mediums were addressed in this release.
- High — Login timing oracle leaked user existence (#77, fixed by @marcotama) —
login_handlershort-circuited to "Invalid email or password" before running Argon2 if the email wasn't registered, leaking a ~10ms vs ~microseconds gap that was usable to enumerate registered emails over the network. Fixed by always running Argon2 against a staticDUMMY_HASH(Argon2id withArgon2::default()parameters) when the user is missing or has no password set, so all three branches (user found + correct, user found + wrong, user not found) take the same time - Medium — OIDC client_secret stored in plaintext (#94) — CalDAV and SMTP credentials were already AES-256-GCM encrypted, but the OIDC client secret in
auth_configsat alongside them as plaintext. Newcrypto::encrypt_value/decrypt_valueAPI with anenc:v1:sentinel prefix unambiguously distinguishes encrypted values from plaintext at migration time (the existing base64 envelope can collide with plaintext OIDC secrets that happen to look like base64). Existing plaintext values are transparently re-encrypted on next startup; the migration is idempotent and uses try-decrypt as a belt-and-suspenders against the prefix-collision edge case - Medium — Rate limiter trusted leftmost X-Forwarded-For (#90) — all six rate-limited handlers extracted the leftmost XFF value, which is exactly the attacker-controlled one (each proxy in the chain appends to the right). Rotating the header per request bypassed per-IP rate limits entirely. Consolidated the six copy-pasted extractions into
client_ip_for_rate_limit()and switched to the rightmost value (the trusted proxy's view of its peer).X-Real-IPis intentionally not honoured because neither default Caddy nor default Nginx overwrite a client-supplied value, so trusting it would have been a worse footgun - Medium — Stored XSS via
company_linkjavascript:scheme (#93) — the admin-controlled company link is rendered as a clickable anchor on every public booking page; an admin (or attacker who took over an admin account) could setjavascript:alert(1)and land arbitrary script on every visitor. Newis_safe_company_link()allowlistshttp(s)://only and is enforced on both write (admin handler returns the user to/dashboard/admin?error=...) and read (silently drops bad values as defense in depth) - Medium — Internal errors leaked to clients (#91) — template render, database, and OIDC errors were
format!'d straight into HTTP response bodies, leaking template paths, schema hints, IdP URLs, and occasionally token contents. ~144 sites insrc/auth.rsandsrc/web/mod.rsnow route through one ofinternal_error_response/internal_error_html/internal_error_body, all of which log the underlying detail viatracing::error!and return a generic message. OIDC has its ownoidc_error_responsewith auth-flow-specific text. Operator-facing CalDAV source-test/sync feedback is intentionally preserved, since the only viewer is the admin debugging their own configuration - Medium — TOCTOU race in first-admin role assignment (#89) — three sites (registration handler, OIDC auto-register, CLI
user create) computed the first-user-is-admin role with a separatehas_any_users()SELECT before the INSERT, letting two concurrent registrations both observe an empty users table and both claim admin on a fresh DB. All three sites now compute the role atomically inside the INSERT viaCASE WHEN NOT EXISTS (SELECT 1 FROM users) THEN 'admin' ELSE 'user' END. Extractedauth::create_local_userso the web and CLI paths share one helper and the test exercises the production code path - Medium — Session tokens used userspace
thread_rng(#86) —generate_session_tokenusedrand::thread_rng()(a userspace ChaCha12 PRNG) for 30-day session secrets whilecrypto.rsalready usesOsRng(kernel CSPRNG viagetrandom) for AES-GCM keys/nonces. Switched toOsRng.fill_bytes, matching the existing pattern. Output shape unchanged (32 bytes hex-encoded → 64 chars) - Medium — CSRF token comparison was not constant-time (#87) —
verify_csrf_tokenusedString == String, which short-circuits on the first differing byte. Replaced withsubtle::ConstantTimeEq::ct_eqon the underlying byte slices. Risk in practice was low (network jitter dwarfs the leaked timing and CSRF tokens are UUID v4) but the fix is one extra direct dependency on a crate that was already transitively pulled in viaargon2
The remaining Lows and Informationals from the same audit are tracked in issue #85 as a punch list.
Added
- Minimum notice for guest cancel and reschedule (closes #95) — two new optional
event_typescolumns (cancel_notice_min,reschedule_notice_min);NULLor0keeps the previous behaviour of allowing cancel/reschedule at any time. Within the configured window, the four guest token endpoints (/booking/cancel/{token}and/booking/reschedule/{token}, GET + POST) render a friendlybooking_action_blockedpage showing the host's contact email instead of mutating booking state. Host- and admin-initiated cancellations from the dashboard are unaffected, since hosts often need to act on real-world emergencies on behalf of a guest. Policy is also surfaced inline on the confirmed page and in the localized confirmation email body so guests aren't surprised at click time. Form fields use a numeric input + minutes/hours/days unit selector - Admin user deletion (#70) — admins can permanently delete users from the admin panel with cascade rules and a confirmation prompt. Self-delete and last-admin delete are blocked; users with future bookings as host are blocked unless they are deleted via the dashboard with explicit acknowledgement
- Estonian locale (
et) — first community-language slot beyond the original four. Stub file is empty (runtime falls back to English on missing keys); new keys are added toi18n/en/main.ftlonly and Weblate picks them up at the next sync
Changed
- Clippy on tests in CI (#75) —
cargo clippy --all-targets -- -D warningsnow also covers test code, catching a class of regressions the previousclippystep missed
Fixed
- Event-type availability defaults respect the user's profile (closes #68, #69) — newly-created event types now seed their availability rules from the creator's per-user default working hours rather than a blanket Mon-Fri 9-17 fallback when the form is submitted without explicit windows
- CalDAV connection check falls back to PROPFIND when OPTIONS doesn't advertise
calendar-access(#71) — some CalDAV servers (notably some SOGo deployments) don't advertisecalendar-accessin theDAV:OPTIONS response header even though they support the protocol; calrs now retries with a PROPFIND probe before giving up, fixing connection-test failures for those backends - Various i18n context-plumbing fixes in
user_profile, public profile, settings, footer, and booking pages (translations rolled in via thei18n → mainmerge)
Internal
- 624 tests total (up from 575 in 1.9.0), all green on pre-commit
crypto::encrypt_value/decrypt_valueintroduced for fields where stored values can ambiguously look like plaintext; future credential additions should use these instead ofencrypt_passworddirectly when migration disambiguation matters- New
client_ip_for_rate_limit(),internal_error_response()(+_html/_body),oidc_error_response(),is_safe_company_link(),auth::create_local_user(),check_notice_window()helpers consolidate copy-pasted handler code - Two months of community translation work merged from the long-lived
i18nbranch (the standardi18n → maindirection;main → i18nremains an explicit anti-pattern)
v1.9.0: bulk private invites + CalDAV write-back fix
Workflow improvements on the invite page and a self-hoster bug fix that affected anyone running calrs without SMTP configured.
Added
- Bulk private invites (closes #58) — the per-recipient invite form is replaced with a paste textarea (one email per line, capped at 100). Each row becomes its own single-use invite token, with a shared optional message and the existing expires/single-use settings. The result page summarizes counts of sent, invalid, duplicate, and failed rows
- Copy-link button on each active sent invite — surfaces the invite URL through the UI so it can be re-shared via Slack, a separate email client, or any out-of-band channel. URL is pre-computed server-side using
CALRS_BASE_URLand the existing team/user route patterns. Hidden on expired and used invites since the link is no longer actionable
Fixed
- Auto-confirm bookings now write back to CalDAV regardless of SMTP availability (closes #65) — across the four booking-creation handlers (
handle_booking_for_user,handle_group_booking,handle_dynamic_group_booking,handle_booking),caldav_push_booking()was nested inside theif let Ok(Some(smtp_config)) = ...block. When SMTP was not configured the entire block was skipped, taking the CalDAV write-back down with it. The host-approval path was already correct, which is why require-confirmation bookings showed in CalDAV but auto-confirm bookings silently didn't. Affected anyone who deployed calrs and tried it before configuring SMTP, which is most first-time self-hosters.BookingDetailsconstruction (and the host-info lookup it depends on) is now hoisted out of the SMTP gate, withcaldav_push_bookingandnotify_watchersrunning as siblings of the SMTP block instead of children.notify_watchersalready self-gated on SMTP for its email part, so its behaviour is unchanged
Internal
- 575 tests total (up from 569 in 1.8.0), all green on pre-commit
calrs 1.8.0 — internationalization (i18n)
Internationalization release. The public booking flow and the highest-volume guest emails (confirmation, reminder, cancellation) are translated. Six locales ship out of the box, with English as the source, French human-translated by maintainers, and Spanish / Polish / German / Italian AI-seeded as starting points for native-speaker refinement on Hosted Weblate.
Added
- Six-language UI — public booking flow (slot picker, booking form, confirmation, cancel/decline/approve/claim/reschedule pages, theme-toggle chrome) renders in English, French, Spanish, Polish, German, or Italian. Translations live in Fluent
.ftlfiles underi18n/{lang}/main.ftl, embedded into the binary at compile time. Single-binary deploy preserved. - Automatic language detection — guests get their browser's
Accept-Language(RFC 7231 with q-weights honoured); logged-in users override via a Language dropdown in Profile & Settings (migration047_user_language). - Translated guest emails — confirmation, reminder, and cancellation emails render in the language captured at booking time. Migration
048_booking_languageaddsbookings.language TEXT. The reminder background task already loads it, so a reminder fired days after the booking still picks the right language. - Server-side date localization —
format_month_yearandformat_long_datehelpers render dates with locale-specific patterns:Tuesday, March 12, 2026(EN),mardi 12 mars 2026(FR),martes, 12 de marzo de 2026(ES),Montag, 12. März 2026(DE),lunedì 12 marzo 2026(IT). The format pattern itself is a Fluent message, so word order is a translation choice. - Hosted Weblate integration — translators contribute via hosted.weblate.org/projects/calrs without git or Rust knowledge. Commits flow back to the long-lived
i18nbranch via the Weblate GitHub App. - Translation-quality table in README — explicitly distinguishes human-translated French from AI-seeded locales and points readers at Weblate as the contribution path.
Fixed
- Docker image build broken by the i18n scaffolding — the multi-stage Dockerfile didn't
COPY i18n/, soinclude_str!on the embedded.ftlfiles failed at release-image build time even though localcargo buildworked. One-line fix landed before this release.
Internal
- Migrations
047_user_languageand048_booking_language - New
src/i18n.rs: concurrentFluentBundleper locale inOnceLock,Accept-Languageparser with q-weight sort, minijinjat(key, **kwargs)global,is_supported/resolve/supported_with_labelshelpers, date-formatter pair - 30 templates and ~25 web handlers wired through, each handler computes
langonce viacrate::i18n::detect_from_headersand threads it into render contexts BookingDetailsandCancellationDetailsgainguest_languageandhost_languagefields; both deriveDefaultso existing call sites use..Default::default()- 569 tests total (up from 545 in 1.7.0), including coverage for
Accept-Languageparsing, date-formatter output across locales, and full-locale parity for Spanish, Polish, German, Italian - Long-lived
i18nbranch documented inCLAUDE.mdas the working branch for translator commits and new translatable-string features
Known limitations
- Host-side emails (notification, reminder, cancellation, approval-request, decline) remain English. Infrastructure (
host_languagefield) is in place; translation pass scheduled for a follow-up. - Pending-notice / decline-notice / reschedule guest emails not yet translated.
decline_booking_by_tokenand dashboard-host-cancel paths don't yet loadbookings.language; cancellation emails sent from those paths fall back to English.- Polish month names are nominative, so date contexts read informally (
27 kwiecień 2026instead of grammatical27 kwietnia 2026). Native-speaker refinement on Weblate is welcome. - Dashboard, admin panel, and CLI command output remain English.
Full changelog: https://github.com/olivierlambert/calrs/blob/v1.8.0/CHANGELOG.md#180---2026-04-26
v1.7.0: OOM fix, explicit event-type timezone, team cross-TZ availability
Correctness and resilience release. Fixes an OOM-triggering infinite loop in slot computation, adds explicit per-event-type timezones, makes team slot grids honour each member's personal working hours across timezones, and parallelizes per-member CalDAV syncs with per-source deduplication.
Added
- Explicit timezone on event types (issue #50) — each event type now carries its own IANA
timezonecolumn; availability rules are interpreted in that timezone rather than silently inheriting the creator's profile. New picker in the event-type form. Migration046_event_type_timezonebackfills existing rows with the current account owner's timezone, so upgrades preserve behaviour. - Per-member working hours on team events — team slot grids intersect each member's personal
user_availability_rules(converted from the member's own timezone into the event's host timezone) with the event-type's rules. Members without explicit personal hours stay unconstrained. Prevents the scenario where a team event pinned to Paris would offer 09:00 Paris bookings to a US-based member whose actual working day is 09:00 Chicago (= 16:00 Paris). - Member timezone shown on event-type priority list — the Member Priority / Required Members section now renders each member's timezone under their name. Makes mis-configured user timezones immediately visible.
Fixed
- Infinite slot loop → OOM when availability window ends near midnight —
compute_slots_from_ruleswalked its inner cursor as aNaiveTime, andNaiveTime + Durationwraps at 24h. On a rule ending at 23:00 with a 60-minute slot duration,cursor + slot_durationwrapped to 00:00 (still ≤ 23:00 as a time-of-day), producing an infinite loop until the kernel OOM-killed the process (~4-minute CPU spike, ~9 GB RAM, ~240 GB of SQLite re-reads under memory pressure). Cursor now walks asNaiveDateTimeso midnight rolls into the next day cleanly. - Dashboard Decline button no-op on pending bookings (#51) —
cancel_bookingfiltered onstatus = 'confirmed', so clicking Decline on a pending booking matched zero rows and silently redirected. Now branches on status: confirmed → cancelled (CalDAV delete + emails), pending → declined (guest decline notice only). Mirrors the email-token decline flow.
Performance
- Parallel per-member CalDAV sync on team / dynamic-group slot pages — fans out via
tokio::task::JoinSet, guarded by a per-source async mutex insidesync_if_stale: same-source concurrent calls serialize and the loser skips after re-checking staleness. At most one CalDAV fetch per source is in flight at any time across the whole process. Team booking pages no longer serialize on the slowest member's CalDAV server.
Internal
- Migration
046_event_type_timezonewith per-row backfill - New helper
normalize_event_type_tzvalidates IANA submissions - Regression tests:
get_host_tz_prefers_explicit_event_type_timezone,compute_slots_terminates_with_window_ending_at_23_00,chicago_member_is_busy_at_paris_morning,member_without_personal_rules_is_unconstrained,source_lock_identity,sync_if_stale_serializes_on_per_source_lock - 545 tests total (up from 537 in 1.6.0), all green on pre-commit
- Verified end-to-end against a copy of a production DB: the previously-OOMing team booking URL now responds in under a second with flat RSS
Full changelog: https://github.com/olivierlambert/calrs/blob/v1.7.0/CHANGELOG.md#170---2026-04-24
v1.6.0: security audit round 2 + CalDAV/ICS compatibility
Security audit follow-ups, CalDAV compatibility with non-standard ports, ICS RFC-5545 compliance, and a reschedule-flow correctness fix.
Security
- OIDC account takeover via email-based linking (audit from #43) —
find_or_create_oidc_userfell through to matching by email without checking the ID token'semail_verifiedclaim. Any IdP that allows unverified registrations (Keycloak's default) let an attacker attach theiroidc_subjectto any existing local account by registering with the target's email. Now gated onemail_verified=truefor both the email-link and auto-register branches. Missing claim treated asfalse. - Stored XSS via backslash injection in inline onclick handlers (#43) — three dashboard templates (event types, sources, team settings) embedded user-controlled strings inside
onclick="… '\{{var}}\'…". MiniJinja doesn't HTML-escape backslashes, so a crafted payload could break out of the JS string and execute arbitrary script. Fix moves the value into adata-confirmattribute read viathis.dataset.confirm. Thanks @marcotama. - CSRF cookie missing Secure flag (audit from #43) — one-line fix;
HttpOnlyintentionally stays off for the double-submit pattern.
Fixed
- CalDAV sync fails with non-standard port (#42) — origin resolver stripped the port, breaking Nextcloud on
:8080. BlueMind unaffected (absolute URLs bypass the resolver). - Missing DTSTAMP in generated ICS (#49) — required by RFC 5545 §3.6.1. Strict clients (RustiCal) rejected invites. Thanks @Handfish.
- Pending bookings auto-cancelled on reschedule approval (#44).
Internal
- 16 new regression tests across the fixes (537 total, up from 521 in 1.5.0)
- Empirically verified that a fourth audit finding (email header injection via guest name) is a false positive — lettre's typed builder rejects or RFC-2047-encodes all tested CRLF payloads; pinned with tests rather than adding a redundant sanitizer
Full changelog: https://github.com/olivierlambert/calrs/blob/v1.6.0/CHANGELOG.md#160---2026-04-23
Thanks to @marcotama and @Handfish for their contributions this cycle.