Skip to content

Commit

Permalink
feat(sms-limiting): email for disabling sms verification (#2133)
Browse files Browse the repository at this point in the history
* feat(templates): add html template for email when verification is disabled

* feat(mail.utils.ts): added new method for generation of email html

* feat(mail.service): new service method to send mail when sms verification disabled

* chore(sms-verification-disabled.view): updated wording

* test(mail.service.spec): adds tests for verification disabled email sending
  • Loading branch information
seaerchin committed Aug 3, 2021
1 parent f011ba9 commit 8f281ba
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/app/modules/verification/verification.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const SMS_VERIFICATION_LIMIT = 10000
113 changes: 112 additions & 1 deletion src/app/services/mail/__tests__/mail.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import ejs from 'ejs'
import { cloneDeep } from 'lodash'
import moment from 'moment-timezone'
import { err, ok, okAsync } from 'neverthrow'
import Mail, { Attachment } from 'nodemailer/lib/mailer'

import { MailSendError } from 'src/app/services/mail/mail.errors'
import {
MailGenerationError,
MailSendError,
} from 'src/app/services/mail/mail.errors'
import { MailService } from 'src/app/services/mail/mail.service'
import {
AutoreplySummaryRenderData,
Expand All @@ -14,6 +18,8 @@ import * as MailUtils from 'src/app/services/mail/mail.utils'
import { HASH_EXPIRE_AFTER_SECONDS } from 'src/shared/util/verification'
import { BounceType, IPopulatedForm, ISubmissionSchema } from 'src/types'

import { SMS_VERIFICATION_LIMIT } from '../../../modules/verification/verification.constants'

const MOCK_VALID_EMAIL = '[email protected]'
const MOCK_VALID_EMAIL_2 = '[email protected]'
const MOCK_VALID_EMAIL_3 = '[email protected]'
Expand Down Expand Up @@ -1243,4 +1249,109 @@ describe('mail.service', () => {
expect(sendMailSpy).toHaveBeenCalledWith(expectedArgs)
})
})

describe('sendSmsVerificationDisabledEmail', () => {
const MOCK_FORM_ID = 'mockFormId'
const MOCK_FORM_TITLE = 'You are all individuals!'
const MOCK_INVALID_EMAIL = 'something wrong@a'

const MOCK_FORM: IPopulatedForm = {
permissionList: [
{ email: MOCK_VALID_EMAIL },
{ email: MOCK_VALID_EMAIL_2 },
],
admin: {
email: MOCK_VALID_EMAIL_3,
},
title: MOCK_FORM_TITLE,
_id: MOCK_FORM_ID,
} as unknown as IPopulatedForm

const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = {
permissionList: [],
admin: {
email: MOCK_INVALID_EMAIL,
},
title: MOCK_FORM_TITLE,
_id: MOCK_FORM_ID,
} as unknown as IPopulatedForm

const generateExpectedMailOptions = async (
count: number,
emailRecipients: string | string[],
) => {
const result = await MailUtils.generateSmsVerificationDisabledHtml({
formTitle: MOCK_FORM_TITLE,
formLink: `${MOCK_APP_URL}/${MOCK_FORM_ID}`,
smsVerificationLimit: SMS_VERIFICATION_LIMIT,
}).map((emailHtml) => {
return {
to: emailRecipients,
from: MOCK_SENDER_STRING,
html: emailHtml,
subject: '[FormSG] SMS Verification - Free Tier Limit Reached',
replyTo: MOCK_SENDER_EMAIL,
bcc: MOCK_SENDER_EMAIL,
}
})
return result._unsafeUnwrap()
}

it('should send verified sms disabled emails successfully', async () => {
// Arrange
// sendMail should return mocked success response
sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse')

// Act
const actualResult = await mailService.sendSmsVerificationDisabledEmail(
MOCK_FORM,
)
const expectedMailOptions = await generateExpectedMailOptions(1000, [
MOCK_VALID_EMAIL,
MOCK_VALID_EMAIL_2,
MOCK_VALID_EMAIL_3,
])

// Assert
expect(actualResult._unsafeUnwrap()).toEqual(true)
// Check arguments passed to sendNodeMail
expect(sendMailSpy).toHaveBeenCalledTimes(1)
expect(sendMailSpy).toHaveBeenCalledWith(expectedMailOptions)
})

it('should return MailSendError when the provided email is invalid', async () => {
// Act
const actualResult = await mailService.sendSmsVerificationDisabledEmail(
MOCK_INVALID_EMAIL_FORM,
)

// Assert
expect(actualResult).toEqual(
err(new MailSendError('Invalid email error')),
)
// Check arguments passed to sendNodeMail
expect(sendMailSpy).toHaveBeenCalledTimes(0)
})

it('should return MailGenerationError when the html template could not be created', async () => {
// Arrange
jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.')

// Act
const actualResult = await mailService.sendSmsVerificationDisabledEmail(
MOCK_INVALID_EMAIL_FORM,
)

// Assert
expect(actualResult).toEqual(
err(
new MailGenerationError(
'Error occurred whilst rendering mail template',
),
),
)
// Check arguments passed to sendNodeMail
expect(sendMailSpy).toHaveBeenCalledTimes(0)
})
})
})
39 changes: 39 additions & 0 deletions src/app/services/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
BounceType,
EmailAdminDataField,
IEmailFormSchema,
IPopulatedForm,
ISubmissionSchema,
} from '../../../types'
import config from '../../config/config'
import { createLoggerWithLabel } from '../../config/logger'
import { SMS_VERIFICATION_LIMIT } from '../../modules/verification/verification.constants'

