fix: filter attendees in emails when seatsShowAttendees is disabled (#27520)

* seated event

* Reorder imports in attendee-scheduled-email.ts
This commit is contained in:
Anik Dhabal Babu
2026-02-02 17:07:35 +00:00
committed by GitHub
parent 4b7ea0c98f
commit 7611e0ea39
4 changed files with 277 additions and 7 deletions
+255
View File
@@ -1,8 +1,10 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { shouldSkipAttendeeEmailWithSettings, fetchOrganizationEmailSettings } from "./email-manager";
import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";
const mockGetEmailSettings = vi.fn();
@@ -16,6 +18,36 @@ vi.mock("@calcom/prisma", () => ({
prisma: {},
}));
// Mock dependencies for AttendeeScheduledEmail tests
vi.mock("./lib/generateIcsFile", () => ({
default: vi.fn(() => "mock-ical-content"),
GenerateIcsRole: {
ATTENDEE: "ATTENDEE",
},
}));
vi.mock("./src/renderEmail", () => ({
default: vi.fn(() => Promise.resolve("<html>mock-email</html>")),
}));
vi.mock("@calcom/lib/getReplyToHeader", () => ({
getReplyToHeader: vi.fn(() => ({})),
}));
vi.mock("@calcom/lib/CalEventParser", () => ({
getRichDescription: vi.fn(() => "mock-description"),
}));
vi.mock("./templates/_base-email", () => {
return {
default: class MockBaseEmail {
getMailerOptions() {
return { from: "test@cal.com" };
}
},
};
});
describe("shouldSkipAttendeeEmailWithSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -150,3 +182,226 @@ describe("shouldSkipAttendeeEmailWithSettings", () => {
});
});
});
describe("AttendeeScheduledEmail - Privacy fix for seated events", () => {
const createMockPerson = (name: string, email: string): Person => ({
name,
email,
timeZone: "America/New_York",
language: {
translate: vi.fn((key: string) => key),
locale: "en",
},
});
const createMockCalendarEvent = (
options: {
seatsPerTimeSlot?: number | null;
seatsShowAttendees?: boolean | null;
attendees?: Person[];
} = {}
): CalendarEvent => {
const attendees = options.attendees || [
createMockPerson("Alice", "alice@example.com"),
createMockPerson("Bob", "bob@example.com"),
createMockPerson("Charlie", "charlie@example.com"),
];
return {
title: "Test Event",
type: "Test Event Type",
startTime: "2024-01-01T10:00:00Z",
endTime: "2024-01-01T11:00:00Z",
organizer: createMockPerson("Organizer", "organizer@example.com"),
attendees,
seatsPerTimeSlot: options.seatsPerTimeSlot ?? null,
seatsShowAttendees: options.seatsShowAttendees ?? null,
} as CalendarEvent;
};
describe("Privacy: seatsShowAttendees setting", () => {
it("should filter attendees to only recipient when seatsShowAttendees is false for seated events", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 5,
seatsShowAttendees: false,
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient);
// Should only contain the recipient
expect(email.calEvent.attendees).toHaveLength(1);
expect(email.calEvent.attendees[0].email).toBe(recipient.email);
expect(email.calEvent.attendees[0].name).toBe(recipient.name);
});
it("should include all attendees when seatsShowAttendees is true for seated events", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient);
// Should contain all attendees
expect(email.calEvent.attendees).toHaveLength(3);
expect(email.calEvent.attendees.map((a) => a.email)).toEqual([
"alice@example.com",
"bob@example.com",
"charlie@example.com",
]);
});
it("should filter attendees when seatsShowAttendees is null for seated events (defaults to false)", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 5,
seatsShowAttendees: null,
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient);
// Should only contain the recipient (null defaults to false)
expect(email.calEvent.attendees).toHaveLength(1);
expect(email.calEvent.attendees[0].email).toBe(recipient.email);
});
it("should include all attendees for non-seated events regardless of seatsShowAttendees", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: null,
seatsShowAttendees: false, // This shouldn't matter for non-seated events
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient);
// Should contain all attendees (non-seated events always show all)
expect(email.calEvent.attendees).toHaveLength(3);
});
});
describe("Explicit showAttendees parameter", () => {
it("should use explicit showAttendees=true parameter even when seatsShowAttendees is false", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 5,
seatsShowAttendees: false,
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient, true);
// Should contain all attendees because explicit parameter overrides
expect(email.calEvent.attendees).toHaveLength(3);
});
it("should use explicit showAttendees=false parameter even when seatsShowAttendees is true", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient, false);
// Should only contain recipient because explicit parameter overrides
expect(email.calEvent.attendees).toHaveLength(1);
expect(email.calEvent.attendees[0].email).toBe(recipient.email);
});
});
describe("Edge cases", () => {
it("should handle single attendee correctly when filtering", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 5,
seatsShowAttendees: false,
attendees: [createMockPerson("Solo", "solo@example.com")],
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient);
expect(email.calEvent.attendees).toHaveLength(1);
expect(email.calEvent.attendees[0].email).toBe("solo@example.com");
});
it("should not mutate original calEvent when filtering attendees", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 5,
seatsShowAttendees: false,
});
const originalAttendeesCount = calEvent.attendees.length;
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient);
// Original calEvent should remain unchanged
expect(calEvent.attendees).toHaveLength(originalAttendeesCount);
// Email's calEvent should be filtered
expect(email.calEvent.attendees).toHaveLength(1);
// They should be different objects (cloned)
expect(email.calEvent).not.toBe(calEvent);
});
it("should use same calEvent reference when not filtering (performance optimization)", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient);
// Should use same reference when not filtering
expect(email.calEvent).toBe(calEvent);
});
});
describe("Real-world scenarios", () => {
it("should protect privacy for paid seated events with sharing disabled", () => {
// This is the reported bug scenario
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 10,
seatsShowAttendees: false, // Privacy setting disabled
attendees: [
createMockPerson("Customer 1", "customer1@example.com"),
createMockPerson("Customer 2", "customer2@example.com"),
createMockPerson("Customer 3", "customer3@example.com"),
],
});
const recipient = calEvent.attendees[1]; // Customer 2
const email = new AttendeeScheduledEmail(calEvent, recipient);
// Customer 2 should only see their own information
expect(email.calEvent.attendees).toHaveLength(1);
expect(email.calEvent.attendees[0].email).toBe("customer2@example.com");
expect(email.calEvent.attendees[0].name).toBe("Customer 2");
// Should not contain other customers' information
expect(email.calEvent.attendees.some((a) => a.email === "customer1@example.com")).toBe(false);
expect(email.calEvent.attendees.some((a) => a.email === "customer3@example.com")).toBe(false);
});
it("should allow sharing when explicitly enabled for seated events", () => {
const calEvent = createMockCalendarEvent({
seatsPerTimeSlot: 10,
seatsShowAttendees: true, // Sharing enabled
attendees: [
createMockPerson("Attendee 1", "attendee1@example.com"),
createMockPerson("Attendee 2", "attendee2@example.com"),
],
});
const recipient = calEvent.attendees[0];
const email = new AttendeeScheduledEmail(calEvent, recipient);
// Should see all attendees when sharing is enabled
expect(email.calEvent.attendees).toHaveLength(2);
expect(email.calEvent.attendees.map((a) => a.email)).toEqual([
"attendee1@example.com",
"attendee2@example.com",
]);
});
});
});
@@ -1,11 +1,9 @@
import type { TFunction } from "i18next";
import { default as cloneDeep } from "lodash/cloneDeep";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { getReplyToHeader } from "@calcom/lib/getReplyToHeader";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { TFunction } from "i18next";
import { default as cloneDeep } from "lodash/cloneDeep";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import renderEmail from "../src/renderEmail";
import BaseEmail from "./_base-email";
@@ -18,7 +16,16 @@ export default class AttendeeScheduledEmail extends BaseEmail {
constructor(calEvent: CalendarEvent, attendee: Person, showAttendees?: boolean | undefined) {
super();
if (!showAttendees && calEvent.seatsPerTimeSlot) {
let shouldShowAttendees: boolean;
if (showAttendees !== undefined) {
shouldShowAttendees = showAttendees;
} else if (calEvent.seatsPerTimeSlot) {
shouldShowAttendees = calEvent.seatsShowAttendees ?? false;
} else {
shouldShowAttendees = true;
}
if (!shouldShowAttendees && calEvent.seatsPerTimeSlot) {
this.calEvent = cloneDeep(calEvent);
this.calEvent.attendees = [attendee];
} else {
@@ -63,8 +70,8 @@ ${this.t(
title
? title
: this.calEvent.recurringEvent?.count
? "your_event_has_been_scheduled_recurring"
: "your_event_has_been_scheduled"
? "your_event_has_been_scheduled_recurring"
: "your_event_has_been_scheduled"
)}
${this.t(subtitle)}
@@ -88,6 +88,8 @@ export async function getBooking(bookingId: number) {
parentId: true,
},
},
seatsPerTimeSlot: true,
seatsShowAttendees: true,
},
},
metadata: true,
@@ -196,6 +198,8 @@ export async function getBooking(bookingId: number) {
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
customReplyToEmail: booking.eventType?.customReplyToEmail,
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
};
return {
@@ -151,6 +151,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
hideCalendarEventDetails: true,
disableGuests: true,
customReplyToEmail: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
metadata: true,
locations: true,
team: {
@@ -322,6 +324,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
hideCalendarEventDetails: booking.eventType?.hideCalendarEventDetails,
eventTypeId: booking.eventType?.id,
customReplyToEmail: booking.eventType?.customReplyToEmail,
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
team: booking.eventType?.team
? {
name: booking.eventType.team.name,