Skip to content

Commit f8f02ff

Browse files
authored
[POSTMAN-UNSUPPORTED-ATTACHMENT-1] PLU-482: Filter unsupported Postman attachments before sending (#1019)
### TL;DR Added validation for email attachments in Postman to filter out unsupported file types and notify users. **Key things to note**: 1. Unsupported attachments: file type(s) that Postman does not accept 1. Password-protected: files that are password-protected and rejected by Postman 1. Postman throws the same "invalid_template" error regardless of whether they are unsupported or password-protected attachments _Retry to strip attachments will be in the next PR_ ### What changed? - Created a list of accepted file extensions for Postman email attachments (https://postman-v1.guides.gov.sg/email-api-guide/programmatic-email-api/send-email-api/attachments#supported-attachment-file-types) - Implemented a filtering mechanism to check attachments before sending emails - Added functionality to send notification emails to users when unsupported attachments are detected - Enhanced error handling to provide clear guidance when attachments are removed - Created a form admin link to help users access their original attachments - Update error message for password-protected files - since we use the same list as postman to filter attachments, invalid_template should only throw when users attempt to send password-protected attachments ### Why make this change? Postman has restrictions on which file types can be sent as email attachments. Previously, users are unable to send emails when attachments include unsupported file types. This change improves the user experience by: 1. Proactively filtering out unsupported attachments 2. Clearly communicating which files were removed and why 3. Providing a way for users to access their original files through the form admin interface ### How to test? Pre-setup: prepare a form that accepts attachments and connect this form to your pipe + turn on notifications for every failed execution Make form(s) submission with varying types of attachments: 1. supported attachment 1. unsupported attachment e.g., `svg` 1. password-protected attachment There are 3 types of notification emails: 1. Error (failed) execution 1. Blacklisted recipient notification 1. Unsupported attachment notification **Test cases** - [ ] Blacklisted recipient (can use `[email protected]`) - [ ] Same behaviour as current implementation - [ ] Blacklisted recipient notification sent (same as current behaviour) - [ ] Unsupported attachment - [ ] Execution marked as partial success - [ ] Email sent without unsupported attachment - [ ] Unsupported attachment notification (new) sent with details of unsupported attachment, submission id, execution id, link to form admin - [ ] Execution page shows link and opens to form responses - [ ] Password-protected attachment - [ ] Execution fails with "Password-protected" error - [ ] Error notification sent - [ ] Execution page shows error with "Password-protected attachment(s)" - [ ] Blacklisted + Unsupported - [ ] Execution partial success - [ ] Email not sent to blacklisted recipient - [ ] Email sent to non-blacklisted recipient without the unsupported attachment - [ ] **TWO** notificiation email sent - [ ] Blacklisted recipient notification (same as current behaviour) - [ ] Unsupported attachment notification (new) - [ ] Blacklisted + Unsupported + Password-protected - [ ] Execution fails - [ ] Error notification sent - [ ] Unsupported + Password-protected - [ ] Execution fails - [ ] Error notification sent - [ ] Execution page shows error with "Password-protected attachment(s)" ### Screenshots **Execution with unsupported attachment** [Screen Recording 2025-06-04 at 11.29.32 AM.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/Wrwhm8Mmhv1Z2GwlSb7V/7ff6ca5a-5fe7-45e4-9938-d9420325b56a.mov" />](https://app.graphite.dev/media/video/Wrwhm8Mmhv1Z2GwlSb7V/7ff6ca5a-5fe7-45e4-9938-d9420325b56a.mov) ![Screenshot 2025-06-04 at 11.29.56 AM.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/Wrwhm8Mmhv1Z2GwlSb7V/1108a42f-63c2-4abd-bd32-1cab6c5a4104.png) ![Screenshot 2025-06-04 at 11.30.10 AM.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/Wrwhm8Mmhv1Z2GwlSb7V/79431763-e73b-4566-ac4c-4703b0b3408c.png) **Execution with unsupported attachment and blacklisted recipient** [Screen Recording 2025-06-04 at 11.32.02 AM.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/Wrwhm8Mmhv1Z2GwlSb7V/e4791c22-085b-4bc9-9847-393bf58a92c1.mov" />](https://app.graphite.dev/media/video/Wrwhm8Mmhv1Z2GwlSb7V/e4791c22-085b-4bc9-9847-393bf58a92c1.mov) **Password-protected attachments** ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/Wrwhm8Mmhv1Z2GwlSb7V/8e1b8252-bf56-4768-b377-fd4f9dd97e38.png)
1 parent cc603d7 commit f8f02ff

File tree

8 files changed

+356
-29
lines changed

8 files changed

+356
-29
lines changed

packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,16 @@ import sendTransactionalEmail from '../../actions/send-transactional-email'
1212
const mocks = vi.hoisted(() => ({
1313
getObjectFromS3Id: vi.fn(),
1414
getDefaultReplyTo: vi.fn(() => '[email protected]'),
15+
filterAttachments: vi.fn(() => {
16+
return {
17+
attachmentFiles: [],
18+
invalidAttachments: [],
19+
submissionId: null,
20+
}
21+
}),
1522
sendBlacklistEmail: vi.fn(),
23+
sendInvalidAttachmentsEmail: vi.fn(),
24+
createInvalidAttachmentsMessage: vi.fn(() => 'test invalid attachment body'),
1625
}))
1726

1827
vi.mock('@/helpers/s3', async () => {
@@ -28,13 +37,19 @@ vi.mock('@/helpers/s3', async () => {
2837

2938
vi.mock('../../common/parameters-helper', () => ({
3039
getDefaultReplyTo: mocks.getDefaultReplyTo,
40+
filterAttachments: mocks.filterAttachments,
3141
}))
3242

3343
vi.mock('../../common/send-blacklist-email', () => ({
3444
sendBlacklistEmail: mocks.sendBlacklistEmail,
3545
createRequestBlacklistFormLink: vi.fn(),
3646
}))
3747

48+
vi.mock('../../common/send-invalid-attachments-email', () => ({
49+
sendInvalidAttachmentsEmail: mocks.sendInvalidAttachmentsEmail,
50+
createInvalidAttachmentsMessage: mocks.createInvalidAttachmentsMessage,
51+
}))
52+
3853
describe('send transactional email', () => {
3954
let $: IGlobalVariable
4055

@@ -137,7 +152,7 @@ describe('send transactional email', () => {
137152
},
138153
errorStatusCode: 400,
139154
errorStatusText: 'Bad Request',
140-
stepErrorName: 'Unsupported attachment file type',
155+
stepErrorName: 'Password-protected attachment(s)',
141156
},
142157
])(
143158
'should throw step error for different postman errors',
@@ -570,4 +585,112 @@ describe('send transactional email', () => {
570585
},
571586
})
572587
})
588+
589+
it('should filter out invalid attachments and send notification email', async () => {
590+
const recipients = ['[email protected]', '[email protected]']
591+
$.step.parameters.destinationEmail = recipients.join(',')
592+
$.step.parameters.attachments = [
593+
's3:my-bucket:abcd/file 1.txt',
594+
's3:my-bucket:wxyz/file-2.svg',
595+
]
596+
mocks.filterAttachments.mockResolvedValueOnce({
597+
attachmentFiles: [],
598+
invalidAttachments: ['file-2.svg'],
599+
submissionId: 'abc',
600+
})
601+
await expect(sendTransactionalEmail.run($)).rejects.toThrowError(
602+
PartialStepError,
603+
)
604+
expect($.http.post).toBeCalledTimes(2)
605+
expect(mocks.sendInvalidAttachmentsEmail).toHaveBeenCalledWith({
606+
flowName: $.flow.name,
607+
flowId: $.flow.id,
608+
userEmail: $.user.email,
609+
executionId: $.execution.id,
610+
submissionId: 'abc',
611+
invalidAttachments: ['file-2.svg'],
612+
hasInvalidAttachments: true,
613+
})
614+
expect($.setActionItem).toHaveBeenCalledWith({
615+
raw: {
616+
status: ['ACCEPTED', 'ACCEPTED'],
617+
recipient: recipients,
618+
subject: 'test subject',
619+
body: 'test body',
620+
from: 'jack',
621+
reply_to: '[email protected]',
622+
},
623+
})
624+
})
625+
626+
it('should send two emails if there are blacklisted recipients and invalid attachments', async () => {
627+
const recipients = ['[email protected]', '[email protected]']
628+
$.step.parameters.destinationEmail = recipients.join(',')
629+
$.step.parameters.attachments = [
630+
's3:my-bucket:abcd/file 1.txt',
631+
's3:my-bucket:wxyz/file-2.svg',
632+
]
633+
634+
mocks.filterAttachments.mockResolvedValueOnce({
635+
attachmentFiles: [],
636+
invalidAttachments: ['file-2.svg'],
637+
submissionId: 'abc',
638+
})
639+
640+
$.http.post = vi
641+
.fn()
642+
.mockResolvedValueOnce({
643+
data: {
644+
params: {
645+
body: 'test body',
646+
subject: 'test subject',
647+
from: 'jack',
648+
reply_to: '[email protected]',
649+
},
650+
},
651+
})
652+
.mockRejectedValueOnce(
653+
new HttpError({
654+
response: {
655+
data: {
656+
code: 'invalid_template',
657+
message: 'Recipient email is blacklisted',
658+
},
659+
status: 400,
660+
statusText: 'Bad Request',
661+
},
662+
} as AxiosError),
663+
)
664+
665+
await expect(sendTransactionalEmail.run($)).rejects.toThrowError(
666+
PartialStepError,
667+
)
668+
expect($.http.post).toBeCalledTimes(2)
669+
expect($.setActionItem).toHaveBeenCalledWith({
670+
raw: {
671+
status: ['ACCEPTED', 'BLACKLISTED'],
672+
recipient: recipients,
673+
subject: 'test subject',
674+
body: 'test body',
675+
from: 'jack',
676+
reply_to: '[email protected]',
677+
},
678+
})
679+
expect(mocks.sendBlacklistEmail).toHaveBeenCalledWith({
680+
flowName: $.flow.name,
681+
flowId: $.flow.id,
682+
userEmail: $.user.email,
683+
executionId: $.execution.id,
684+
blacklistedRecipients: [recipients[1]],
685+
})
686+
expect(mocks.sendInvalidAttachmentsEmail).toHaveBeenCalledWith({
687+
flowName: $.flow.name,
688+
flowId: $.flow.id,
689+
userEmail: $.user.email,
690+
executionId: $.execution.id,
691+
submissionId: 'abc',
692+
invalidAttachments: ['file-2.svg'],
693+
hasInvalidAttachments: true,
694+
})
695+
})
573696
})

packages/backend/src/apps/postman/actions/send-transactional-email/index.ts

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { fromZodError } from 'zod-validation-error'
55

66
import StepError from '@/errors/step'
77
import logger from '@/helpers/logger'
8-
import { getObjectFromS3Id } from '@/helpers/s3'
98
import Step from '@/models/step'
109

1110
import { dataOutSchema } from '../../common/data-out-validator'
@@ -14,8 +13,12 @@ import {
1413
transactionalEmailFields,
1514
transactionalEmailSchema,
1615
} from '../../common/parameters'
17-
import { getDefaultReplyTo } from '../../common/parameters-helper'
16+
import {
17+
filterAttachments,
18+
getDefaultReplyTo,
19+
} from '../../common/parameters-helper'
1820
import { sendBlacklistEmail } from '../../common/send-blacklist-email'
21+
import { sendInvalidAttachmentsEmail } from '../../common/send-invalid-attachments-email'
1922
import { throwPostmanStepError } from '../../common/throw-errors'
2023

2124
const action: IRawAction = {
@@ -71,18 +74,8 @@ const action: IRawAction = {
7174
)
7275
}
7376

74-
const attachmentFiles = await Promise.all(
75-
result.data.attachments?.map(async (attachment) => {
76-
// We verify the flowId here to ensure that the attachment is from the same flow and not
77-
// maliciously/ manually injected by another user who does not have access to this attachment
78-
const obj = await getObjectFromS3Id(
79-
attachment,
80-
{ flowId: $.flow.id },
81-
$,
82-
)
83-
return { fileName: obj.name, data: obj.data }
84-
}),
85-
)
77+
const { attachmentFiles, invalidAttachments, submissionId } =
78+
await filterAttachments(result.data.attachments, $)
8679

8780
let recipientsToSend = result.data.destinationEmail
8881
/**
@@ -165,16 +158,27 @@ const action: IRawAction = {
165158
(_, i) => dataOut.status[i] === 'BLACKLISTED',
166159
)
167160

161+
const defaultSendEmailParams = {
162+
flowId: $.flow.id,
163+
flowName: $.flow.name,
164+
userEmail: $.user.email,
165+
executionId: $.execution.id,
166+
}
167+
const hasInvalidAttachments = invalidAttachments.length > 0
168+
const invalidAttachmentParams = {
169+
hasInvalidAttachments,
170+
submissionId,
171+
invalidAttachments,
172+
}
173+
168174
/**
169175
* Send blacklist notification email if any
176+
* If there are any invalid attachments, it will be included in this email
170177
*/
171178
if (blacklistedRecipients.length > 0 && !$.execution.testRun) {
172179
try {
173180
await sendBlacklistEmail({
174-
flowId: $.flow.id,
175-
flowName: $.flow.name,
176-
userEmail: $.user.email,
177-
executionId: $.execution.id,
181+
...defaultSendEmailParams,
178182
blacklistedRecipients,
179183
})
180184
} catch (e) {
@@ -188,17 +192,36 @@ const action: IRawAction = {
188192
})
189193
}
190194
}
195+
196+
/**
197+
* Send invalid attachments notification email
198+
* Do not send on partial retry as we would have already sent this once with the blacklist email
199+
*/
200+
if (hasInvalidAttachments && !isPartialRetry && !$.execution.testRun) {
201+
await sendInvalidAttachmentsEmail({
202+
...defaultSendEmailParams,
203+
...invalidAttachmentParams,
204+
})
205+
206+
logger.warn({
207+
message: 'Invalid attachments',
208+
flowId: $.flow.id,
209+
executionId: $.execution.id,
210+
invalidAttachments: invalidAttachments.join(', '),
211+
})
212+
}
191213
/**
192214
* If there's any rate-limit error, we will throw the rate-limit error
193215
* else we just throw the first error we encounter
194216
*/
195-
if (error && errorStatus) {
217+
if ((error && errorStatus) || invalidAttachments.length > 0) {
196218
throwPostmanStepError({
197219
$,
198220
status: errorStatus,
199221
error,
200-
isPartialSuccess: hasAtLeastOneSuccess,
222+
isPartialSuccess: hasAtLeastOneSuccess || invalidAttachments.length > 0,
201223
blacklistedRecipients,
224+
invalidAttachments,
202225
})
203226
}
204227
},
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export const POSTMAN_ACCEPTED_EXTENSIONS = [
2+
'txt',
3+
'asc',
4+
'avi',
5+
'bmp',
6+
'csv',
7+
'dgn',
8+
'docx',
9+
'dwf',
10+
'dwg',
11+
'dxf',
12+
'ent',
13+
'gif',
14+
'jpg',
15+
'jpeg',
16+
'mpeg',
17+
'mpg',
18+
'mpp',
19+
'odb',
20+
'odf',
21+
'odg',
22+
'ods',
23+
'pdf',
24+
'png',
25+
'pptx',
26+
'rtf',
27+
'sxc',
28+
'sxd',
29+
'sxi',
30+
'sxw',
31+
'tif',
32+
'tiff',
33+
'wmv',
34+
'xlsx',
35+
]
36+
37+
export const POSTMAN_SUPPORTED_ATTACHMENTS_GUIDE_URL =
38+
'https://postman-v1.guides.gov.sg/email-api-guide/programmatic-email-api/send-email-api/attachments#list-of-supported-attachment-file-types'
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,47 @@
1+
import { IGlobalVariable } from '@plumber/types'
2+
3+
import { COMMON_S3_BUCKET, getObjectFromS3Id } from '@/helpers/s3'
14
import Flow from '@/models/flow'
25

