feat: add customReplyToEmail for EventTypeSettings atom (#23686)

* init: endpoint for fetching verified emails

* fix: enable custom reply to email in frontend

* init endpoint to add verified emails

* add verified emails option for platform in frontend

* fixup: move useGetVerifiedEmails hook to correct folder

* update atoms module

* fixup: teamId should be string

* add methond to fetch team member emails

* update logic to fetch and add emails

* fixup: append client id with email

* fixup: pass teamId for fetching verified emails

* fixup: simplify check for existing emails

* fix: cleanup comments

* fix: implement code rabbit feedback

* fix: add translations

* fixup: update transaltions

* fix: update logic for addVerifiedEmail

* add changesets

* fix: implement PR feedback
This commit is contained in:
Rajiv Sahal
2025-09-09 16:52:28 +03:00
committed by GitHub
parent e86d6169ba
commit cfd1992733
16 changed files with 508 additions and 47 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
This PR adds customReplyEmailTo feature for EventTypeSettings atom
@@ -17,6 +17,7 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@@ -36,6 +37,7 @@ import { Module } from "@nestjs/common";
SchedulesAtomsService,
VerificationAtomsService,
RedisService,
TeamsRepository,
],
exports: [EventTypesAtomService],
controllers: [
@@ -64,4 +64,47 @@ export class AtomsRepository {
return userTeams;
}
async getSecondaryEmails(userId: number) {
return await this.dbRead.prisma.secondaryEmail.findMany({
where: {
userId,
emailVerified: {
not: null,
},
},
});
}
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
userId_email: { userId, email },
},
});
return existingSecondaryEmailRecord?.email;
}
async getExistingSecondaryEmail(email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
email,
},
});
return existingSecondaryEmailRecord?.email;
}
async addSecondaryEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
data: {
userId,
email,
emailVerified: new Date(),
},
});
return existingSecondaryEmailRecord?.email;
}
}
@@ -1,8 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { AddVerifiedEmailInput } from "@/modules/atoms/inputs/add-verified-email.input";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { GetVerifiedEmailsParams } from "@/modules/atoms/inputs/get-verified-emails-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { GetVerifiedEmailsOutput } from "@/modules/atoms/outputs/get-verified-emails-output";
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
@@ -105,4 +108,44 @@ export class AtomsVerificationController {
status: SUCCESS_STATUS,
};
}
@Get("/emails/verified-emails")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@Query() query: GetVerifiedEmailsParams,
@GetUser() user: UserWithProfile
): Promise<GetVerifiedEmailsOutput> {
const verifiedEmails = await this.verificationService.getVerifiedEmails({
userId: user.id,
userEmail: user.email,
teamId: query.teamId ? Number(query.teamId) : undefined,
});
return {
data: verifiedEmails,
status: SUCCESS_STATUS,
};
}
@Post("/emails/verified-emails")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async addVerifiedEmails(
@Body() body: AddVerifiedEmailInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<{ emailVerified: boolean }>> {
const emailVerified = await this.verificationService.addVerifiedEmail({
userId: user.id,
existingPrimaryEmail: user.email,
email: body.email,
});
return {
data: { emailVerified },
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,8 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail } from "class-validator";
export class AddVerifiedEmailInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNumber, IsOptional, IsString, IsEmail } from "class-validator";
export class GetVerifiedEmailsParams {
@ApiPropertyOptional({ example: "12345" })
@IsOptional()
@IsString()
teamId?: string;
}
export class GetVerifiedEmailsInput {
@ApiProperty()
@IsNumber()
userId!: number;
@ApiProperty()
@IsEmail()
@IsString()
userEmail!: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
teamId?: number;
}
@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsString, IsArray } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetVerifiedEmailsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@IsArray()
@IsString({ each: true })
@ApiProperty({ type: [String] })
readonly data!: string[];
}
@@ -1,6 +1,9 @@
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
@@ -13,6 +16,11 @@ import {
@Injectable()
export class VerificationAtomsService {
constructor(
private readonly atomsRepository: AtomsRepository,
private readonly teamsRepository: TeamsRepository
) {}
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
return await checkEmailVerificationRequired(input);
}
@@ -61,4 +69,78 @@ export class VerificationAtomsService {
isVerifyingEmail: input.isVerifyingEmail,
});
}
async getVerifiedEmails(input: GetVerifiedEmailsInput): Promise<string[]> {
const { userId, userEmail, teamId } = input;
const userEmailWithoutOauthClientId = this.removeClientIdFromEmail(userEmail);
if (teamId) {
const verifiedEmails: string[] = [];
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
if (teamMembers.length === 0) {
return verifiedEmails;
}
teamMembers.forEach((member) => {
const memberEmailWithoutOauthClientId = this.removeClientIdFromEmail(member.email);
verifiedEmails.push(memberEmailWithoutOauthClientId);
member.secondaryEmails.forEach((secondaryEmail) => {
verifiedEmails.push(this.removeClientIdFromEmail(secondaryEmail.email));
});
});
return verifiedEmails;
}
let verifiedEmails = [userEmailWithoutOauthClientId];
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
verifiedEmails = verifiedEmails.concat(
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
);
return verifiedEmails;
}
async addVerifiedEmail({
userId,
existingPrimaryEmail,
email,
}: {
userId: number;
existingPrimaryEmail: string;
email: string;
}): Promise<boolean> {
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
userId,
email
);
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
if (alreadyExistingEmail) {
throw new BadRequestException("Email already exists");
}
if (existingPrimaryEmail === email || existingSecondaryEmail === email) {
return true;
}
await this.atomsRepository.addSecondaryEmail(userId, email);
return true;
}
removeClientIdFromEmail(email: string): string {
const [localPart, domain] = email.split("@");
const localPartSegments = localPart.split("+");
localPartSegments.pop();
const baseEmail = localPartSegments.join("+");
const normalizedEmail = `${baseEmail}@${domain}`;
return normalizedEmail;
}
}
@@ -120,4 +120,30 @@ export class TeamsRepository {
},
});
}
async getTeamMemberEmails(teamId: number) {
return this.dbRead.prisma.user.findMany({
where: {
teams: {
some: {
teamId,
},
},
},
select: {
id: true,
email: true,
secondaryEmails: {
where: {
emailVerified: {
not: null,
},
},
select: {
email: true,
},
},
},
});
}
}
@@ -230,6 +230,7 @@
"org_upgraded_successfully": "Your Organization was upgraded successfully!",
"use_link_to_reset_password": "Use the link below to reset your password",
"hey_there": "Hey there",
"there": "there",
"forgot_your_password_calcom": "Forgot your password? - {{appName}}",
"delete_webhook_confirmation_message": "Are you sure you want to delete this webhook? You will no longer receive {{appName}} meeting data at a specified URL, in real-time, when an event is scheduled or canceled.",
"confirm_delete_webhook": "Yes, delete webhook",
@@ -3344,6 +3345,8 @@
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events",
"you_and_conjunction": "You &",
"select_verified_email": "Select a verified email",
"add_verified_email": "Add verified email",
"add_verified_emails": "Add verified emails",
"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.",
@@ -0,0 +1,106 @@
import { useState } from "react";
import { VerifyCodeDialog } from "@calcom/features/bookings/components/VerifyCodeDialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { TextField, Label } from "@calcom/ui/components/form";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { useAddVerifiedEmail } from "../../../platform/atoms/event-types/hooks/useAddVerifiedEmail";
import { useGetVerifiedEmails } from "../../../platform/atoms/event-types/hooks/useGetVerifiedEmails";
import { useVerifyCode } from "../../../platform/atoms/hooks/useVerifyCode";
import { useVerifyEmail } from "../../../platform/atoms/hooks/useVerifyEmail";
type AddVerifiedEmailProps = {
username?: string;
showToast: (message: string, variant: "success" | "warning" | "error") => void;
};
const AddVerifiedEmail = ({ username, showToast }: AddVerifiedEmailProps) => {
const { t } = useLocale();
const [verifiedEmail, setVerifiedEmail] = useState("");
const [isEmailVerificationModalVisible, setIsEmailVerificationModalVisible] = useState(false);
const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const { refetch: refetchVerifiedEmails } = useGetVerifiedEmails();
const { mutateAsync: addVerifiedEmail } = useAddVerifiedEmail({
onSuccess: () => {
refetchVerifiedEmails();
showToast(t("email_verified"), "success");
},
onError: () => {
showToast(t("something_went_wrong"), "error");
},
});
const verifyEmail = useVerifyEmail({
email: verifiedEmail,
name: username || t("there"),
requiresBookerEmailVerification: true,
});
const { handleVerifyEmail } = verifyEmail;
const verifyCode = useVerifyCode({
onSuccess: async () => {
setIsEmailVerificationModalVisible(false);
setVerifiedEmail("");
await addVerifiedEmail({
email: verifiedEmail,
});
},
});
return (
<div>
<div className="border-subtle border border-t-0 p-6">
<Label
className={classNames("text-emphasis mb-2 block text-sm font-medium leading-none")}
htmlFor="add-verified-emails">
{t("add_verified_emails")}
</Label>
<TextField
id="add-verified-emails"
containerClassName={classNames("w-full")}
value={verifiedEmail}
onChange={(e) => setVerifiedEmail(e.target.value)}
data-testid="add-verified-emails"
addOnSuffix={
<Tooltip content={t("add_verified_email")}>
<Button
type="button"
color="minimal"
size="sm"
StartIcon="arrow-right"
onClick={() => {
if (!!verifiedEmail && isValidEmail(verifiedEmail)) {
setIsEmailVerificationModalVisible(true);
handleVerifyEmail();
}
}}
/>
</Tooltip>
}
/>
</div>
<>
<VerifyCodeDialog
isOpenDialog={isEmailVerificationModalVisible}
setIsOpenDialog={setIsEmailVerificationModalVisible}
email={verifiedEmail}
isUserSessionRequiredToVerify={false}
verifyCodeWithSessionNotRequired={verifyCode.verifyCodeWithSessionNotRequired}
verifyCodeWithSessionRequired={verifyCode.verifyCodeWithSessionRequired}
error={verifyCode.error}
resetErrors={verifyCode.resetErrors}
isPending={verifyCode.isPending}
setIsPending={verifyCode.setIsPending}
/>
</>
</div>
);
};
export default AddVerifiedEmail;
@@ -62,6 +62,7 @@ import {
} from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import AddVerifiedEmail from "../../AddVerifiedEmail";
import type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal";
import CustomEventTypeModal from "./CustomEventTypeModal";
import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmailsSetting";
@@ -508,7 +509,7 @@ export const EventAdvancedTab = ({
"allowReschedulingCancelledBookings"
);
const { isLocked, ...eventNameLocked } = shouldLockDisableProps("eventName");
const { isLocked: _isLocked, ...eventNameLocked } = shouldLockDisableProps("eventName");
if (isManagedEventType) {
multiplePrivateLinksLocked.disabled = true;
@@ -1134,52 +1135,53 @@ 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="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
);
}}>
{isPlatform && (
<AddVerifiedEmail username={eventType.users[0]?.name || "there"} showToast={showToast} />
)}
<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={() => (
@@ -0,0 +1,64 @@
import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import { useAtomsContext } from "../../hooks/useAtomsContext";
import { appendClientIdToEmail } from "../../lib/appendClientIdToEmail";
import http from "../../lib/http";
interface IUseAddVerifiedEmail {
onSuccess?: (res: ApiResponse) => void;
onError?: (err: ApiErrorResponse | Error) => void;
}
export const useAddVerifiedEmail = (
{ onSuccess, onError }: IUseAddVerifiedEmail = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const { clientId } = useAtomsContext();
const verifiedEmailEntry = useMutation<
ApiResponse<{
status: string;
data: {
emailVerified: boolean;
};
}>,
unknown,
{
email: string;
}
>({
mutationFn: (data) => {
const { email } = data;
const emailToSend = appendClientIdToEmail(email, clientId);
return http
.post(`/atoms/emails/verified-emails`, {
email: emailToSend,
})
.then((res) => {
return res.data;
});
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data);
} else {
onError?.(data);
}
},
onError: (err) => {
onError?.(err as ApiErrorResponse);
},
});
return verifiedEmailEntry;
};
@@ -0,0 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import { useAtomsContext } from "../../hooks/useAtomsContext";
import http from "../../lib/http";
export const QUERY_KEY = "use-get-verified-emails";
export const useGetVerifiedEmails = (teamId?: number) => {
const pathname = `/atoms/emails/verified-emails${!!teamId ? `?teamId=${teamId}` : ""}`;
const { isInit, accessToken } = useAtomsContext();
return useQuery({
queryKey: [QUERY_KEY, teamId],
queryFn: () => {
return http?.get<ApiResponse<string[]>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<string[]>).data;
}
throw new Error(res.data.error.message);
});
},
enabled: isInit && !!accessToken,
staleTime: 5000,
});
};
@@ -2,14 +2,18 @@ import type { EventAdvancedBaseProps } from "@calcom/features/eventtypes/compone
import { EventAdvancedTab } from "@calcom/features/eventtypes/components/tabs/advanced/EventAdvancedTab";
import { useConnectedCalendars } from "../../hooks/useConnectedCalendars";
import { useGetVerifiedEmails } from "../hooks/useGetVerifiedEmails";
const EventAdvancedPlatformWrapper = (props: EventAdvancedBaseProps) => {
const { isPending, data: connectedCalendarsQuery, error } = useConnectedCalendars({});
const { data: verifiedEmails } = useGetVerifiedEmails(props.team?.id);
return (
<EventAdvancedTab
{...props}
calendarsQuery={{ data: connectedCalendarsQuery, isPending, error }}
showBookerLayoutSelector={false}
verifiedEmails={verifiedEmails}
/>
);
};
@@ -0,0 +1,4 @@
export function appendClientIdToEmail(email: string, clientId: string): string {
const [localPart, domain] = email.split("@");
return `${localPart}+${clientId}@${domain}`;
}