feat: custom reply To Email (#20771)

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
This commit is contained in:
Anik Dhabal Babu
2025-04-23 06:44:44 -07:00
committed by GitHub
co-authored by Joe Au-Yeung
parent 45904c07bc
commit c6cb3423fa
56 changed files with 315 additions and 30 deletions
+1
View File
@@ -22,6 +22,7 @@ export const getBooking = async (bookingId: number) => {
teamId: true,
parentId: true,
canSendCalVideoTranscriptionEmails: true,
customReplyToEmail: true,
},
},
user: {
@@ -34,6 +34,7 @@ export const getCalendarEvent = async (booking: getBookingResponse) => {
},
attendees: attendeesList,
uid: booking.uid,
customReplyToEmail: booking.eventType?.customReplyToEmail,
};
return Promise.resolve(evt);
@@ -3141,6 +3141,9 @@
"meeting_not_joined_question": "didn't join the meeting?",
"reschedule_cta_short": "Reschedule here.",
"you_and_conjunction": "You &",
"select_verified_email": "Select a verified email",
"custom_reply_to_email_title": "Custom 'Reply-To' email",
"custom_reply_to_email_description": "Use a different email address as the replyTo for confirmation emails instead of the organizer's email",
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.",
"sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.",
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
@@ -142,6 +142,7 @@ describe("handleChildrenEventTypes", () => {
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: undefined,
userId: 4,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
@@ -192,6 +193,7 @@ describe("handleChildrenEventTypes", () => {
...rest,
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
customReplyToEmail: undefined,
rrSegmentQueryValue: undefined,
locations: [],
scheduleId: null,
@@ -299,6 +301,7 @@ describe("handleChildrenEventTypes", () => {
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: undefined,
instantMeetingScheduleId: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
@@ -354,6 +357,7 @@ describe("handleChildrenEventTypes", () => {
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
rrSegmentQueryValue: undefined,
customReplyToEmail: undefined,
locations: [],
hashedLink: {
deleteMany: {},
@@ -444,6 +448,7 @@ describe("handleChildrenEventTypes", () => {
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: undefined,
instantMeetingScheduleId: undefined,
locations: [],
lockTimeZoneToggleOnBookingPage: false,
@@ -470,6 +475,7 @@ describe("handleChildrenEventTypes", () => {
locations: [],
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
customReplyToEmail: undefined,
rrSegmentQueryValue: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -12,7 +14,7 @@ export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -6,7 +8,7 @@ export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.attendee.language.translate("complete_your_booking_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,3 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -14,7 +15,7 @@ export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("event_cancelled_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -6,7 +8,7 @@ export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("event_no_longer_attending_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,6 +1,7 @@
// TODO: We should find a way to keep App specific email templates within the App itself
import type { TFunction } from "i18next";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -29,7 +30,7 @@ export default class AttendeeDailyVideoDownloadRecordingEmail extends BaseEmail
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.t("download_recording_subject", {
title: this.calEvent.title,
@@ -1,5 +1,6 @@
import type { TFunction } from "i18next";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -40,7 +41,7 @@ export default class AttendeeDailyVideoDownloadTranscriptEmail extends BaseEmail
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.t("download_transcript_email_subject", {
title: this.calEvent.title,
@@ -1,3 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -8,7 +9,7 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("event_declined_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -12,7 +14,7 @@ export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("location_changed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
@@ -1,4 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -15,7 +16,7 @@ export default class AttendeeRequestEmail extends AttendeeScheduledEmail {
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.calEvent.attendees[0].language.translate("booking_submitted_subject", {
title: this.calEvent.title,
@@ -1,3 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -18,7 +19,7 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.attendee.language.translate("event_type_has_been_rescheduled_on_time_date", {
title: this.calEvent.title,
@@ -2,6 +2,7 @@ import type { TFunction } from "i18next";
import { default as cloneDeep } from "lodash/cloneDeep";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -43,7 +44,7 @@ export default class AttendeeScheduledEmail extends BaseEmail {
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.calEvent.title}`,
html: await this.getHtml(clonedCalEvent, this.attendee),
@@ -1,3 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -18,7 +19,7 @@ export default class AttendeeUpdatedEmail extends AttendeeScheduledEmail {
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.attendee.language.translate("event_type_has_been_updated", {
title: this.calEvent.title,
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -7,7 +9,7 @@ export default class NoShowFeeChargedEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.attendee.language.translate("no_show_fee_charged_email_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -17,7 +18,7 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
replyTo: [...this.calEvent.attendees.map(({ email }) => email), getReplyToEmail(this.calEvent)],
subject: `${this.calEvent.organizer.language.translate("event_type_has_been_rescheduled_on_time_date", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -51,6 +51,7 @@ export class CalendarEventBuilder {
seatsPerTimeSlot?: number | null;
seatsShowAttendees?: boolean | null;
seatsShowAvailabilityCount?: boolean | null;
customReplyToEmail?: string | null;
}) {
this.event = {
...this.event,
@@ -64,6 +65,7 @@ export class CalendarEventBuilder {
// if seats are not enabled we should default true
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true,
customReplyToEmail: eventType.customReplyToEmail,
};
return this;
}
@@ -74,6 +74,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
seatsShowAttendees: true,
metadata: true,
schedulingType: true,
customReplyToEmail: true,
hosts: {
select: {
user: true,
@@ -266,6 +266,7 @@ async function handler(input: CancelBookingInput) {
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
customReplyToEmail: bookingToDelete.eventType?.customReplyToEmail,
};
const dataForWebhooks = { evt, webhooks, eventTypeInfo };
@@ -1024,6 +1024,7 @@ async function handler(
// if seats are not enabled we should default true
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true,
customReplyToEmail: eventType.customReplyToEmail,
})
.withOrganizer({
id: organizerUser.id,
@@ -80,6 +80,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
beforeEventBuffer: true,
customReplyToEmail: true,
afterEventBuffer: true,
parentId: true,
parent: {
@@ -271,6 +271,7 @@ export const roundRobinManualReassignment = async ({
bookingFields: eventType.bookingFields ?? null,
booking,
}),
customReplyToEmail: eventType?.customReplyToEmail,
location: bookingLocation,
...(platformClientParams ? platformClientParams : {}),
};
@@ -318,6 +318,7 @@ export const roundRobinReassignment = async ({
bookingFields: eventType?.bookingFields ?? null,
booking,
}),
customReplyToEmail: eventType?.customReplyToEmail,
location: bookingLocation,
...(platformClientParams ? platformClientParams : {}),
};
@@ -332,7 +332,10 @@ export async function handler(req: NextRequest) {
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
replyTo:
reminder.booking?.eventType?.customReplyToEmail ??
reminder.booking?.userPrimaryEmail ??
reminder.booking.user?.email,
attachments: reminder.workflowStep.includeCalendarEvent
? [
{
@@ -407,7 +410,10 @@ export async function handler(req: NextRequest) {
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
replyTo:
reminder.booking?.eventType?.customReplyToEmail ||
reminder.booking?.userPrimaryEmail ||
reminder.booking.user?.email,
sender: reminder.workflowStep?.sender,
})
);
@@ -190,6 +190,7 @@ export const select = Prisma.validator<Prisma.WorkflowReminderSelect>()({
hideBranding: true,
},
},
customReplyToEmail: true,
},
},
},
@@ -251,7 +251,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
return {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
replyTo: evt.organizer.email,
replyTo: evt?.eventType?.customReplyToEmail || evt.organizer.email,
attachments,
sender,
};
@@ -53,6 +53,7 @@ export type BookingInfo = {
eventType?: {
slug: string;
recurringEvent?: RecurringEvent | null;
customReplyToEmail?: string | null;
};
startTime: string;
endTime: string;
@@ -99,6 +99,7 @@ export type EventAdvancedTabCustomClassNames = {
warningText?: string;
};
roundRobinReschedule?: SettingsToggleClassNames;
customReplyToEmail?: SettingsToggleClassNames;
emailNotifications?: EmailNotificationToggleCustomClassNames;
};
@@ -120,6 +121,7 @@ export type EventAdvancedTabProps = EventAdvancedBaseProps & {
error: unknown;
};
showBookerLayoutSelector: boolean;
verifiedEmails?: string[];
};
type CalendarSettingsProps = {
@@ -399,6 +401,7 @@ export const EventAdvancedTab = ({
showToast,
showBookerLayoutSelector,
customClassNames,
verifiedEmails,
}: EventAdvancedTabProps) => {
const isPlatform = useIsPlatform();
const platformContext = useAtomsContext();
@@ -488,6 +491,7 @@ export const EventAdvancedTab = ({
const lockTimeZoneToggleOnBookingPageLocked = shouldLockDisableProps("lockTimeZoneToggleOnBookingPage");
const multiplePrivateLinksLocked = shouldLockDisableProps("multiplePrivateLinks");
const reschedulingPastBookingsLocked = shouldLockDisableProps("allowReschedulingPastBookings");
const customReplyToEmailLocked = shouldLockDisableProps("customReplyToEmail");
const disableCancellingLocked = shouldLockDisableProps("disableCancelling");
const disableReschedulingLocked = shouldLockDisableProps("disableRescheduling");
@@ -506,6 +510,8 @@ export const EventAdvancedTab = ({
const [isEventTypeColorChecked, setIsEventTypeColorChecked] = useState(!!eventType.eventTypeColor);
const customReplyToEmail = formMethods.watch("customReplyToEmail");
const [eventTypeColorState, setEventTypeColorState] = useState(
eventType.eventTypeColor || {
lightEventTypeColor: DEFAULT_LIGHT_BRAND_COLOR,
@@ -1000,6 +1006,52 @@ export const EventAdvancedTab = ({
/>
)}
/>
{!isPlatform && (
<>
<Controller
name="customReplyToEmail"
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName={classNames("text-sm", customClassNames?.customReplyToEmail?.label)}
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
!!value && "rounded-b-none",
customClassNames?.customReplyToEmail?.container
)}
descriptionClassName={customClassNames?.customReplyToEmail?.description}
childrenClassName={classNames("lg:ml-0", customClassNames?.customReplyToEmail?.children)}
title={t("custom_reply_to_email_title")}
{...customReplyToEmailLocked}
data-testid="custom-reply-to-email"
description={t("custom_reply_to_email_description")}
checked={!!customReplyToEmail}
onCheckedChange={(e) => {
onChange(
e
? customReplyToEmail || eventType.customReplyToEmail || verifiedEmails?.[0] || null
: null
);
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<SelectField
className="w-full"
label={t("custom_reply_to_email_title")}
required={!!customReplyToEmail}
placeholder={t("select_verified_email")}
data-testid="custom-reply-to-email-input"
value={value ? { label: value, value } : undefined}
onChange={(option) => onChange(option?.value || null)}
options={verifiedEmails?.map((email) => ({ label: email, value: email })) || []}
/>
</div>
</SettingsToggle>
</>
)}
/>
</>
)}
<Controller
name="eventTypeColor"
render={() => (
@@ -90,6 +90,7 @@ export type FormValues = {
hideCalendarNotes: boolean;
multiplePrivateLinks: string[] | undefined;
eventTypeColor: z.infer<typeof eventTypeColor>;
customReplyToEmail: string | null;
locations: EventLocation[];
aiPhoneCallConfig: PhoneCallConfig;
customInputs: CustomInputParsed[];
+2
View File
@@ -36,6 +36,7 @@ type EventType = {
recurringEvent: Prisma.JsonValue | null;
seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null;
customReplyToEmail: string | null;
};
type Booking = {
@@ -104,5 +105,6 @@ export const buildCalEventFromBooking = async ({
: [],
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
customReplyToEmail: booking.eventType?.customReplyToEmail,
};
};
@@ -34,6 +34,7 @@ class CalendarEventClass implements CalendarEvent {
additionalNotes?: string | null | undefined;
recurrence?: string;
iCalUID?: string | null;
customReplyToEmail?: string | null;
constructor(initProps?: CalendarEvent) {
// If more parameters are given we update this
+1
View File
@@ -128,6 +128,7 @@ const commons = {
maxLeadThreshold: null,
useEventLevelSelectedCalendars: false,
rrResetInterval: null,
customReplyToEmail: null,
};
export const dynamicEvent = {
+37
View File
@@ -0,0 +1,37 @@
import type { TFunction } from "i18next";
import { describe, expect, it } from "vitest";
import { getReplyToEmail } from "./getReplyToEmail";
describe("getReplyToEmail", () => {
it("returns the customReplyToEmail if it is set", () => {
const calEvent = {
customReplyToEmail: "customReplyToEmail@example.com",
organizer: {
name: "Organizer",
email: "organizer@example.com",
timeZone: "UTC",
language: {
locale: "en",
translate: ((key: string) => key) as TFunction,
},
},
};
expect(getReplyToEmail(calEvent)).toBe("customReplyToEmail@example.com");
});
it("returns the organizer's email if customReplyToEmail is not set", () => {
const calEvent = {
customReplyToEmail: null,
organizer: {
name: "Organizer",
email: "organizer@example.com",
timeZone: "UTC",
language: {
locale: "en",
translate: ((key: string) => key) as TFunction,
},
},
};
expect(getReplyToEmail(calEvent)).toBe("organizer@example.com");
});
});
+8
View File
@@ -0,0 +1,8 @@
import type { CalendarEvent } from "@calcom/types/Calendar";
/**
* Returns the reply-to email address to use, with fallback to organizer's email
*/
export const getReplyToEmail = (calEvent: Pick<CalendarEvent, "customReplyToEmail" | "organizer">) => {
return calEvent.customReplyToEmail || calEvent.organizer.email;
};
+2
View File
@@ -42,6 +42,7 @@ export async function getBooking(bookingId: number) {
price: true,
requiresConfirmation: true,
metadata: true,
customReplyToEmail: true,
title: true,
teamId: true,
parentId: true,
@@ -170,6 +171,7 @@ export async function getBooking(bookingId: number) {
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
customReplyToEmail: booking.eventType?.customReplyToEmail,
};
return {
+1
View File
@@ -67,4 +67,5 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
assignRRMembersUsingSegment: true,
maxLeadThreshold: true,
useEventLevelSelectedCalendars: true,
customReplyToEmail: true,
});
@@ -526,6 +526,7 @@ export class EventTypeRepository {
currency: true,
bookingFields: true,
useEventTypeDestinationCalendarEmail: true,
customReplyToEmail: true,
owner: {
select: {
id: true,
@@ -0,0 +1,84 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, it, expect } from "vitest";
import { WorkflowRepository } from "./workflow";
describe("WorkflowRepository", () => {
describe("getVerifiedEmails", () => {
it("should return the email of the user", async () => {
prismaMock.secondaryEmail.findMany.mockResolvedValue([]);
prismaMock.verifiedEmail.findMany.mockResolvedValue([]);
const emails = await WorkflowRepository.getVerifiedEmails({
userEmail: "user@example.com",
userId: 1,
});
expect(emails).toEqual(["user@example.com"]);
});
it("should return the email of the user and verified secondary emails", async () => {
prismaMock.verifiedEmail.findMany.mockResolvedValue([]);
prismaMock.secondaryEmail.findMany.mockResolvedValue([
{
id: 101,
email: "secondary@example.com",
emailVerified: new Date(),
},
]);
const emails = await WorkflowRepository.getVerifiedEmails({
userEmail: "user@example.com",
userId: 1,
});
expect(emails).toEqual(["user@example.com", "secondary@example.com"]);
});
it("should return the email of the user and verified secondary emails from the team", async () => {
prismaMock.verifiedEmail.findMany.mockResolvedValue([]);
prismaMock.secondaryEmail.findMany.mockResolvedValue([
{
id: 101,
email: "secondary@example.com",
emailVerified: new Date(),
},
]);
prismaMock.user.findMany.mockResolvedValue([
{
id: 1,
email: "organizer@example.com",
},
{
id: 2,
email: "teammember1@example.com",
secondaryEmails: [],
},
{
id: 3,
email: "teammember2@example.com",
secondaryEmails: [
{
email: "secondary2@example.com",
emailVerified: new Date(),
},
],
},
]);
const emails = await WorkflowRepository.getVerifiedEmails({
userEmail: "user@example.com",
userId: 1,
teamId: 1,
});
expect(emails).toEqual([
"user@example.com",
"secondary@example.com",
"teammember1@example.com",
"teammember2@example.com",
"secondary2@example.com",
]);
});
});
});
+35 -13
View File
@@ -139,36 +139,58 @@ export class WorkflowRepository {
let verifiedEmails: string[] = [userEmail];
const secondaryEmails = await prisma.secondaryEmail.findMany({
where: {
userId,
emailVerified: {
not: null,
},
},
});
verifiedEmails = verifiedEmails.concat(secondaryEmails.map((secondaryEmail) => secondaryEmail.email));
if (teamId) {
const team = await prisma.team.findFirst({
const teamMembers = await prisma.user.findMany({
where: {
id: teamId,
teams: {
some: {
teamId,
},
},
},
select: {
members: {
include: {
user: {
select: {
id: true,
email: true,
},
id: true,
email: true,
secondaryEmails: {
where: {
emailVerified: {
not: null,
},
},
select: {
email: true,
},
},
},
});
if (!team) {
if (!teamMembers.length) {
throw new Error("Team not found");
}
const isTeamMember = team.members.some((member) => member.userId === userId);
const isTeamMember = teamMembers.some((member) => member.id === userId);
if (!isTeamMember) {
throw new Error("You are not a member of this team");
}
verifiedEmails = verifiedEmails.concat(team.members.map((member) => member.user.email));
teamMembers.forEach((member) => {
if (member.id === userId) {
return;
}
verifiedEmails.push(member.email);
member.secondaryEmails.forEach((secondaryEmail) => {
verifiedEmails.push(secondaryEmail.email);
});
});
}
const emails = (
+1
View File
@@ -150,6 +150,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
autoTranslateDescriptionEnabled: false,
useEventLevelSelectedCalendars: false,
allowReschedulingPastBookings: false,
customReplyToEmail: null,
...eventType,
};
};
@@ -127,6 +127,7 @@ export const useEventTypeForm = ({
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
maxLeadThreshold: eventType.maxLeadThreshold,
useEventLevelSelectedCalendars: eventType.useEventLevelSelectedCalendars,
customReplyToEmail: eventType.customReplyToEmail || null,
};
}, [eventType, periodDates]);
@@ -280,6 +281,7 @@ export const useEventTypeForm = ({
durationLimits,
recurringEvent,
eventTypeColor,
customReplyToEmail,
locations,
metadata,
customInputs,
@@ -357,6 +359,7 @@ export const useEventTypeForm = ({
onlyShowFirstAvailableSlot,
durationLimits,
eventTypeColor,
customReplyToEmail,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
@@ -4,6 +4,9 @@ import { trpc } from "@calcom/trpc/react";
const EventAdvancedWebWrapper = ({ ...props }: EventAdvancedBaseProps) => {
const connectedCalendarsQuery = trpc.viewer.calendars.connectedCalendars.useQuery();
const { data: verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({
teamId: props.team?.id,
});
return (
<EventAdvancedTab
{...props}
@@ -13,6 +16,7 @@ const EventAdvancedWebWrapper = ({ ...props }: EventAdvancedBaseProps) => {
error: connectedCalendarsQuery.error,
}}
showBookerLayoutSelector={true}
verifiedEmails={verifiedEmails}
/>
);
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "customReplyToEmail" TEXT;
+2
View File
@@ -165,6 +165,8 @@ model EventType {
maxLeadThreshold Int?
selectedCalendars SelectedCalendar[]
allowReschedulingPastBookings Boolean @default(false)
/// @zod.custom(imports.emailSchema)
customReplyToEmail String?
/// @zod.custom(imports.eventTypeColor)
eventTypeColor Json?
@@ -128,6 +128,7 @@ export const Handler = async ({ ctx, input }: Options) => {
metadata: true,
customInputs: true,
parentId: true,
customReplyToEmail: true,
team: {
select: {
id: true,
@@ -208,6 +209,7 @@ export const Handler = async ({ ctx, input }: Options) => {
requiresConfirmation: false,
eventTypeId: eventType?.id,
videoCallData,
customReplyToEmail: eventType?.customReplyToEmail,
team: !!updatedBooking.eventType?.team
? {
name: updatedBooking.eventType.team.name,
@@ -144,6 +144,7 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
: [],
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
customReplyToEmail: booking.eventType?.customReplyToEmail,
};
if (videoCallReference) {
@@ -81,6 +81,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
price: true,
bookingFields: true,
disableGuests: true,
customReplyToEmail: true,
metadata: true,
locations: true,
team: {
@@ -226,6 +227,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
: [],
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
eventTypeId: booking.eventType?.id,
customReplyToEmail: booking.eventType?.customReplyToEmail,
team: !!booking.eventType?.team
? {
name: booking.eventType.team.name,
@@ -101,6 +101,7 @@ export async function getBookings({
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
eventTypeColor: true,
customReplyToEmail: true,
allowReschedulingPastBookings: true,
disableCancelling: true,
disableRescheduling: true,
@@ -202,6 +202,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
),
organizer,
iCalUID: bookingToReschedule.iCalUID,
customReplyToEmail: bookingToReschedule.eventType?.customReplyToEmail,
team: !!bookingToReschedule.eventType?.team
? {
name: bookingToReschedule.eventType.team.name,
@@ -76,6 +76,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
bookingLimits,
durationLimits,
eventTypeColor,
customReplyToEmail,
metadata,
workflows,
hashedLink,
@@ -115,6 +116,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
bookingLimits: bookingLimits ?? undefined,
durationLimits: durationLimits ?? undefined,
eventTypeColor: eventTypeColor ?? undefined,
customReplyToEmail: customReplyToEmail ?? undefined,
metadata: metadata === null ? Prisma.DbNull : metadata,
bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields,
rrSegmentQueryValue:
@@ -91,6 +91,7 @@ export const paymentsRouter = router({
currency: payment.currency,
paymentOption: payment.paymentOption,
},
customReplyToEmail: booking.eventType?.customReplyToEmail,
};
const paymentCredential = await prisma.credential.findFirst({
@@ -205,6 +205,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
select: {
schedulingType: true,
slug: true,
customReplyToEmail: true,
hosts: {
select: {
user: {
@@ -259,6 +260,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
hosts: booking.eventType?.hosts,
},
metadata: booking.metadata,
customReplyToEmail: booking.eventType?.customReplyToEmail,
};
for (const step of eventTypeWorkflow.steps) {
if (
@@ -57,6 +57,7 @@ export const bookingSelect = {
slug: true,
id: true,
schedulingType: true,
customReplyToEmail: true,
hosts: {
select: {
user: {
@@ -648,6 +649,7 @@ export async function scheduleBookingReminders(
hosts: booking.eventType?.hosts,
},
metadata: booking.metadata,
customReplyToEmail: booking.eventType?.customReplyToEmail,
};
if (
step.action === WorkflowActions.EMAIL_HOST ||
+1
View File
@@ -218,6 +218,7 @@ export interface CalendarEvent {
oneTimePassword?: string | null;
delegationCredentialId?: string | null;
domainWideDelegationCredentialId?: string | null;
customReplyToEmail?: string | null;
}
export interface EntryPoint {