Files
calendar/packages/lib/CalendarService.ts
T
45ea59ceb8 fix: prevent CalDAV duplicate invitations with RFC-compliant SCHEDULE-AGENT (#22434)
* fix: prevent CalDAV duplicate invitations with RFC-compliant SCHEDULE-AGENT

Fixes #9485

Adds SCHEDULE-AGENT=CLIENT to ATTENDEE lines in CalDAV createEvent and
updateEvent operations per RFC 6638 Section 7.1. This tells CalDAV servers
(Fastmail, NextCloud, etc.) that the client handles scheduling, preventing
duplicate invitation emails.

Implementation includes:
- RFC 5545 compliant line folding (max 75 octets per line)
- UTF-8 aware byte counting for international names
- Proper handling of :mailto: format in ATTENDEE lines
- Skips lines that already have SCHEDULE-AGENT parameter

* fix: address Cubic review - unfold lines and case-insensitive matching

- Add unfoldLines() to handle RFC 5545 folded lines before processing
- Use case-insensitive regex (gim flag) per RFC 5545 spec
- Simplify regex to properly capture params and value separately

* fix: only unfold/refold ATTENDEE lines, preserve other lines

- Match ATTENDEE lines including folded continuations
- Unfold only the matched ATTENDEE line
- Re-fold after adding SCHEDULE-AGENT=CLIENT
- Other iCal lines remain untouched (no RFC 5545 violation)

* fix: check SCHEDULE-AGENT only in params, not value

Only check for existing SCHEDULE-AGENT in the params portion,
not the value (which contains email/CN). This prevents false
positives when email contains "schedule-agent" substring.

* fix: handle quoted colons and exact SCHEDULE-AGENT matching

1. Colon parsing: Match :mailto:/:http:/:urn: patterns, or fallback
   to finding colon not inside quotes
2. SCHEDULE-AGENT check: Use regex to match exact parameter name
   (preceded by ; or at start), not substring match

* add tests

* Update .env.example

---------

Co-authored-by: Anik Dhabal Babu <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-01-22 16:23:06 +05:30

806 lines
29 KiB
TypeScript

/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../types/ical.d.ts"/>
import ICAL from "ical.js";
import type { Attendee, DateArray, DurationObject } from "ics";
import { createEvent } from "ics";
import type { DAVAccount, DAVCalendar, DAVObject } from "tsdav";
import {
createAccount,
createCalendarObject,
deleteCalendarObject,
fetchCalendarObjects,
fetchCalendars,
getBasicAuthHeaders,
updateCalendarObject,
} from "tsdav";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import sanitizeCalendarObject from "@calcom/lib/sanitizeCalendarObject";
import type { Person as AttendeeInCalendarEvent } from "@calcom/types/Calendar";
import type {
Calendar,
CalendarServiceEvent,
CalendarEvent,
CalendarEventType,
EventBusyDate,
GetAvailabilityParams,
IntegrationCalendar,
NewCalendarEventType,
TeamMember,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import { getLocation, getRichDescription } from "./CalEventParser";
import { symmetricDecrypt } from "./crypto";
import logger from "./logger";
const TIMEZONE_FORMAT = "YYYY-MM-DDTHH:mm:ss[Z]";
const DEFAULT_CALENDAR_TYPE = "caldav";
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
type FetchObjectsWithOptionalExpandOptionsType = {
selectedCalendars: IntegrationCalendar[];
startISOString: string;
dateTo: string;
headers?: Record<string, string>;
};
function hasFileExtension(url: string): boolean {
// Get the last portion of the URL (after the last '/')
const fileName = url.substring(url.lastIndexOf("/") + 1);
// Check if the file name has a '.' in it and no '/' after the '.'
return fileName.includes(".") && !fileName.substring(fileName.lastIndexOf(".")).includes("/");
}
function getFileExtension(url: string): string {
// Return null if the URL does not have a file extension
if (!hasFileExtension(url)) return "ics";
// Get the last portion of the URL (after the last '/')
const fileName = url.substring(url.lastIndexOf("/") + 1);
// Extract the file extension
return fileName.substring(fileName.lastIndexOf(".") + 1);
}
// for Apple's Travel Time feature only (for now)
const getTravelDurationInSeconds = (vevent: ICAL.Component, log: typeof logger) => {
const travelDuration: ICAL.Duration = vevent.getFirstPropertyValue("x-apple-travel-duration");
if (!travelDuration) return 0;
// we can't rely on this being a valid duration and it's painful to check, so just try and catch if anything throws
try {
const travelSeconds = travelDuration.toSeconds();
// integer validation as we can never be sure with ical.js
if (!Number.isInteger(travelSeconds)) return 0;
return travelSeconds;
} catch (e) {
log.error("invalid travelDuration?", e);
return 0;
}
};
const applyTravelDuration = (event: ICAL.Event, seconds: number) => {
if (seconds <= 0) return event;
// move event start date back by the specified travel time
event.startDate.second -= seconds;
return event;
};
const convertDate = (date: string): DateArray =>
dayjs(date)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray;
const getDuration = (start: string, end: string): DurationObject => ({
minutes: dayjs(end).diff(dayjs(start), "minute"),
});
/** Folds lines per RFC 5545 (max 75 octets, UTF-8 aware) */
const foldLine = (line: string): string => {
const encoder = new TextEncoder();
if (encoder.encode(line).length <= 75) return line;
const result: string[] = [];
let segment = "";
let byteCount = 0;
for (const char of line) {
const charBytes = encoder.encode(char).length;
const limit = result.length === 0 ? 75 : 74;
if (byteCount + charBytes > limit) {
result.push(segment);
segment = char;
byteCount = charBytes;
} else {
segment += char;
byteCount += charBytes;
}
}
if (segment) result.push(segment);
return result.join("\r\n ");
};
/** Finds the value separator colon, skipping colons inside quoted strings */
const findValueColon = (str: string): number => {
let inQuotes = false;
for (let i = 0; i < str.length; i++) {
if (str[i] === '"') inQuotes = !inQuotes;
if (str[i] === ":" && !inQuotes) return i;
}
return -1;
};
/** Checks if SCHEDULE-AGENT parameter exists in the params portion */
const hasScheduleAgent = (params: string): boolean =>
/;SCHEDULE-AGENT=/i.test(params) || /^SCHEDULE-AGENT=/i.test(params);
/**
* Injects SCHEDULE-AGENT=CLIENT into ORGANIZER and ATTENDEE properties.
* Prevents CalDAV servers from sending duplicate invitation emails (RFC 6638).
*/
const injectScheduleAgent = (iCalString: string): string => {
// Remove METHOD:PUBLISH per RFC 4791 Section 4.1
let result = iCalString.replace(/METHOD:[^\r\n]+[\r\n]+/g, "");
// Process ORGANIZER and ATTENDEE lines (handles folded lines)
result = result.replace(
/^(ORGANIZER|ATTENDEE)((?:[^\r\n]|\r?\n[ \t])*)(\r?\n(?![ \t]))/gim,
(match, property, rest, lineEnding) => {
const unfolded = rest.replace(/\r?\n[ \t]/g, "");
// Try to find colon via :mailto:/:http: pattern first
const valueMatch = unfolded.match(/:(mailto:|http:|https:|urn:)/i);
const colonIndex = valueMatch?.index ?? findValueColon(unfolded);
if (colonIndex === -1) return match;
const params = unfolded.slice(0, colonIndex);
const value = unfolded.slice(colonIndex);
if (hasScheduleAgent(params)) {
return foldLine(property + unfolded) + lineEnding;
}
return foldLine(property + params + ";SCHEDULE-AGENT=CLIENT" + value) + lineEnding;
}
);
return result;
};
const mapAttendees = (attendees: AttendeeInCalendarEvent[] | TeamMember[]): Attendee[] =>
attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
export default abstract class BaseCalendarService implements Calendar {
private url = "";
private credentials: Record<string, string> = {};
private headers: Record<string, string> = {};
protected integrationName = "";
private log: typeof logger;
private credential: CredentialPayload;
constructor(credential: CredentialPayload, integrationName: string, url?: string) {
this.integrationName = integrationName;
const {
username,
password,
url: credentialURL,
} = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
this.url = url || credentialURL;
this.credentials = { username, password };
this.headers = getBasicAuthHeaders({ username, password });
this.credential = credential;
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private getAttendees(event: CalendarEvent) {
const attendees = mapAttendees(event.attendees);
if (event.team?.members) {
const teamAttendeesWithoutCurrentUser = event.team.members.filter(
(member) => member.email !== this.credential.user?.email
);
attendees.push(...mapAttendees(teamAttendeesWithoutCurrentUser));
}
return attendees;
}
async createEvent(event: CalendarServiceEvent, credentialId: number): Promise<NewCalendarEventType> {
try {
const calendars = await this.listCalendars(event);
const uid = uuidv4();
// We create local ICS files
const { error, value: iCalString } = createEvent({
uid,
startInputType: "utc",
start: convertDate(event.startTime),
duration: getDuration(event.startTime, event.endTime),
title: event.title,
description: event.calendarDescription,
location: getLocation(event),
organizer: { email: event.organizer.email, name: event.organizer.name },
attendees: this.getAttendees(event),
/** according to https://datatracker.ietf.org/doc/html/rfc2446#section-3.2.1, in a published iCalendar component.
* "Attendees" MUST NOT be present
* `attendees: this.getAttendees(event.attendees),`
* [UPDATE]: Since we're not using the PUBLISH method to publish the iCalendar event and creating the event directly on iCal,
* this shouldn't be an issue and we should be able to add attendees to the event right here.
*/
...(event.hideCalendarEventDetails ? { classification: "PRIVATE" } : {}),
});
if (error || !iCalString)
throw new Error(`Error creating iCalString:=> ${error?.message} : ${error?.name} `);
const mainHostDestinationCalendar = event.destinationCalendar
? event.destinationCalendar.find((cal) => cal.credentialId === credentialId) ??
event.destinationCalendar[0]
: undefined;
// We create the event directly on iCal
const responses = await Promise.all(
calendars
.filter((c) =>
mainHostDestinationCalendar?.externalId
? c.externalId === mainHostDestinationCalendar.externalId
: true
)
.map((calendar) =>
createCalendarObject({
calendar: {
url: calendar.externalId,
},
filename: `${uid}.ics`,
iCalString: injectScheduleAgent(iCalString),
headers: this.headers,
})
)
);
if (responses.some((r) => !r.ok)) {
throw new Error(
`Error creating event: ${(await Promise.all(responses.map((r) => r.text()))).join(", ")}`
);
}
return {
uid,
id: uid,
type: this.integrationName,
password: "",
url: "",
additionalInfo: {},
};
} catch (reason) {
logger.error(reason);
throw reason;
}
}
async updateEvent(
uid: string,
event: CalendarEvent
): Promise<NewCalendarEventType | NewCalendarEventType[]> {
try {
const events = await this.getEventsByUID(uid);
/** We generate the ICS files */
const { error, value: iCalString } = createEvent({
uid,
startInputType: "utc",
start: convertDate(event.startTime),
duration: getDuration(event.startTime, event.endTime),
title: event.title,
description: getRichDescription(event),
location: getLocation(event),
organizer: { email: event.organizer.email, name: event.organizer.name },
attendees: this.getAttendees(event),
});
if (error) {
this.log.debug("Error creating iCalString");
return {
uid,
type: event.type,
id: typeof event.uid === "string" ? event.uid : "-1",
password: "",
url: typeof event.location === "string" ? event.location : "-1",
additionalInfo: {},
};
}
let calendarEvent: CalendarEventType;
const eventsToUpdate = events.filter((e) => e.uid === uid);
return Promise.all(
eventsToUpdate.map((eventItem) => {
calendarEvent = eventItem;
return updateCalendarObject({
calendarObject: {
url: calendarEvent.url,
data: injectScheduleAgent(iCalString ?? ""),
etag: calendarEvent?.etag,
},
headers: this.headers,
});
})
).then((responses) =>
responses.map((response) => {
if (response.status >= 200 && response.status < 300) {
return {
uid,
type: this.credentials.type,
id: typeof calendarEvent.uid === "string" ? calendarEvent.uid : "-1",
password: "",
url: calendarEvent.url,
additionalInfo:
typeof event.additionalInformation === "string" ? event.additionalInformation : {},
};
} else {
this.log.error("Error: Status Code", response.status);
return {
uid,
type: event.type,
id: typeof event.uid === "string" ? event.uid : "-1",
password: "",
url: typeof event.location === "string" ? event.location : "-1",
additionalInfo:
typeof event.additionalInformation === "string" ? event.additionalInformation : {},
};
}
})
);
} catch (reason) {
this.log.error(reason);
throw reason;
}
}
async deleteEvent(uid: string): Promise<void> {
try {
const events = await this.getEventsByUID(uid);
const eventsToDelete = events.filter((event) => event.uid === uid);
await Promise.all(
eventsToDelete.map((event) => {
return deleteCalendarObject({
calendarObject: {
url: event.url,
etag: event?.etag,
},
headers: this.headers,
});
})
);
} catch (reason) {
this.log.error(reason);
throw reason;
}
}
/**
* getUserTimezoneFromDB() retrieves the timezone of a user from the database.
*
* @param {number} id - The user's unique identifier.
* @returns {Promise<string | undefined>} - A Promise that resolves to the user's timezone or "Europe/London" as a default value if the timezone is not found.
*/
getUserTimezoneFromDB = async (id: number): Promise<string | undefined> => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const user = await prisma.user.findUnique({
where: {
id,
},
select: {
timeZone: true,
},
});
return user?.timeZone;
};
/**
* getUserId() extracts the user ID from the first calendar in an array of IntegrationCalendars.
*
* @param {IntegrationCalendar[]} selectedCalendars - An array of IntegrationCalendars.
* @returns {number | null} - The user ID associated with the first calendar in the array, or null if the array is empty or the user ID is not found.
*/
getUserId = (selectedCalendars: IntegrationCalendar[]): number | null => {
if (selectedCalendars.length === 0) {
return null;
}
return selectedCalendars[0].userId || null;
};
isValidFormat = (url: string): boolean => {
const allowedExtensions = ["eml", "ics"];
const urlExtension = getFileExtension(url);
if (!allowedExtensions.includes(urlExtension)) {
logger.error(`Unsupported calendar object format: ${urlExtension}`);
return false;
}
return true;
};
async getAvailability(params: GetAvailabilityParams): Promise<EventBusyDate[]> {
const { dateFrom, dateTo, selectedCalendars } = params;
const startISOString = new Date(dateFrom).toISOString();
const objects = await this.fetchObjectsWithOptionalExpand({
selectedCalendars,
startISOString,
dateTo,
headers: this.headers,
});
const userId = this.getUserId(selectedCalendars);
// we use the userId from selectedCalendars to fetch the user's timeZone from the database primarily for all-day events without any timezone information
const userTimeZone = userId ? await this.getUserTimezoneFromDB(userId) : "Europe/London";
const events: { start: string; end: string }[] = [];
objects.forEach((object) => {
if (!object || object.data == null || JSON.stringify(object.data) == "{}") return;
let vcalendar: ICAL.Component;
try {
const jcalData = ICAL.parse(sanitizeCalendarObject(object));
vcalendar = new ICAL.Component(jcalData);
} catch (e) {
logger.error("Error parsing calendar object: ", e);
return;
}
const vevents = vcalendar.getAllSubcomponents("vevent");
vevents.forEach((vevent) => {
// if event status is free or transparent, return
if (vevent?.getFirstPropertyValue("transp") === "TRANSPARENT") return;
const event = new ICAL.Event(vevent);
const dtstartProperty = vevent.getFirstProperty("dtstart");
const tzidFromDtstart = dtstartProperty ? (dtstartProperty as any).jCal[1].tzid : undefined;
const dtstart: { [key: string]: string } | undefined = vevent?.getFirstPropertyValue("dtstart");
// biome-ignore lint/complexity/useLiteralKeys: accessing dynamic property from ICAL.js object
const timezone = dtstart ? dtstart["timezone"] : undefined;
// We check if the dtstart timezone is in UTC which is actually represented by Z instead, but not recognized as that in ICAL.js as UTC
const isUTC = timezone === "Z";
// Fix precedence: prioritize TZID from DTSTART property, then standalone TZID, then UTC, then fallback
const tzid: string | undefined =
tzidFromDtstart || vevent?.getFirstPropertyValue("tzid") || (isUTC ? "UTC" : timezone);
// In case of icalendar, when only tzid is available without vtimezone, we need to add vtimezone explicitly to take care of timezone diff
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");
// get timezone offset
const tzoffsetfrom = dayjs(event.startDate.toJSDate()).tz(timezoneToUse).format("Z");
const tzoffsetto = dayjs(event.endDate.toJSDate()).tz(timezoneToUse).format("Z");
// set timezone offset
standard.addPropertyWithValue("tzoffsetfrom", tzoffsetfrom);
standard.addPropertyWithValue("tzoffsetto", tzoffsetto);
// provide a standard dtstart
standard.addPropertyWithValue("dtstart", "1601-01-01T00:00:00");
timezoneComp.addSubcomponent(standard);
vcalendar.addSubcomponent(timezoneComp);
} catch (e) {
// Adds try-catch to ensure the code proceeds when Apple Calendar provides non-standard TZIDs
logger.warn("error in adding vtimezone", e);
}
} else {
logger.warn("No timezone found");
}
}
let vtimezone = null;
if (tzid) {
const allVtimezones = vcalendar.getAllSubcomponents("vtimezone");
vtimezone = allVtimezones.find((vtz) => vtz.getFirstPropertyValue("tzid") === tzid);
}
if (!vtimezone) {
vtimezone = vcalendar.getFirstSubcomponent("vtimezone");
}
// mutate event to consider travel time
applyTravelDuration(event, getTravelDurationInSeconds(vevent, this.log));
if (event.isRecurring()) {
let maxIterations = 365;
if (["HOURLY", "SECONDLY", "MINUTELY"].includes(event.getRecurrenceTypes())) {
logger.warn(`Won't handle [${event.getRecurrenceTypes()}] recurrence`);
return;
}
const start = dayjs(dateFrom);
const end = dayjs(dateTo);
const startDate = ICAL.Time.fromDateTimeString(startISOString);
startDate.hour = event.startDate.hour;
startDate.minute = event.startDate.minute;
startDate.second = event.startDate.second;
const iterator = event.iterator(startDate);
let current: ICAL.Time;
let currentEvent: ReturnType<typeof event.getOccurrenceDetails> | undefined;
let currentStart: ReturnType<typeof dayjs> | null = null;
let currentError: string | undefined;
while (
maxIterations > 0 &&
(currentStart === null || currentStart.isAfter(end) === false) &&
// this iterator was poorly implemented, normally done is expected to be
// returned
(current = iterator.next())
) {
maxIterations -= 1;
try {
// @see https://github.com/mozilla-comm/ical.js/issues/514
currentEvent = event.getOccurrenceDetails(current);
} catch (error) {
if (error instanceof Error && error.message !== currentError) {
currentError = error.message;
this.log.error("error", error);
}
}
if (!currentEvent) return;
// do not mix up caldav and icalendar! For the recurring events here, the timezone
// provided is relevant, not as pointed out in https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5
// where recurring events are always in utc (in caldav!). Thus, apply the time zone here.
if (vtimezone) {
const zone = new ICAL.Timezone(vtimezone);
currentEvent.startDate = currentEvent.startDate.convertToZone(zone);
currentEvent.endDate = currentEvent.endDate.convertToZone(zone);
}
currentStart = dayjs(currentEvent.startDate.toJSDate());
if (currentStart.isBetween(start, end) === true) {
events.push({
start: currentStart.toISOString(),
end: dayjs(currentEvent.endDate.toJSDate()).toISOString(),
});
}
}
if (maxIterations <= 0) {
logger.warn("Could not find any occurrence for recurring event in 365 iterations");
}
return;
}
if (vtimezone) {
const zone = new ICAL.Timezone(vtimezone);
event.startDate = event.startDate.convertToZone(zone);
event.endDate = event.endDate.convertToZone(zone);
}
const finalStartISO = dayjs(event.startDate.toJSDate()).toISOString();
const finalEndISO = dayjs(event.endDate.toJSDate()).toISOString();
return events.push({
start: finalStartISO,
end: finalEndISO,
});
});
});
return Promise.resolve(events);
}
async listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]> {
try {
const account = await this.getAccount();
const calendars = (await fetchCalendars({
account,
headers: this.headers,
})) /** @url https://github.com/natelindev/tsdav/pull/139 */ as (Omit<DAVCalendar, "displayName"> & {
displayName?: string | Record<string, unknown>;
})[];
return calendars.reduce<IntegrationCalendar[]>((newCalendars, calendar) => {
if (!calendar.components?.includes("VEVENT")) return newCalendars;
const [mainHostDestinationCalendar] = event?.destinationCalendar ?? [];
newCalendars.push({
externalId: calendar.url,
/** @url https://github.com/calcom/cal.com/issues/7186 */
name: typeof calendar.displayName === "string" ? calendar.displayName : "",
primary: mainHostDestinationCalendar?.externalId
? mainHostDestinationCalendar.externalId === calendar.url
: false,
integration: this.integrationName,
email: this.credentials.username ?? "",
});
return newCalendars;
}, []);
} catch (reason) {
logger.error(reason);
throw reason;
}
}
/**
* The fetchObjectsWithOptionalExpand function is responsible for fetching calendar objects
* from an array of selectedCalendars. It attempts to fetch objects with the expand option
* alone such that it works if a calendar supports it. If any calendar object has an undefined 'data' property
* and etag isn't undefined, the function makes a new request without the expand option to retrieve the data.
* The result is a flattened array of calendar objects with the structure { url: ..., etag: ..., data: ...}.
*
* @param {Object} options - The options object containing the following properties:
* @param {IntegrationCalendar[]} options.selectedCalendars - An array of IntegrationCalendar objects to fetch data from.
* @param {string} options.startISOString - The start date of the date range to fetch events from, in ISO 8601 format.
* @param {string} options.dateTo - The end date of the date range to fetch events from.
* @param {Object} options.headers - Headers to be included in the API requests.
* @returns {Promise<Array>} - A promise that resolves to a flattened array of calendar objects with the structure { url: ..., etag: ..., data: ...}.
*/
async fetchObjectsWithOptionalExpand({
selectedCalendars,
startISOString,
dateTo,
headers,
}: FetchObjectsWithOptionalExpandOptionsType): Promise<DAVObject[]> {
const filteredCalendars = selectedCalendars.filter((sc) => sc.externalId);
const fetchPromises = filteredCalendars.map(async (sc) => {
const response = await fetchCalendarObjects({
urlFilter: (url) => this.isValidFormat(url),
calendar: {
url: sc.externalId,
},
headers,
expand: true,
timeRange: {
start: startISOString,
end: new Date(dateTo).toISOString(),
},
});
const processedResponse = await Promise.all(
response.map(async (calendarObject) => {
const calendarObjectHasEtag = calendarObject.etag !== undefined;
const calendarObjectDataUndefined = calendarObject.data === undefined;
if (calendarObjectDataUndefined && calendarObjectHasEtag) {
const responseWithoutExpand = await fetchCalendarObjects({
urlFilter: (url) => this.isValidFormat(url),
calendar: {
url: sc.externalId,
},
headers,
expand: false,
timeRange: {
start: startISOString,
end: new Date(dateTo).toISOString(),
},
});
return responseWithoutExpand.find(
(obj) => obj.url === calendarObject.url && obj.etag === calendarObject.etag
);
}
return calendarObject;
})
);
return processedResponse;
});
const resolvedPromises = await Promise.allSettled(fetchPromises);
const fulfilledPromises = resolvedPromises.filter(
(promise): promise is PromiseFulfilledResult<(DAVObject | undefined)[]> =>
promise.status === "fulfilled"
);
const flatResult = fulfilledPromises
.map((promise) => promise.value)
.flat()
.filter((obj) => obj !== null);
return flatResult as DAVObject[];
}
private async getEvents(
calId: string,
dateFrom: string | null,
dateTo: string | null,
objectUrls?: string[] | null
) {
try {
const objects = await fetchCalendarObjects({
calendar: {
url: calId,
},
objectUrls: objectUrls ? objectUrls : undefined,
timeRange:
dateFrom && dateTo
? {
start: dayjs(dateFrom).utc().format(TIMEZONE_FORMAT),
end: dayjs(dateTo).utc().format(TIMEZONE_FORMAT),
}
: undefined,
headers: this.headers,
});
const events = objects
.filter((e) => !!e.data)
.map((object) => {
const jcalData = ICAL.parse(sanitizeCalendarObject(object));
const vcalendar = new ICAL.Component(jcalData);
const vevent = vcalendar.getFirstSubcomponent("vevent");
const event = new ICAL.Event(vevent);
const calendarTimezone =
vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue<string>("tzid") || "";
const startDate = calendarTimezone
? dayjs.tz(event.startDate.toString(), calendarTimezone)
: new Date(event.startDate.toUnixTime() * 1000);
const endDate = calendarTimezone
? dayjs.tz(event.endDate.toString(), calendarTimezone)
: new Date(event.endDate.toUnixTime() * 1000);
return {
uid: event.uid,
etag: object.etag,
url: object.url,
summary: event.summary,
description: event.description,
location: event.location,
sequence: event.sequence,
startDate,
endDate,
duration: {
weeks: event.duration.weeks,
days: event.duration.days,
hours: event.duration.hours,
minutes: event.duration.minutes,
seconds: event.duration.seconds,
isNegative: event.duration.isNegative,
},
organizer: event.organizer,
attendees: event.attendees.map((a) => a.getValues()),
recurrenceId: event.recurrenceId,
timezone: calendarTimezone,
};
});
return events;
} catch (reason) {
logger.error(reason);
throw reason;
}
}
private async getEventsByUID(uid: string): Promise<CalendarEventType[]> {
type EventsType = Awaited<ReturnType<typeof this.getEvents>>;
const events: EventsType = [];
const calendars = await this.listCalendars();
for (const cal of calendars) {
const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]);
for (const ev of calEvents) {
events.push(ev);
}
}
return events;
}
private async getAccount(): Promise<DAVAccount> {
return createAccount({
account: {
serverUrl: this.url,
accountType: DEFAULT_CALENDAR_TYPE,
credentials: this.credentials,
},
headers: this.headers,
});
}
}