feat: custom reply To Email (#20771)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
This commit is contained in:
co-authored by
Joe Au-Yeung
parent
45904c07bc
commit
c6cb3423fa
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,6 +128,7 @@ const commons = {
|
||||
maxLeadThreshold: null,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
rrResetInterval: null,
|
||||
customReplyToEmail: null,
|
||||
};
|
||||
|
||||
export const dynamicEvent = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ||
|
||||
|
||||
Vendored
+1
@@ -218,6 +218,7 @@ export interface CalendarEvent {
|
||||
oneTimePassword?: string | null;
|
||||
delegationCredentialId?: string | null;
|
||||
domainWideDelegationCredentialId?: string | null;
|
||||
customReplyToEmail?: string | null;
|
||||
}
|
||||
|
||||
export interface EntryPoint {
|
||||
|
||||
Reference in New Issue
Block a user