Skip to content

Commit 35d6c41

Browse files
authored
feat: Disable booking emails to guests (calcom#25217)
* feat: disable SMS org setting * chore: undo * fix: cal evnet * feat: add more settings * tests: add email manager unit tests * fix: update * fix: type error * fix: test * refactor: UI * refactor: UI * perf: fetch only once * perf: fetch only once * chore: use org * fix: test * test: add unit tests * refactor: email manager * fix: add confirmation dialog for individual checkbox * fix: sms * chore: common.json
1 parent 1c3ced5 commit 35d6c41

32 files changed

Lines changed: 937 additions & 28 deletions

File tree

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
9191
href: "/settings/organizations/general",
9292
trackingMetadata: { section: "organization", page: "general" },
9393
},
94+
{
95+
name: "guest_notifications",
96+
href: "/settings/organizations/guest-notifications",
97+
},
9498
...(orgBranding
9599
? [
96100
{
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { _generateMetadata, getTranslate } from "app/_utils";
2+
import { redirect } from "next/navigation";
3+
4+
import GuestNotificationsView from "@calcom/features/ee/organizations/pages/settings/guest-notifications";
5+
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
6+
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
7+
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
8+
import { MembershipRole } from "@calcom/prisma/enums";
9+
10+
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
11+
12+
export const generateMetadata = async () =>
13+
await _generateMetadata(
14+
(t) => t("guest_notifications"),
15+
(t) => t("guest_notifications_description"),
16+
undefined,
17+
undefined,
18+
"/settings/organizations/guest-notifications"
19+
);
20+
21+
const Page = async () => {
22+
const session = await validateUserHasOrg();
23+
const t = await getTranslate();
24+
25+
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
26+
return redirect("/settings/profile");
27+
}
28+
29+
const { canRead, canEdit } = await getResourcePermissions({
30+
userId: session.user.id,
31+
teamId: session.user.profile.organizationId,
32+
resource: Resource.Organization,
33+
userRole: session.user.org.role,
34+
fallbackRoles: {
35+
read: {
36+
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
37+
},
38+
update: {
39+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
40+
},
41+
},
42+
});
43+
44+
if (!canRead) {
45+
return redirect("/settings/profile");
46+
}
47+
48+
return (
49+
<SettingsHeader title={t("guest_notifications")} description={t("guest_notifications_description")}>
50+
<GuestNotificationsView permissions={{ canRead, canEdit }} />
51+
</SettingsHeader>
52+
);
53+
};
54+
55+
export default Page;

apps/web/public/static/locales/en/common.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@
308308
"guest": "Guest",
309309
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
310310
"confirmation": "Confirmation",
311+
"cancellation": "Cancellation",
312+
"request": "Request",
311313
"what_booker_should_provide": "What your booker should provide to receive confirmations",
312314
"404_the_user": "The username",
313315
"username": "Username",
@@ -3932,6 +3934,37 @@
39323934
"no_description_provided": "No description provided",
39333935
"organization_blocklist": "Organization Blocklist",
39343936
"manage_blocked_emails_and_domains": "Manage blocked emails and domains for your organization",
3937+
"guest_notifications": "Guest Notifications",
3938+
"guest_notifications_description": "Manage guest booking emails across all event types in your organization",
3939+
"guest_booking_email_notifications": "Guest Booking Email Notifications",
3940+
"guest_booking_email_notifications_description": "Manage which booking emails are sent to guests across all event types in your organization",
3941+
"disable_guest_emails_warning": "These settings control important booking emails to keep your guests updated. They apply to all event types in your organization. If you disable emails, we recommend setting up org-wide workflows instead.",
3942+
"disable_all_booking_emails_to_guests": "Disable all booking emails to guests",
3943+
"disable_all": "Disable All",
3944+
"enable_all": "Enable All",
3945+
"disable_all_booking_emails_to_guests_description": "When enabled, guests will not receive any booking-related emails. This does not affect Workflow emails.",
3946+
"disable_all_guest_booking_emails_confirm_title": "Are you sure you want to disable all booking emails?",
3947+
"disable_all_guest_booking_emails_confirm_description": "This will prevent guests from receiving important booking updates across all event types in your organization. We recommend setting up custom org-wide workflow.",
3948+
"enable_all_guest_booking_emails_confirm_title": "Are you sure you want to enable all booking emails?",
3949+
"enable_all_guest_booking_emails_confirm_description": "This will allow guests to receive all booking-related email notifications across all event types in your organization.",
3950+
"disable_individual_guest_email_confirm_title": "Are you sure you want to disable {{emailType}} emails?",
3951+
"disable_individual_guest_email_confirm_description": "This will prevent guests from receiving {{emailType}} emails across all event types in your organization. We recommend setting up a custom org-wide Workflow for this.",
3952+
"email_type": "Email Type",
3953+
"guest_confirmation_email_description": "Email sent when booking is created",
3954+
"guest_cancellation_email_description": "Email sent when booking is cancelled",
3955+
"guest_rescheduled_email_description": "Email sent when booking is rescheduled",
3956+
"guest_request_email_description": "Email sent when booking is requested",
3957+
"attendee_request_email_description": "Email sent when booking is requested or declined",
3958+
"attendee_reassigned_email_description": "Email sent when host is reassigned for the booking",
3959+
"attendee_awaiting_payment_email_description": "Email sent when payment is pending",
3960+
"attendee_reschedule_request_email_description": "Email sent when organizer requests attendee to reschedule",
3961+
"attendee_location_change_email_description": "Email sent when meeting location is updated",
3962+
"attendee_new_event_email_description": "Email sent to existing attendees when new guests are added",
3963+
"host_reassignment": "Host Reassignment",
3964+
"awaiting_payment": "Awaiting Payment",
3965+
"reschedule_request": "Reschedule Request",
3966+
"location_change": "Location Change",
3967+
"guest_added": "Guest Added",
39353968
"invalid_domain_format": "Invalid domain format. Example: example.com",
39363969
"invalid_email_address": "Invalid email address. Example: user@example.com",
39373970
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
3+
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
4+
5+
import { shouldSkipAttendeeEmailWithSettings, fetchOrganizationEmailSettings } from "./email-manager";
6+
7+
const mockGetEmailSettings = vi.fn();
8+
9+
vi.mock("@calcom/features/organizations/repositories/OrganizationSettingsRepository", () => ({
10+
OrganizationSettingsRepository: vi.fn().mockImplementation(() => ({
11+
getEmailSettings: mockGetEmailSettings,
12+
})),
13+
}));
14+
15+
vi.mock("@calcom/prisma", () => ({
16+
prisma: {},
17+
}));
18+
19+
describe("shouldSkipAttendeeEmailWithSettings", () => {
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
});
23+
24+
describe.each([
25+
["confirmation", "disableAttendeeConfirmationEmail"],
26+
["cancellation", "disableAttendeeCancellationEmail"],
27+
["rescheduled", "disableAttendeeRescheduledEmail"],
28+
["request", "disableAttendeeRequestEmail"],
29+
["reassigned", "disableAttendeeReassignedEmail"],
30+
["awaiting_payment", "disableAttendeeAwaitingPaymentEmail"],
31+
["reschedule_request", "disableAttendeeRescheduleRequestEmail"],
32+
["location_change", "disableAttendeeLocationChangeEmail"],
33+
["new_event", "disableAttendeeNewEventEmail"],
34+
] as const)("Email type: %s", (emailType, settingKey) => {
35+
it(`should skip email when organization has ${settingKey} enabled`, async () => {
36+
const orgSettings = {
37+
disableAttendeeConfirmationEmail: settingKey === "disableAttendeeConfirmationEmail",
38+
disableAttendeeCancellationEmail: settingKey === "disableAttendeeCancellationEmail",
39+
disableAttendeeRescheduledEmail: settingKey === "disableAttendeeRescheduledEmail",
40+
disableAttendeeRequestEmail: settingKey === "disableAttendeeRequestEmail",
41+
disableAttendeeReassignedEmail: settingKey === "disableAttendeeReassignedEmail",
42+
disableAttendeeAwaitingPaymentEmail: settingKey === "disableAttendeeAwaitingPaymentEmail",
43+
disableAttendeeRescheduleRequestEmail: settingKey === "disableAttendeeRescheduleRequestEmail",
44+
disableAttendeeLocationChangeEmail: settingKey === "disableAttendeeLocationChangeEmail",
45+
disableAttendeeNewEventEmail: settingKey === "disableAttendeeNewEventEmail",
46+
};
47+
48+
const result = shouldSkipAttendeeEmailWithSettings(undefined, orgSettings, emailType);
49+
expect(result).toBe(true);
50+
});
51+
52+
it(`should send email when organization has ${settingKey} disabled`, async () => {
53+
const orgSettings = {
54+
disableAttendeeConfirmationEmail: false,
55+
disableAttendeeCancellationEmail: false,
56+
disableAttendeeRescheduledEmail: false,
57+
disableAttendeeRequestEmail: false,
58+
disableAttendeeReassignedEmail: false,
59+
disableAttendeeAwaitingPaymentEmail: false,
60+
disableAttendeeRescheduleRequestEmail: false,
61+
disableAttendeeLocationChangeEmail: false,
62+
disableAttendeeNewEventEmail: false,
63+
};
64+
65+
const result = shouldSkipAttendeeEmailWithSettings(undefined, orgSettings, emailType);
66+
expect(result).toBe(false);
67+
});
68+
});
69+
70+
describe("Metadata fallback", () => {
71+
it("should skip email when metadata has disableStandardEmails.all.attendee enabled", () => {
72+
const metadata: EventTypeMetadata = {
73+
disableStandardEmails: {
74+
all: {
75+
attendee: true,
76+
},
77+
},
78+
};
79+
80+
const result = shouldSkipAttendeeEmailWithSettings(metadata, null, "confirmation");
81+
expect(result).toBe(true);
82+
});
83+
});
84+
85+
describe("Priority: organization settings override metadata", () => {
86+
it("should skip email when org setting is enabled even if metadata allows", () => {
87+
const orgSettings = {
88+
disableAttendeeConfirmationEmail: true,
89+
disableAttendeeCancellationEmail: false,
90+
disableAttendeeRescheduledEmail: false,
91+
disableAttendeeRequestEmail: false,
92+
disableAttendeeReassignedEmail: false,
93+
disableAttendeeAwaitingPaymentEmail: false,
94+
disableAttendeeRescheduleRequestEmail: false,
95+
disableAttendeeLocationChangeEmail: false,
96+
disableAttendeeNewEventEmail: false,
97+
};
98+
99+
const metadata: EventTypeMetadata = {
100+
disableStandardEmails: {
101+
confirmation: {
102+
attendee: false,
103+
},
104+
},
105+
};
106+
107+
const result = shouldSkipAttendeeEmailWithSettings(metadata, orgSettings, "confirmation");
108+
expect(result).toBe(true);
109+
});
110+
});
111+
112+
describe("Edge cases", () => {
113+
it("should send email when organizationSettings is null", () => {
114+
const result = shouldSkipAttendeeEmailWithSettings(undefined, null, "confirmation");
115+
expect(result).toBe(false);
116+
});
117+
118+
it("should send email when emailType is undefined", () => {
119+
const orgSettings = {
120+
disableAttendeeConfirmationEmail: true,
121+
disableAttendeeCancellationEmail: false,
122+
disableAttendeeRescheduledEmail: false,
123+
disableAttendeeRequestEmail: false,
124+
disableAttendeeReassignedEmail: false,
125+
disableAttendeeAwaitingPaymentEmail: false,
126+
disableAttendeeRescheduleRequestEmail: false,
127+
disableAttendeeLocationChangeEmail: false,
128+
disableAttendeeNewEventEmail: false,
129+
};
130+
131+
const result = shouldSkipAttendeeEmailWithSettings(undefined, orgSettings, undefined);
132+
expect(result).toBe(false);
133+
});
134+
135+
it("should send email when metadata is undefined and org settings are disabled", () => {
136+
const orgSettings = {
137+
disableAttendeeConfirmationEmail: false,
138+
disableAttendeeCancellationEmail: false,
139+
disableAttendeeRescheduledEmail: false,
140+
disableAttendeeRequestEmail: false,
141+
disableAttendeeReassignedEmail: false,
142+
disableAttendeeAwaitingPaymentEmail: false,
143+
disableAttendeeRescheduleRequestEmail: false,
144+
disableAttendeeLocationChangeEmail: false,
145+
disableAttendeeNewEventEmail: false,
146+
};
147+
148+
const result = shouldSkipAttendeeEmailWithSettings(undefined, orgSettings, "confirmation");
149+
expect(result).toBe(false);
150+
});
151+
});
152+
});

0 commit comments

Comments
 (0)