feat: Workflow — Send Email to Specific Email (#14815)

* 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>
This commit is contained in:
Amit Sharma
2024-05-20 21:23:19 +00:00
committed by GitHub
co-authored by Carina Wollendorfer v0ltZzie
parent 5ee3744015
commit bd6c471652
21 changed files with 491 additions and 70 deletions
@@ -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",
@@ -7,7 +7,10 @@ export const VerifyEmailByCode = (
props: EmailVerifyCode & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("verify_email_subject", { appName: APP_NAME })}>
<BaseEmailHtml
subject={props.language(`verify_email_subject${props.isVerifyingEmail ? "_verifying_email" : ""}`, {
appName: APP_NAME,
})}>
<p
style={{
fontWeight: 600,
@@ -12,6 +12,7 @@ export type EmailVerifyCode = {
email: string;
};
verificationEmailCode: string;
isVerifyingEmail?: boolean;
};
export default class AttendeeVerifyEmail extends BaseEmail {
@@ -27,9 +28,12 @@ export default class AttendeeVerifyEmail extends BaseEmail {
return {
to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`,
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")}
+8 -1
View File
@@ -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 };
@@ -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: {
@@ -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 && (
<div className="bg-muted mt-5 rounded-md p-4">
<EmailField
required
disabled={props.readOnly}
label={t("email_address")}
{...form.register(`steps.${step.stepNumber - 1}.sendTo`)}
/>
<Label>{t("email_address")}</Label>
<div className="block sm:flex">
<Controller
name={`steps.${step.stepNumber - 1}.sendTo`}
render={({ field: { value, onChange } }) => (
<EmailField
required
containerClassName="w-full"
className="h-10 min-w-fit sm:rounded-r-none sm:rounded-bl-md sm:rounded-tl-md"
placeholder={t("email_address")}
value={value}
disabled={props.readOnly}
onChange={(val) => {
const isAlreadyVerified = !!verifiedEmails
?.concat([])
.find((email) => email === val.target.value);
setEmailVerified(isAlreadyVerified);
onChange(val);
}}
/>
)}
/>
<Button
color="secondary"
disabled={emailVerified || props.readOnly || false}
className={classNames(
"-ml-[3px] h-[40px] min-w-fit sm:block sm:rounded-bl-none sm:rounded-tl-none",
emailVerified ? "hidden" : "mt-3 sm:mt-0"
)}
onClick={() => {
const email = form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "";
sendEmailVerificationCodeMutation.mutate({
email,
isVerifyingEmail: true,
});
}}>
{t("send_code")}
</Button>
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
{emailVerified ? (
<div className="mt-1">
<Badge variant="green">{t("email_verified")}</Badge>
</div>
) : (
!props.readOnly && (
<>
<div className="mt-3 flex">
<TextField
className="rounded-r-none border-r-transparent"
placeholder="Verification code"
disabled={props.readOnly}
value={verificationCode}
onChange={(e) => {
setVerificationCode(e.target.value);
}}
required
/>
<Button
color="secondary"
className="-ml-[3px] h-[36px] min-w-fit py-0 sm:block sm:rounded-bl-none sm:rounded-tl-none "
disabled={verifyEmailCodeMutation.isPending || props.readOnly}
onClick={() => {
verifyEmailCodeMutation.mutate({
code: verificationCode,
email: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
teamId,
});
}}>
{t("verify")}
</Button>
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
</>
)
)}
</div>
)}
<div className="mt-5">
@@ -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) {
@@ -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;
@@ -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;
@@ -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) {
@@ -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;
+14
View File
@@ -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
@@ -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;
@@ -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<typeof ZSendVerifyEmailCodeSchema>;
@@ -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(
@@ -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({
@@ -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<TrpcSessionUser>;
};
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;
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetVerifiedEmailsInputSchema = z.object({
teamId: z.number().optional(),
});
export type TGetVerifiedEmailsInputSchema = z.infer<typeof ZGetVerifiedEmailsInputSchema>;
+66 -17
View File
@@ -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({
@@ -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<TrpcSessionUser>;
};
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;
};
@@ -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<typeof ZVerifyEmailCodeInputSchema>;