Skip to content

Commit cfd1992

Browse files
authored
feat: add customReplyToEmail for EventTypeSettings atom (calcom#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
1 parent e86d616 commit cfd1992

16 files changed

Lines changed: 508 additions & 47 deletions

File tree

.changeset/sour-nights-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@calcom/atoms": minor
3+
---
4+
5+
This PR adds customReplyEmailTo feature for EventTypeSettings atom

apps/api/v2/src/modules/atoms/atoms.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
1717
import { PrismaModule } from "@/modules/prisma/prisma.module";
1818
import { RedisService } from "@/modules/redis/redis.service";
1919
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
20+
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
2021
import { UsersService } from "@/modules/users/services/users.service";
2122
import { UsersRepository } from "@/modules/users/users.repository";
2223
import { Module } from "@nestjs/common";
@@ -36,6 +37,7 @@ import { Module } from "@nestjs/common";
3637
SchedulesAtomsService,
3738
VerificationAtomsService,
3839
RedisService,
40+
TeamsRepository,
3941
],
4042
exports: [EventTypesAtomService],
4143
controllers: [

apps/api/v2/src/modules/atoms/atoms.repository.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,47 @@ export class AtomsRepository {
6464

6565
return userTeams;
6666
}
67+
68+
async getSecondaryEmails(userId: number) {
69+
return await this.dbRead.prisma.secondaryEmail.findMany({
70+
where: {
71+
userId,
72+
emailVerified: {
73+
not: null,
74+
},
75+
},
76+
});
77+
}
78+
79+
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
80+
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
81+
where: {
82+
userId_email: { userId, email },
83+
},
84+
});
85+
86+
return existingSecondaryEmailRecord?.email;
87+
}
88+
89+
async getExistingSecondaryEmail(email: string) {
90+
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
91+
where: {
92+
email,
93+
},
94+
});
95+
96+
return existingSecondaryEmailRecord?.email;
97+
}
98+
99+
async addSecondaryEmail(userId: number, email: string) {
100+
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
101+
data: {
102+
userId,
103+
email,
104+
emailVerified: new Date(),
105+
},
106+
});
107+
108+
return existingSecondaryEmailRecord?.email;
109+
}
67110
}

