Skip to content

Commit

Permalink
chore: update email bounce template (#7788)
Browse files Browse the repository at this point in the history
* feat: update content and migrate to tsx

* feat: add to storybook

* feat: combine transient and perm bounces

* chore: generalise test cases, use snapshot testing

* chore: remove overtested test cases

fix: failing stringify
  • Loading branch information
KenLSM authored Oct 29, 2024
1 parent 7e4a015 commit c9e4cf7
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 165 deletions.
23 changes: 23 additions & 0 deletions react-email-preview/emails/BounceNotification.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Meta, StoryFn } from '@storybook/react'

import BounceNotification, {
type BounceNotificationHtmlData,
} from './BounceNotification'

export default {
title: 'EmailPreview/BounceNotification',
component: BounceNotification,
decorators: [],
} as Meta

const Template: StoryFn<BounceNotificationHtmlData> = (args) => (
<BounceNotification {...args} />
)

export const Default = Template.bind({})
Default.args = {
formTitle: 'Sum ting wong form',
formLink: 'https://example.com',
bouncedRecipients: 'recipient 1',
appName: 'FormSG',
}
5 changes: 5 additions & 0 deletions react-email-preview/emails/BounceNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { BounceNotification } from '../../src/app/views/templates/BounceNotification'

export type { BounceNotificationHtmlData } from '../../src/app/services/mail/mail.types'

export default BounceNotification
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`mail.service sendBounceNotification should send permanent bounce notification successfully 1`] = `
[
[
{
"from": "mockApp <from@example.com>",
"headers": {
"X-Formsg-Email-Type": "Admin (bounce notification)",
"X-Formsg-Form-ID": "mockFormId",
},
"html": "<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/></head><body><p style="font-size:14px;line-height:24px;margin:16px 0">Dear form admins(s),</p><p style="font-size:14px;line-height:24px;margin:16px 0">We’re reaching out urgently regarding your FormSG form You are all individuals!(mockApp.example.com/mockFormId). Responses to the following recipient(s) could not be delivered: to3@example.com, to4@example.com. This was likely due to their mailbox being full.</p><p style="font-size:14px;line-height:24px;margin:16px 0">Please refer to our <a href="https://go.gov.sg/formsg-guide-bounces" style="color:#067df7;text-decoration:none" target="_blank">guide</a> for next steps on how to resolve this issue.</p><p style="font-size:14px;line-height:24px;margin:16px 0">The mockApp Support Team</p></body></html>",
"subject": "[Urgent] FormSG Response Delivery Failure / Bounce",
"to": [
"to@example.com",
"to2@example.com",
],
},
],
]
`;

