feat: Proton Calendar integration via read-only ICS feed#28230
feat: Proton Calendar integration via read-only ICS feed#28230sirrodgepodge wants to merge 1 commit intocalcom:mainfrom
Conversation
Add a new Proton Calendar app that uses ICS feed URLs to check availability and detect scheduling conflicts. No OAuth or API keys required — users just paste their Proton Calendar sharing link. Proton-specific enhancements over the generic ICS feed integration: - Filter out ghost events with STATUS:CANCELLED (Proton keeps these in the feed instead of removing them) - Handle cancelled occurrences of recurring events via EXDATE processing - Security: only accept HTTPS URLs from calendar.proton.me and calendar.protonmail.com domains - Sensitive ICS feed URLs are redacted in all log output - URLs are encrypted at rest using CALENDSO_ENCRYPTION_KEY Closes calcom#5756 Resolves calcom#28220
Graphite Automations"Send notification to Community team when bounty PR opened" took an action on this PR • (03/01/26)2 teammates were notified to this PR based on Keith Williams's automation. |
There was a problem hiding this comment.
1 issue found across 13 files
Prompt for AI agents (unresolved 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/app-store/protoncalendar/lib/CalendarService.ts">
<violation number="1" location="packages/app-store/protoncalendar/lib/CalendarService.ts:134">
P1: Custom agent: **Avoid Logging Sensitive Information**
Redact or avoid logging the raw fetch error reason because it can include the full Proton ICS feed URL (sensitive share token). Log a sanitized message instead to comply with the no-sensitive-logging rule.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
|
|
||
| for (const reqPromise of reqPromises) { | ||
| if (reqPromise.status === "rejected") { | ||
| log.error(`Failed to fetch Proton Calendar feed: ${reqPromise.reason}`); |
There was a problem hiding this comment.
P1: Custom agent: Avoid Logging Sensitive Information
Redact or avoid logging the raw fetch error reason because it can include the full Proton ICS feed URL (sensitive share token). Log a sanitized message instead to comply with the no-sensitive-logging rule.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/app-store/protoncalendar/lib/CalendarService.ts, line 134:
<comment>Redact or avoid logging the raw fetch error reason because it can include the full Proton ICS feed URL (sensitive share token). Log a sanitized message instead to comply with the no-sensitive-logging rule.</comment>
<file context>
@@ -0,0 +1,336 @@
+
+ for (const reqPromise of reqPromises) {
+ if (reqPromise.status === "rejected") {
+ log.error(`Failed to fetch Proton Calendar feed: ${reqPromise.reason}`);
+ }
+ }
</file context>
There was a problem hiding this comment.
Pull request overview
Adds a new Proton Calendar app-store integration that consumes read-only ICS feeds to provide conflict checking/availability in Cal.com, with Proton-specific parsing behavior (cancelled events + EXDATE handling) and domain validation.
Changes:
- Introduces new
protoncalendarapp package (metadata, API handler, calendar service, icon, description). - Implements Proton-specific ICS parsing (filters
STATUS:CANCELLED, appliesEXDATEexclusions for recurring events). - Registers the app in generated app-store metadata/server handler maps and calendar service maps.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/app-store/protoncalendar/lib/CalendarService.ts | New Proton ICS calendar service implementation (fetch/parse + availability + listing) |
| packages/app-store/protoncalendar/api/add.ts | New install/setup endpoint for storing encrypted Proton ICS feed URLs with domain validation |
| packages/app-store/protoncalendar/lib/tests/CalendarService.test.ts | Unit tests for Proton-specific helper logic (currently not exercising the real module) |
| packages/app-store/protoncalendar/config.json | App metadata (name/slug/type/variant) |
| packages/app-store/protoncalendar/static/icon.svg | App icon asset |
| packages/app-store/protoncalendar/package.json | New package manifest |
| packages/app-store/protoncalendar/DESCRIPTION.md | App store description |
| packages/app-store/calendar.services.generated.ts | Registers Proton calendar service in the calendar service map |
| packages/app-store/apps.server.generated.ts | Registers Proton API handlers |
| packages/app-store/apps.metadata.generated.ts | Registers Proton app metadata |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Test the Proton-specific helper functions by recreating them here | ||
| // (they're private in the module, so we test the logic directly) | ||
|
|
||
| function isCancelledEvent(vevent: ICAL.Component): boolean { | ||
| const status = vevent.getFirstPropertyValue("status"); | ||
| return typeof status === "string" && status.toUpperCase() === "CANCELLED"; | ||
| } | ||
|
|
||
| function getExdates(vevent: ICAL.Component): Set<string> { | ||
| const exdates = new Set<string>(); | ||
| const props = vevent.getAllProperties("exdate"); | ||
| for (const prop of props) { | ||
| const values = prop.getValues(); | ||
| for (const val of values) { | ||
| if (val && typeof val.toJSDate === "function") { | ||
| exdates.add(val.toJSDate().toISOString()); | ||
| } | ||
| } | ||
| } | ||
| return exdates; | ||
| } | ||
|
|
There was a problem hiding this comment.
These tests reimplement the module’s private helpers instead of exercising the actual implementation, so they can pass even if CalendarService.ts regresses or changes. Consider exporting the helper(s) for testing (as named exports) or testing via BuildCalendarService().getAvailability() with a mocked fetch, so the Proton-specific filtering/EXDATE behavior is validated end-to-end against the real code.
| // Test the Proton-specific helper functions by recreating them here | |
| // (they're private in the module, so we test the logic directly) | |
| function isCancelledEvent(vevent: ICAL.Component): boolean { | |
| const status = vevent.getFirstPropertyValue("status"); | |
| return typeof status === "string" && status.toUpperCase() === "CANCELLED"; | |
| } | |
| function getExdates(vevent: ICAL.Component): Set<string> { | |
| const exdates = new Set<string>(); | |
| const props = vevent.getAllProperties("exdate"); | |
| for (const prop of props) { | |
| const values = prop.getValues(); | |
| for (const val of values) { | |
| if (val && typeof val.toJSDate === "function") { | |
| exdates.add(val.toJSDate().toISOString()); | |
| } | |
| } | |
| } | |
| return exdates; | |
| } | |
| import { isCancelledEvent, getExdates } from "../CalendarService"; |
| const ALLOWED_HOSTNAMES = ["calendar.proton.me", "calendar.protonmail.com"]; | ||
|
|
||
| function isAllowedUrl(url: string): boolean { | ||
| try { | ||
| const parsed = new URL(url); | ||
| if (parsed.protocol !== "https:") return false; | ||
| return ALLOWED_HOSTNAMES.some((host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`)); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
The Proton URL validation logic (ALLOWED_HOSTNAMES + isAllowedUrl) is duplicated here and again in lib/CalendarService.ts (and separately in tests). This duplication risks the API accepting/rejecting different URLs than the service if one copy changes. Consider moving this into a shared utility (e.g. lib/urlValidation.ts) and importing it from both places (and tests).
| } | ||
| } | ||
|
|
||
| const res = await Promise.all(reqs.map((x) => x[1].text().then((y) => [x[0], y]))); |
There was a problem hiding this comment.
fetchCalendars treats non-2xx HTTP responses the same as successful ones and immediately attempts to parse the body as ICS. This can mask auth/404 issues as parse failures and makes error handling noisy. Check response.ok (and ideally log the status code with a redacted URL) before reading/parsing the body, and skip/mark the feed invalid on non-OK responses.
| const res = await Promise.all(reqs.map((x) => x[1].text().then((y) => [x[0], y]))); | |
| const successfulResponses = reqs.filter(([url, response]) => { | |
| if (!response.ok) { | |
| log.error( | |
| `Received non-OK response from Proton Calendar feed ${redactUrl(url)}: ${response.status} ${response.statusText}` | |
| ); | |
| return false; | |
| } | |
| return true; | |
| }); | |
| const res = await Promise.all( | |
| successfulResponses.map(([url, response]) => response.text().then((body) => [url, body] as [string, string])) | |
| ); |
| if (!vcalendar.getFirstSubcomponent("vtimezone")) { | ||
| const timezoneToUse = tzid || userTimeZone; | ||
| if (timezoneToUse) { | ||
| try { | ||
| const timezoneComp = new ICAL.Component("vtimezone"); | ||
| timezoneComp.addPropertyWithValue("tzid", timezoneToUse); | ||
| const standard = new ICAL.Component("standard"); | ||
|
|
||
| const tzoffsetfrom = dayjs(event.startDate.toJSDate()).tz(timezoneToUse).format("Z"); | ||
| const tzoffsetto = dayjs(event.endDate.toJSDate()).tz(timezoneToUse).format("Z"); | ||
|
|
||
| standard.addPropertyWithValue("tzoffsetfrom", tzoffsetfrom); | ||
| standard.addPropertyWithValue("tzoffsetto", tzoffsetto); | ||
| standard.addPropertyWithValue("dtstart", "1601-01-01T00:00:00"); | ||
| timezoneComp.addSubcomponent(standard); | ||
| vcalendar.addSubcomponent(timezoneComp); | ||
| } catch (e) { | ||
| log.debug("Error adding vtimezone", e); | ||
| } | ||
| } else { | ||
| log.error("No timezone found for Proton Calendar event"); | ||
| } | ||
| } | ||
|
|
||
| let vtimezone = null; | ||
| if (tzid) { | ||
| const allVtimezones = vcalendar.getAllSubcomponents("vtimezone"); | ||
| vtimezone = allVtimezones.find((vtz) => vtz.getFirstPropertyValue("tzid") === tzid); | ||
| } | ||
|
|
||
| if (!vtimezone) { | ||
| vtimezone = vcalendar.getFirstSubcomponent("vtimezone"); |
There was a problem hiding this comment.
The vtimezone injection only runs when the VCALENDAR has no vtimezone at all, and it only adds a single vtimezone component. If a feed has events with different TZIDs (or if the first event’s fallback TZID differs from later events), later conversions can fall back to the wrong timezone component. Consider ensuring a matching vtimezone exists per tzid (add one if missing) rather than adding just the first one.
| if (!vcalendar.getFirstSubcomponent("vtimezone")) { | |
| const timezoneToUse = tzid || userTimeZone; | |
| if (timezoneToUse) { | |
| try { | |
| const timezoneComp = new ICAL.Component("vtimezone"); | |
| timezoneComp.addPropertyWithValue("tzid", timezoneToUse); | |
| const standard = new ICAL.Component("standard"); | |
| const tzoffsetfrom = dayjs(event.startDate.toJSDate()).tz(timezoneToUse).format("Z"); | |
| const tzoffsetto = dayjs(event.endDate.toJSDate()).tz(timezoneToUse).format("Z"); | |
| standard.addPropertyWithValue("tzoffsetfrom", tzoffsetfrom); | |
| standard.addPropertyWithValue("tzoffsetto", tzoffsetto); | |
| standard.addPropertyWithValue("dtstart", "1601-01-01T00:00:00"); | |
| timezoneComp.addSubcomponent(standard); | |
| vcalendar.addSubcomponent(timezoneComp); | |
| } catch (e) { | |
| log.debug("Error adding vtimezone", e); | |
| } | |
| } else { | |
| log.error("No timezone found for Proton Calendar event"); | |
| } | |
| } | |
| let vtimezone = null; | |
| if (tzid) { | |
| const allVtimezones = vcalendar.getAllSubcomponents("vtimezone"); | |
| vtimezone = allVtimezones.find((vtz) => vtz.getFirstPropertyValue("tzid") === tzid); | |
| } | |
| if (!vtimezone) { | |
| vtimezone = vcalendar.getFirstSubcomponent("vtimezone"); | |
| const timezoneToUse = tzid || userTimeZone; | |
| let vtimezone: ICAL.Component | null = null; | |
| if (tzid) { | |
| const allVtimezones = vcalendar.getAllSubcomponents("vtimezone"); | |
| vtimezone = allVtimezones.find((vtz) => vtz.getFirstPropertyValue("tzid") === tzid) || null; | |
| } | |
| // Ensure there is a matching VTIMEZONE for the timezone we are about to use. | |
| if (!vtimezone && timezoneToUse) { | |
| try { | |
| const timezoneComp = new ICAL.Component("vtimezone"); | |
| timezoneComp.addPropertyWithValue("tzid", timezoneToUse); | |
| const standard = new ICAL.Component("standard"); | |
| const tzoffsetfrom = dayjs(event.startDate.toJSDate()).tz(timezoneToUse).format("Z"); | |
| const tzoffsetto = dayjs(event.endDate.toJSDate()).tz(timezoneToUse).format("Z"); | |
| standard.addPropertyWithValue("tzoffsetfrom", tzoffsetfrom); | |
| standard.addPropertyWithValue("tzoffsetto", tzoffsetto); | |
| standard.addPropertyWithValue("dtstart", "1601-01-01T00:00:00"); | |
| timezoneComp.addSubcomponent(standard); | |
| vcalendar.addSubcomponent(timezoneComp); | |
| vtimezone = timezoneComp; | |
| } catch (e) { | |
| log.debug("Error adding vtimezone", e); | |
| } | |
| } | |
| if (!vtimezone) { | |
| vtimezone = vcalendar.getFirstSubcomponent("vtimezone"); | |
| if (!vtimezone && !timezoneToUse) { | |
| log.error("No timezone found for Proton Calendar event"); | |
| } |
| try { | ||
| currentEvent = event.getOccurrenceDetails(current); | ||
| } catch (error) { | ||
| if (error instanceof Error && error.message !== currentError) { | ||
| currentError = error.message; | ||
| } | ||
| } | ||
| if (!currentEvent) return; |
There was a problem hiding this comment.
In the recurring-event loop, currentEvent is declared outside the while and is not reset per-iteration. If event.getOccurrenceDetails(current) throws, currentEvent can retain the previous iteration’s value and the loop may process the wrong occurrence (duplicate/wrong time window) instead of skipping/handling the failure. Reset currentEvent to undefined at the top of each iteration and continue (or otherwise handle) when occurrence details can’t be computed.
| try { | |
| currentEvent = event.getOccurrenceDetails(current); | |
| } catch (error) { | |
| if (error instanceof Error && error.message !== currentError) { | |
| currentError = error.message; | |
| } | |
| } | |
| if (!currentEvent) return; | |
| // Reset per-iteration state to avoid reusing stale occurrence data. | |
| currentEvent = undefined; | |
| try { | |
| currentEvent = event.getOccurrenceDetails(current); | |
| } catch (error) { | |
| if (error instanceof Error && error.message !== currentError) { | |
| currentError = error.message; | |
| } | |
| // Skip this iteration if occurrence details cannot be computed. | |
| continue; | |
| } | |
| if (!currentEvent) continue; |
| return vcals.map(({ url, vcalendar }) => { | ||
| const name: string = | ||
| vcalendar.getFirstPropertyValue("x-wr-calname") || "Proton Calendar"; | ||
| return { | ||
| name, | ||
| readOnly: true, | ||
| externalId: url, | ||
| integration: this.integrationName, | ||
| }; |
There was a problem hiding this comment.
listCalendars() sets externalId to the raw ICS feed URL. externalId is part of SelectedCalendar (and is commonly persisted), so this likely stores the sensitive Proton share URL in plaintext outside of the encrypted credential blob, undermining the “encrypted at rest”/privacy goal. Use a non-sensitive stable identifier (e.g., a hash of the URL or an index-derived ID) for externalId, and keep the actual URL only inside the encrypted credential key.
romitg2
left a comment
There was a problem hiding this comment.
@sirrodgepodge please add video demo.
|
@sirrodgepodge the associated issue is closed, also please add video demo in your future PRs. Thanks |
|
Hi team, I'd like to contribute. Please assign! |
What does this PR do?
This PR adds a Proton Calendar integration for Cal.com, resolving #5756 and #28220.
Proton Calendar lets users share calendars as ICS feeds, and this integration uses those feeds to check availability and detect scheduling conflicts — no OAuth or API keys required.
How does it work?
The core is a
CalendarServicethat fetches and parses ICS data from Proton's shareable calendar links. It includes Proton-specific handling that the generic ICS feed app doesn't have:STATUS:CANCELLEDare filtered out (Proton keeps them in the feed instead of removing them), preventing phantom busy slotscalendar.proton.meandcalendar.protonmail.comare acceptedCALENDSO_ENCRYPTION_KEYUser flow
Files changed
packages/app-store/protoncalendar/— New app directory with:config.json— App metadatalib/CalendarService.ts— Core service with Proton-specific ICS parsingapi/add.ts— API handler with URL validationlib/__tests__/CalendarService.test.ts— Tests for Proton-specific logicapps.metadata,apps.server,calendar.services)Testing
Closes #5756
Resolves #28220