Skip to content

feat: Proton Calendar integration via read-only ICS feed#28230

Closed
sirrodgepodge wants to merge 1 commit intocalcom:mainfrom
sirrodgepodge:feat/proton-calendar-ics
Closed

feat: Proton Calendar integration via read-only ICS feed#28230
sirrodgepodge wants to merge 1 commit intocalcom:mainfrom
sirrodgepodge:feat/proton-calendar-ics

Conversation

@sirrodgepodge
Copy link

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 CalendarService that 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:

  • Ghost event filtering: Events with STATUS:CANCELLED are filtered out (Proton keeps them in the feed instead of removing them), preventing phantom busy slots
  • EXDATE processing: Cancelled occurrences of recurring events are properly excluded via EXDATE handling
  • Domain validation: Only HTTPS URLs from calendar.proton.me and calendar.protonmail.com are accepted
  • URL encryption: ICS feed URLs are encrypted at rest using CALENDSO_ENCRYPTION_KEY
  • Log redaction: Sensitive feed URLs are redacted from all log output

User flow

  1. Go to Apps → Proton Calendar → Install
  2. In Proton Calendar, go to Settings → Calendars → Share → Create link and copy the ICS URL
  3. Paste it in the setup page and click Save
  4. The calendar appears under Installed Apps → Calendar for conflict detection

Files changed

  • packages/app-store/protoncalendar/ — New app directory with:
    • config.json — App metadata
    • lib/CalendarService.ts — Core service with Proton-specific ICS parsing
    • api/add.ts — API handler with URL validation
    • lib/__tests__/CalendarService.test.ts — Tests for Proton-specific logic
  • Updated generated files (apps.metadata, apps.server, calendar.services)

Testing

  • Unit tests cover: cancelled event filtering, EXDATE parsing, URL validation, ICS parsing
  • Manual testing: install app, paste Proton ICS URL, verify calendar appears for conflict checking

Closes #5756
Resolves #28220

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
Copilot AI review requested due to automatic review settings March 1, 2026 20:22
@sirrodgepodge sirrodgepodge requested a review from a team as a code owner March 1, 2026 20:22
@graphite-app graphite-app bot added the community Created by Linear-GitHub Sync label Mar 1, 2026
@github-actions github-actions bot added $200 app-store area: app store, apps, calendar integrations, google calendar, outlook, lark, apple calendar Low priority Created by Linear-GitHub Sync ✨ feature New feature or request 💎 Bounty A bounty on Algora.io labels Mar 1, 2026
@graphite-app
Copy link

graphite-app bot commented Mar 1, 2026

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.

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 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}`);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 1, 2026

Choose a reason for hiding this comment

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

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>
Fix with Cubic

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 protoncalendar app package (metadata, API handler, calendar service, icon, description).
  • Implements Proton-specific ICS parsing (filters STATUS:CANCELLED, applies EXDATE exclusions 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.

Comment on lines +3 to +25

// 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;
}

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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";

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +21
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;
}
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
}
}

const res = await Promise.all(reqs.map((x) => x[1].text().then((y) => [x[0], y])));
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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]))
);

Copilot uses AI. Check for mistakes.
Comment on lines +198 to +229
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");
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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");
}

Copilot uses AI. Check for mistakes.
Comment on lines +261 to +268
try {
currentEvent = event.getOccurrenceDetails(current);
} catch (error) {
if (error instanceof Error && error.message !== currentError) {
currentError = error.message;
}
}
if (!currentEvent) return;
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +318 to +326
return vcals.map(({ url, vcalendar }) => {
const name: string =
vcalendar.getFirstPropertyValue("x-wr-calname") || "Proton Calendar";
return {
name,
readOnly: true,
externalId: url,
integration: this.integrationName,
};
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Member

@romitg2 romitg2 left a comment

Choose a reason for hiding this comment

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

@sirrodgepodge please add video demo.

@romitg2
Copy link
Member

romitg2 commented Mar 2, 2026

@sirrodgepodge the associated issue is closed, also please add video demo in your future PRs. Thanks

@romitg2 romitg2 closed this Mar 2, 2026
@Moses-main
Copy link

Hi team, I'd like to contribute. Please assign!

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

Labels

app-store area: app store, apps, calendar integrations, google calendar, outlook, lark, apple calendar 💎 Bounty A bounty on Algora.io community Created by Linear-GitHub Sync ✨ feature New feature or request Low priority Created by Linear-GitHub Sync size/XL $200

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proton Calendar Integration

4 participants