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: use 2 nodemailer clients #5369

Merged
merged 9 commits into from
Nov 15, 2022
6 changes: 6 additions & 0 deletions __tests__/e2e/setup/.test-env
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ SESSION_SECRET=sandcrawler-138577
SES_PORT=1025
SES_HOST=0.0.0.0

# TODO #130 Remove these when SES migration is over (opengovsg/formsg-private#130)
SES_PORT_US=1025
SES_HOST_US=0.0.0.0
SES_PORT_SG=1025
SES_HOST_SG=0.0.0.0

SERVICES=s3

IMAGE_S3_BUCKET=local-image-bucket
Expand Down
67 changes: 59 additions & 8 deletions src/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ const dbConfig: DbConfig = {
},
}

const mailConfig: MailConfig = (function () {
// TODO #130 Delete this mail config once SES migration is over (opengovsg/formsg-private#130)
const mailConfig_us: MailConfig = (function () {
const mailFrom = basicVars.mail.from
const official = basicVars.mail.official
const mailer = {
Expand All @@ -133,12 +134,12 @@ const mailConfig: MailConfig = (function () {
let transporter: Mail
if (!isDev) {
const options: SMTPPool.Options = {
host: prodOnlyVars.host,
host: prodOnlyVars.host_us,
auth: {
user: prodOnlyVars.user,
pass: prodOnlyVars.pass,
user: prodOnlyVars.user_us,
pass: prodOnlyVars.pass_us,
},
port: prodOnlyVars.port,
port: prodOnlyVars.port_us,
// Options as advised from https://nodemailer.com/usage/bulk-mail/
// pool connections instead of creating fresh one for each email
pool: true,
Expand All @@ -155,8 +156,56 @@ const mailConfig: MailConfig = (function () {
transporter = nodemailer.createTransport(options)
} else {
transporter = nodemailer.createTransport({
port: prodOnlyVars.port,
host: prodOnlyVars.host,
port: prodOnlyVars.port_us,
host: prodOnlyVars.host_us,
ignoreTLS: true,
})
}

return {
mailFrom,
official,
mailer,
transporter,
}
})()

// TODO #130 Use this mail config when SES migration is over, after renaming env vars back to without _sg suffix (opengovsg/formsg-private#130)
const mailConfig_sg: MailConfig = (function () {
const mailFrom = basicVars.mail.from
const official = basicVars.mail.official
const mailer = {
from: `${basicVars.appConfig.title} <${mailFrom}>`,
}

// Creating mail transport
let transporter: Mail
if (!isDev) {
const options: SMTPPool.Options = {
host: prodOnlyVars.host_sg,
auth: {
user: prodOnlyVars.user_sg,
pass: prodOnlyVars.pass_sg,
},
port: prodOnlyVars.port_sg,
// Options as advised from https://nodemailer.com/usage/bulk-mail/
// pool connections instead of creating fresh one for each email
pool: true,
maxMessages: basicVars.mail.maxMessages,
maxConnections: basicVars.mail.maxConnections,
socketTimeout: basicVars.mail.socketTimeout,
// If set to true then logs to console. If value is not set or is false
// then nothing is logged.
logger: basicVars.mail.logger,
// If set to true, then logs SMTP traffic, otherwise logs only transaction
// events.
debug: basicVars.mail.debug,
}
transporter = nodemailer.createTransport(options)
} else {
transporter = nodemailer.createTransport({
port: prodOnlyVars.port_sg,
host: prodOnlyVars.host_sg,
ignoreTLS: true,
})
}
Expand Down Expand Up @@ -207,7 +256,9 @@ const config: Config = {
app: basicVars.appConfig,
db: dbConfig,
aws: awsConfig,
mail: mailConfig,
mail_us: mailConfig_us,
mail_sg: mailConfig_sg,
nodemailer_sg_warmup_start_date: prodOnlyVars.nodemailer_sg_warmup_start_date,
cookieSettings,
isDev,
nodeEnv,
Expand Down
50 changes: 42 additions & 8 deletions src/app/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,29 +382,30 @@ export const optionalVarsSchema: Schema<IOptionalVarsSchema> = {
}

export const prodOnlyVarsSchema: Schema<IProdOnlyVarsSchema> = {
port: {
// TODO #130 Delete these env vars when SES migration is over (opengovsg/formsg-private#130)
port_us: {
doc: 'SMTP port number',
format: 'port',
default: null,
env: 'SES_PORT',
env: 'SES_PORT_US',
},
host: {
host_us: {
doc: 'SMTP hostname',
format: String,
default: null,
env: 'SES_HOST',
env: 'SES_HOST_US',
},
user: {
user_us: {
doc: 'SMTP username',
format: String,
default: null,
env: 'SES_USER',
env: 'SES_USER_US',
},
pass: {
pass_us: {
doc: 'SMTP password',
format: String,
default: null,
env: 'SES_PASS',
env: 'SES_PASS_US',
sensitive: true,
},
dbHost: {
Expand Down Expand Up @@ -436,6 +437,39 @@ export const prodOnlyVarsSchema: Schema<IProdOnlyVarsSchema> = {
env: 'DB_HOST',
sensitive: true,
},
// TODO #130 Rename these env vars to without the _sg suffix when SES migration is over (opengovsg/formsg-private#130)
port_sg: {
doc: 'SMTP port number',
format: 'port',
default: null,
env: 'SES_PORT_SG',
},
host_sg: {
doc: 'SMTP hostname',
format: String,
default: null,
env: 'SES_HOST_SG',
},
user_sg: {
doc: 'SMTP username',
format: String,
default: null,
env: 'SES_USER_SG',
},
pass_sg: {
doc: 'SMTP password',
format: String,
default: null,
env: 'SES_PASS_SG',
sensitive: true,
},
// TODO #130 Remove this when SES migration is over (opengovsg/formsg-private#130)
nodemailer_sg_warmup_start_date: {
doc: 'Date where SG nodemailer client will start sending emails',
format: String,
default: '2099-01-01T23:59:59+08:00',
env: 'NODEMAILER_SG_WARMUP_START_DATE',
},
}

export const loadS3BucketUrlSchema = ({
Expand Down
8 changes: 6 additions & 2 deletions src/app/services/mail/__tests__/mail.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ describe('mail.service', () => {
beforeEach(() => sendMailSpy.mockReset())

const mailService = new MailService({
transporter: mockTransporter,
// Remove references to US when SES migration is over (opengovsg/formsg-private#130)
transporter_us: mockTransporter,
transporter_sg: mockTransporter,
senderMail: MOCK_SENDER_EMAIL,
officialMail: MOCK_SENDER_EMAIL,
appName: MOCK_APP_NAME,
Expand All @@ -67,7 +69,9 @@ describe('mail.service', () => {
it('should throw error when invalid senderMail param is passed', () => {
// Arrange
const invalidParams = {
transporter: mockTransporter,
// TODO #130 Remove references to US when SES migration is over (opengovsg/formsg-private#130)
transporter_us: mockTransporter,
transporter_sg: mockTransporter,
senderMail: 'notAnEmail',
}
// Act + Assert
Expand Down
55 changes: 45 additions & 10 deletions src/app/services/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ const DEFAULT_RETRY_PARAMS: MailServiceParams['retryParams'] = {
minTimeout: 5000,
}

// TODO #130 Delete references to US SES when SES migration is over (opengovsg/formsg-private#130)
const START_TS = new Date(config.nodemailer_sg_warmup_start_date).getTime()
const WARM_UP_DURATION = 6 * 7 * 24 * 60 * 60 * 1000 // 6 weeks

const getWarmUpThreshold = () => {
// if START_TS is an empty string or unparseable input, set threshold to 0
if (isNaN(START_TS)) {
return 0
}
const now = Date.now()
const elapsed = Math.max(0, Math.min(now - START_TS, WARM_UP_DURATION))
const linear = elapsed / WARM_UP_DURATION
return linear * linear
}

export class MailService {
/**
* The application name to be shown in some sent emails' fields such as mail
Expand All @@ -76,9 +91,13 @@ export class MailService {
*/
#appUrl: Required<MailServiceParams>['appUrl']
/**
* The transporter to be used to send mail.
* The transporter to be used to send mail (SES in US).
*/
#transporter_us: Required<MailServiceParams>['transporter_us']
/**
* The transporter to be used to send mail (SES in SG).
*/
#transporter: Required<MailServiceParams>['transporter']
#transporter_sg: Required<MailServiceParams>['transporter_sg']
/**
* The email string to denote the "from" field of the email.
*/
Expand All @@ -104,9 +123,10 @@ export class MailService {
constructor({
appName = config.app.title,
appUrl = config.app.appUrl,
transporter = config.mail.transporter,
senderMail = config.mail.mailFrom,
officialMail = config.mail.official,
transporter_us = config.mail_us.transporter,
transporter_sg = config.mail_sg.transporter,
senderMail = config.mail_us.mailFrom,
officialMail = config.mail_us.official,
retryParams = DEFAULT_RETRY_PARAMS,
}: MailServiceParams = {}) {
// Email validation
Expand All @@ -127,7 +147,8 @@ export class MailService {
this.#appUrl = appUrl
this.#senderMail = senderMail
this.#senderFromString = `${appName} <${senderMail}>`
this.#transporter = transporter
this.#transporter_us = transporter_us
this.#transporter_sg = transporter_sg
this.#officialMail = officialMail
this.#retryParams = retryParams
}
Expand Down Expand Up @@ -157,13 +178,27 @@ export class MailService {
})

try {
const info = await tracer.trace('nodemailer/sendMail', () =>
this.#transporter.sendMail(mail),
)
const rand = Math.random()
const threshold = getWarmUpThreshold()
const sendMailFromSG = rand < threshold
const info = await tracer.trace('nodemailer/sendMail', () => {
const span = tracer.scope().active()
if (span) span.setTag('ses.region', sendMailFromSG ? 'sg' : 'us')
return sendMailFromSG
? this.#transporter_sg.sendMail(mail)
: this.#transporter_us.sendMail(mail)
})

const logNodemailerMeta = {
action: 'Nodemailer evaluation done',
sendMailFromSG: sendMailFromSG,
mathRandom: rand,
threshold: threshold,
sendFromSGStartDate: START_TS,
}
logger.info({
message: `Mail successfully sent on attempt ${attemptNum}`,
meta: { ...logMeta, info },
meta: { ...logMeta, info, ...logNodemailerMeta },
})

return true
Expand Down
4 changes: 3 additions & 1 deletion src/app/services/mail/mail.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ export type SendAutoReplyEmailsArgs = {
autoReplyMailDatas: AutoReplyMailData[]
}

// TODO #130 Remove references to US SES when SES migration is over (opengovsg/formsg-private#130)
export type MailServiceParams = {
appName?: string
appUrl?: string
transporter?: Mail
transporter_us?: Mail
transporter_sg?: Mail
senderMail?: string
officialMail?: string
retryParams?: Partial<OperationOptions>
Expand Down
19 changes: 14 additions & 5 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ export type Config = {
app: AppConfig
db: DbConfig
aws: AwsConfig
mail: MailConfig
// TODO #130 Remove references to US SES when SES migration is over (opengovsg/formsg-private#130)
mail_us: MailConfig
mail_sg: MailConfig
nodemailer_sg_warmup_start_date: string

cookieSettings: SessionOptions['cookie']
// Consts
Expand Down Expand Up @@ -104,11 +107,17 @@ export type Config = {

// Interface
export interface IProdOnlyVarsSchema {
port: number
host: string
user: string
pass: string
// TODO #130 Remove references to US SES when SES migration is over (opengovsg/formsg-private#130)
port_us: number
host_us: string
user_us: string
pass_us: string
dbHost: string
port_sg: number
host_sg: string
user_sg: string
pass_sg: string
nodemailer_sg_warmup_start_date: string
}

export interface ICompulsoryVarsSchema {
Expand Down
6 changes: 6 additions & 0 deletions tests/.test-env
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ SESSION_SECRET=sandcrawler-138577
SES_PORT=1025
SES_HOST=0.0.0.0

# TODO #130 Remove these when SES migration is over (opengovsg/formsg-private#130)
SES_PORT_US=1025
SES_HOST_US=0.0.0.0
SES_PORT_SG=1025
SES_HOST_SG=0.0.0.0

SERVICES=s3

IMAGE_S3_BUCKET=local-image-bucket
Expand Down