From 3c765b5a9a822b3ac834d5c9c13fe456e178cdfe Mon Sep 17 00:00:00 2001 From: ogp-weeloong Date: Mon, 25 May 2026 16:24:38 +0800 Subject: [PATCH] feat(postman): redirect test-run emails to the test runner's address Sending a "Send email" test step previously delivered to whatever recipients were configured on the step, which could spam unrelated people while a builder iterated on a template. The action now rewrites both `destinationEmail` and `destinationEmailCc` to `$.user.email` when `$.execution.testRun` is true, and surfaces a `testStepTooltip` on the Check step button so the user knows test emails will only go to their own address. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../actions/send-transactional-email.test.ts | 72 +++++++++++++++++-- .../actions/send-transactional-email/index.ts | 13 +++- packages/frontend/src/app-extensions/index.ts | 9 ++- .../check-step-button.tsx | 18 +++++ .../src/app-extensions/postman/index.ts | 11 +++ 5 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 packages/frontend/src/app-extensions/postman/actions/send-transactional-email/check-step-button.tsx create mode 100644 packages/frontend/src/app-extensions/postman/index.ts diff --git a/packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts b/packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts index 3cd38054c0..94e7189b3b 100644 --- a/packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts +++ b/packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts @@ -548,7 +548,7 @@ describe('send transactional email', () => { }) }) - it('should send to all recipients in test runs', async () => { + it('skips partial retry and sends only to the test runner in test runs', async () => { const recipients = [ 'recipient1@open.gov.sg', 'recipient2@open.gov.sg', @@ -573,11 +573,11 @@ describe('send transactional email', () => { }) $.execution.testRun = true await expect(sendTransactionalEmail.run($)).resolves.not.toThrow() - expect($.http.post).toBeCalledTimes(5) + expect($.http.post).toBeCalledTimes(1) expect($.setActionItem).toHaveBeenCalledWith({ raw: { - status: ['ACCEPTED', 'ACCEPTED', 'ACCEPTED', 'ACCEPTED', 'ACCEPTED'], - recipient: recipients, + status: ['ACCEPTED'], + recipient: [$.user.email], subject: 'test subject', body: 'test body', from: 'jack', @@ -623,6 +623,70 @@ describe('send transactional email', () => { }) }) + describe('test-run recipient override', () => { + it("redirects email to the test runner's address and drops CCs when testRun is true", async () => { + $.step.parameters.destinationEmail = 'recipient@example.com' + $.step.parameters.destinationEmailCc = 'cc@example.com' + $.user.email = 'me@example.com' + $.execution.testRun = true + + await expect(sendTransactionalEmail.run($)).resolves.not.toThrow() + expect($.http.post).toBeCalledTimes(1) + expect($.setActionItem).toHaveBeenCalledWith({ + raw: { + status: ['ACCEPTED'], + recipient: ['me@example.com'], + subject: 'test subject', + body: 'test body', + from: 'jack', + reply_to: 'replyTo@open.gov.sg', + }, + }) + }) + + it('does not override recipients on a normal (non-test) run', async () => { + const recipients = ['recipient1@open.gov.sg', 'recipient2@open.gov.sg'] + const ccRecipients = ['cc1@open.gov.sg', 'cc2@open.gov.sg'] + $.step.parameters.destinationEmail = recipients.join(',') + $.step.parameters.destinationEmailCc = ccRecipients.join(',') + $.user.email = 'me@example.com' + $.execution.testRun = false + + await expect(sendTransactionalEmail.run($)).resolves.not.toThrow() + expect($.setActionItem).toHaveBeenCalledWith({ + raw: { + status: ['ACCEPTED', 'ACCEPTED'], + recipient: recipients, + subject: 'test subject', + body: 'test body', + cc: ccRecipients, + from: 'jack', + reply_to: 'replyTo@open.gov.sg', + }, + }) + }) + + it("collapses multiple configured recipients to the test runner's address in test mode", async () => { + $.step.parameters.destinationEmail = 'a@x.com, b@x.com, c@x.com' + $.step.parameters.destinationEmailCc = 'cc1@x.com, cc2@x.com' + $.user.email = 'me@example.com' + $.execution.testRun = true + + await expect(sendTransactionalEmail.run($)).resolves.not.toThrow() + expect($.http.post).toBeCalledTimes(1) + expect($.setActionItem).toHaveBeenCalledWith({ + raw: { + status: ['ACCEPTED'], + recipient: ['me@example.com'], + subject: 'test subject', + body: 'test body', + from: 'jack', + reply_to: 'replyTo@open.gov.sg', + }, + }) + }) + }) + it('should send two emails if there are blacklisted recipients and invalid attachments', async () => { const recipients = ['recipient1@open.gov.sg', 'recipient2@open.gov.sg'] $.step.parameters.destinationEmail = recipients.join(',') diff --git a/packages/backend/src/apps/postman/actions/send-transactional-email/index.ts b/packages/backend/src/apps/postman/actions/send-transactional-email/index.ts index 40d3eb0c61..5d0038da2f 100644 --- a/packages/backend/src/apps/postman/actions/send-transactional-email/index.ts +++ b/packages/backend/src/apps/postman/actions/send-transactional-email/index.ts @@ -93,9 +93,18 @@ const action: IRawAction = { replyTo, attachments = [], } = $.step.parameters + // During test runs, redirect all recipients to the test runner's own + // email so test sends never spam unrelated addresses configured on the + // step. + const effectiveDestinationEmail = $.execution.testRun + ? $.user.email + : destinationEmail + const effectiveDestinationEmailCc = $.execution.testRun + ? undefined + : destinationEmailCc const result = transactionalEmailSchema.safeParse({ - destinationEmail, - destinationEmailCc, + destinationEmail: effectiveDestinationEmail, + destinationEmailCc: effectiveDestinationEmailCc, senderName, subject, body, diff --git a/packages/frontend/src/app-extensions/index.ts b/packages/frontend/src/app-extensions/index.ts index 53942d2aba..5bed618766 100644 --- a/packages/frontend/src/app-extensions/index.ts +++ b/packages/frontend/src/app-extensions/index.ts @@ -1,7 +1,12 @@ +import postmanExtensions from './postman' import type { FrontEndAppExtension } from './types' -// Nothing for now -const APP_EXTENSIONS: Record = {} +/** + * Keys are `${appKey}-${stepKey}`. + */ +const APP_EXTENSIONS: Record = { + ...postmanExtensions, +} export function getExtension( appKey?: string, diff --git a/packages/frontend/src/app-extensions/postman/actions/send-transactional-email/check-step-button.tsx b/packages/frontend/src/app-extensions/postman/actions/send-transactional-email/check-step-button.tsx new file mode 100644 index 0000000000..7842ce8034 --- /dev/null +++ b/packages/frontend/src/app-extensions/postman/actions/send-transactional-email/check-step-button.tsx @@ -0,0 +1,18 @@ +import { Tooltip } from '@chakra-ui/react' + +import type { CheckStepButtonExtensionProps } from '@/app-extensions/types' + +function CheckStepButtonExtension({ children }: CheckStepButtonExtensionProps) { + return ( + + {children} + + ) +} + +export default CheckStepButtonExtension diff --git a/packages/frontend/src/app-extensions/postman/index.ts b/packages/frontend/src/app-extensions/postman/index.ts new file mode 100644 index 0000000000..2f2589dc91 --- /dev/null +++ b/packages/frontend/src/app-extensions/postman/index.ts @@ -0,0 +1,11 @@ +import type { FrontEndAppExtension } from '@/app-extensions/types' + +import CheckStepButton from './actions/send-transactional-email/check-step-button' + +const extensions = { + 'postman-sendTransactionalEmail': { + CheckStepButton: CheckStepButton, + }, +} satisfies Record + +export default extensions