apps/api/v2/src/modules/atoms/controllers/atoms.verification.controller.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
22
import { Throttle } from "@/lib/endpoint-throttler-decorator";
3+
import { AddVerifiedEmailInput } from "@/modules/atoms/inputs/add-verified-email.input";
34
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
5+
import { GetVerifiedEmailsParams } from "@/modules/atoms/inputs/get-verified-emails-params";
46
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
57
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
8+
import { GetVerifiedEmailsOutput } from "@/modules/atoms/outputs/get-verified-emails-output";
69
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
710
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
811
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
@@ -105,4 +108,44 @@ export class AtomsVerificationController {
105108
status: SUCCESS_STATUS,
106109
};
107110
}
111+
112+
@Get("/emails/verified-emails")
113+
@Version(VERSION_NEUTRAL)
114+
@UseGuards(ApiAuthGuard)
115+
@HttpCode(HttpStatus.OK)
116+
async getVerifiedEmails(
117+
@Query() query: GetVerifiedEmailsParams,
118+
@GetUser() user: UserWithProfile
119+
): Promise<GetVerifiedEmailsOutput> {
120+
const verifiedEmails = await this.verificationService.getVerifiedEmails({
121+
userId: user.id,
122+
userEmail: user.email,
123+
teamId: query.teamId ? Number(query.teamId) : undefined,
124+
});
125+
126+
return {
127+
data: verifiedEmails,
128+
status: SUCCESS_STATUS,
129+
};
130+
}
131+
132+
@Post("/emails/verified-emails")
133+
@Version(VERSION_NEUTRAL)
134+
@UseGuards(ApiAuthGuard)
135+
@HttpCode(HttpStatus.OK)
136+
async addVerifiedEmails(
137+
@Body() body: AddVerifiedEmailInput,
138+
@GetUser() user: UserWithProfile
139+
): Promise<ApiResponse<{ emailVerified: boolean }>> {
140+
const emailVerified = await this.verificationService.addVerifiedEmail({
141+
userId: user.id,
142+
existingPrimaryEmail: user.email,
143+
email: body.email,
144+
});
145+
146+
return {
147+
data: { emailVerified },
148+
status: SUCCESS_STATUS,
149+
};
150+
}
108151
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ApiProperty } from "@nestjs/swagger";
2+
import { IsEmail } from "class-validator";
3+
4+
export class AddVerifiedEmailInput {
5+
@ApiProperty({ example: "user@example.com" })
6+
@IsEmail()
7+
email!: string;
8+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
2+
import { IsNumber, IsOptional, IsString, IsEmail } from "class-validator";
3+
4+
export class GetVerifiedEmailsParams {
5+
@ApiPropertyOptional({ example: "12345" })
6+
@IsOptional()
7+
@IsString()
8+
teamId?: string;
9+
}
10+
11+
export class GetVerifiedEmailsInput {
12+
@ApiProperty()
13+
@IsNumber()
14+
userId!: number;
15+
16+
@ApiProperty()
17+
@IsEmail()
18+
@IsString()
19+
userEmail!: string;
20+
21+
@ApiPropertyOptional()
22+
@IsOptional()
23+
@IsNumber()
24+
teamId?: number;
25+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ApiProperty } from "@nestjs/swagger";
2+
import { Expose } from "class-transformer";
3+
import { IsString, IsArray } from "class-validator";
4+
5+
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
6+
7+
export class GetVerifiedEmailsOutput {
8+
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
9+
@IsString()
10+
@Expose()
11+
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
12+
13+
@IsArray()
14+
@IsString({ each: true })
15+
@ApiProperty({ type: [String] })
16+
readonly data!: string[];
17+
}

apps/api/v2/src/modules/atoms/services/verification-atom.service.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
12
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
3+
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
24
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
35
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
6+
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
47
import { UserWithProfile } from "@/modules/users/users.repository";
58
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
69

@@ -13,6 +16,11 @@ import {
1316

1417
@Injectable()
1518
export class VerificationAtomsService {
19+
constructor(
20+
private readonly atomsRepository: AtomsRepository,
21+
private readonly teamsRepository: TeamsRepository
22+
) {}
23+
1624
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
1725
return await checkEmailVerificationRequired(input);
1826
}
@@ -61,4 +69,78 @@ export class VerificationAtomsService {
6169
isVerifyingEmail: input.isVerifyingEmail,
6270
});
6371
}
72+
73+
async getVerifiedEmails(input: GetVerifiedEmailsInput): Promise<string[]> {
74+
const { userId, userEmail, teamId } = input;
75+
const userEmailWithoutOauthClientId = this.removeClientIdFromEmail(userEmail);
76+
77+
if (teamId) {
78+
const verifiedEmails: string[] = [];
79+
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
80+
81+
if (teamMembers.length === 0) {
82+
return verifiedEmails;
83+
}
84+
85+
teamMembers.forEach((member) => {
86+
const memberEmailWithoutOauthClientId = this.removeClientIdFromEmail(member.email);
87+
88+
verifiedEmails.push(memberEmailWithoutOauthClientId);
89+
member.secondaryEmails.forEach((secondaryEmail) => {
90+
verifiedEmails.push(this.removeClientIdFromEmail(secondaryEmail.email));
91+
});
92+
});
93+
94+
return verifiedEmails;
95+
}
96+
97+
let verifiedEmails = [userEmailWithoutOauthClientId];
98+
99+
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
100+
verifiedEmails = verifiedEmails.concat(
101+
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
102+
);
103+
104+
return verifiedEmails;
105+
}
106+
107+
async addVerifiedEmail({
108+
userId,
109+
existingPrimaryEmail,
110+
email,
111+
}: {
112+
userId: number;
113+
existingPrimaryEmail: string;
114+
email: string;
115+
}): Promise<boolean> {
116+
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
117+
userId,
118+
email
119+
);
120+
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
121+
122+
if (alreadyExistingEmail) {
123+
throw new BadRequestException("Email already exists");
124+
}
125+
126+
if (existingPrimaryEmail === email || existingSecondaryEmail === email) {
127+
return true;
128+
}
129+
130+
await this.atomsRepository.addSecondaryEmail(userId, email);
131+
132+
return true;
133+
}
134+
135+
removeClientIdFromEmail(email: string): string {
136+
const [localPart, domain] = email.split("@");
137+
const localPartSegments = localPart.split("+");
138+
139+
localPartSegments.pop();
140+
141+
const baseEmail = localPartSegments.join("+");
142+
const normalizedEmail = `${baseEmail}@${domain}`;
143+
144+
return normalizedEmail;
145+
}
64146
}

apps/api/v2/src/modules/teams/teams/teams.repository.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,30 @@ export class TeamsRepository {
120120
},
121121
});
122122
}
123+
124+
async getTeamMemberEmails(teamId: number) {
125+
return this.dbRead.prisma.user.findMany({
126+
where: {
127+
teams: {
128+
some: {
129+
teamId,
130+
},
131+
},
132+
},
133+
select: {
134+
id: true,
135+
email: true,
136+
secondaryEmails: {
137+
where: {
138+
emailVerified: {
139+
not: null,
140+
},
141+
},
142+
select: {
143+
email: true,
144+
},
145+
},
146+
},
147+
});
148+
}
123149
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@
230230
"org_upgraded_successfully": "Your Organization was upgraded successfully!",
231231
"use_link_to_reset_password": "Use the link below to reset your password",
232232
"hey_there": "Hey there",
233+
"there": "there",
233234
"forgot_your_password_calcom": "Forgot your password? - {{appName}}",
234235
"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.",
235236
"confirm_delete_webhook": "Yes, delete webhook",
@@ -3344,6 +3345,8 @@
33443345
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events",
33453346
"you_and_conjunction": "You &",
33463347
"select_verified_email": "Select a verified email",
3348+
"add_verified_email": "Add verified email",
3349+
"add_verified_emails": "Add verified emails",
33473350
"custom_reply_to_email_title": "Custom 'Reply-To' email",
33483351
"custom_reply_to_email_description": "Use a different email address as the replyTo for confirmation emails instead of the organizer's email",
33493352
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.",

0 commit comments

Comments
 (0)