Skip to content

Commit

Permalink
feat: move to react email, update copy (#7985)
Browse files Browse the repository at this point in the history
  • Loading branch information
KenLSM authored Dec 11, 2024
1 parent b61c831 commit 6d929a8
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 72 deletions.
23 changes: 23 additions & 0 deletions react-email-preview/emails/EmailAddressVerificationOtp.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<EmailAddressVerificationOtpHtmlData> = (args) => (
<EmailAddressVerificationOtp {...args} />
)

export const Default = Template.bind({})
Default.args = {
otpPrefix: 'ABC',
otp: '123456',
minutesToExpiry: 30,
appName: 'FormSG',
}
5 changes: 5 additions & 0 deletions react-email-preview/emails/EmailAddressVerificationOtp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { EmailAddressVerificationOtp } from '../../src/app/views/templates/EmailAddressVerificationOtp'

export type { EmailAddressVerificationOtpHtmlData } from '../../src/app/views/templates/EmailAddressVerificationOtp'

export default EmailAddressVerificationOtp
41 changes: 10 additions & 31 deletions src/app/services/mail/__tests__/mail.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import {
ISubmissionSchema,
} from 'src/types'

import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../../shared/utils/verification'

const MOCK_VALID_EMAIL = '[email protected]'
const MOCK_VALID_EMAIL_2 = '[email protected]'
const MOCK_VALID_EMAIL_3 = '[email protected]'
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -148,8 +133,6 @@ describe('mail.service', () => {
.mockRejectedValueOnce(mock4xxReject)
.mockResolvedValueOnce('mockedSuccessResponse')

const expectedArgument = await generateExpectedArg()

// Act
const actualResult = await mailService.sendVerificationOtp(
MOCK_VALID_EMAIL,
Expand All @@ -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 () => {
Expand All @@ -174,8 +157,6 @@ describe('mail.service', () => {
}
sendMailSpy.mockRejectedValue(mock4xxReject)

const expectedArgument = await generateExpectedArg()

// Act
const actualResult = await mailService.sendVerificationOtp(
MOCK_VALID_EMAIL,
Expand All @@ -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 () => {
Expand All @@ -206,8 +187,6 @@ describe('mail.service', () => {
.mockRejectedValueOnce(mock4xxReject)
.mockRejectedValueOnce(mockError)

const expectedArgument = await generateExpectedArg()

// Act
const actualResult = await mailService.sendVerificationOtp(
MOCK_VALID_EMAIL,
Expand All @@ -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)
})
})

Expand Down
64 changes: 48 additions & 16 deletions src/app/services/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,7 +57,6 @@ import {
generatePaymentConfirmationHtml,
generatePaymentOnboardingHtml,
generateSubmissionToAdminHtml,
generateVerificationOtpHtml,
isToFieldValid,
} from './mail.utils'

Expand Down Expand Up @@ -338,22 +341,51 @@ export class MailService {
): ResultAsync<true, MailSendError> => {
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
})
})
}

/**
Expand Down
25 changes: 0 additions & 25 deletions src/app/services/mail/mail.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<p>You are currently submitting a form on ${appName}.</p>
<p>
Your OTP is ${otpPrefix}-<b>${otp}</b>. It will expire in ${minutesToExpiry} minutes.
Please use this to verify your submission.
</p>
<p>If your OTP does not work, please request for a new OTP.</p>
<br />
<p>If you did not make this request, you may ignore this email.</p>
<br />
<p>The ${appName} Support Team</p>
`
}

export const generateSubmissionToAdminHtml = (
htmlData: SubmissionToAdminHtmlData,
): ResultAsync<string, MailGenerationError> => {
Expand Down
33 changes: 33 additions & 0 deletions src/app/views/templates/EmailAddressVerificationOtp.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Html>
<Head />
<Body>
<Text>You are currently submitting a form on {appName}.</Text>
<Text>
Your OTP is {otpPrefix}-<b>{otp}</b>. It will expire in{' '}
{minutesToExpiry} minutes. Please use this to verify your submission.
</Text>
<Text>
Never share your OTP with anyone else. If you did not request this
OTP, you can safely ignore this email.
</Text>
<Text>The {appName} Support Team</Text>
</Body>
</Html>
)
}

0 comments on commit 6d929a8

Please sign in to comment.