From bd6c471652dbab6895a0bcfb0fc068b36d321119 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Tue, 21 May 2024 02:53:19 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Workflow=20=E2=80=94=20Send=20Email=20t?= =?UTF-8?q?o=20Specific=20Email=20(#14815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Workflow — Send Email to Specific Email * disable button when verifyEmail pending * add verified emails table and routes * fix: include teamId in verifyEmailCode * fix: include email address in activate handler * fix: check team before adding email * feat: check verified email, add to team if only users * remove logs * fix: if statement Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * change email to verify your email * fix: show error if saving unverified email * fix: show email in subject * verify email when editing steps * remove double calls from same block * verify email when creating new step --------- Co-authored-by: v0ltZzie <161201747+v0ltZzie@users.noreply.github.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> --- apps/web/public/static/locales/en/common.json | 2 + .../src/templates/VerifyEmailByCode.tsx | 5 +- .../emails/templates/attendee-verify-email.ts | 15 +- packages/features/auth/lib/verifyEmail.ts | 9 +- .../workflows/api/scheduleEmailReminders.ts | 52 ++++--- .../components/WorkflowStepContainer.tsx | 133 +++++++++++++++++- .../features/ee/workflows/lib/getOptions.ts | 19 ++- .../lib/reminders/emailReminderManager.ts | 7 +- .../lib/reminders/reminderScheduler.ts | 9 +- .../features/ee/workflows/pages/workflow.tsx | 16 +++ .../migration.sql | 21 +++ packages/prisma/schema.prisma | 14 ++ .../auth/sendVerifyEmailCode.handler.ts | 1 + .../viewer/auth/sendVerifyEmailCode.schema.ts | 1 + .../routers/viewer/workflows/_router.tsx | 40 ++++++ .../workflows/activateEventType.handler.ts | 9 +- .../workflows/getVerifiedEmails.handler.ts | 68 +++++++++ .../workflows/getVerifiedEmails.schema.ts | 7 + .../viewer/workflows/update.handler.ts | 83 ++++++++--- .../workflows/verifyEmailCode.handler.ts | 41 ++++++ .../workflows/verifyEmailCode.schema.ts | 9 ++ 21 files changed, 491 insertions(+), 70 deletions(-) create mode 100644 packages/prisma/migrations/20240513101457_add_verified_emails/migration.sql create mode 100644 packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.schema.ts mode change 100644 => 100755 packages/trpc/server/routers/viewer/workflows/update.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/verifyEmailCode.handler.ts create mode 100644 packages/trpc/server/routers/viewer/workflows/verifyEmailCode.schema.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 943fab682f..2b2e2503a8 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -12,6 +12,7 @@ "have_any_questions": "Have questions? We're here to help.", "reset_password_subject": "{{appName}}: Reset password instructions", "verify_email_subject": "{{appName}}: Verify your account", + "verify_email_subject_verifying_email": "{{appName}}: Verify your email", "check_your_email": "Check your email", "old_email_address": "Old Email", "new_email_address": "New Email", @@ -1942,6 +1943,7 @@ "filters": "Filters", "add_filter": "Add filter", "remove_filters": "Clear all filters", + "email_verified": "Email Verified", "select_user": "Select User", "select_event_type": "Select Event Type", "select_date_range": "Select Date Range", diff --git a/packages/emails/src/templates/VerifyEmailByCode.tsx b/packages/emails/src/templates/VerifyEmailByCode.tsx index 6ce7947c82..4d20a6ad17 100644 --- a/packages/emails/src/templates/VerifyEmailByCode.tsx +++ b/packages/emails/src/templates/VerifyEmailByCode.tsx @@ -7,7 +7,10 @@ export const VerifyEmailByCode = ( props: EmailVerifyCode & Partial> ) => { return ( - +

`, from: `${APP_NAME} <${this.getMailerOptions().from}>`, - subject: this.verifyAccountInput.language("verify_email_subject", { - appName: APP_NAME, - }), + subject: this.verifyAccountInput.language( + `verify_email_subject${this.verifyAccountInput.isVerifyingEmail ? "_verifying_email" : ""}`, + { + appName: APP_NAME, + } + ), html: await renderEmail("VerifyEmailByCode", this.verifyAccountInput), text: this.getTextBody(), }; @@ -37,7 +41,10 @@ export default class AttendeeVerifyEmail extends BaseEmail { protected getTextBody(): string { return ` -${this.verifyAccountInput.language("verify_email_subject", { appName: APP_NAME })} +${this.verifyAccountInput.language( + `verify_email_subject${this.verifyAccountInput.isVerifyingEmail ? "_verifying_email" : ""}`, + { appName: APP_NAME } +)} ${this.verifyAccountInput.language("verify_email_email_header")} ${this.verifyAccountInput.language("hi_user_name", { name: this.verifyAccountInput.user.name })}, ${this.verifyAccountInput.language("verify_email_by_code_email_body")} diff --git a/packages/features/auth/lib/verifyEmail.ts b/packages/features/auth/lib/verifyEmail.ts index 7d67da502f..d2cd94d65a 100644 --- a/packages/features/auth/lib/verifyEmail.ts +++ b/packages/features/auth/lib/verifyEmail.ts @@ -20,6 +20,7 @@ interface VerifyEmailType { email: string; language?: string; secondaryEmailId?: number; + isVerifyingEmail?: boolean; isPlatform?: boolean; } @@ -75,7 +76,12 @@ export const sendEmailVerification = async ({ return { ok: true, skipped: false }; }; -export const sendEmailVerificationByCode = async ({ email, language, username }: VerifyEmailType) => { +export const sendEmailVerificationByCode = async ({ + email, + language, + username, + isVerifyingEmail, +}: VerifyEmailType) => { const translation = await getTranslation(language ?? "en", "common"); const secret = createHash("md5") .update(email + process.env.CALENDSO_ENCRYPTION_KEY) @@ -91,6 +97,7 @@ export const sendEmailVerificationByCode = async ({ email, language, username }: email, name: username, }, + isVerifyingEmail, }); return { ok: true, skipped: false }; diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index 151e160acc..efdbe624f3 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -258,34 +258,30 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) { const batchId = await getBatchId(); - if (reminder.workflowStep.action !== WorkflowActions.EMAIL_ADDRESS) { - sendEmailPromises.push( - sendSendgridMail( - { - to: sendTo, - subject: emailContent.emailSubject, - html: emailContent.emailBody, - batchId: batchId, - sendAt: dayjs(reminder.scheduledDate).unix(), - replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email, - attachments: reminder.workflowStep.includeCalendarEvent - ? [ - { - content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString( - "base64" - ), - filename: "event.ics", - type: "text/calendar; method=REQUEST", - disposition: "attachment", - contentId: uuidv4(), - }, - ] - : undefined, - }, - { sender: reminder.workflowStep.sender } - ) - ); - } + sendEmailPromises.push( + sendSendgridMail( + { + to: sendTo, + subject: emailContent.emailSubject, + html: emailContent.emailBody, + batchId: batchId, + sendAt: dayjs(reminder.scheduledDate).unix(), + replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email, + attachments: reminder.workflowStep.includeCalendarEvent + ? [ + { + content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"), + filename: "event.ics", + type: "text/calendar; method=REQUEST", + disposition: "attachment", + contentId: uuidv4(), + }, + ] + : undefined, + }, + { sender: reminder.workflowStep.sender } + ) + ); await prisma.workflowReminder.update({ where: { diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index fe54b09894..8dce2dfe1f 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -78,9 +78,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { { enabled: !!teamId } ); + const { data: _verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({ teamId }); + const timeFormat = getTimeFormatStringFromUserTimeFormat(props.user.timeFormat); const verifiedNumbers = _verifiedNumbers?.map((number) => number.phoneNumber) || []; + const verifiedEmails = _verifiedEmails?.map((verified) => verified.email) || []; const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false); const [verificationCode, setVerificationCode] = useState(""); @@ -172,9 +175,15 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { (number: string) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`) ); + const getEmailVerificationStatus = () => + !!step && + !!verifiedEmails.find((email: string) => email === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)); + const [numberVerified, setNumberVerified] = useState(getNumberVerificationStatus()); + const [emailVerified, setEmailVerified] = useState(getEmailVerificationStatus()); useEffect(() => setNumberVerified(getNumberVerificationStatus()), [verifiedNumbers.length]); + useEffect(() => setEmailVerified(getEmailVerificationStatus()), [verifiedEmails.length]); const addVariableBody = (variable: string) => { if (step) { @@ -231,6 +240,36 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { }, }); + const sendEmailVerificationCodeMutation = trpc.viewer.auth.sendVerifyEmailCode.useMutation({ + onSuccess() { + showToast(t("email_sent"), "success"); + }, + onError: () => { + showToast(t("email_not_sent"), "error"); + }, + }); + + const verifyEmailCodeMutation = trpc.viewer.workflows.verifyEmailCode.useMutation({ + onSuccess: (isVerified) => { + showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success"); + setEmailVerified(true); + if ( + step && + form?.formState?.errors?.steps && + form.formState.errors.steps[step.stepNumber - 1]?.sendTo && + isVerified + ) { + form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`); + } + utils.viewer.workflows.getVerifiedEmails.invalidate(); + }, + onError: (err) => { + if (err.message === "invalid_code") { + showToast(t("code_provided_invalid"), "error"); + setEmailVerified(false); + } + }, + }); /* const testActionMutation = trpc.viewer.workflows.testAction.useMutation({ onSuccess: async () => { showToast(t("notification_sent"), "success"); @@ -687,12 +726,94 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { )} {isEmailAddressNeeded && (

- + +
+ ( + { + const isAlreadyVerified = !!verifiedEmails + ?.concat([]) + .find((email) => email === val.target.value); + setEmailVerified(isAlreadyVerified); + onChange(val); + }} + /> + )} + /> + +
+ + {form.formState.errors.steps && + form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( +

+ {form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""} +

+ )} + + {emailVerified ? ( +
+ {t("email_verified")} +
+ ) : ( + !props.readOnly && ( + <> +
+ { + setVerificationCode(e.target.value); + }} + required + /> + +
+ {form.formState.errors.steps && + form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( +

+ {form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""} +

+ )} + + ) + )}
)}
diff --git a/packages/features/ee/workflows/lib/getOptions.ts b/packages/features/ee/workflows/lib/getOptions.ts index 7b4cec8d49..cc5c92099b 100644 --- a/packages/features/ee/workflows/lib/getOptions.ts +++ b/packages/features/ee/workflows/lib/getOptions.ts @@ -1,6 +1,6 @@ import type { TFunction } from "next-i18next"; -import { WorkflowActions } from "@calcom/prisma/enums"; +import type { WorkflowActions } from "@calcom/prisma/enums"; import { isSMSOrWhatsappAction, isWhatsappAction, isEmailToAttendeeAction } from "./actionHelperFunctions"; import { @@ -13,16 +13,15 @@ import { } from "./constants"; export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, isOrgsPlan?: boolean) { - return WORKFLOW_ACTIONS.filter((action) => action !== WorkflowActions.EMAIL_ADDRESS) //removing EMAIL_ADDRESS for now due to abuse episode - .map((action) => { - const actionString = t(`${action.toLowerCase()}_action`); + return WORKFLOW_ACTIONS.map((action) => { + const actionString = t(`${action.toLowerCase()}_action`); - return { - label: actionString.charAt(0).toUpperCase() + actionString.slice(1), - value: action, - needsTeamsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan, - }; - }); + return { + label: actionString.charAt(0).toUpperCase() + actionString.slice(1), + value: action, + needsTeamsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan, + }; + }); } export function getWorkflowTriggerOptions(t: TFunction) { diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index 0e6b0d87bb..9a886a0add 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -120,7 +120,6 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => isMandatoryReminder, action, } = args; - if (action === WorkflowActions.EMAIL_ADDRESS) return; const { startTime, endTime } = evt; const uid = evt.uid as string; const currentDate = dayjs(); @@ -141,6 +140,12 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => let timeZone = ""; switch (action) { + case WorkflowActions.EMAIL_ADDRESS: + name = ""; + attendeeToBeUsedInMail = evt.attendees[0]; + attendeeName = evt.attendees[0].name; + timeZone = evt.organizer.timeZone; + break; case WorkflowActions.EMAIL_HOST: attendeeToBeUsedInMail = evt.attendees[0]; name = evt.organizer.name; diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 5a4848249e..aecac5b9f0 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -77,10 +77,17 @@ const processWorkflowStep = async ( isVerificationPending: step.numberVerificationPending, seatReferenceUid, }); - } else if (step.action === WorkflowActions.EMAIL_ATTENDEE || step.action === WorkflowActions.EMAIL_HOST) { + } else if ( + step.action === WorkflowActions.EMAIL_ATTENDEE || + step.action === WorkflowActions.EMAIL_HOST || + step.action === WorkflowActions.EMAIL_ADDRESS + ) { let sendTo: string[] = []; switch (step.action) { + case WorkflowActions.EMAIL_ADDRESS: + sendTo = [step.sendTo || ""]; + break; case WorkflowActions.EMAIL_HOST: sendTo = [evt.organizer?.email || ""]; break; diff --git a/packages/features/ee/workflows/pages/workflow.tsx b/packages/features/ee/workflows/pages/workflow.tsx index 816d376567..57a9c178fc 100644 --- a/packages/features/ee/workflows/pages/workflow.tsx +++ b/packages/features/ee/workflows/pages/workflow.tsx @@ -124,6 +124,10 @@ function WorkflowPage() { } ); + const { data: verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({ + teamId: workflow?.team?.id, + }); + const readOnly = workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role === MembershipRole.MEMBER; @@ -252,6 +256,18 @@ function WorkflowPage() { message: t("not_verified"), }); } + + if ( + step.action === WorkflowActions.EMAIL_ADDRESS && + !verifiedEmails?.find((verifiedEmail) => verifiedEmail.email === step.sendTo) + ) { + isVerified = false; + + form.setError(`steps.${step.stepNumber - 1}.sendTo`, { + type: "custom", + message: t("not_verified"), + }); + } }); if (!isEmpty && isVerified) { diff --git a/packages/prisma/migrations/20240513101457_add_verified_emails/migration.sql b/packages/prisma/migrations/20240513101457_add_verified_emails/migration.sql new file mode 100644 index 0000000000..c73db0250a --- /dev/null +++ b/packages/prisma/migrations/20240513101457_add_verified_emails/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "VerifiedEmail" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "teamId" INTEGER, + "email" TEXT NOT NULL, + + CONSTRAINT "VerifiedEmail_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "VerifiedEmail_userId_idx" ON "VerifiedEmail"("userId"); + +-- CreateIndex +CREATE INDEX "VerifiedEmail_teamId_idx" ON "VerifiedEmail"("teamId"); + +-- AddForeignKey +ALTER TABLE "VerifiedEmail" ADD CONSTRAINT "VerifiedEmail_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VerifiedEmail" ADD CONSTRAINT "VerifiedEmail_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 5b52a3bab4..0f710ae213 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -300,6 +300,7 @@ model User { workflows Workflow[] routingForms App_RoutingForms_Form[] @relation("routing-form") verifiedNumbers VerifiedNumber[] + verifiedEmails VerifiedEmail[] hosts Host[] organizationId Int? organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull) @@ -379,6 +380,7 @@ model Team { brandColor String? darkBrandColor String? verifiedNumbers VerifiedNumber[] + verifiedEmails VerifiedEmail[] bannerUrl String? parentId Int? parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade) @@ -1063,6 +1065,18 @@ model VerifiedNumber { @@index([teamId]) } +model VerifiedEmail { + id Int @id @default(autoincrement()) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + email String + + @@index([userId]) + @@index([teamId]) +} + model Feature { // The feature slug, ex: 'v2-workflows' slug String @id @unique diff --git a/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.handler.ts b/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.handler.ts index 97f3f9c8f8..1cca32bbcc 100644 --- a/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.handler.ts +++ b/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.handler.ts @@ -24,6 +24,7 @@ export const sendVerifyEmailCodeHandler = async ({ input, req }: SendVerifyEmail email: input.email, username: input.username, language: input.language, + isVerifyingEmail: input.isVerifyingEmail, }); return email; diff --git a/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.schema.ts b/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.schema.ts index f5cbcc60ae..7566e67856 100644 --- a/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.schema.ts +++ b/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.schema.ts @@ -4,6 +4,7 @@ export const ZSendVerifyEmailCodeSchema = z.object({ email: z.string().min(1), username: z.string().optional(), language: z.string().optional(), + isVerifyingEmail: z.boolean().optional(), }); export type TSendVerifyEmailCodeSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/_router.tsx b/packages/trpc/server/routers/viewer/workflows/_router.tsx index 1d146aa05c..9b9ad10be5 100644 --- a/packages/trpc/server/routers/viewer/workflows/_router.tsx +++ b/packages/trpc/server/routers/viewer/workflows/_router.tsx @@ -5,10 +5,12 @@ import { ZCreateInputSchema } from "./create.schema"; import { ZDeleteInputSchema } from "./delete.schema"; import { ZFilteredListInputSchema } from "./filteredList.schema"; import { ZGetInputSchema } from "./get.schema"; +import { ZGetVerifiedEmailsInputSchema } from "./getVerifiedEmails.schema"; import { ZGetVerifiedNumbersInputSchema } from "./getVerifiedNumbers.schema"; import { ZListInputSchema } from "./list.schema"; import { ZSendVerificationCodeInputSchema } from "./sendVerificationCode.schema"; import { ZUpdateInputSchema } from "./update.schema"; +import { ZVerifyEmailCodeInputSchema } from "./verifyEmailCode.schema"; import { ZVerifyPhoneNumberInputSchema } from "./verifyPhoneNumber.schema"; type WorkflowsRouterHandlerCache = { @@ -23,6 +25,8 @@ type WorkflowsRouterHandlerCache = { getVerifiedNumbers?: typeof import("./getVerifiedNumbers.handler").getVerifiedNumbersHandler; getWorkflowActionOptions?: typeof import("./getWorkflowActionOptions.handler").getWorkflowActionOptionsHandler; filteredList?: typeof import("./filteredList.handler").filteredListHandler; + getVerifiedEmails?: typeof import("./getVerifiedEmails.handler").getVerifiedEmailsHandler; + verifyEmailCode?: typeof import("./verifyEmailCode.handler").verifyEmailCodeHandler; }; const UNSTABLE_HANDLER_CACHE: WorkflowsRouterHandlerCache = {}; @@ -182,6 +186,42 @@ export const workflowsRouter = router({ }); }), + getVerifiedEmails: authedProcedure.input(ZGetVerifiedEmailsInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getVerifiedEmails) { + UNSTABLE_HANDLER_CACHE.getVerifiedEmails = await import("./getVerifiedEmails.handler").then( + (mod) => mod.getVerifiedEmailsHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getVerifiedEmails) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getVerifiedEmails({ + ctx, + input, + }); + }), + + verifyEmailCode: authedProcedure.input(ZVerifyEmailCodeInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.verifyPhoneNumber) { + UNSTABLE_HANDLER_CACHE.verifyEmailCode = await import("./verifyEmailCode.handler").then( + (mod) => mod.verifyEmailCodeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.verifyEmailCode) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.verifyEmailCode({ + ctx, + input, + }); + }), + getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => { if (!UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions) { UNSTABLE_HANDLER_CACHE.getWorkflowActionOptions = await import( diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts index b4c5569108..cdf6236d3e 100644 --- a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts @@ -184,7 +184,11 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType }, }; for (const step of eventTypeWorkflow.steps) { - if (step.action === WorkflowActions.EMAIL_ATTENDEE || step.action === WorkflowActions.EMAIL_HOST) { + if ( + step.action === WorkflowActions.EMAIL_ATTENDEE || + step.action === WorkflowActions.EMAIL_HOST || + step.action === WorkflowActions.EMAIL_ADDRESS + ) { let sendTo: string[] = []; switch (step.action) { @@ -194,6 +198,9 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType case WorkflowActions.EMAIL_ATTENDEE: sendTo = bookingInfo.attendees.map((attendee) => attendee.email); break; + case WorkflowActions.EMAIL_ADDRESS: + sendTo = step.sendTo ? [step.sendTo] : []; + break; } await scheduleEmailReminder({ diff --git a/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.handler.ts b/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.handler.ts new file mode 100644 index 0000000000..cf880bd1f3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.handler.ts @@ -0,0 +1,68 @@ +import type { VerifiedEmail } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TGetVerifiedEmailsInputSchema } from "./getVerifiedEmails.schema"; + +type GetVerifiedEmailsOptions = { + ctx: { + user: NonNullable; + }; + input: TGetVerifiedEmailsInputSchema; +}; + +export const getVerifiedEmailsHandler = async ({ ctx, input }: GetVerifiedEmailsOptions) => { + const { user } = ctx; + const { teamId } = input; + let verifiedEmails: VerifiedEmail[] = []; + + if (teamId) { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + }, + select: { + members: { + include: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); + } + + const isTeamMember = team.members.some((member) => member.userId === user.id); + + if (!isTeamMember) { + throw new TRPCError({ code: "FORBIDDEN", message: "You are not a member of this team" }); + } + + verifiedEmails = team.members.map((member) => ({ + email: member.user.email, + userId: user.id, + teamId, + id: member.user.id, + })); + } + + const emails = await prisma.verifiedEmail.findMany({ + where: { + OR: [{ userId: user.id }, { teamId: input.teamId }], + }, + }); + + verifiedEmails = verifiedEmails.concat(emails); + + return verifiedEmails; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.schema.ts b/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.schema.ts new file mode 100644 index 0000000000..82c1fd49e3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/getVerifiedEmails.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetVerifiedEmailsInputSchema = z.object({ + teamId: z.number().optional(), +}); + +export type TGetVerifiedEmailsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts old mode 100644 new mode 100755 index 331b6c9ca3..08c55d5fcd --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -39,6 +39,45 @@ type UpdateOptions = { input: TUpdateInputSchema; }; +const verifyEmailSender = async ( + email: string, + userId: number, + teamId: number | null, + prisma: PrismaClient +) => { + const verifiedEmail = await prisma.verifiedEmail.findFirst({ + where: { + email, + OR: [{ userId }, { teamId }], + }, + }); + + if (!verifiedEmail) { + throw new TRPCError({ code: "NOT_FOUND", message: "Email not verified" }); + } + + if (teamId) { + if (!verifiedEmail.teamId) { + await prisma.verifiedEmail.update({ + where: { + id: verifiedEmail.id, + }, + data: { + teamId, + }, + }); + } else if (verifiedEmail.teamId !== teamId) { + await prisma.verifiedEmail.create({ + data: { + email, + userId, + teamId, + }, + }); + } + } +}; + export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const { user } = ctx; const { id, name, activeOn, steps, trigger, time, timeUnit } = input; @@ -297,8 +336,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }; if ( step.action === WorkflowActions.EMAIL_HOST || - step.action === WorkflowActions.EMAIL_ATTENDEE /*|| - step.action === WorkflowActions.EMAIL_ADDRESS*/ + step.action === WorkflowActions.EMAIL_ATTENDEE || + step.action === WorkflowActions.EMAIL_ADDRESS ) { let sendTo: string[] = []; @@ -309,8 +348,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { case WorkflowActions.EMAIL_ATTENDEE: sendTo = bookingInfo.attendees.map((attendee) => attendee.email); break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = step.sendTo || "";*/ + case WorkflowActions.EMAIL_ADDRESS: + await verifyEmailSender(step.sendTo || "", user.id, userWorkflow.teamId, ctx.prisma); + sendTo = [step.sendTo || ""]; + break; } await scheduleEmailReminder({ @@ -425,17 +466,20 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); } const requiresSender = - newStep.action === WorkflowActions.SMS_NUMBER || newStep.action === WorkflowActions.WHATSAPP_NUMBER; + newStep.action === WorkflowActions.SMS_NUMBER || + newStep.action === WorkflowActions.WHATSAPP_NUMBER || + newStep.action === WorkflowActions.EMAIL_ADDRESS; + + if (newStep.action === WorkflowActions.EMAIL_ADDRESS) { + await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId, ctx.prisma); + } await ctx.prisma.workflowStep.update({ where: { id: oldStep.id, }, data: { action: newStep.action, - sendTo: requiresSender /*|| - newStep.action === WorkflowActions.EMAIL_ADDRESS*/ - ? newStep.sendTo - : null, + sendTo: requiresSender ? newStep.sendTo : null, stepNumber: newStep.stepNumber, workflowId: newStep.workflowId, reminderBody: newStep.reminderBody, @@ -522,8 +566,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }; if ( newStep.action === WorkflowActions.EMAIL_HOST || - newStep.action === WorkflowActions.EMAIL_ATTENDEE /*|| - newStep.action === WorkflowActions.EMAIL_ADDRESS*/ + newStep.action === WorkflowActions.EMAIL_ATTENDEE || + newStep.action === WorkflowActions.EMAIL_ADDRESS ) { let sendTo: string[] = []; @@ -534,8 +578,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { case WorkflowActions.EMAIL_ATTENDEE: sendTo = bookingInfo.attendees.map((attendee) => attendee.email); break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = newStep.sendTo || "";*/ + case WorkflowActions.EMAIL_ADDRESS: + sendTo = newStep.sendTo ? [newStep.sendTo] : []; + break; } await scheduleEmailReminder({ @@ -611,6 +656,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { sender: newStep.sender || null, senderName: senderName, }); + if (newStep.action === WorkflowActions.EMAIL_ADDRESS) { + await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId, ctx.prisma); + } const createdStep = await ctx.prisma.workflowStep.create({ data: { ...newStep, numberVerificationPending: false }, }); @@ -665,8 +713,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { if ( step.action === WorkflowActions.EMAIL_ATTENDEE || - step.action === WorkflowActions.EMAIL_HOST /*|| - step.action === WorkflowActions.EMAIL_ADDRESS*/ + step.action === WorkflowActions.EMAIL_HOST || + step.action === WorkflowActions.EMAIL_ADDRESS ) { let sendTo: string[] = []; @@ -677,8 +725,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { case WorkflowActions.EMAIL_ATTENDEE: sendTo = bookingInfo.attendees.map((attendee) => attendee.email); break; - /*case WorkflowActions.EMAIL_ADDRESS: - sendTo = step.sendTo || "";*/ + case WorkflowActions.EMAIL_ADDRESS: + sendTo = step.sendTo ? [step.sendTo] : []; + break; } await scheduleEmailReminder({ diff --git a/packages/trpc/server/routers/viewer/workflows/verifyEmailCode.handler.ts b/packages/trpc/server/routers/viewer/workflows/verifyEmailCode.handler.ts new file mode 100644 index 0000000000..8123d39e01 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/verifyEmailCode.handler.ts @@ -0,0 +1,41 @@ +import { createHash } from "crypto"; + +import { totpRawCheck } from "@calcom/lib/totp"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TVerifyEmailCodeInputSchema } from "./verifyEmailCode.schema"; + +type VerifyEmailCodeOptions = { + ctx: { + user: NonNullable; + }; + input: TVerifyEmailCodeInputSchema; +}; + +export const verifyEmailCodeHandler = async ({ ctx, input }: VerifyEmailCodeOptions) => { + const { code, email, teamId } = input; + const { id } = ctx.user; + + if (!code || !email) throw new TRPCError({ code: "BAD_REQUEST" }); + + const secret = createHash("md5") + .update(email + process.env.CALENDSO_ENCRYPTION_KEY) + .digest("hex"); + + const isValidToken = totpRawCheck(code, secret, { step: 900 }); + + if (!isValidToken) throw new TRPCError({ code: "BAD_REQUEST", message: "invalid_code" }); + + await prisma.verifiedEmail.create({ + data: { + email, + userId: id, + teamId, + }, + }); + + return isValidToken; +}; diff --git a/packages/trpc/server/routers/viewer/workflows/verifyEmailCode.schema.ts b/packages/trpc/server/routers/viewer/workflows/verifyEmailCode.schema.ts new file mode 100644 index 0000000000..496cc1a6cf --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/verifyEmailCode.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZVerifyEmailCodeInputSchema = z.object({ + teamId: z.number().optional(), + code: z.string(), + email: z.string(), +}); + +export type TVerifyEmailCodeInputSchema = z.infer;