feat: add feature flag for sending workflow emails with smtp (#21187)

* add feature flag

* add unit tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina Wollendorfer
2025-05-08 22:39:14 +00:00
committed by GitHub
co-authored by CarinaWolli
parent 50173658f8
commit 80fa172f79
12 changed files with 165 additions and 7 deletions
@@ -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,
});
}
@@ -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,
});
}
@@ -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();
@@ -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({
@@ -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;
@@ -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 }));
@@ -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();
});
});
+1
View File
@@ -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;
};
+1
View File
@@ -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,
};
@@ -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;
@@ -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({
@@ -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({