Skip to content

Releases: olivierlambert/calrs

v1.12.1

26 May 19:35

Choose a tag to compare

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

24 May 08:29

Choose a tag to compare

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") and POST /dashboard/sources/{id}/edit (apply), both scoped by user_id. CLI mirror: calrs source update <id-prefix> [--name ...] [--url ...] [--username ...] [--password]. --password is a flag that prompts via rpassword for scripted rotation. Empty password on either surface preserves the stored encrypted blob untouched. URL changes pass validate_caldav_url for 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_EMAIL required; CALRS_SMTP_PORT (default 587), CALRS_SMTP_TLS_MODE (starttls/tls), CALRS_SMTP_FROM_NAME optional. Env vars take priority over the DB; partial config errors loudly. New SmtpTlsMode enum branches send_email on relay() (implicit TLS) vs starttls_relay() to fix the port-465 hang. Applies to DB-configured SMTP too via new tls_mode column (migration 052, defaults to 'starttls'); calrs config smtp prompt asks for the mode. Admin panel surfaces env-sourced status. SmtpConfig's Debug impl 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-test produced Error: Invalid input whenever from_name was None: the old fallback yielded a string like you@example.com <you@example.com> whose two @'s failed RFC 5322 parsing. All 17 send sites now build From through a new SmtpConfig::mailbox_from() helper using Mailbox::new(Option<String>, Address), which also handles display names containing commas and other characters that need quoting. Unit test locks in both the None case and the Some("Name, With Comma") case
  • Team event type management gated through a single capability check (PR #55) — personal /dashboard/event-types/{slug}/* mutation routes now require et.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_invite now strictly requires can_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). New OptionalAuthUser extractor with shared resolve_session_user lets 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_page lists 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)

24 May 08:32

Choose a tag to compare

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_slots now runs apply_frequency_limit_filter after 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 bare Html("..."))
  • Per-member booking frequency limits (closes #117, PR #118) — new booking_frequency_limits.per_member flag (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_limit takes Option<&str> assigned_user_id and scopes the count by assignee; pick_group_member excludes candidates already at their per-member cap so the picker doesn't route to a doomed user; apply_frequency_limit_filter hides 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_booking is 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 through event_types → accounts → caldav_sources and 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_form never queried booking_frequency_limits, and create_group_event_type / update_group_event_type never 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_error renamed to render_booking_action_error since the template (booking_action_error.html) isn't claim-specific
  • Coverage CI: under_tarpaulin() now uses cfg!(tarpaulin) (the previous CARGO_TARPAULIN_VERSION env-var check never fired, so the racy tracing-capture tests were leaking through the supposed-to-skip guard); Cargo.toml registers cfg(tarpaulin) via the unexpected_cfgs lint config

v1.10.2: hotfix for phantom sync-collection booking cancellations

14 May 17:33

Choose a tag to compare

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 deletionsdelete_events_by_href in src/commands/sync.rs cancelled any booking whose UID matched an href the server reported as deleted, regardless of whether the local events table 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-only cancel_orphaned_booking lookup still found the confirmed booking, cancelled it, and emailed both host and guest. The cancellation is now gated on rows_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 mode
    • delete_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_booking by 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

12 May 15:22

Choose a tag to compare

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_York and a host in Europe/Paris, the dashboard showed 10:00 with no qualifier and let the host believe the meeting was at 10:00 Paris time. The dashboard now reads et.timezone (with fallback to users.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. Because email::generate_ics/generate_cancel_ics both call convert_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. New booking_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

04 May 20:05

Choose a tag to compare

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_handler short-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 static DUMMY_HASH (Argon2id with Argon2::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_config sat alongside them as plaintext. New crypto::encrypt_value / decrypt_value API with an enc: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-IP is 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_link javascript: 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 set javascript:alert(1) and land arbitrary script on every visitor. New is_safe_company_link() allowlists http(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 in src/auth.rs and src/web/mod.rs now route through one of internal_error_response / internal_error_html / internal_error_body, all of which log the underlying detail via tracing::error! and return a generic message. OIDC has its own oidc_error_response with 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 separate has_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 via CASE WHEN NOT EXISTS (SELECT 1 FROM users) THEN 'admin' ELSE 'user' END. Extracted auth::create_local_user so 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_token used rand::thread_rng() (a userspace ChaCha12 PRNG) for 30-day session secrets while crypto.rs already uses OsRng (kernel CSPRNG via getrandom) for AES-GCM keys/nonces. Switched to OsRng.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_token used String == String, which short-circuits on the first differing byte. Replaced with subtle::ConstantTimeEq::ct_eq on 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 via argon2

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_types columns (cancel_notice_min, reschedule_notice_min); NULL or 0 keeps 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 friendly booking_action_blocked page 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 to i18n/en/main.ftl only and Weblate picks them up at the next sync

Changed

  • Clippy on tests in CI (#75) — cargo clippy --all-targets -- -D warnings now also covers test code, catching a class of regressions the previous clippy step 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 advertise calendar-access in the DAV: 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 the i18n → main merge)

Internal

  • 624 tests total (up from 575 in 1.9.0), all green on pre-commit
  • crypto::encrypt_value / decrypt_value introduced for fields where stored values can ambiguously look like plaintext; future credential additions should use these instead of encrypt_password directly 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 i18n branch (the standard i18n → main direction; main → i18n remains an explicit anti-pattern)

v1.9.0: bulk private invites + CalDAV write-back fix

28 Apr 21:26

Choose a tag to compare

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_URL and 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 the if 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. BookingDetails construction (and the host-info lookup it depends on) is now hoisted out of the SMTP gate, with caldav_push_booking and notify_watchers running as siblings of the SMTP block instead of children. notify_watchers already 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)

26 Apr 19:38

Choose a tag to compare

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 .ftl files under i18n/{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 (migration 047_user_language).
  • Translated guest emails — confirmation, reminder, and cancellation emails render in the language captured at booking time. Migration 048_booking_language adds bookings.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 localizationformat_month_year and format_long_date helpers 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 i18n branch 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/, so include_str! on the embedded .ftl files failed at release-image build time even though local cargo build worked. One-line fix landed before this release.

Internal

  • Migrations 047_user_language and 048_booking_language
  • New src/i18n.rs: concurrent FluentBundle per locale in OnceLock, Accept-Language parser with q-weight sort, minijinja t(key, **kwargs) global, is_supported / resolve / supported_with_labels helpers, date-formatter pair
  • 30 templates and ~25 web handlers wired through, each handler computes lang once via crate::i18n::detect_from_headers and threads it into render contexts
  • BookingDetails and CancellationDetails gain guest_language and host_language fields; both derive Default so existing call sites use ..Default::default()
  • 569 tests total (up from 545 in 1.7.0), including coverage for Accept-Language parsing, date-formatter output across locales, and full-locale parity for Spanish, Polish, German, Italian
  • Long-lived i18n branch documented in CLAUDE.md as 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_language field) is in place; translation pass scheduled for a follow-up.
  • Pending-notice / decline-notice / reschedule guest emails not yet translated.
  • decline_booking_by_token and dashboard-host-cancel paths don't yet load bookings.language; cancellation emails sent from those paths fall back to English.
  • Polish month names are nominative, so date contexts read informally (27 kwiecień 2026 instead of grammatical 27 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

24 Apr 16:19

Choose a tag to compare

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 timezone column; availability rules are interpreted in that timezone rather than silently inheriting the creator's profile. New picker in the event-type form. Migration 046_event_type_timezone backfills 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 midnightcompute_slots_from_rules walked its inner cursor as a NaiveTime, and NaiveTime + Duration wraps at 24h. On a rule ending at 23:00 with a 60-minute slot duration, cursor + slot_duration wrapped 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 as NaiveDateTime so midnight rolls into the next day cleanly.
  • Dashboard Decline button no-op on pending bookings (#51) — cancel_booking filtered on status = '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 inside sync_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_timezone with per-row backfill
  • New helper normalize_event_type_tz validates 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

23 Apr 19:35

Choose a tag to compare

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_user fell through to matching by email without checking the ID token's email_verified claim. Any IdP that allows unverified registrations (Keycloak's default) let an attacker attach their oidc_subject to any existing local account by registering with the target's email. Now gated on email_verified=true for both the email-link and auto-register branches. Missing claim treated as false.
  • 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 a data-confirm attribute read via this.dataset.confirm. Thanks @marcotama.
  • CSRF cookie missing Secure flag (audit from #43) — one-line fix; HttpOnly intentionally 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.