exports[`mail.service sendBounceNotification should send transient bounce notification successfully 1`] = `
[
[
{
"from": "mockApp <from@example.com>",
"headers": {
"X-Formsg-Email-Type": "Admin (bounce notification)",
"X-Formsg-Form-ID": "mockFormId",
},
"html": "<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/></head><body><p style="font-size:14px;line-height:24px;margin:16px 0">Dear form admins(s),</p><p style="font-size:14px;line-height:24px;margin:16px 0">We’re reaching out urgently regarding your FormSG form You are all individuals!(mockApp.example.com/mockFormId). Responses to the following recipient(s) could not be delivered: to3@example.com, to4@example.com. This was likely due to their mailbox being full.</p><p style="font-size:14px;line-height:24px;margin:16px 0">Please refer to our <a href="https://go.gov.sg/formsg-guide-bounces" style="color:#067df7;text-decoration:none" target="_blank">guide</a> for next steps on how to resolve this issue.</p><p style="font-size:14px;line-height:24px;margin:16px 0">The mockApp Support Team</p></body></html>",
"subject": "[Urgent] FormSG Response Delivery Failure / Bounce",
"to": [
"to@example.com",
"to2@example.com",
],
},
],
]
`;
54 changes: 19 additions & 35 deletions src/app/services/mail/__tests__/mail.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,30 +1186,6 @@ describe('mail.service', () => {
const MOCK_FORM_TITLE = 'You are all individuals!'
const MOCK_BOUNCE_TYPE = BounceType.Permanent

const generateExpectedArg = async (bounceType: BounceType) => {
return {
to: MOCK_RECIPIENTS,
from: MOCK_SENDER_STRING,
subject: `[Urgent] FormSG Response Delivery Failure / Bounce`,
html: (
await MailUtils.generateBounceNotificationHtml(
{
appName: MOCK_APP_NAME,
bouncedRecipients: MOCK_BOUNCED_EMAILS.join(', '),
formLink: `${MOCK_APP_URL}/${MOCK_FORM_ID}`,
formTitle: MOCK_FORM_TITLE,
},
bounceType,
)
)._unsafeUnwrap(),
headers: {
// Hardcode in tests in case something changes this.
'X-Formsg-Email-Type': 'Admin (bounce notification)',
'X-Formsg-Form-ID': MOCK_FORM_ID,
},
}
}

it('should send permanent bounce notification successfully', async () => {
// Arrange
// sendMail should return mocked success response
Expand All @@ -1223,12 +1199,20 @@ describe('mail.service', () => {
formId: MOCK_FORM_ID,
formTitle: MOCK_FORM_TITLE,
})
const expectedArgs = await generateExpectedArg(BounceType.Permanent)
// Assert
expect(actualResult._unsafeUnwrap()).toEqual(true)
// Check arguments passed to sendNodeMail
expect(sendMailSpy).toHaveBeenCalledTimes(1)
expect(sendMailSpy).toHaveBeenCalledWith(expectedArgs)
expect(sendMailSpy).toHaveBeenCalledWith(
expect.objectContaining({
to: MOCK_RECIPIENTS,
from: MOCK_SENDER_STRING,
subject: `[Urgent] FormSG Response Delivery Failure / Bounce`,
html: expect.stringMatching(MOCK_FORM_ID),
}),
)

expect(sendMailSpy.mock.calls).toMatchSnapshot()
})

it('should send transient bounce notification successfully', async () => {
Expand All @@ -1244,12 +1228,19 @@ describe('mail.service', () => {
formId: MOCK_FORM_ID,
formTitle: MOCK_FORM_TITLE,
})
const expectedArgs = await generateExpectedArg(BounceType.Transient)
// Assert
expect(actualResult._unsafeUnwrap()).toEqual(true)
// Check arguments passed to sendNodeMail
expect(sendMailSpy).toHaveBeenCalledTimes(1)
expect(sendMailSpy).toHaveBeenCalledWith(expectedArgs)
expect(sendMailSpy).toHaveBeenCalledWith(
expect.objectContaining({
to: MOCK_RECIPIENTS,
from: MOCK_SENDER_STRING,
subject: `[Urgent] FormSG Response Delivery Failure / Bounce`,
html: expect.stringMatching(MOCK_FORM_ID),
}),
)
expect(sendMailSpy.mock.calls).toMatchSnapshot()
})

it('should reject with error when email is invalid', async () => {
Expand Down Expand Up @@ -1290,16 +1281,13 @@ describe('mail.service', () => {
formId: MOCK_FORM_ID,
formTitle: MOCK_FORM_TITLE,
})
const expectedArgs = await generateExpectedArg(MOCK_BOUNCE_TYPE)

// Assert
expect(actualResult._unsafeUnwrap()).toEqual(true)
// Check arguments passed to sendNodeMail
// Should have been called two times since it rejected the first one and
// resolved
expect(sendMailSpy).toHaveBeenCalledTimes(2)
expect(sendMailSpy).toHaveBeenNthCalledWith(1, expectedArgs)
expect(sendMailSpy).toHaveBeenNthCalledWith(2, expectedArgs)
})

it('should autoretry MOCK_RETRY_COUNT times and return error when all retries fail with 4xx errors', async () => {
Expand All @@ -1318,7 +1306,6 @@ describe('mail.service', () => {
formId: MOCK_FORM_ID,
formTitle: MOCK_FORM_TITLE,
})
const expectedArgs = await generateExpectedArg(MOCK_BOUNCE_TYPE)

// Assert
const actualError = actualResult._unsafeUnwrapErr()
Expand All @@ -1327,7 +1314,6 @@ 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(expectedArgs)
})

it('should stop autoretrying when the returned error is not a 4xx error', async () => {
Expand All @@ -1349,7 +1335,6 @@ describe('mail.service', () => {
formId: MOCK_FORM_ID,
formTitle: MOCK_FORM_TITLE,
})
const expectedArgs = await generateExpectedArg(MOCK_BOUNCE_TYPE)

// Assert
const actualError = actualResult._unsafeUnwrapErr()
Expand All @@ -1360,7 +1345,6 @@ 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(expectedArgs)
})
})

