diff --git a/backend/Dockerfile b/backend/Dockerfile index 7127ef23c..ffd68de25 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,7 +21,10 @@ RUN apk update && apk upgrade && apk add --no-cache --virtual builds-deps build- RUN apk add jq -RUN python3 -m pip install awscli +# There was a breaking change in the base image used that prevents us from installing via pip +# Instead of activating a virtual env, this is a simpler workaround +# https://github.com/python/cpython/issues/102134 +RUN apk add --no-cache aws-cli RUN aws configure set default.region ap-southeast-1 diff --git a/backend/src/core/config.ts b/backend/src/core/config.ts index 832d0b0eb..8343529bb 100644 --- a/backend/src/core/config.ts +++ b/backend/src/core/config.ts @@ -83,6 +83,7 @@ interface ConfigSchema { } mailFrom: string mailConfigurationSet: string + noTrackingMailConfigurationSet: string mailVia: string mailDefaultRate: number transactionalEmail: { @@ -468,6 +469,11 @@ const config: Config = convict({ default: 'postman-email-open', env: 'BACKEND_SES_CONFIGURATION_SET', }, + noTrackingMailConfigurationSet: { + doc: 'AWS SES Configuration set that does not include open and read tracking', + default: 'postman-email-no-tracking', + env: 'BACKEND_SES_NO_TRACKING_CONFIGURATION_SET', + }, mailVia: { doc: 'Text to appended to custom sender name', default: 'via Postman', diff --git a/backend/src/core/services/mail.service.ts b/backend/src/core/services/mail.service.ts index 2d72b3810..7ed68c84c 100644 --- a/backend/src/core/services/mail.service.ts +++ b/backend/src/core/services/mail.service.ts @@ -5,7 +5,8 @@ const mailClient = new MailClient( config.get('mailOptions'), config.get('emailCallback.hashSecret'), config.get('emailFallback.activate') ? config.get('mailFrom') : undefined, - config.get('mailConfigurationSet') + config.get('mailConfigurationSet'), + config.get('noTrackingMailConfigurationSet') ) export const MailService = { diff --git a/backend/src/email/middlewares/email-transactional.middleware.ts b/backend/src/email/middlewares/email-transactional.middleware.ts index 5d5726b32..15ebf3294 100644 --- a/backend/src/email/middlewares/email-transactional.middleware.ts +++ b/backend/src/email/middlewares/email-transactional.middleware.ts @@ -67,6 +67,7 @@ export const InitEmailTransactionalMiddleware = ( tag?: string cc?: string[] bcc?: string[] + disable_tracking?: boolean } type ReqBodyWithId = ReqBody & { emailMessageTransactionalId: string } @@ -210,6 +211,7 @@ export const InitEmailTransactionalMiddleware = ( cc, bcc, emailMessageTransactionalId, // added by saveMessage middleware + disable_tracking: disableTracking, } = req.body try { @@ -275,6 +277,7 @@ export const InitEmailTransactionalMiddleware = ( ? bcc.filter((c) => !blacklistedRecipients.includes(c)) : undefined, emailMessageTransactionalId, + disableTracking, }) emailMessageTransactional.set( 'status', diff --git a/backend/src/email/routes/email-transactional.routes.ts b/backend/src/email/routes/email-transactional.routes.ts index 9421a825a..ff930bd45 100644 --- a/backend/src/email/routes/email-transactional.routes.ts +++ b/backend/src/email/routes/email-transactional.routes.ts @@ -62,6 +62,7 @@ export const InitEmailTransactionalRoute = ( .items( Joi.string().trim().email().options({ convert: true }).lowercase() ), + disable_tracking: Joi.boolean().default(false), }), } const getByIdValidator = { diff --git a/backend/src/email/routes/tests/email-transactional.routes.test.ts b/backend/src/email/routes/tests/email-transactional.routes.test.ts index 68e40ea4f..107b33dfc 100644 --- a/backend/src/email/routes/tests/email-transactional.routes.test.ts +++ b/backend/src/email/routes/tests/email-transactional.routes.test.ts @@ -368,7 +368,7 @@ describe(`${emailTransactionalRoute}/send`, () => { ).id.toString(), attachments: undefined, }, - { extraSmtpHeaders: { isTransactional: true } } + { disableTracking: false, extraSmtpHeaders: { isTransactional: true } } ) }) test('Should throw a 400 error if the body size is too large (JSON payload)', async () => { @@ -616,6 +616,7 @@ describe(`${emailTransactionalRoute}/send`, () => { ], }, { + disableTracking: false, extraSmtpHeaders: { isTransactional: true }, } ) @@ -692,6 +693,7 @@ describe(`${emailTransactionalRoute}/send`, () => { ], }, { + disableTracking: false, extraSmtpHeaders: { isTransactional: true }, } ) @@ -825,6 +827,7 @@ describe(`${emailTransactionalRoute}/send`, () => { ], }, { + disableTracking: false, extraSmtpHeaders: { isTransactional: true }, } ) diff --git a/backend/src/email/services/email-transactional.service.ts b/backend/src/email/services/email-transactional.service.ts index f299d3d5d..fb7afb79d 100644 --- a/backend/src/email/services/email-transactional.service.ts +++ b/backend/src/email/services/email-transactional.service.ts @@ -41,6 +41,7 @@ async function sendMessage({ cc, bcc, emailMessageTransactionalId, + disableTracking, }: { subject: string body: string @@ -51,6 +52,7 @@ async function sendMessage({ cc?: string[] bcc?: string[] emailMessageTransactionalId: string + disableTracking?: boolean }): Promise { // TODO: flagging this coupling for future refactoring: // currently, we are using EmailTemplateService to sanitize both tx emails and campaign emails @@ -99,6 +101,7 @@ async function sendMessage({ // receive from SES, but not saving to DB const isEmailSent = await EmailService.sendEmail(mailToSend, { extraSmtpHeaders: { isTransactional: true }, + disableTracking, }) if (!isEmailSent) { throw new Error('Failed to send transactional email') diff --git a/shared/src/clients/mail-client.class/index.ts b/shared/src/clients/mail-client.class/index.ts index 3cb22cfee..552b38d9b 100644 --- a/shared/src/clients/mail-client.class/index.ts +++ b/shared/src/clients/mail-client.class/index.ts @@ -10,19 +10,29 @@ export * from './interfaces' export type SendEmailOpts = { extraSmtpHeaders: Record + disableTracking?: boolean } export default class MailClient { private mailer: nodemailer.Transporter private email: string private hashSecret: string - private configSet: string | undefined + private defaultConfigSet: string | undefined + /* + The AWS SES events to be tracked are defined in configuration sets within the AWS console. + When an email is sent, we specify the configuration set to be used by setting "X-SES-CONFIGURATION-SET" in the API call header. + + There is no option to turn off tracking via parameters in the API call, it can only be configured through a configuration set. + Thus, we need multiple configuration sets to toggle the tracking feature for read and open receipts. + */ + private noTrackingConfigSet: string | undefined constructor( credentials: MailCredentials, hashSecret: string, email?: string, - configSet?: string + defaultConfigSet?: string, + noTrackingConfigSet?: string ) { const { host, port, auth } = credentials this.hashSecret = hashSecret @@ -35,7 +45,8 @@ export default class MailClient { pass: auth.pass, }, }) - this.configSet = configSet + this.defaultConfigSet = defaultConfigSet + this.noTrackingConfigSet = noTrackingConfigSet } public sendMail( @@ -61,14 +72,7 @@ export default class MailClient { let headers: any = { [REFERENCE_ID_HEADER]: JSON.stringify(xSmtpHeader), } - if (this.configSet) { - headers = { - ...headers, - // Specify this to configure callback endpoint for notifications other - // than delivery and bounce through SES configuration set - [CONFIGURATION_SET_HEADER]: this.configSet, - } - } + headers = this.setSesConfigurationHeader(headers, option?.disableTracking) if (input.unsubLink) { headers = { ...headers, @@ -96,4 +100,28 @@ export default class MailClient { }) }) } + + private setSesConfigurationHeader( + headers: object, + disableTracking: boolean | undefined + ): object { + // 1. If there is no default config set, we will not set any configuration header + if (!this.defaultConfigSet) { + return headers + } + // 2. If the user wants to disable tracking and there is a no tracking configuration, we set it + if (disableTracking && this.noTrackingConfigSet) { + return { + ...headers, + // Configuration header does not include open and read notification + [CONFIGURATION_SET_HEADER]: this.noTrackingConfigSet, + } + } + // 3. Otherwise, we will use the default tracking SES configuration set + return { + ...headers, + // Configuration header includes open and read notification + [CONFIGURATION_SET_HEADER]: this.defaultConfigSet, + } + } } diff --git a/worker/Dockerfile b/worker/Dockerfile index f043b93bc..e7667fbb5 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -21,7 +21,10 @@ RUN apk update && apk upgrade && apk add --no-cache --virtual builds-deps build- RUN apk add jq -RUN python3 -m pip install awscli +# There was a breaking change in the base image used that prevents us from installing via pip +# Instead of activating a virtual env, this is a simpler workaround +# https://github.com/python/cpython/issues/102134 +RUN apk add --no-cache aws-cli RUN aws configure set default.region ap-southeast-1