From 1c9bcee038d2851758b9d44c257835a8202badf8 Mon Sep 17 00:00:00 2001
From: Kishen Kumar <kishen1198@gmail.com>
Date: Thu, 25 Jan 2024 14:57:38 +0800
Subject: [PATCH] feat: disable tracking in transactional email (#2241)

* feat: disable tracking for transactional emails

* chore: update dockerfile

* chore: set default value of disable_tracking to false

* chore: add comments to Dockerfile

* chore: add comment to explain use of noTrackingConfigSet

* test: update failing tests

---------

Co-authored-by: KishenKumarrrrr <kishen@open.gov.sg>
---
 backend/Dockerfile                            |  5 +-
 backend/src/core/config.ts                    |  6 +++
 backend/src/core/services/mail.service.ts     |  3 +-
 .../email-transactional.middleware.ts         |  3 ++
 .../routes/email-transactional.routes.ts      |  1 +
 .../tests/email-transactional.routes.test.ts  |  5 +-
 .../services/email-transactional.service.ts   |  3 ++
 shared/src/clients/mail-client.class/index.ts | 50 +++++++++++++++----
 worker/Dockerfile                             |  5 +-
 9 files changed, 66 insertions(+), 15 deletions(-)

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<ConfigSchema> = 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<void> {
   // 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<string, any>
+  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