Expand Down
58 changes: 28 additions & 30 deletions src/app/services/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
getAdminEmails,
} from '../../modules/form/form.utils'
import { formatAsPercentage } from '../../utils/formatters'
import { BounceNotification } from '../../views/templates/BounceNotification'
import MrfWorkflowCompletionEmail, {
QuestionAnswer,
WorkflowOutcome,
Expand Down Expand Up @@ -62,7 +63,6 @@ import {
import {
generateAutoreplyHtml,
generateAutoreplyPdf,
generateBounceNotificationHtml,
generateIssueReportedNotificationHtml,
generateLoginOtpHtml,
generatePaymentConfirmationHtml,
Expand Down Expand Up @@ -455,37 +455,35 @@ export class MailService {
appName: this.#appName,
}

return generateBounceNotificationHtml(htmlData, bounceType).andThen(
(mailHtml) => {
const mail: MailOptions = {
to: emailRecipients,
from: this.#senderFromString,
subject: '[Urgent] FormSG Response Delivery Failure / Bounce',
html: mailHtml,
headers: {
[EMAIL_HEADERS.emailType]: EmailType.AdminBounce,
[EMAIL_HEADERS.formId]: formId,
},
}
const generatedHtml = okAsync(render(BounceNotification(htmlData)))

return this.#sendNodeMail(mail, { mailId: 'bounce' }).mapErr(
(error) => {
// Add additional logging.
logger.error({
message: 'Error sending bounce notification email',
meta: {
action: 'sendBounceNotification',
bounceType,
formTitle,
formId,
},
error,
})
return error
return generatedHtml.andThen((mailHtml) => {
const mail: MailOptions = {
to: emailRecipients,
from: this.#senderFromString,
subject: '[Urgent] FormSG Response Delivery Failure / Bounce',
html: mailHtml,
headers: {
[EMAIL_HEADERS.emailType]: EmailType.AdminBounce,
[EMAIL_HEADERS.formId]: formId,
},
}

return this.#sendNodeMail(mail, { mailId: 'bounce' }).mapErr((error) => {
// Add additional logging.
logger.error({
message: 'Error sending bounce notification email',
meta: {
action: 'sendBounceNotification',
bounceType,
formTitle,
formId,
},
)
},
)
error,
})
return error
})
})
}

/**
Expand Down
33 changes: 33 additions & 0 deletions src/app/views/templates/BounceNotification.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'

import { outerContainerStyle } from './styles'
import { BounceNotificationHtmlData } from '../../services/mail/mail.types'

export const BounceNotification = ({
formTitle,
formLink,
bouncedRecipients,
appName,
}: BounceNotificationHtmlData): JSX.Element => {
return (
<Html>
<Head />
<Body>
<Text>Dear form admins(s),</Text>
<Text>
We’re reaching out urgently regarding your FormSG form {formTitle}(
{formLink}). Responses to the following recipient(s) could not be
delivered: {bouncedRecipients}. This was likely due to their mailbox
being full.
</Text>
<Text>
Please refer to our{' '}
<Link href="https://go.gov.sg/formsg-guide-bounces">guide</Link> for
next steps on how to resolve this issue.
</Text>

<Text>The {appName} Support Team</Text>
</Body>
</Html>
)
}
Loading

0 comments on commit c9e4cf7

Please sign in to comment.