From 80fa172f7907a1da29c2002fc054044926032bd4 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Fri, 9 May 2025 00:39:14 +0200 Subject: [PATCH] feat: add feature flag for sending workflow emails with smtp (#21187) * add feature flag * add unit tests --------- Co-authored-by: CarinaWolli --- .../roundRobinManualReassignment.ts | 4 + .../ee/round-robin/roundRobinReassignment.ts | 4 + .../workflows/api/scheduleEmailReminders.ts | 4 +- .../lib/reminders/emailReminderManager.ts | 17 ++- .../lib/reminders/reminderScheduler.ts | 2 + .../reminders/scheduleMandatoryReminder.ts | 1 + .../ee/workflows/lib/test/workflows.test.ts | 125 +++++++++++++++++- packages/features/flags/config.ts | 1 + packages/features/flags/hooks/index.ts | 1 + .../migration.sql | 9 ++ .../workflows/activateEventType.handler.ts | 2 + .../server/routers/viewer/workflows/util.ts | 2 + 12 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 packages/prisma/migrations/20250508131736_add_workflow_smtp_feature_flag/migration.sql diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 6e9761db0a..d662fa16ae 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -423,6 +423,8 @@ async function handleWorkflowsUpdate({ template: true, workflow: { select: { + userId: true, + teamId: true, trigger: true, time: true, timeUnit: true, @@ -468,6 +470,8 @@ async function handleWorkflowsUpdate({ includeCalendarEvent: workflowStep.includeCalendarEvent, workflowStepId: workflowStep.id, verifiedAt: workflowStep.verifiedAt, + userId: workflow.userId, + teamId: workflow.teamId, }); } diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index ae748558dd..da2be63592 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -460,6 +460,8 @@ export const roundRobinReassignment = async ({ template: true, workflow: { select: { + userId: true, + teamId: true, trigger: true, time: true, timeUnit: true, @@ -506,6 +508,8 @@ export const roundRobinReassignment = async ({ includeCalendarEvent: workflowStep.includeCalendarEvent, workflowStepId: workflowStep.id, verifiedAt: workflowStep.verifiedAt, + userId: workflow.userId, + teamId: workflow.teamId, }); } diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index 5c3d93c8d4..4fc22d1425 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -40,7 +40,7 @@ export async function handler(req: NextRequest) { return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } - const isSendgridEnabled = process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL; + const isSendgridEnabled = !!(process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL); if (isSendgridEnabled) { const remindersToDelete: { referenceId: string | null; id: number }[] = await getAllRemindersToDelete(); @@ -65,9 +65,7 @@ export async function handler(req: NextRequest) { }); await Promise.allSettled(handlePastCancelledReminders); - } - if (isSendgridEnabled) { //cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour const remindersToCancel: { referenceId: string | null; id: number }[] = await getAllRemindersToCancel(); diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index 3dbf116e57..9ef0367353 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from "uuid"; import dayjs from "@calcom/dayjs"; import generateIcsString from "@calcom/emails/lib/generateIcsString"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils"; import tasker from "@calcom/features/tasker"; import { WEBSITE_URL } from "@calcom/lib/constants"; @@ -50,6 +51,8 @@ interface scheduleEmailReminderArgs extends ScheduleReminderArgs { evt: BookingInfo; sendTo: string[]; action: ScheduleEmailReminderAction; + userId?: number | null; + teamId?: number | null; emailSubject?: string; emailBody?: string; hideBranding?: boolean; @@ -75,6 +78,8 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => isMandatoryReminder, action, verifiedAt, + userId, + teamId, } = args; if (!verifiedAt) { @@ -259,9 +264,17 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => const mailData = await prepareEmailData(); - const isSendgridEnabled = process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL; + const isSendgridEnabled = !!(process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL); - if (!isSendgridEnabled) { + const featureRepo = new FeaturesRepository(); + + const isWorkflowSmtpEmailsEnabled = teamId + ? await featureRepo.checkIfTeamHasFeature(teamId, "workflow-smtp-emails") + : userId + ? await featureRepo.checkIfUserHasFeature(userId, "workflow-smtp-emails") + : false; + + if (isWorkflowSmtpEmailsEnabled || !isSendgridEnabled) { let reminderUid; if (scheduledDate) { const reminder = await prisma.workflowReminder.create({ diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 01ec8d8c0f..aa48c09a52 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -151,6 +151,8 @@ const processWorkflowStep = async ( seatReferenceUid, includeCalendarEvent: step.includeCalendarEvent, verifiedAt: step.verifiedAt, + userId: workflow.userId, + teamId: workflow.teamId, }); } else if (isWhatsappAction(step.action)) { const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo; diff --git a/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts b/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts index bcac4749a2..15f6fa1b2f 100644 --- a/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts +++ b/packages/features/ee/workflows/lib/reminders/scheduleMandatoryReminder.ts @@ -65,6 +65,7 @@ export async function scheduleMandatoryReminder({ isMandatoryReminder: true, // Template is fixed so we don't have to verify verifiedAt: new Date(), + userId: evt.organizer.id, }); } catch (error) { log.error("Error while scheduling mandatory reminders", JSON.stringify({ error })); diff --git a/packages/features/ee/workflows/lib/test/workflows.test.ts b/packages/features/ee/workflows/lib/test/workflows.test.ts index 5df43b0823..7b34740376 100644 --- a/packages/features/ee/workflows/lib/test/workflows.test.ts +++ b/packages/features/ee/workflows/lib/test/workflows.test.ts @@ -13,10 +13,16 @@ import { } from "@calcom/web/test/utils/bookingScenario/expects"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; -import { describe, expect, beforeAll, vi } from "vitest"; +import { describe, expect, beforeAll, vi, beforeEach } from "vitest"; import dayjs from "@calcom/dayjs"; -import { BookingStatus, WorkflowMethods, TimeUnit } from "@calcom/prisma/enums"; +import { + BookingStatus, + WorkflowMethods, + TimeUnit, + WorkflowTriggerEvents, + WorkflowActions, +} from "@calcom/prisma/enums"; import { deleteRemindersOfActiveOnIds, scheduleBookingReminders, @@ -24,7 +30,11 @@ import { } from "@calcom/trpc/server/routers/viewer/workflows/util"; import { test } from "@calcom/web/test/fixtures/fixtures"; +import { FeaturesRepository } from "../../../../flags/features.repository"; import { deleteWorkfowRemindersOfRemovedMember } from "../../../teams/lib/deleteWorkflowRemindersOfRemovedMember"; +import { scheduleEmailReminder } from "../reminders/emailReminderManager"; +import * as emailProvider from "../reminders/providers/emailProvider"; +import * as sendgridProvider from "../reminders/providers/sendgridProvider"; const workflowSelect = { id: true, @@ -949,3 +959,114 @@ describe("deleteWorkfowRemindersOfRemovedMember", () => { ); }); }); + +describe("Workflow SMTP Emails Feature Flag", () => { + vi.spyOn(sendgridProvider, "sendSendgridMail"); + vi.spyOn(emailProvider, "sendOrScheduleWorkflowEmails"); + + const mockEvt = { + uid: "test-uid", + title: "Test Event", + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + bookerUrl: "https://cal.com", + attendees: [ + { + name: "Test Attendee", + email: "attendee@test.com", + timeZone: "UTC", + language: { locale: "en" }, + }, + ], + organizer: { + name: "Test Organizer", + email: "organizer@test.com", + timeZone: "UTC", + language: { locale: "en" }, + }, + }; + + const baseArgs = { + evt: mockEvt, + triggerEvent: WorkflowTriggerEvents.NEW_EVENT, + timeSpan: { time: 1, timeUnit: TimeUnit.HOUR }, + sendTo: ["test@example.com"], + action: WorkflowActions.EMAIL_ATTENDEE, + verifiedAt: new Date(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Mock SendGrid environment variables + process.env.SENDGRID_API_KEY = "test-key"; + process.env.SENDGRID_EMAIL = "test@example.com"; + }); + + test("should use SMTP when team has workflow-smtp-emails feature", async () => { + const featuresRepository = new FeaturesRepository(); + vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(true); + + await scheduleEmailReminder({ + ...baseArgs, + teamId: 123, + }); + expect(sendgridProvider.sendSendgridMail).not.toHaveBeenCalled(); + expect(emailProvider.sendOrScheduleWorkflowEmails).toHaveBeenCalled(); + }); + + test("should use SMTP when user has workflow-smtp-emails feature", async () => { + const featuresRepository = new FeaturesRepository(); + vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(true); + + await scheduleEmailReminder({ + ...baseArgs, + userId: 123, + }); + expect(sendgridProvider.sendSendgridMail).not.toHaveBeenCalled(); + expect(emailProvider.sendOrScheduleWorkflowEmails).toHaveBeenCalled(); + }); + + test("should use SendGrid when workflow-smtp-emails feature is not enabled for team", async () => { + const featuresRepository = new FeaturesRepository(); + vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(false); + + await scheduleEmailReminder({ + ...baseArgs, + teamId: 123, + }); + + expect(sendgridProvider.sendSendgridMail).toHaveBeenCalled(); + expect(emailProvider.sendOrScheduleWorkflowEmails).not.toHaveBeenCalled(); + }); + + test("should use SendGrid when workflow-smtp-emails feature is not enabled for user", async () => { + const featuresRepository = new FeaturesRepository(); + vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(false); + + await scheduleEmailReminder({ + ...baseArgs, + userId: 123, + }); + + expect(sendgridProvider.sendSendgridMail).toHaveBeenCalled(); + expect(emailProvider.sendOrScheduleWorkflowEmails).not.toHaveBeenCalled(); + }); + + test("should use SMTP when SendGrid is not configured", async () => { + const featuresRepository = new FeaturesRepository(); + vi.spyOn(featuresRepository, "checkIfTeamHasFeature").mockResolvedValue(false); + vi.spyOn(featuresRepository, "checkIfUserHasFeature").mockResolvedValue(false); + + delete process.env.SENDGRID_API_KEY; + delete process.env.SENDGRID_EMAIL; + + await scheduleEmailReminder({ + ...baseArgs, + teamId: 123, + userId: 456, + }); + + expect(sendgridProvider.sendSendgridMail).not.toHaveBeenCalled(); + expect(emailProvider.sendOrScheduleWorkflowEmails).toHaveBeenCalled(); + }); +}); diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 5a03e67618..640bc7fb48 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -18,5 +18,6 @@ export type AppFlags = { "organizer-request-email-v2": boolean; "delegation-credential": boolean; "salesforce-crm-tasker": boolean; + "workflow-smtp-emails": boolean; "cal-video-log-in-overlay": boolean; }; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index 4f10d5d8fc..6cadd3bcd3 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -17,6 +17,7 @@ const initialData: AppFlags = { "organizer-request-email-v2": false, "delegation-credential": false, "salesforce-crm-tasker": false, + "workflow-smtp-emails": false, "cal-video-log-in-overlay": false, }; diff --git a/packages/prisma/migrations/20250508131736_add_workflow_smtp_feature_flag/migration.sql b/packages/prisma/migrations/20250508131736_add_workflow_smtp_feature_flag/migration.sql new file mode 100644 index 0000000000..06a355b2bd --- /dev/null +++ b/packages/prisma/migrations/20250508131736_add_workflow_smtp_feature_flag/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'workflow-smtp-emails', + false, + 'Whether to use SMTP for workflow emails or SendGrid on a team/user basis.', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts index 29fc9b2bf0..c8d8c6b096 100644 --- a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts @@ -314,6 +314,8 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType sender: step.sender, workflowStepId: step.id, verifiedAt: step.verifiedAt, + userId: eventTypeWorkflow.userId, + teamId: eventTypeWorkflow.teamId, }); } else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) { await scheduleSMSReminder({ diff --git a/packages/trpc/server/routers/viewer/workflows/util.ts b/packages/trpc/server/routers/viewer/workflows/util.ts index 526a1d9578..2d833215d1 100644 --- a/packages/trpc/server/routers/viewer/workflows/util.ts +++ b/packages/trpc/server/routers/viewer/workflows/util.ts @@ -704,6 +704,8 @@ export async function scheduleBookingReminders( sender: step.sender, workflowStepId: step.id, verifiedAt: step?.verifiedAt ?? null, + userId, + teamId, }); } else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) { await scheduleSMSReminder({