From 9a025e5b94ce0a5ab45bf4fc0dc7eb89a77dd911 Mon Sep 17 00:00:00 2001 From: Khaled Mashaly Date: Tue, 27 Feb 2024 17:06:53 +0200 Subject: [PATCH 1/5] refactor(api): extract email sending logic to smtp email sender --- .../email/email-sender/smtp-email-sender.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts new file mode 100644 index 0000000000..9bbe737d5e --- /dev/null +++ b/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts @@ -0,0 +1,84 @@ +import { readFile } from 'node:fs/promises' +import Mustache from 'mustache' +import nodemailer, { Transporter } from 'nodemailer' +import { Platform } from '@activepieces/ee-shared' +import { SystemProp, system } from 'server-shared' +import { platformService } from '../../../platform/platform.service' +import { EmailSender, EmailTemplateData } from './email-sender' +import { defaultTheme } from '../../../../flags/theme' + +/** + * Sends emails using SMTP + */ +export const smtpEmailSender: EmailSender = { + async send({ email, platformId, templateData }) { + const platform = await getPlatform(platformId) + const emailSubject = getEmailSubject(templateData.name) + const senderName = platform?.name ?? system.get(SystemProp.SMTP_SENDER_NAME) + const senderEmail = platform?.smtpSenderEmail ?? system.get(SystemProp.SMTP_SENDER_EMAIL) + + const emailBody = await renderEmailBody({ + platform, + templateData, + }) + + const smtpClient = initSmtpClient(platform) + + await smtpClient.sendMail({ + from: `${senderName} <${senderEmail}>`, + to: email, + subject: emailSubject, + html: emailBody, + }) + }, +} + +const getPlatform = async (platformId: string | null): Promise => { + return platformId ? platformService.getOne(platformId) : null +} + +const renderEmailBody = async ({ platform, templateData }: RenderEmailBodyArgs): Promise => { + const templatePath = `packages/server/api/src/assets/emails/${templateData.name}.html` + const template = await readFile(templatePath, 'utf-8') + + const primaryColor = platform?.primaryColor ?? defaultTheme.colors.primary.default + const fullLogoUrl = platform?.fullLogoUrl ?? defaultTheme.logos.fullLogoUrl + const platformName = platform?.name ?? defaultTheme.websiteName + + return Mustache.render(template, { + ...templateData.vars, + primaryColor, + fullLogoUrl, + platformName, + }) +} + +const initSmtpClient = (platform: Platform | null): Transporter => { + return nodemailer.createTransport({ + host: platform?.smtpHost ?? system.getOrThrow(SystemProp.SMTP_HOST), + port: platform?.smtpPort ?? Number.parseInt(system.getOrThrow(SystemProp.SMTP_PORT)), + secure: platform?.smtpUseSSL ?? system.getBoolean(SystemProp.SMTP_USE_SSL), + auth: { + user: platform?.smtpUser ?? system.getOrThrow(SystemProp.SMTP_USERNAME), + pass: platform?.smtpPassword ?? system.getOrThrow(SystemProp.SMTP_PASSWORD), + }, + }) +} + +const getEmailSubject = (templateName: EmailTemplateData['name']): string => { + const templateToSubject: Record = { + 'invitation-email': 'You have been invited to a team', + 'quota-50': '[ACTION REQUIRED] 50% of your Activepieces tasks are consumed', + 'quota-90': '[URGENT] 90% of your Activepieces tasks are consumed', + 'quota-100': '[URGENT] 100% of your Activepieces tasks are consumed', + 'verify-email': 'Verify your email address', + 'reset-password': 'Reset your password', + } + + return templateToSubject[templateName] +} + +type RenderEmailBodyArgs = { + platform: Platform | null + templateData: EmailTemplateData +} From f519bb44c0d04008b0ab6b643db05153674ee941 Mon Sep 17 00:00:00 2001 From: Khaled Mashaly Date: Tue, 27 Feb 2024 17:07:26 +0200 Subject: [PATCH 2/5] refactor(api): create an email sender that logs emails to console --- .../email/email-sender/log-email-sender.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/server/api/src/app/ee/helper/email/email-sender/log-email-sender.ts diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/log-email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/log-email-sender.ts new file mode 100644 index 0000000000..3533ef58f4 --- /dev/null +++ b/packages/server/api/src/app/ee/helper/email/email-sender/log-email-sender.ts @@ -0,0 +1,16 @@ +import { logger } from 'server-shared' +import { EmailSender } from './email-sender' + +/** + * Logs sent emails to the console + */ +export const logEmailSender: EmailSender = { + async send({ email, platformId, templateData }) { + logger.debug({ + name: 'LogEmailSender#send', + email, + platformId, + templateData, + }) + }, +} From 8cbe208b36331a8ef511e98697406c836791cc86 Mon Sep 17 00:00:00 2001 From: Khaled Mashaly Date: Tue, 27 Feb 2024 17:08:07 +0200 Subject: [PATCH 3/5] refactor(api): send emails in production only --- .../helper/email/email-sender/email-sender.ts | 56 +++++ .../src/app/ee/helper/email/email-service.ts | 215 ++++-------------- 2 files changed, 104 insertions(+), 167 deletions(-) create mode 100644 packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts new file mode 100644 index 0000000000..d4130c93f5 --- /dev/null +++ b/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts @@ -0,0 +1,56 @@ +import { ApEnvironment } from '@activepieces/shared' +import { system, SystemProp } from 'server-shared' +import { logEmailSender } from './log-email-sender' +import { smtpEmailSender } from './smtp-email-sender' + +export type EmailSender = { + send: (args: SendArgs) => Promise +} + +const getEmailSenderInstance = (): EmailSender => { + const env = system.get(SystemProp.ENVIRONMENT) + + if (env === ApEnvironment.PRODUCTION) { + return smtpEmailSender + } + + return logEmailSender +} + +export const emailSender = getEmailSenderInstance() + +type BaseEmailTemplateData> = { + name: Name + vars: Vars +} + +type InvitationEmailTemplateData = BaseEmailTemplateData<'invitation-email', { + projectName: string + setupLink: string +}> + +type QuotaEmailTemplateData = BaseEmailTemplateData<'quota-50' | 'quota-90' | 'quota-100', { + resetDate: string + firstName: string +}> + +type ResetPasswordEmailTemplateData = BaseEmailTemplateData<'reset-password', { + setupLink: string + firstName: string +}> + +type VerifyEmailTemplateData = BaseEmailTemplateData<'verify-email', { + setupLink: string +}> + +export type EmailTemplateData = + | InvitationEmailTemplateData + | QuotaEmailTemplateData + | ResetPasswordEmailTemplateData + | VerifyEmailTemplateData + +type SendArgs = { + email: string + platformId: string | null + templateData: EmailTemplateData +} diff --git a/packages/server/api/src/app/ee/helper/email/email-service.ts b/packages/server/api/src/app/ee/helper/email/email-service.ts index 4a98702a49..0ecc1223e9 100644 --- a/packages/server/api/src/app/ee/helper/email/email-service.ts +++ b/packages/server/api/src/app/ee/helper/email/email-service.ts @@ -1,63 +1,45 @@ +import { OtpType } from '@activepieces/ee-shared' +import { ApEdition, User, assertNotNullOrUndefined, isNil } from '@activepieces/shared' +import { logger } from 'server-shared' import { getEdition } from '../../../helper/secret-helper' -import { - ApEdition, - User, - assertNotNullOrUndefined, - isNil, -} from '@activepieces/shared' -import fs from 'node:fs/promises' -import Mustache from 'mustache' -import nodemailer from 'nodemailer' - -import { platformService } from '../../platform/platform.service' -import { defaultTheme } from '../../../flags/theme' import { projectService } from '../../../project/project-service' -import { SystemProp, system } from 'server-shared' -import { OtpType, Platform } from '@activepieces/ee-shared' -import { logger } from 'server-shared' import { platformDomainHelper } from '../platform-domain-helper' import { jwtUtils } from '../../../helper/jwt-utils' import { ProjectMemberToken } from '../../project-members/project-member.service' +import { EmailTemplateData, emailSender } from './email-sender/email-sender' const EDITION = getEdition() -const EDITION_IS_NOT_PAID = ![ApEdition.CLOUD, ApEdition.ENTERPRISE].includes( - EDITION, -) + +const EDITION_IS_NOT_PAID = ![ApEdition.CLOUD, ApEdition.ENTERPRISE].includes(EDITION) + const EDITION_IS_NOT_CLOUD = EDITION !== ApEdition.CLOUD export const emailService = { - async sendInvitation({ - email, - invitationId, - projectId, - }: { - email: string - invitationId: string - projectId: string - }): Promise { + async sendInvitation({ email, invitationId, projectId }: SendInvitationArgs): Promise { if (EDITION_IS_NOT_PAID) { return } const project = await projectService.getOneOrThrow(projectId) - const memberToken: ProjectMemberToken = { - id: invitationId, - } + const token = await jwtUtils.sign({ - payload: memberToken, + payload: { + id: invitationId, + }, key: await jwtUtils.getJwtSecret(), }) + const setupLink = await platformDomainHelper.constructUrlFrom({ platformId: project.platformId, path: `invitation?token=${token}&email=${encodeURIComponent(email)}`, }) - await sendEmail({ + await emailSender.send({ email, - platformId: project.platformId, - template: { - templateName: 'invitation-email', - data: { + platformId: project.platformId ?? null, + templateData: { + name: 'invitation-email', + vars: { setupLink, projectName: project.displayName, }, @@ -65,19 +47,7 @@ export const emailService = { }) }, - async sendQuotaAlert({ - email, - projectId, - resetDate, - firstName, - templateId, - }: { - email: string - projectId: string - resetDate: string - firstName: string - templateId: 'quota-50' | 'quota-90' | 'quota-100' - }): Promise { + async sendQuotaAlert({ email, projectId, resetDate, firstName, templateName }: SendQuotaAlertArgs): Promise { if (EDITION_IS_NOT_CLOUD) { return } @@ -90,31 +60,28 @@ export const emailService = { return } - await sendEmail({ + await emailSender.send({ email, - platformId: project.platformId, - template: { - templateName: templateId, - data: { + platformId: project.platformId || null, + templateData: { + name: templateName, + vars: { resetDate, firstName, }, }, }) }, - async sendOtpEmail({ - platformId, - user, - otp, - type, - }: SendOtpEmailParams): Promise { - const edition = getEdition() - if (![ApEdition.CLOUD, ApEdition.ENTERPRISE].includes(edition)) { + + async sendOtpEmail({ platformId, user, otp, type }: SendOtpArgs): Promise { + if (EDITION_IS_NOT_PAID) { return } + if (user.verified && type === OtpType.EMAIL_VERIFICATION) { return } + logger.info('Sending OTP email', { email: user.email, otp, @@ -122,6 +89,7 @@ export const emailService = { firstName: user.email, type, }) + const frontendPath = { [OtpType.EMAIL_VERIFICATION]: 'verify-email', [OtpType.PASSWORD_RESET]: 'reset-password', @@ -132,134 +100,47 @@ export const emailService = { path: frontendPath[type] + `?otpcode=${otp}&userId=${user.id}`, }) - const otpToTemplate: Record = { + const otpToTemplate: Record = { [OtpType.EMAIL_VERIFICATION]: { - templateName: 'verify-email', - data: { + name: 'verify-email', + vars: { setupLink, }, }, [OtpType.PASSWORD_RESET]: { - templateName: 'reset-password', - data: { + name: 'reset-password', + vars: { setupLink, firstName: user.firstName, }, }, } - await sendEmail({ + await emailSender.send({ email: user.email, - platformId: platformId ?? undefined, - template: otpToTemplate[type], + platformId, + templateData: otpToTemplate[type], }) }, } -async function sendEmail({ - platformId, - email, - template, -}: { - template: EmailTemplate +type SendInvitationArgs = { email: string - platformId: string | undefined -}): Promise { - const platform = isNil(platformId) - ? null - : await platformService.getOne(platformId) - const transporter = nodemailer.createTransport({ - host: platform?.smtpHost ?? system.getOrThrow(SystemProp.SMTP_HOST), - port: platform?.smtpPort ?? system.getNumber(SystemProp.SMTP_PORT)!, - auth: { - user: platform?.smtpUser ?? system.getOrThrow(SystemProp.SMTP_USERNAME), - pass: - platform?.smtpPassword ?? system.getOrThrow(SystemProp.SMTP_PASSWORD), - }, - secure: platform?.smtpUseSSL ?? system.getBoolean(SystemProp.SMTP_USE_SSL), - }) - const templateToSubject = { - 'invitation-email': 'You have been invited to a team', - 'quota-50': '[ACTION REQUIRED] 50% of your Activepieces tasks are consumed', - 'quota-90': '[URGENT] 90% of your Activepieces tasks are consumed', - 'quota-100': '[URGENT] 100% of your Activepieces tasks are consumed', - 'verify-email': 'Verify your email address', - 'reset-password': 'Reset your password', - } - - const senderName = platform?.name ?? system.get(SystemProp.SMTP_SENDER_NAME) - const senderEmail = - platform?.smtpSenderEmail ?? system.get(SystemProp.SMTP_SENDER_EMAIL) - await transporter.sendMail({ - from: `${senderName} <${senderEmail}>`, - to: email, - subject: templateToSubject[template.templateName], - html: await renderTemplate({ platform, request: template }), - }) -} - -async function renderTemplate({ - platform, - request, -}: { - request: EmailTemplate - platform: Platform | null -}): Promise { - const templateHtml = await readTemplateFile(request.templateName) - return Mustache.render(templateHtml, { - ...request.data, - primaryColor: platform?.primaryColor ?? defaultTheme.colors.primary.default, - fullLogoUrl: platform?.fullLogoUrl ?? defaultTheme.logos.fullLogoUrl, - platformName: platform?.name ?? defaultTheme.websiteName, - }) -} - -async function readTemplateFile(templateName: string): Promise { - return fs.readFile( - `./packages/server/api/src/assets/emails/${templateName}.html`, - 'utf-8', - ) + invitationId: string + projectId: string } -type InvitationEmailTemplate = { - templateName: 'invitation-email' - data: { - projectName: string - setupLink: string - } -} - -type QuotaEmailTemplate = { +type SendQuotaAlertArgs = { + email: string + projectId: string + resetDate: string + firstName: string templateName: 'quota-50' | 'quota-90' | 'quota-100' - data: { - resetDate: string - firstName: string - } -} - -type VerifyEmailTemplate = { - templateName: 'verify-email' - data: { - setupLink: string - } -} - -type ResetPasswordTemplate = { - templateName: 'reset-password' - data: { - setupLink: string - firstName: string - } } -type EmailTemplate = - | InvitationEmailTemplate - | QuotaEmailTemplate - | VerifyEmailTemplate - | ResetPasswordTemplate -type SendOtpEmailParams = { +type SendOtpArgs = { type: OtpType - platformId: string | undefined | null + platformId: string | null otp: string user: User } From e5efd0e0b19735a37989300aafac41422cf36ba5 Mon Sep 17 00:00:00 2001 From: Khaled Mashaly Date: Tue, 27 Feb 2024 17:18:20 +0200 Subject: [PATCH 4/5] chore(api): fix lint issues --- .../src/app/ee/helper/email/email-sender/email-sender.ts | 2 +- .../ee/helper/email/email-sender/smtp-email-sender.ts | 2 +- .../server/api/src/app/ee/helper/email/email-service.ts | 9 ++++----- packages/server/api/src/app/ee/otp/otp-service.ts | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts index d4130c93f5..4309e3f4ef 100644 --- a/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts +++ b/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts @@ -51,6 +51,6 @@ export type EmailTemplateData = type SendArgs = { email: string - platformId: string | null + platformId: string | undefined templateData: EmailTemplateData } diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts index 9bbe737d5e..3100d791a4 100644 --- a/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts +++ b/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts @@ -33,7 +33,7 @@ export const smtpEmailSender: EmailSender = { }, } -const getPlatform = async (platformId: string | null): Promise => { +const getPlatform = async (platformId: string | undefined): Promise => { return platformId ? platformService.getOne(platformId) : null } diff --git a/packages/server/api/src/app/ee/helper/email/email-service.ts b/packages/server/api/src/app/ee/helper/email/email-service.ts index 0ecc1223e9..15b92a75ea 100644 --- a/packages/server/api/src/app/ee/helper/email/email-service.ts +++ b/packages/server/api/src/app/ee/helper/email/email-service.ts @@ -5,7 +5,6 @@ import { getEdition } from '../../../helper/secret-helper' import { projectService } from '../../../project/project-service' import { platformDomainHelper } from '../platform-domain-helper' import { jwtUtils } from '../../../helper/jwt-utils' -import { ProjectMemberToken } from '../../project-members/project-member.service' import { EmailTemplateData, emailSender } from './email-sender/email-sender' const EDITION = getEdition() @@ -36,7 +35,7 @@ export const emailService = { await emailSender.send({ email, - platformId: project.platformId ?? null, + platformId: project.platformId, templateData: { name: 'invitation-email', vars: { @@ -62,7 +61,7 @@ export const emailService = { await emailSender.send({ email, - platformId: project.platformId || null, + platformId: project.platformId, templateData: { name: templateName, vars: { @@ -73,7 +72,7 @@ export const emailService = { }) }, - async sendOtpEmail({ platformId, user, otp, type }: SendOtpArgs): Promise { + async sendOtp({ platformId, user, otp, type }: SendOtpArgs): Promise { if (EDITION_IS_NOT_PAID) { return } @@ -118,7 +117,7 @@ export const emailService = { await emailSender.send({ email: user.email, - platformId, + platformId: platformId ?? undefined, templateData: otpToTemplate[type], }) }, diff --git a/packages/server/api/src/app/ee/otp/otp-service.ts b/packages/server/api/src/app/ee/otp/otp-service.ts index 7c68e611c9..3ba4b00a0f 100644 --- a/packages/server/api/src/app/ee/otp/otp-service.ts +++ b/packages/server/api/src/app/ee/otp/otp-service.ts @@ -36,7 +36,7 @@ export const otpService = { state: OtpState.PENDING, } await repo.upsert(newOtp, ['userId', 'type']) - await emailService.sendOtpEmail({ + await emailService.sendOtp({ platformId, user, otp: newOtp.value, From e0b1b9e1d8830107b1407f0b6889ae93ad11a74d Mon Sep 17 00:00:00 2001 From: Khaled Mashaly Date: Tue, 27 Feb 2024 17:27:11 +0200 Subject: [PATCH 5/5] test: fix tests --- .../api/test/integration/cloud/authn/cloud-authn.test.ts | 6 +++--- packages/server/api/test/integration/cloud/otp/otp.test.ts | 6 +++--- .../server/api/test/integration/ee/authn/ee-authn.test.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts b/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts index 41be48f931..760c53cec4 100644 --- a/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts +++ b/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts @@ -41,7 +41,7 @@ beforeAll(async () => { }) beforeEach(async () => { - emailService.sendOtpEmail = jest.fn() + emailService.sendOtp = jest.fn() stripeHelper.getOrCreateCustomer = jest .fn() .mockResolvedValue(faker.string.alphanumeric()) @@ -185,8 +185,8 @@ describe('Authentication API', () => { expect(response?.statusCode).toBe(StatusCodes.OK) const responseBody = response?.json() - expect(emailService.sendOtpEmail).toBeCalledTimes(1) - expect(emailService.sendOtpEmail).toHaveBeenCalledWith({ + expect(emailService.sendOtp).toBeCalledTimes(1) + expect(emailService.sendOtp).toHaveBeenCalledWith({ otp: expect.stringMatching(/^([0-9A-F]|-){36}$/i), platformId: mockPlatform.id, type: OtpType.EMAIL_VERIFICATION, diff --git a/packages/server/api/test/integration/cloud/otp/otp.test.ts b/packages/server/api/test/integration/cloud/otp/otp.test.ts index 08b9c1d605..9ee75ffa55 100644 --- a/packages/server/api/test/integration/cloud/otp/otp.test.ts +++ b/packages/server/api/test/integration/cloud/otp/otp.test.ts @@ -14,7 +14,7 @@ beforeAll(async () => { }) beforeEach(() => { - emailService.sendOtpEmail = jest.fn() + emailService.sendOtp = jest.fn() }) afterAll(async () => { @@ -62,8 +62,8 @@ describe('OTP API', () => { // assert expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT) - expect(emailService.sendOtpEmail).toBeCalledTimes(1) - expect(emailService.sendOtpEmail).toHaveBeenCalledWith({ + expect(emailService.sendOtp).toBeCalledTimes(1) + expect(emailService.sendOtp).toHaveBeenCalledWith({ otp: expect.stringMatching(/^([0-9A-F]|-){36}$/i), platformId: null, type: OtpType.EMAIL_VERIFICATION, diff --git a/packages/server/api/test/integration/ee/authn/ee-authn.test.ts b/packages/server/api/test/integration/ee/authn/ee-authn.test.ts index 83429b1263..a61a9c0f30 100644 --- a/packages/server/api/test/integration/ee/authn/ee-authn.test.ts +++ b/packages/server/api/test/integration/ee/authn/ee-authn.test.ts @@ -20,7 +20,7 @@ beforeAll(async () => { }) beforeEach(async () => { - emailService.sendOtpEmail = jest.fn() + emailService.sendOtp = jest.fn() stripeHelper.getOrCreateCustomer = jest .fn() .mockResolvedValue(faker.string.alphanumeric())