Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"morgan": "^1.10.0",
"multer": "2.1.1",
"nanoid": "3.3.8",
"nodemailer": "^8.0.7",
"objection": "^3.0.0",
"openid-client": "5.4.0",
"p-limit": "3.1.0",
Expand Down Expand Up @@ -117,6 +118,7 @@
"@types/luxon": "^2.4.0",
"@types/morgan": "^1.9.3",
"@types/multer": "2.0.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.6.1",
"axios-mock-adapter": "^2.1.0",
"cpy-cli": "^7.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const mocks = vi.hoisted(() => ({
createInvalidAttachmentsMessage: vi.fn(() => 'test invalid attachment body'),
getLdFlagValue: vi.fn(async () => [] as string[]),
sesSend: vi.fn(async () => ({})),
sendEmailViaSes: vi.fn(async () => {
return
}),
getSuppressedEmails: vi.fn(async () => [] as string[]),
}))

Expand All @@ -38,6 +41,7 @@ vi.mock('@/helpers/ses-email-helper', async () => {
return {
...actual,
getSesClient: () => ({ send: mocks.sesSend }),
sendEmailViaSes: mocks.sendEmailViaSes,
}
})

Expand Down Expand Up @@ -723,40 +727,49 @@ describe('send transactional email', () => {

describe('SES routing via ses_enabled_domains flag', () => {
it('routes to SES when all recipients are in flagged domains', async () => {
mocks.getLdFlagValue.mockImplementationOnce(async () => ['open.gov.sg'])
mocks.getLdFlagValue.mockImplementation(async () => ['open.gov.sg'])
$.step.parameters.destinationEmail = 'a@open.gov.sg,b@open.gov.sg'
$.step.parameters.attachments = []

await expect(sendTransactionalEmail.run($)).resolves.not.toThrow()

expect($.http.post).not.toHaveBeenCalled()
expect(mocks.sesSend).toHaveBeenCalledTimes(2)
expect(mocks.sendEmailViaSes).toHaveBeenCalledTimes(2)
})

it('falls back to Postman when any recipient is outside flagged domains', async () => {
mocks.getLdFlagValue.mockImplementationOnce(async () => ['open.gov.sg'])
mocks.getLdFlagValue.mockImplementation(async () => ['open.gov.sg'])
$.step.parameters.destinationEmail = 'a@open.gov.sg,b@gmail.com'
$.step.parameters.attachments = []

await expect(sendTransactionalEmail.run($)).resolves.not.toThrow()

expect(mocks.sesSend).not.toHaveBeenCalled()
expect(mocks.sendEmailViaSes).not.toHaveBeenCalled()
expect($.http.post).toHaveBeenCalledTimes(2)
})

it('falls back to Postman when batch has attachments even if all domains qualify', async () => {
mocks.getLdFlagValue.mockImplementationOnce(async () => ['*'])
it('routes to SES even when batch has attachments (denylist applied)', async () => {
mocks.getLdFlagValue.mockImplementation(async () => ['*'])
$.step.parameters.destinationEmail = 'a@open.gov.sg'
mocks.filterAttachments.mockReturnValueOnce({
attachmentFiles: [{ fileName: 'f.txt', data: new Uint8Array([0]) }],
attachmentFiles: [
{ fileName: 'safe.txt', data: new Uint8Array([0]) },
{ fileName: 'malware.exe', data: new Uint8Array([0]) },
],
invalidAttachments: [],
submissionId: null,
})

await expect(sendTransactionalEmail.run($)).resolves.not.toThrow()

expect(mocks.sesSend).not.toHaveBeenCalled()
expect($.http.post).toHaveBeenCalledTimes(1)
expect($.http.post).not.toHaveBeenCalled()
expect(mocks.sendEmailViaSes).toHaveBeenCalledTimes(1)
// Denylist filtered out the .exe; only the safe attachment is passed.
expect(mocks.sendEmailViaSes).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [{ fileName: 'safe.txt', data: new Uint8Array([0]) }],
}),
)
})

it('uses Postman when ses_enabled_domains flag is empty (default kill switch)', async () => {
Expand All @@ -765,7 +778,7 @@ describe('send transactional email', () => {

await expect(sendTransactionalEmail.run($)).resolves.not.toThrow()

expect(mocks.sesSend).not.toHaveBeenCalled()
expect(mocks.sendEmailViaSes).not.toHaveBeenCalled()
expect($.http.post).toHaveBeenCalledTimes(1)
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { fromZodError } from 'zod-validation-error'
import StepError from '@/errors/step'
import { TableVariableMarker } from '@/helpers/compute-parameters'
import { formatTable } from '@/helpers/format-table-variable'
import { getLdFlagValue } from '@/helpers/launch-darkly'
import logger from '@/helpers/logger'
import { shouldUseSes } from '@/helpers/ses-email-helper'
import Step from '@/models/step'

import { dataOutSchema } from '../../common/data-out-validator'
Expand Down Expand Up @@ -141,6 +143,18 @@ const action: IRawAction = {
// Don't do partial retry in test runs! always send to all recipients
!$.execution.testRun

// Compute provider routing BEFORE filtering attachments so we can apply
// the correct allowlist/denylist. Mirrors the all-or-nothing routing
// decision in sendTransactionalEmails.
const sesEnabledDomains = await getLdFlagValue<string[]>(
'ses_enabled_domains',
null,
[],
)
const useSes = recipientsToSend.every((r) =>
shouldUseSes(r, sesEnabledDomains),
)

const {
attachmentFiles,
invalidAttachments,
Expand All @@ -151,6 +165,7 @@ const action: IRawAction = {
attachmentsList: result.data.attachments,
isPartialRetry,
lastExecutionStep,
useSes,
})

if (isPartialRetry) {
Expand Down
65 changes: 33 additions & 32 deletions packages/backend/src/apps/postman/common/email-helper.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { IHttpClient } from '@plumber/types'

import { SendEmailCommand } from '@aws-sdk/client-sesv2'
import FormData from 'form-data'
import { sortBy } from 'lodash'

import appConfig from '@/config/app'
import HttpError from '@/errors/http'
import { getLdFlagValue } from '@/helpers/launch-darkly'
import logger from '@/helpers/logger'
import { incrementMetric } from '@/helpers/metrics'
import { getSesClient, shouldUseSes } from '@/helpers/ses-email-helper'
import {
filterSesAttachments,
sendEmailViaSes,
shouldUseSes,
} from '@/helpers/ses-email-helper'
import EmailSuppressionEntry from '@/models/email-suppression-entry'

import {
Expand Down Expand Up @@ -135,31 +137,16 @@ async function sendViaSes(
recipientEmail: string,
email: Email,
): Promise<PostmanPromiseFulfilled> {
const client = getSesClient()
const fromAddress = `${email.senderName} <${appConfig.ses.fromAddress}>`

await client.send(
new SendEmailCommand({
FromEmailAddress: fromAddress,
Destination: {
ToAddresses: [recipientEmail],
...(email.ccList?.length && { CcAddresses: email.ccList }),
},
Content: {
Simple: {
Subject: { Data: email.subject, Charset: 'UTF-8' },
Body: {
Html: { Data: email.body, Charset: 'UTF-8' },
},
},
},
...(email.replyTo && { ReplyToAddresses: [email.replyTo] }),
...(appConfig.ses.configurationSet && {
ConfigurationSetName: appConfig.ses.configurationSet,
}),
}),
)
incrementMetric('ses.email.sent')
await sendEmailViaSes({
subject: email.subject,
body: email.body,
recipient: recipientEmail,
replyTo: email.replyTo,
cc: email.ccList,
attachments: email.attachments,
})

// TODO: remove this log once the SES rollout is verified and stable.
logger.info('Email sent via SES', {
Expand Down Expand Up @@ -197,12 +184,26 @@ export async function sendTransactionalEmails(
[],
)

// Use SES only if ALL recipients are in SES-enabled domains and no
// attachments (SES Phase 1 does not support attachments). Otherwise,
// send everything via Postman to avoid mixed error-handling paths.
const useSes =
!email.attachments?.length &&
recipients.every((r) => shouldUseSes(r, sesEnabledDomains))
// Use SES only if ALL recipients are in SES-enabled domains. Mixed
// batches go entirely through Postman to keep error handling simple.
// SES now handles attachments via raw MIME (see sendEmailViaSes).
const useSes = recipients.every((r) => shouldUseSes(r, sesEnabledDomains))

// When routing via SES, filter out blocked attachment types up-front so
// every recipient in the batch gets a consistent payload. The denylist
// is applied defensively inside sendEmailViaSes as well; doing it here
// also lets us surface the dropped attachments via the existing
// sendInvalidAttachmentsEmail flow if needed downstream.
if (useSes && email.attachments?.length) {
const { allowed, blocked } = filterSesAttachments(email.attachments)
if (blocked.length > 0) {
logger.warn('Blocked attachment(s) filtered before SES batch send', {
event: 'ses-blocked-attachment-batch',
blocked,
})
}
email = { ...email, attachments: allowed }
}

// Pre-send suppression check (SES path only)
let suppressedSet = new Set<string>()
Expand Down
25 changes: 23 additions & 2 deletions packages/backend/src/apps/postman/common/parameters-helper.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import { IExecutionStep, IGlobalVariable, IJSONObject } from '@plumber/types'

import { COMMON_S3_BUCKET, getObjectFromS3Id } from '@/helpers/s3'
import { isBlockedAttachment } from '@/helpers/ses-email-helper'
import Flow from '@/models/flow'

import { POSTMAN_ACCEPTED_EXTENSIONS } from './constants'

/**
* Provider-aware attachment filter.
*
* - SES: anything not in the SES denylist (BLOCKED_ATTACHMENT_EXTENSIONS).
* Much broader — allows e.g. SVG, ZIP, JSON, WEBP, MP4.
* - Postman: extension must be in POSTMAN_ACCEPTED_EXTENSIONS (35-type
* allowlist enforced by the Postman API).
*/
function isAttachmentAllowed(fileName: string, useSes: boolean): boolean {
const ext = fileName.split('.').pop()?.toLowerCase()
if (!ext) {
return false
}
if (useSes) {
return !isBlockedAttachment(fileName)
}
return POSTMAN_ACCEPTED_EXTENSIONS.includes(ext)
}

export async function getDefaultReplyTo(flowId: string): Promise<string> {
const flow = await Flow.query()
.findById(flowId)
Expand All @@ -18,11 +38,13 @@ export async function filterAttachments({
attachmentsList,
isPartialRetry,
lastExecutionStep,
useSes = false,
}: {
$: IGlobalVariable
attachmentsList: string[]
isPartialRetry: boolean
lastExecutionStep: IExecutionStep | null
useSes?: boolean
}) {
let submissionId: string | null = null
const invalidAttachments: string[] = []
Expand Down Expand Up @@ -63,14 +85,13 @@ export async function filterAttachments({
// maliciously/ manually injected by another user who does not have access to this attachment
const obj = await getObjectFromS3Id(attachment, { flowId: $.flow.id })
const fileName = obj.name
const fileType = obj.name.split('.').pop()?.toLowerCase()

if (isRetryWithoutAttachments) {
invalidAttachments.push(fileName)
return
}

if (!fileType || !POSTMAN_ACCEPTED_EXTENSIONS.includes(fileType)) {
if (!isAttachmentAllowed(fileName, useSes)) {
invalidAttachments.push(fileName)

if (submissionId === null) {
Expand Down
24 changes: 24 additions & 0 deletions packages/backend/src/helpers/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ export const MALWARE_SCAN_FAILURE = [
MALWARE_SCAN_STATUS.FAILED,
]

// Union of types accepted at upload time. The actual filter applied at
// send time depends on the email provider:
// - Postman path: see POSTMAN_ACCEPTED_EXTENSIONS (allowlist)
// - SES path: see BLOCKED_ATTACHMENT_EXTENSIONS (denylist)
// Files that pass upload but fail the send-time provider filter trigger
// the existing "unsupported attachment" notification email.
export const ACCEPTED_FILE_TYPES = [
// --- Postman-compatible types ---
'text/plain', // .txt, .asc
'video/x-msvideo', // .avi
'image/bmp', // .bmp
Expand Down Expand Up @@ -69,6 +76,23 @@ export const ACCEPTED_FILE_TYPES = [
'image/tiff', // .tif, .tiff
'video/x-ms-wmv', // .wmv
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
// --- SES-only types (Postman API will still reject these at send time) ---
'image/svg+xml', // .svg
'image/webp', // .webp
'image/heic', // .heic
'image/heif', // .heif
'image/avif', // .avif
'application/zip', // .zip
'application/x-7z-compressed', // .7z
'application/vnd.rar', // .rar
'application/json', // .json
'application/xml', // .xml
'text/calendar', // .ics — meeting invites
'text/markdown', // .md
'message/rfc822', // .eml — saved emails
'application/vnd.ms-outlook', // .msg — Outlook saved emails
'video/mp4', // .mp4
'video/webm', // .webm
]
export const MAX_FILE_SIZE = 1024 * 1024 * 10 // 10MB

Expand Down
Loading
Loading