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:
co-authored by
Carina Wollendorfer
v0ltZzie
parent
5ee3744015
commit
bd6c471652
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
Regular → Executable
+66
-17
@@ -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>;
|
||||
Reference in New Issue
Block a user