From 3147f3658921b493c03a460709a091fad5e289ad Mon Sep 17 00:00:00 2001 From: Ken Date: Wed, 11 Dec 2024 10:59:30 +0800 Subject: [PATCH] feat: move to react email, update copy --- .../EmailAddressVerificationOtp.stories.tsx | 23 +++++++ .../emails/EmailAddressVerificationOtp.tsx | 5 ++ .../mail/__tests__/mail.service.spec.ts | 41 +++--------- src/app/services/mail/mail.service.ts | 64 ++++++++++++++----- src/app/services/mail/mail.utils.ts | 25 -------- .../templates/EmailAddressVerificationOtp.tsx | 33 ++++++++++ 6 files changed, 119 insertions(+), 72 deletions(-) create mode 100644 react-email-preview/emails/EmailAddressVerificationOtp.stories.tsx create mode 100644 react-email-preview/emails/EmailAddressVerificationOtp.tsx create mode 100644 src/app/views/templates/EmailAddressVerificationOtp.tsx diff --git a/react-email-preview/emails/EmailAddressVerificationOtp.stories.tsx b/react-email-preview/emails/EmailAddressVerificationOtp.stories.tsx new file mode 100644 index 0000000000..6c4a698483 --- /dev/null +++ b/react-email-preview/emails/EmailAddressVerificationOtp.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryFn } from '@storybook/react' + +import EmailAddressVerificationOtp, { + type EmailAddressVerificationOtpHtmlData, +} from './EmailAddressVerificationOtp' + +export default { + title: 'EmailPreview/EmailAddressVerificationOtp', + component: EmailAddressVerificationOtp, + decorators: [], +} as Meta + +const Template: StoryFn = (args) => ( + +) + +export const Default = Template.bind({}) +Default.args = { + otpPrefix: 'ABC', + otp: '123456', + minutesToExpiry: 30, + appName: 'FormSG', +} diff --git a/react-email-preview/emails/EmailAddressVerificationOtp.tsx b/react-email-preview/emails/EmailAddressVerificationOtp.tsx new file mode 100644 index 0000000000..7d5b327a48 --- /dev/null +++ b/react-email-preview/emails/EmailAddressVerificationOtp.tsx @@ -0,0 +1,5 @@ +import { EmailAddressVerificationOtp } from '../../src/app/views/templates/EmailAddressVerificationOtp' + +export type { EmailAddressVerificationOtpHtmlData } from '../../src/app/views/templates/EmailAddressVerificationOtp' + +export default EmailAddressVerificationOtp diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index 49c8e8d10b..052c83ae9a 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -20,8 +20,6 @@ import { ISubmissionSchema, } from 'src/types' -import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../../shared/utils/verification' - const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' const MOCK_VALID_EMAIL_3 = 'to3@example.com' @@ -81,31 +79,18 @@ describe('mail.service', () => { describe('sendVerificationOtp', () => { const MOCK_OTP = '123456' - const generateExpectedArg = async () => { - return { - to: MOCK_VALID_EMAIL, - from: MOCK_SENDER_STRING, - subject: `Your OTP for submitting a form on ${MOCK_APP_NAME}`, - html: MailUtils.generateVerificationOtpHtml({ - appName: MOCK_APP_NAME, - otp: MOCK_OTP, - minutesToExpiry: HASH_EXPIRE_AFTER_SECONDS / 60, - otpPrefix: MOCK_OTP_PREFIX, - }), - headers: { - // Hardcode in tests in case something changes this. - 'X-Formsg-Email-Type': 'Verification OTP', - }, - } - } + const expectedArg = expect.objectContaining({ + to: MOCK_VALID_EMAIL, + from: MOCK_SENDER_STRING, + subject: `Your OTP for submitting a form on ${MOCK_APP_NAME}`, + html: expect.stringMatching(MOCK_OTP), + }) it('should send verification otp successfully', async () => { // Arrange // sendMail should return mocked success response sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') - const expectedArgument = await generateExpectedArg() - // Act const actualResult = await mailService.sendVerificationOtp( MOCK_VALID_EMAIL, @@ -117,7 +102,7 @@ describe('mail.service', () => { expect(actualResult._unsafeUnwrap()).toEqual(true) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) - expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) it('should reject with error when email is invalid', async () => { @@ -148,8 +133,6 @@ describe('mail.service', () => { .mockRejectedValueOnce(mock4xxReject) .mockResolvedValueOnce('mockedSuccessResponse') - const expectedArgument = await generateExpectedArg() - // Act const actualResult = await mailService.sendVerificationOtp( MOCK_VALID_EMAIL, @@ -163,7 +146,7 @@ describe('mail.service', () => { // Should have been called two times since it rejected the first one and // resolved expect(sendMailSpy).toHaveBeenCalledTimes(2) - expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) it('should autoretry MOCK_RETRY_COUNT times and return error when all retries fail with 4xx errors', async () => { @@ -174,8 +157,6 @@ describe('mail.service', () => { } sendMailSpy.mockRejectedValue(mock4xxReject) - const expectedArgument = await generateExpectedArg() - // Act const actualResult = await mailService.sendVerificationOtp( MOCK_VALID_EMAIL, @@ -192,7 +173,7 @@ describe('mail.service', () => { // Check arguments passed to sendNodeMail // Should have been called MOCK_RETRY_COUNT + 1 times expect(sendMailSpy).toHaveBeenCalledTimes(MOCK_RETRY_COUNT + 1) - expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) it('should stop autoretrying when the returned error is not a 4xx error', async () => { @@ -206,8 +187,6 @@ describe('mail.service', () => { .mockRejectedValueOnce(mock4xxReject) .mockRejectedValueOnce(mockError) - const expectedArgument = await generateExpectedArg() - // Act const actualResult = await mailService.sendVerificationOtp( MOCK_VALID_EMAIL, @@ -223,7 +202,7 @@ describe('mail.service', () => { // Should retry two times and stop since the second rejected value is // non-4xx error. expect(sendMailSpy).toHaveBeenCalledTimes(2) - expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) }) diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index 60a8ec3e73..f7d30c5b19 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -23,6 +23,10 @@ import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' import { getAdminEmails } from '../../modules/form/form.utils' import { BounceNotification } from '../../views/templates/BounceNotification' +import { + EmailAddressVerificationOtp, + EmailAddressVerificationOtpHtmlData, +} from '../../views/templates/EmailAddressVerificationOtp' import MrfWorkflowCompletionEmail, { QuestionAnswer, WorkflowOutcome, @@ -53,7 +57,6 @@ import { generatePaymentConfirmationHtml, generatePaymentOnboardingHtml, generateSubmissionToAdminHtml, - generateVerificationOtpHtml, isToFieldValid, } from './mail.utils' @@ -338,22 +341,51 @@ export class MailService { ): ResultAsync => { const minutesToExpiry = Math.floor(HASH_EXPIRE_AFTER_SECONDS / 60) - const mail: MailOptions = { - to: recipient, - from: this.#senderFromString, - subject: `Your OTP for submitting a form on ${this.#appName}`, - html: generateVerificationOtpHtml({ - appName: this.#appName, - minutesToExpiry, - otp, - otpPrefix, - }), - headers: { - [EMAIL_HEADERS.emailType]: EmailType.VerificationOtp, - }, + const htmlData: EmailAddressVerificationOtpHtmlData = { + appName: this.#appName, + minutesToExpiry, + otp, + otpPrefix, } - // Error gets caught in getNewOtp - return this.#sendNodeMail(mail, { mailId: 'verify' }) + const generatedHtml = fromPromise( + render(EmailAddressVerificationOtp(htmlData)), + (e) => { + logger.error({ + message: 'Failed to render EmailAddressVerificationOtp', + meta: { + action: 'sendVerificationOtp', + error: e, + }, + }) + + return new MailGenerationError( + 'Error generating email address otp verification email', + ) + }, + ) + + return generatedHtml.andThen((mailHtml) => { + const mail: MailOptions = { + to: recipient, + from: this.#senderFromString, + subject: `Your OTP for submitting a form on ${this.#appName}`, + html: mailHtml, + headers: { + [EMAIL_HEADERS.emailType]: EmailType.VerificationOtp, + }, + } + return this.#sendNodeMail(mail, { mailId: 'verify' }).mapErr((error) => { + logger.error({ + message: 'Error sending email address otp verification email', + meta: { + action: 'sendVerificationOtp', + htmlData, + }, + error, + }) + return error + }) + }) } /** diff --git a/src/app/services/mail/mail.utils.ts b/src/app/services/mail/mail.utils.ts index 622ac00b7a..f2d56ff0f6 100644 --- a/src/app/services/mail/mail.utils.ts +++ b/src/app/services/mail/mail.utils.ts @@ -65,31 +65,6 @@ export const generateLoginOtpHtml = (htmlData: { return safeRenderFile(pathToTemplate, htmlData) } -export const generateVerificationOtpHtml = ({ - otp, - otpPrefix, - appName, - minutesToExpiry, -}: { - otp: string - otpPrefix: string - appName: string - minutesToExpiry: number -}): string => { - return dedent` -

You are currently submitting a form on ${appName}.

-

- Your OTP is ${otpPrefix}-${otp}. It will expire in ${minutesToExpiry} minutes. - Please use this to verify your submission. -

-

If your OTP does not work, please request for a new OTP.

-
-

If you did not make this request, you may ignore this email.

-
-

The ${appName} Support Team

- ` -} - export const generateSubmissionToAdminHtml = ( htmlData: SubmissionToAdminHtmlData, ): ResultAsync => { diff --git a/src/app/views/templates/EmailAddressVerificationOtp.tsx b/src/app/views/templates/EmailAddressVerificationOtp.tsx new file mode 100644 index 0000000000..7bda8dab5c --- /dev/null +++ b/src/app/views/templates/EmailAddressVerificationOtp.tsx @@ -0,0 +1,33 @@ +import { Body, Head, Html, Text, Link } from '@react-email/components' + +export type EmailAddressVerificationOtpHtmlData = { + otpPrefix: string + otp: string + minutesToExpiry: number + appName: string +} + +export const EmailAddressVerificationOtp = ({ + otpPrefix, + otp, + minutesToExpiry, + appName, +}: EmailAddressVerificationOtpHtmlData): JSX.Element => { + return ( + + + + You are currently submitting a form on {appName}. + + Your OTP is {otpPrefix}-{otp}. It will expire in{' '} + {minutesToExpiry} minutes. Please use this to verify your submission. + + + Never share your OTP with anyone else. If you did not request this + OTP, you can safely ignore this email. + + The {appName} Support Team + + + ) +}