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>
This commit is contained in:
Yuvraj Angad Singh
2026-01-22 16:23:06 +05:30
committed by GitHub
co-authored by Anik Dhabal Babu Anik Dhabal Babu
parent be13bdf18e
commit 45ea59ceb8
2 changed files with 476 additions and 4 deletions
+400
View File
@@ -0,0 +1,400 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createEvent as createIcsEvent } from "ics";
import { createCalendarObject, updateCalendarObject } from "tsdav";
vi.mock("ics", () => ({
createEvent: vi.fn(),
}));
vi.mock("tsdav", () => ({
createAccount: vi.fn(),
fetchCalendars: vi.fn(),
fetchCalendarObjects: vi.fn(),
createCalendarObject: vi.fn().mockResolvedValue({ ok: true }),
updateCalendarObject: vi.fn().mockResolvedValue({ status: 200 }),
deleteCalendarObject: vi.fn(),
getBasicAuthHeaders: vi.fn().mockReturnValue({}),
}));
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
},
}));
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn().mockImplementation((text) => {
if (typeof text === "object") {
return JSON.stringify(text);
}
return text;
}),
}));
vi.mock("./CalEventParser", () => ({
getLocation: vi.fn().mockReturnValue("Test Location"),
getRichDescription: vi.fn().mockReturnValue("Test Description"),
}));
import type { CalendarServiceEvent } from "@calcom/types/Calendar";
import BaseCalendarService from "./CalendarService";
const createMockEvent = (overrides: Partial<CalendarServiceEvent> = {}): CalendarServiceEvent => ({
type: "caldav",
title: "Test Event",
startTime: "2023-01-01T10:00:00Z",
endTime: "2023-01-01T11:00:00Z",
organizer: {
name: "Test",
email: "test@example.com",
timeZone: "UTC",
language: { translate: ((key: string) => key) as never, locale: "en" },
},
attendees: [],
calendarDescription: "Test Description",
...overrides,
});
class TestCalendarService extends BaseCalendarService {
constructor() {
super(
{
id: 1,
type: "caldav_calendar",
delegationCredentialId: null,
user: { email: "test@example.com" },
userId: 1,
teamId: null,
appId: "caldav",
invalid: false,
key: {
username: "test",
password: "test",
url: "https://caldav.example.com",
},
},
"caldav",
"https://caldav.example.com"
);
}
async listCalendars() {
return [
{
externalId: "https://caldav.example.com/calendar/",
name: "Test Calendar",
primary: true,
readOnly: false,
email: "test@example.com",
integrationName: "caldav",
credentialId: 1,
},
];
}
async testUpdateEvent(uid: string, event: CalendarServiceEvent, externalCalendarId: string) {
return this.updateEvent(uid, event);
}
}
describe("CalendarService - SCHEDULE-AGENT injection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("injectScheduleAgent", () => {
it("should inject SCHEDULE-AGENT=CLIENT into ATTENDEE lines", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("ATTENDEE;CN=Guest;SCHEDULE-AGENT=CLIENT:mailto:guest@example.com");
expect(unfolded).not.toContain("METHOD:PUBLISH");
});
it("should inject SCHEDULE-AGENT=CLIENT into ORGANIZER lines", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nORGANIZER;CN=Test:mailto:test@example.com\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("ORGANIZER;CN=Test;SCHEDULE-AGENT=CLIENT:mailto:test@example.com");
expect(unfolded).toContain("ATTENDEE;CN=Guest;SCHEDULE-AGENT=CLIENT:mailto:guest@example.com");
});
it("should not duplicate SCHEDULE-AGENT if already present", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;SCHEDULE-AGENT=SERVER;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const scheduleAgentCount = (iCalString.match(/SCHEDULE-AGENT/g) || []).length;
expect(scheduleAgentCount).toBe(1);
});
it("should handle quoted colons in CN parameter", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN="John: CEO":mailto:john@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain('ATTENDEE;CN="John: CEO";SCHEDULE-AGENT=CLIENT:mailto:john@example.com');
});
it("should remove METHOD:PUBLISH per RFC 4791", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
expect(iCalString).not.toContain("METHOD:PUBLISH");
});
it("should handle case-insensitive ATTENDEE matching", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nattendee;cn=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
});
describe("RFC 5545 Line Folding", () => {
it("should fold long lines at 75 octets", async () => {
const service = new TestCalendarService();
const longName = "A".repeat(60);
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN=${longName}:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const lines = iCalString.split("\r\n");
lines.forEach((line) => {
const byteLength = new TextEncoder().encode(line).length;
expect(byteLength).toBeLessThanOrEqual(75);
});
});
it("should handle UTF-8 multi-byte characters correctly", async () => {
const service = new TestCalendarService();
const unicodeName = "\u4E2D".repeat(30);
const mockIcsOutput = `BEGIN:VCALENDAR\r\nORGANIZER;CN=${unicodeName}:mailto:test@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const lines = iCalString.split("\r\n");
lines.forEach((line) => {
const byteLength = new TextEncoder().encode(line).length;
expect(byteLength).toBeLessThanOrEqual(75);
});
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
it("should handle folded input lines correctly", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN=Very Long Name That Will Be\r\n Folded:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
});
describe("updateEvent", () => {
it("should inject SCHEDULE-AGENT=CLIENT on update", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
(service as unknown as Record<string, unknown>).getEventsByUID = vi.fn().mockResolvedValue([
{
uid: "test-uid",
url: "https://caldav.example.com/calendar/test.ics",
etag: '"etag123"',
},
]);
const event = createMockEvent({ uid: "test-uid" });
await service.testUpdateEvent("test-uid", event, "https://caldav.example.com/calendar/");
const calledArg = vi.mocked(updateCalendarObject).mock.calls[0][0];
const data = calledArg.calendarObject.data;
const unfolded = data.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
});
describe("Edge cases", () => {
it("should handle ATTENDEE with multiple parameters", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("PARTSTAT=NEEDS-ACTION");
expect(unfolded).toContain("RSVP=TRUE");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
it("should handle ATTENDEE with http URI", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN=Guest:http://example.com/user\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("ATTENDEE;CN=Guest;SCHEDULE-AGENT=CLIENT:http://example.com/user");
});
it("should preserve other iCal properties unchanged", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cal.com//NONSGML//EN\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDTSTART:20230101T100000Z\r\nDTEND:20230101T110000Z\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
expect(iCalString).toContain("VERSION:2.0");
expect(iCalString).toContain("PRODID:-//Cal.com//NONSGML//EN");
expect(iCalString).toContain("DTSTART:20230101T100000Z");
expect(iCalString).toContain("DTEND:20230101T110000Z");
});
it("should handle empty iCalString gracefully", async () => {
const service = new TestCalendarService();
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: "",
});
const event = createMockEvent();
await expect(service.createEvent(event, 1)).rejects.toThrow();
});
});
});
+76 -4
View File
@@ -98,6 +98,80 @@ 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" }));
@@ -187,8 +261,7 @@ export default abstract class BaseCalendarService implements Calendar {
url: calendar.externalId,
},
filename: `${uid}.ics`,
// according to https://datatracker.ietf.org/doc/html/rfc4791#section-4.1, Calendar object resources contained in calendar collections MUST NOT specify the iCalendar METHOD property.
iCalString: iCalString.replace(/METHOD:[^\r\n]+\r\n/g, ""),
iCalString: injectScheduleAgent(iCalString),
headers: this.headers,
})
)
@@ -255,8 +328,7 @@ export default abstract class BaseCalendarService implements Calendar {
return updateCalendarObject({
calendarObject: {
url: calendarEvent.url,
// ensures compliance with standard iCal string (known as iCal2.0 by some) required by various providers
data: iCalString?.replace(/METHOD:[^\r\n]+\r\n/g, ""),
data: injectScheduleAgent(iCalString ?? ""),
etag: calendarEvent?.etag,
},
headers: this.headers,