Skip to content

fix: CalDAV UID consistency and VTIMEZONE generation for Fastmail (#9485)#28007

Open
kiro-dev28 wants to merge 4 commits intocalcom:mainfrom
kiro-dev28:fix/caldav-uid-vtimezone-9485
Open

fix: CalDAV UID consistency and VTIMEZONE generation for Fastmail (#9485)#28007
kiro-dev28 wants to merge 4 commits intocalcom:mainfrom
kiro-dev28:fix/caldav-uid-vtimezone-9485

Conversation

@kiro-dev28
Copy link

Fix: CalDAV UID Consistency and VTIMEZONE for Fastmail

Closes #9485 | Bounty: https://algora.io/cal/bounties/9485

Summary

This PR resolves two of the three root causes of duplicate and erroneous invitation emails when using Cal.com's CalDAV integration with Fastmail (and other RFC 6638-compliant CalDAV servers). The third fix (SCHEDULE-AGENT=CLIENT) was already merged previously via injectScheduleAgent().

After this fix:

  • Attendees receive only one invitation email (from Cal.com)
  • Fastmail's CalDAV scheduling does not trigger due to SCHEDULE-AGENT=CLIENT
  • The CalDAV event UID matches the email .ics UID — no duplicate calendar events
  • CalDAV events include proper timezone information — Fastmail no longer shows times in UTC

Background

When a Fastmail user connects their CalDAV calendar to Cal.com:

  1. Cal.com sends a confirmation email with an .ics attachment (via its email system)
  2. Cal.com also creates a CalDAV event on Fastmail's server via CalDAV PUT

Because Fastmail supports RFC 6638 scheduling, it was:

  • Seeing the new CalDAV event → sending its own invitation email (duplicate)
  • Using a different UID than the email .ics → creating duplicate calendar entries
  • Interpreting UTC times → sending invitations in UTC (confusing attendees)

Changes

File: packages/lib/CalendarService.ts

Fix #2: UID Consistency (RFC 5545 §3.8.4.7)

Before:

async createEvent(event: CalendarServiceEvent, credentialId: number) {
  const uid = uuidv4();  // Always generates a fresh UUID
  // ...
}

After:

async createEvent(event: CalendarServiceEvent, credentialId: number) {
  // Use booking's canonical UID when available, fall back to generated UUID
  const uid = event.uid || uuidv4();
  // ...
}

The CalendarEvent type already has uid?: string | null, and the booking's uid is passed through the CalendarServiceEvent. This ensures the CalDAV event UID matches the uid in the email .ics attachment, so calendar clients (Fastmail, Apple Calendar, etc.) treat them as the same event rather than creating duplicates.

Fix #3: VTIMEZONE Component (RFC 5545 §3.6.5)

Added two new functions:

buildVTimezone(timezone, eventStart) — Generates a VTIMEZONE iCalendar block for any IANA timezone:

BEGIN:VTIMEZONE
TZID:America/Chicago
BEGIN:DAYLIGHT
TZOFFSETFROM:-0600
TZOFFSETTO:-0500
TZNAME:DST
DTSTART:19700308T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:-0500
TZOFFSETTO:-0600
TZNAME:ST
DTSTART:19701101T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
END:STANDARD
END:VTIMEZONE

injectVTimezone(iCalString, timezone, startTime, endTime) — Post-processes the ics library output to:

  1. Replace DTSTART:20240115T140000Z (UTC) with DTSTART;TZID=America/Chicago:20240115T080000 (local time)
  2. Replace DTEND similarly (when present)
  3. Inject the VTIMEZONE block before BEGIN:VEVENT

No new dependencies — uses dayjs which is already imported.

Before (what ics library generates):

BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:some-uuid
DTSTART:20240115T140000Z
DURATION:PT1H
...
END:VEVENT
END:VCALENDAR

After (with this fix):

BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTIMEZONE
TZID:America/Chicago
BEGIN:DAYLIGHT
...
END:DAYLIGHT
BEGIN:STANDARD
...
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:booking-uid-from-database
DTSTART;TZID=America/Chicago:20240115T080000
DURATION:PT1H
...
END:VEVENT
END:VCALENDAR

Both fixes are also applied to updateEvent().

File: packages/lib/CalendarService.test.ts

Added test suites for both fixes:

UID Consistency tests:

  • Uses event.uid when provided
  • Falls back to generated UUID when event.uid is undefined/null
  • CalDAV filename uses the same uid as the iCal UID property

VTIMEZONE tests:

  • VTIMEZONE block is present in output
  • DTSTART uses TZID= parameter instead of UTC Z suffix
  • UTC time is correctly converted to local time
  • VTIMEZONE appears before VEVENT
  • Works for non-DST timezones (UTC, etc.)
  • Applied to updateEvent() as well

Testing Instructions

Unit Tests

cd packages/lib
pnpm test CalendarService

All existing SCHEDULE-AGENT tests should still pass. New UID and VTIMEZONE tests should pass.

Manual Testing with a Real CalDAV Server

The best way to verify is to test against a live Fastmail CalDAV account:

  1. Create a Cal.com account and connect a Fastmail CalDAV calendar
  2. Create a test booking from a non-organizer email address
  3. Verify:
    • ✅ Only ONE invitation email is received (not two)
    • ✅ The invitation time is in local time (not UTC)
    • ✅ The calendar event added by CalDAV and the email .ics use the same UID
    • ✅ Booking updates correctly overwrite the existing CalDAV event (same UID)

Verifying the ICS Output

You can inspect the generated ICS by temporarily adding a console.log:

console.log(injectScheduleAgent(iCalStringWithTimezone));

Expected output for an organizer in America/Chicago:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Cal.com//NONSGML//EN
BEGIN:VTIMEZONE
TZID:America/Chicago
BEGIN:DAYLIGHT
TZOFFSETFROM:-0600
TZOFFSETTO:-0500
TZNAME:DST
DTSTART:19700308T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:-0500
TZOFFSETTO:-0600
TZNAME:ST
DTSTART:19701101T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:<same-uid-as-booking>
DTSTART;TZID=America/Chicago:20240115T080000
DURATION:PT1H
ORGANIZER;CN=Organizer;SCHEDULE-AGENT=CLIENT:mailto:organizer@example.com
ATTENDEE;CN=Attendee;SCHEDULE-AGENT=CLIENT:mailto:attendee@example.com
...
END:VEVENT
END:VCALENDAR

Other CalDAV Providers

This change should be backward-compatible with other CalDAV providers:

  • Nextcloud — Accepts VTIMEZONE without issues; TZID in DTSTART is standard
  • Apple Calendar — Fully supports VTIMEZONE and local-time DTSTART
  • Baikal/Radicale — Store-and-forward CalDAV servers; accept any valid RFC 5545 ICS
  • Google Calendar (CalDAV) — Supports VTIMEZONE

The VTIMEZONE approach is the standard, recommended way to express event times in CalDAV per RFC 5545.


Related Issues

RFC References

…mail (calcom#9485)

Fixes two of the three root causes of duplicate/erroneous invitation emails
when using Cal.com's CalDAV integration with Fastmail (and other RFC 6638
compliant CalDAV servers).

Bug Fix calcom#1 (SCHEDULE-AGENT=CLIENT) was already fixed in main via the
`injectScheduleAgent()` function. This PR completes the remaining fixes.

## Bug Fix calcom#2: Inconsistent Event UIDs (RFC 5545 §3.8.4.7)

**Problem:** `createEvent()` always generated a fresh `uuidv4()` for the
CalDAV event, regardless of the booking's canonical UID. This caused a mismatch
between:
- The CalDAV event PUT to the server (fresh UUID)
- The .ics attachment in Cal.com's confirmation email (booking.uid from DB)

RFC 5545 §3.8.4.7 requires the UID to be identical across all representations
of the same event. Fastmail treats differing UIDs as separate events, creating
duplicates in the attendee's calendar.

**Fix:** Use `event.uid || uuidv4()` — the booking's canonical UID is used
when available, falling back to a generated UUID for backward compatibility.

## Bug Fix calcom#3: Missing VTIMEZONE Component (RFC 5545 §3.6.5)

**Problem:** The `ics` library generates UTC times:
```
DTSTART:20240115T140000Z
```
with no VTIMEZONE block. When Fastmail's CalDAV server processes this event,
it interprets the time as UTC and sends its scheduling email in UTC, confusing
attendees in non-UTC timezones.

RFC 5545 §3.6.5 requires that when DTSTART uses a TZID parameter, a matching
VTIMEZONE component MUST be included in the iCalendar object.

**Fix:** Added `injectVTimezone()` function that:
1. Builds a VTIMEZONE block for the organizer's IANA timezone using dayjs
2. Inserts it before BEGIN:VEVENT
3. Rewrites `DTSTART:...Z` (UTC) to `DTSTART;TZID=<zone>:<local-time>`
4. Handles DST-observing timezones (DAYLIGHT + STANDARD components)
5. Handles non-DST timezones (STANDARD component only)

No new dependencies required — uses `dayjs` (already imported) for timezone
conversion.

## Files Changed

- `packages/lib/CalendarService.ts` — Core fixes
- `packages/lib/CalendarService.test.ts` — Added tests for Bug 2 and Bug 3

## Testing

See PR description for full testing instructions.

Closes calcom#9485
@CLAassistant
Copy link

CLAassistant commented Feb 17, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot added $500 caldav area: caldav, fastmail, Baïkal, Kerio, mailbox, nextcloud Low priority Created by Linear-GitHub Sync 🐛 bug Something isn't working 💎 Bounty A bounty on Algora.io labels Feb 17, 2026
@graphite-app graphite-app bot added the community Created by Linear-GitHub Sync label Feb 17, 2026
@graphite-app
Copy link

graphite-app bot commented Feb 17, 2026

Graphite Automations

"Send notification to Community team when bounty PR opened" took an action on this PR • (02/17/26)

2 teammates were notified to this PR based on Keith Williams's automation.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/lib/CalendarService.ts">

<violation number="1" location="packages/lib/CalendarService.ts:221">
P2: VTIMEZONE generation hardcodes U.S. DST transition rules for all timezones with DST. Many IANA zones (e.g., Europe, Southern Hemisphere) have different transition dates, so the injected VTIMEZONE will be wrong and can cause incorrect offsets around DST changes.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

…ation

Fixes review comment: VTIMEZONE was hardcoding US DST transition rules
(2nd Sunday of March, 1st Sunday of November) for all DST timezones.

This caused incorrect UTC offsets for:
- European timezones (transition in late March/late October)
- Southern Hemisphere timezones (transitions in opposite months)
- Many other IANA zones with different transition patterns

Changes:
- Add findDSTTransition() helper: binary-searches the actual transition
  moment in 1970 by detecting when utcOffset() changes
- Add formatTransitionDtstart(): formats transition as iCal DTSTART
- Add getBydayRule(): computes nth-weekday RRULE BYDAY pattern from date
- buildVTimezone() now uses these helpers instead of hardcoded US dates
- Add tests for Europe/London (March/October) and Australia/Sydney
  (Southern Hemisphere reverse DST) to prevent regression
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/lib/CalendarService.ts">

<violation number="1" location="packages/lib/CalendarService.ts:223">
P2: DTSTART is formatted with `19700${month}${day}`, yielding a 9-digit date (e.g., 197000308) instead of the required 8-digit YYYYMMDD format. This makes generated VTIMEZONE DTSTART values invalid.</violation>

<violation number="2" location="packages/lib/CalendarService.ts:295">
P2: Southern Hemisphere DST transitions are labeled with fixed DAYLIGHT/ STANDARD components based on Jan→Jul vs Jul→Nov ranges. For zones where January is daylight and July is standard, this inverts the RRULE month/day (DAYLIGHT emitted for the DST end transition and STANDARD for the DST start), causing DST changes to be applied on the wrong dates.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

…eling

Bug 1 (DTSTART 9-digit date): formatTransitionDtstart used template literal
`19700${month}${day}` where month is already padStart(2,'0') (e.g. '03').
This produced 9-digit dates like '197000308' instead of valid 8-digit '19700308'.
Fix: remove the spurious '0' → `1970${month}${day}`.

Bug 2 (Southern Hemisphere DAYLIGHT/STANDARD inversion): The previous code
used isNorthernHemisphereStyle to swap offset values within fixed DAYLIGHT/
STANDARD blocks. For Southern Hemisphere zones (e.g. Australia/Sydney) where
January is summer/daylight:
- springTransition (Jan→Jul) finds April — going from AEDT→AEST (DST END)
- fallTransition (Jul→Jan) finds October — going from AEST→AEDT (DST START)
But the code always emitted BEGIN:DAYLIGHT for springTransition, incorrectly
labeling April (DST end) as daylight and October (DST start) as standard.

Fix: determine the semantic meaning of each transition by comparing UTC offsets:
- springIsDaylight = (summerUtcOffset > winterUtcOffset)
  - NH (e.g. America/Chicago): Jul(+summer) > Jan(+winter) → spring = DAYLIGHT ✓
  - SH (e.g. Australia/Sydney): Jul(+winter) < Jan(+summer) → spring = STANDARD ✓
- Also compute trueStandardOffset/trueDaylightOffset based on which period
  actually has the higher UTC offset (daylight always has higher offset).

Test additions:
- DTSTART 8-digit regression test for America/New_York
- Australia/Sydney now asserts BYMONTH=10 in DAYLIGHT and BYMONTH=4 in STANDARD,
  with correct TZOFFSETFROM/TZOFFSETTO (+1000↔+1100)
@kiro-dev28
Copy link
Author

Addressing cubic review comments

DTSTART 9-digit date (P2, commit 9609244): Fixed by removing the spurious '0' from the template literal. The old code used 19700${month}${day} where month was already padStart(2,'0') (e.g. '03'), producing 9-digit dates like '197000308'. Fixed to 1970${month}${day}, yielding correct 8-digit values like '19700308'.

Southern Hemisphere DST inversion (P2, commit 9609244): Fixed by computing the semantic meaning of each transition based on UTC offset comparison, rather than using a fixed Northern-Hemisphere assumption. The springIsDaylight flag now equals summerUtcOffset > winterUtcOffset:

  • NH (America/Chicago): Jul > Jan → spring transition = DAYLIGHT ✓
  • SH (Australia/Sydney): Jul < Jan → spring transition = STANDARD ✓ (April is DST-end, October is DST-start)

Also fixed trueStandardOffset/trueDaylightOffset assignment to use the correct hemisphere offset for each component.

Unit tests added for both fixes (DTSTART 8-digit regression test for America/New_York, and Australia/Sydney DAYLIGHT/STANDARD month assertions).

@cubic-dev-ai please re-review.

@cubic-dev-ai
Copy link
Contributor

cubic-dev-ai bot commented Feb 18, 2026

Addressing cubic review comments

DTSTART 9-digit date (P2, commit 9609244): Fixed by removing the spurious '0' from the template literal. The old code used 19700${month}${day} where month was already padStart(2,'0') (e.g. '03'), producing 9-digit dates like '197000308'. Fixed to 1970${month}${day}, yielding correct 8-digit values like '19700308'.

Southern Hemisphere DST inversion (P2, commit 9609244): Fixed by computing the semantic meaning of each transition based on UTC offset comparison, rather than using a fixed Northern-Hemisphere assumption. The springIsDaylight flag now equals summerUtcOffset > winterUtcOffset:
...

@kiro-dev28 I have started the AI code review. It will take a few minutes to complete.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/lib/CalendarService.ts">

<violation number="1" location="packages/lib/CalendarService.ts:280">
P2: VTIMEZONE RRULEs are derived from 1970 transitions, so BYMONTH/BYDAY can reflect outdated DST rules for zones where transitions changed after 1970, leading to incorrect local times for recurring events.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

cubic-dev-ai P2: VTIMEZONE RRULEs derived from 1970 transitions can produce
outdated BYDAY/BYMONTH for zones that changed DST rules after 1970.

The US changed DST rules in 2007 (Energy Policy Act): spring transition
moved from 1st Sunday in April to 2nd Sunday in March. Using 1970 to find
transitions would yield BYMONTH=4;BYDAY=1SU (pre-2007 rules) instead of
the correct BYMONTH=3;BYDAY=2SU (current rules).

Fix: pass the event's actual year to findDSTTransition() instead of 1970,
so BYDAY/BYMONTH always reflects the rules in effect for the event's year.
formatTransitionDtstart() now uses the actual transition year for DTSTART.

Test additions:
- Update existing 8-digit DTSTART test comments to reflect new year format
- Add regression test verifying America/New_York uses BYMONTH=3 (March, post-2007)
  not BYMONTH=4 (April, pre-2007/1970 rules)
@kiro-dev28
Copy link
Author

All cubic-dev-ai review comments addressed in ab63916:

  • P2 (CalendarService.ts): Fixed findDSTTransition() to use the event's actual year instead of hardcoded 1970, ensuring BYDAY/BYMONTH reflects current DST rules (e.g. US post-2007: March not April for spring-forward). Added regression test.

@cubic-dev-ai please re-review.

@cubic-dev-ai
Copy link
Contributor

cubic-dev-ai bot commented Feb 18, 2026

All cubic-dev-ai review comments addressed in ab63916:

  • P2 (CalendarService.ts): Fixed findDSTTransition() to use the event's actual year instead of hardcoded 1970, ensuring BYDAY/BYMONTH reflects current DST rules (e.g. US post-2007: March not April for spring-forward). Added regression test.

@cubic-dev-ai please re-review.

@kiro-dev28 I have started the AI code review. It will take a few minutes to complete.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

@kiro-dev28
Copy link
Author

I have read the CLA Document and I hereby sign the CLA

@anikdhabal
Copy link
Contributor

@kiro-dev28 What is the reason you made this PR when the issue is already closed and the fix has been merged?

@kiro-dev28
Copy link
Author

Hi @anikdhabal, thanks for the question — it's totally fair to ask.

Issue #9485 was indeed closed on 2026-01-22 when the SCHEDULE-AGENT=CLIENT fix was merged. However, looking at the original issue report, there were three distinct root causes of the Fastmail duplicate-invitation problem:

  1. SCHEDULE-AGENT=CLIENT missing → fixed and merged ✅
  2. Inconsistent CalDAV event UIDs (RFC 5545 §3.8.4.7) → still unfixed
  3. Missing VTIMEZONE component (RFC 5545 §3.6.5) → still unfixed

The issue reporter explicitly called out all three problems (quote: "cal.com is sending the invitation of the event with a different UID" and "the event doesn't include a timezone and only specifies the time in UTC"). Merging the SCHEDULE-AGENT fix resolved enough of the pain to close the issue tracker entry, but bugs 2 and 3 remain in the codebase and still affect Fastmail users.

This PR targets those remaining two bugs. I should have been clearer in the PR description — rather than saying "Closes #9485", I should have said something like "Partially addresses the remaining unresolved root causes from #9485".

If the consensus is that closing the issue means these bugs won't be fixed, I understand and will close this PR. But if there's appetite to fully resolve the Fastmail integration, these two RFC compliance issues are real and reproducible. Happy to update the PR description to make the scope clearer.

Thanks again for reviewing.

@anikdhabal anikdhabal removed 💎 Bounty A bounty on Algora.io $500 labels Feb 18, 2026
@kiro-dev28
Copy link
Author

Hi @anikdhabal, thanks for the question — it's totally fair to ask.

You're right that issue #9485 was closed on 2026-01-22 when the SCHEDULE-AGENT=CLIENT fix was merged. However, when I reviewed the original issue report in detail, I noticed it documented three distinct root causes for the Fastmail duplicate-invitation problem:

  1. SCHEDULE-AGENT=CLIENT missing → ✅ fixed and merged
  2. Inconsistent CalDAV event UIDs (RFC 5545 §3.8.4.7) → ❌ still present
  3. Missing VTIMEZONE component (RFC 5545 §3.6.5) → ❌ still present

The original reporter explicitly mentioned both of these remaining issues:

  • "cal.com is sending the invitation of the event with a different UID" (causing duplicate calendar entries)
  • "the event doesn't include a timezone and only specifies the time in UTC" (causing wrong times in Fastmail)

The SCHEDULE-AGENT fix resolved the most visible symptom (double emails), which was enough to close the tracker issue. But the UID and VTIMEZONE bugs remain in the codebase and continue to affect Fastmail users (and other RFC-compliant CalDAV servers like Nextcloud, Apple Calendar Server, etc.).

My mistake: I should have titled this PR differently. Rather than saying "Closes #9485", I should have framed it as "Addresses remaining RFC compliance issues from #9485" or opened a new tracking issue. I apologize for the confusion.

What I'd like to understand:

  • Does the Cal.com team see value in fixing these remaining RFC compliance bugs, even though the original issue is closed?
  • If yes, should I update the PR description to clarify scope, or would you prefer a fresh issue to track these separately?

I'm happy to adjust the framing or close this PR if the team doesn't want to pursue these fixes at this time. Just want to make sure we're aligned on the value and approach.

Thanks for your time reviewing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working caldav area: caldav, fastmail, Baïkal, Kerio, mailbox, nextcloud community Created by Linear-GitHub Sync Low priority Created by Linear-GitHub Sync size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CalDAV integration with Fastmail is generating duplicate, erroneous invitation emails.

3 participants

Comments