Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: move to react email, update copy #7985

Merged
merged 1 commit into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
)
}
Loading