import { EMAIL_HEADERS, EmailType } from './mail.constants'
import { MailGenerationError, MailSendError } from './mail.errors'
Expand All @@ -25,12 +27,14 @@ import {
SendAutoReplyEmailsArgs,
SendMailOptions,
SendSingleAutoreplyMailArgs,
SmsVerificationDisabledData,
} from './mail.types'
import {
generateAutoreplyHtml,
generateAutoreplyPdf,
generateBounceNotificationHtml,
generateLoginOtpHtml,
generateSmsVerificationDisabledHtml,
generateSubmissionToAdminHtml,
generateVerificationOtpHtml,
isToFieldValid,
Expand Down Expand Up @@ -572,6 +576,41 @@ export class MailService {
}),
)
}

/**
* Sends a email to the admin and collaborators of the form when the verified sms feature will be disabled.
* This happens only when the admin has hit a certain limit of sms verifications on his account
* @param form The form whose admin and collaborators will be issued the email
* @returns ok(true) when mail sending is successful
* @returns err(MailGenerationError) when there was an error in generating the html data for the mail
* @returns err(MailSendError) when there was an error in sending the mail
*/
sendSmsVerificationDisabledEmail = (
form: Pick<IPopulatedForm, 'permissionList' | 'admin' | 'title' | '_id'>,
): ResultAsync<true, MailGenerationError | MailSendError> => {
const htmlData: SmsVerificationDisabledData = {
formTitle: form.title,
formLink: `${this.#appUrl}/${form._id}`,
smsVerificationLimit: SMS_VERIFICATION_LIMIT,
}

return generateSmsVerificationDisabledHtml(htmlData).andThen((mailHtml) => {
const emailRecipients = form.permissionList
.map(({ email }) => email)
.concat(form.admin.email)

const mailOptions: MailOptions = {
to: emailRecipients,
from: this.#senderFromString,
html: mailHtml,
subject: '[FormSG] SMS Verification - Free Tier Limit Reached',
replyTo: this.#senderMail,
bcc: this.#senderMail,
}

return this.#sendNodeMail(mailOptions, { formId: form._id })
})
}
}

export default new MailService()
6 changes: 6 additions & 0 deletions src/app/services/mail/mail.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,9 @@ export type BounceNotificationHtmlData = {
bouncedRecipients: string
appName: string
}

export type SmsVerificationDisabledData = {
formLink: string
formTitle: string
smsVerificationLimit: number
}
8 changes: 8 additions & 0 deletions src/app/services/mail/mail.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
AutoreplyHtmlData,
AutoreplySummaryRenderData,
BounceNotificationHtmlData,
SmsVerificationDisabledData,
SubmissionToAdminHtmlData,
} from './mail.types'

Expand Down Expand Up @@ -169,3 +170,10 @@ export const isToFieldValid = (addresses: string | string[]): boolean => {
// Every address must be an email to be valid.
return mails.every((addr) => validator.isEmail(addr))
}

export const generateSmsVerificationDisabledHtml = (
htmlData: SmsVerificationDisabledData,
): ResultAsync<string, MailGenerationError> => {
const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled.server.view.html`
return safeRenderFile(pathToTemplate, htmlData)
}
38 changes: 38 additions & 0 deletions src/app/views/templates/sms-verification-disabled.server.view.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head> </head>
<body>
<p>Dear form admin(s),</p>
<p>
You are receiving this as you have enabled SMS OTP verification on a
Mobile Number field of your form:
<a href="<%= formLink %>"> '<%= formTitle %>' </a>.
</p>
<p>
SMS OTP verification has been <b>automatically disabled</b> on your form
as you have reached the free tier limit of <%= smsVerificationLimit %>
responses.
</p>

<p>
We would have previously notified all form admins upon your account
reaching 2500, 5000 and 7500 responses while free SMS OTP verification was
enabled.
</p>
<p>
If you require SMS OTP verification for more than <%= smsVerificationLimit
%> responses, please
<a
href="https://guide.form.gov.sg/AdvancedGuide.html#how-do-i-arrange-payment-for-verified-sms"
>
arrange advance billing with us.
</a>
</p>
<p>
<b>Important: </b> Please refrain from creating multiple forms for the
same use case in order to avoid this limit, as such forms risk being
flagged for abuse and blacklisted.
</p>
<p>The FormSG Team</p>
</body>
</html>

0 comments on commit 8f281ba

Please sign in to comment.