diff --git a/src/app/modules/verification/verification.constants.ts b/src/app/modules/verification/verification.constants.ts new file mode 100644 index 0000000000..c344320aa6 --- /dev/null +++ b/src/app/modules/verification/verification.constants.ts @@ -0,0 +1 @@ +export const SMS_VERIFICATION_LIMIT = 10000 diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index dc2c8be9bf..5324952b53 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -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, @@ -13,6 +17,8 @@ import { import * as MailUtils from 'src/app/services/mail/mail.utils' import { BounceType, IPopulatedForm, ISubmissionSchema } from 'src/types' +import { SMS_VERIFICATION_LIMIT } from '../../../modules/verification/verification.constants' + const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' const MOCK_VALID_EMAIL_3 = 'to3@example.com' @@ -1242,4 +1248,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) + }) + }) }) diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index 39073a036f..ad2a3708c1 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -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' @@ -25,12 +27,14 @@ import { SendAutoReplyEmailsArgs, SendMailOptions, SendSingleAutoreplyMailArgs, + SmsVerificationDisabledData, } from './mail.types' import { generateAutoreplyHtml, generateAutoreplyPdf, generateBounceNotificationHtml, generateLoginOtpHtml, + generateSmsVerificationDisabledHtml, generateSubmissionToAdminHtml, generateVerificationOtpHtml, isToFieldValid, @@ -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, + ): ResultAsync => { + 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() diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index 5d1a62d392..6100de4ae3 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -86,3 +86,9 @@ export type BounceNotificationHtmlData = { bouncedRecipients: string appName: string } + +export type SmsVerificationDisabledData = { + formLink: string + formTitle: string + smsVerificationLimit: number +} diff --git a/src/app/services/mail/mail.utils.ts b/src/app/services/mail/mail.utils.ts index d6df270e53..148ac5a65e 100644 --- a/src/app/services/mail/mail.utils.ts +++ b/src/app/services/mail/mail.utils.ts @@ -14,6 +14,7 @@ import { AutoreplyHtmlData, AutoreplySummaryRenderData, BounceNotificationHtmlData, + SmsVerificationDisabledData, SubmissionToAdminHtmlData, } from './mail.types' @@ -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 => { + const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled.server.view.html` + return safeRenderFile(pathToTemplate, htmlData) +} diff --git a/src/app/views/templates/sms-verification-disabled.server.view.html b/src/app/views/templates/sms-verification-disabled.server.view.html new file mode 100644 index 0000000000..957f4260da --- /dev/null +++ b/src/app/views/templates/sms-verification-disabled.server.view.html @@ -0,0 +1,38 @@ + + + + +

Dear form admin(s),

+

+ You are receiving this as you have enabled SMS OTP verification on a + Mobile Number field of your form: + '<%= formTitle %>' . +

+

+ SMS OTP verification has been automatically disabled on your form + as you have reached the free tier limit of <%= smsVerificationLimit %> + responses. +

+ +

+ We would have previously notified all form admins upon your account + reaching 2500, 5000 and 7500 responses while free SMS OTP verification was + enabled. +

+

+ If you require SMS OTP verification for more than <%= smsVerificationLimit + %> responses, please + + arrange advance billing with us. + +

+

+ Important: 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. +

+

The FormSG Team

+ +