6+
import { POSTMAN_ACCEPTED_EXTENSIONS } from './constants'
7+
38
export async function getDefaultReplyTo(flowId: string): Promise<string> {
49
const flow = await Flow.query()
510
.findById(flowId)
611
.withGraphFetched({ user: true })
712
.throwIfNotFound()
813
return flow.user.email
914
}
15+
16+
export async function filterAttachments(
17+
attachmentsList: string[],
18+
$: IGlobalVariable,
19+
) {
20+
let submissionId: string | null = null
21+
const invalidAttachments: string[] = []
22+
const attachmentFiles: { fileName: string; data: Uint8Array }[] = []
23+
24+
await Promise.all(
25+
attachmentsList?.map(async (attachment) => {
26+
// We verify the flowId here to ensure that the attachment is from the same flow and not
27+
// maliciously/ manually injected by another user who does not have access to this attachment
28+
const obj = await getObjectFromS3Id(attachment, { flowId: $.flow.id }, $)
29+
const fileName = obj.name
30+
const fileType = obj.name.split('.').pop()?.toLowerCase()
31+
if (!fileType || !POSTMAN_ACCEPTED_EXTENSIONS.includes(fileType)) {
32+
invalidAttachments.push(fileName)
33+
34+
if (submissionId === null) {
35+
submissionId = attachment.slice(
36+
`s3:${COMMON_S3_BUCKET}:`.length,
37+
attachment.indexOf('/', `s3:${COMMON_S3_BUCKET}:`.length),
38+
)
39+
}
40+
} else {
41+
attachmentFiles.push({ fileName, data: obj.data })
42+
}
43+
return
44+
}),
45+
)
46+
return { attachmentFiles, invalidAttachments, submissionId }
47+
}

packages/backend/src/apps/postman/common/parameters.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { z } from 'zod'
66

77
import { parseS3Id } from '@/helpers/s3'
88

9+
import { POSTMAN_SUPPORTED_ATTACHMENTS_GUIDE_URL } from './constants'
10+
911
function recipientStringToArray(value: string) {
1012
const recipientArray = value
1113
.split(',')
@@ -81,8 +83,7 @@ export const transactionalEmailFields: IField[] = [
8183
{
8284
label: 'Attachments',
8385
key: 'attachments',
84-
description:
85-
'Check supported file types [here](https://postman-v1.guides.gov.sg/email-api-guide/programmatic-email-api/send-email-api/attachments#list-of-supported-attachment-file-types).\nPlease note that the maximum file size for each file is 2MB, and the total size of all attachments cannot exceed 10MB.',
86+
description: `Check supported file types [here](${POSTMAN_SUPPORTED_ATTACHMENTS_GUIDE_URL}).\nPlease note that the maximum file size for each file is 2MB, and the total size of all attachments cannot exceed 10MB.`,
8687
type: 'attachment' as const,
8788
required: false,
8889
variables: true,

0 commit comments

Comments
 (0)