From 1d3f0df9c0326c96973039194c9cdf497f8f5ea0 Mon Sep 17 00:00:00 2001 From: m0nggh Date: Wed, 20 May 2026 17:55:29 +0800 Subject: [PATCH] add draft for sending attachments with ses --- package-lock.json | 21 +++ packages/backend/package.json | 2 + .../actions/send-transactional-email.test.ts | 33 +++- .../actions/send-transactional-email/index.ts | 15 ++ .../src/apps/postman/common/email-helper.ts | 65 +++---- .../apps/postman/common/parameters-helper.ts | 25 ++- packages/backend/src/helpers/s3.ts | 24 +++ .../backend/src/helpers/ses-email-helper.ts | 174 +++++++++++++++++- .../components/AttachmentSuggestions/utils.ts | 23 +++ 9 files changed, 331 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8a29349ab..83368e11c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10010,6 +10010,16 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pad-left": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/pad-left/-/pad-left-2.1.1.tgz", @@ -25452,6 +25462,15 @@ "asn1": "^0.2.4" } }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -34129,6 +34148,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", @@ -34162,6 +34182,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", diff --git a/packages/backend/package.json b/packages/backend/package.json index c09191aab..25490f1ae 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", @@ -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", 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 ae8aacac7..84fcbb9e2 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 @@ -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[]), })) @@ -38,6 +41,7 @@ vi.mock('@/helpers/ses-email-helper', async () => { return { ...actual, getSesClient: () => ({ send: mocks.sesSend }), + sendEmailViaSes: mocks.sendEmailViaSes, } }) @@ -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 () => { @@ -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) }) }) 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 40d3eb0c6..5055c5a84 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 @@ -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' @@ -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( + 'ses_enabled_domains', + null, + [], + ) + const useSes = recipientsToSend.every((r) => + shouldUseSes(r, sesEnabledDomains), + ) + const { attachmentFiles, invalidAttachments, @@ -151,6 +165,7 @@ const action: IRawAction = { attachmentsList: result.data.attachments, isPartialRetry, lastExecutionStep, + useSes, }) if (isPartialRetry) { diff --git a/packages/backend/src/apps/postman/common/email-helper.ts b/packages/backend/src/apps/postman/common/email-helper.ts index e3e2c425e..cc032a84f 100644 --- a/packages/backend/src/apps/postman/common/email-helper.ts +++ b/packages/backend/src/apps/postman/common/email-helper.ts @@ -1,6 +1,5 @@ import { IHttpClient } from '@plumber/types' -import { SendEmailCommand } from '@aws-sdk/client-sesv2' import FormData from 'form-data' import { sortBy } from 'lodash' @@ -8,8 +7,11 @@ 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 { @@ -135,31 +137,16 @@ async function sendViaSes( recipientEmail: string, email: Email, ): Promise { - 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', { @@ -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() diff --git a/packages/backend/src/apps/postman/common/parameters-helper.ts b/packages/backend/src/apps/postman/common/parameters-helper.ts index 6159ba7d2..c0b4a252c 100644 --- a/packages/backend/src/apps/postman/common/parameters-helper.ts +++ b/packages/backend/src/apps/postman/common/parameters-helper.ts @@ -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 { const flow = await Flow.query() .findById(flowId) @@ -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[] = [] @@ -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) { diff --git a/packages/backend/src/helpers/s3.ts b/packages/backend/src/helpers/s3.ts index a20aabb3e..f0c59a5e7 100644 --- a/packages/backend/src/helpers/s3.ts +++ b/packages/backend/src/helpers/s3.ts @@ -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 @@ -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 diff --git a/packages/backend/src/helpers/ses-email-helper.ts b/packages/backend/src/helpers/ses-email-helper.ts index 20631c822..313a01206 100644 --- a/packages/backend/src/helpers/ses-email-helper.ts +++ b/packages/backend/src/helpers/ses-email-helper.ts @@ -1,5 +1,6 @@ import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2' import { fromTemporaryCredentials } from '@aws-sdk/credential-providers' +import MailComposer from 'nodemailer/lib/mail-composer' import appConfig from '@/config/app' import EmailSuppressionEntry from '@/models/email-suppression-entry' @@ -44,12 +45,117 @@ export function shouldUseSes( return domain ? normalizedEnabledDomains.includes(domain) : false } +/** + * Extensions blocked when sending via SES. These are the common executable + * and script types used in phishing/malware payloads. Recipient mail servers + * (Outlook/Gmail) typically block these too, but we drop them defensively + * so SES never carries them. + */ +export const BLOCKED_ATTACHMENT_EXTENSIONS = new Set([ + // Executables + 'exe', + 'bat', + 'cmd', + 'com', + 'msi', + 'scr', + 'pif', + // Scripts + 'vbs', + 'vbe', + 'js', + 'jse', + 'wsf', + 'wsh', + 'ps1', + // System / shortcut + 'reg', + 'inf', + 'lnk', +]) + +/** + * SES raw email size limit is 10MB including base64 encoding overhead and + * MIME headers. Base64 inflates payload by ~33%, so cap raw bytes at ~7MB + * to leave headroom for headers. + */ +export const SES_MAX_ATTACHMENT_TOTAL_BYTES = 7 * 1024 * 1024 + +export interface SesAttachment { + fileName: string + data: Uint8Array +} + +function getExtension(fileName: string): string { + const idx = fileName.lastIndexOf('.') + if (idx < 0 || idx === fileName.length - 1) { + return '' + } + return fileName.slice(idx + 1).toLowerCase() +} + +export function isBlockedAttachment(fileName: string): boolean { + return BLOCKED_ATTACHMENT_EXTENSIONS.has(getExtension(fileName)) +} + +/** + * Split incoming attachments into allowed vs blocked. Blocked attachments + * are dropped from the send; callers can use the `blocked` array to + * notify the user (e.g. via sendInvalidAttachmentsEmail). + */ +export function filterSesAttachments(attachments: SesAttachment[]): { + allowed: SesAttachment[] + blocked: string[] +} { + const allowed: SesAttachment[] = [] + const blocked: string[] = [] + for (const att of attachments) { + if (isBlockedAttachment(att.fileName)) { + blocked.push(att.fileName) + } else { + allowed.push(att) + } + } + return { allowed, blocked } +} + interface SesEmailParams { subject: string body: string recipient: string replyTo?: string cc?: string[] + attachments?: SesAttachment[] +} + +/** + * Build a raw MIME message via nodemailer's MailComposer. MailComposer + * handles RFC-compliant header encoding, base64 wrapping, boundary safety, + * and CRLF line endings — all the parts of MIME we don't want to roll + * ourselves. SES v2's `Content.Raw` accepts the resulting Buffer directly. + */ +async function buildRawMimeMessage(params: { + fromAddress: string + recipient: string + cc?: string[] + replyTo?: string + subject: string + htmlBody: string + attachments: SesAttachment[] +}): Promise { + const composer = new MailComposer({ + from: params.fromAddress, + to: params.recipient, + cc: params.cc?.length ? params.cc : undefined, + replyTo: params.replyTo, + subject: params.subject, + html: params.htmlBody, + attachments: params.attachments.map((a) => ({ + filename: a.fileName, + content: Buffer.from(a.data), + })), + }) + return await composer.compile().build() } export async function sendEmailViaSes({ @@ -58,6 +164,7 @@ export async function sendEmailViaSes({ recipient, replyTo, cc, + attachments, }: SesEmailParams): Promise { const client = getSesClient() const fromAddress = `Plumber <${appConfig.ses.fromAddress}>` @@ -76,9 +183,62 @@ export async function sendEmailViaSes({ ) } + // Apply the denylist defensively even if the caller filtered upstream. + // A blocked extension reaching this layer is unexpected, so warn so + // operators can investigate the upstream filter. + const { allowed, blocked } = filterSesAttachments(attachments ?? []) + if (blocked.length > 0) { + logger.warn('Blocked attachment(s) dropped before SES send', { + event: 'ses-blocked-attachment', + recipient, + blocked, + }) + } + + // Fail fast on oversized payloads so SES doesn't reject a 10MB+ raw + // message after we've built it. + const totalBytes = allowed.reduce((sum, a) => sum + a.data.byteLength, 0) + if (totalBytes > SES_MAX_ATTACHMENT_TOTAL_BYTES) { + logger.error('Attachments too large for SES send', { + event: 'ses-attachment-too-large', + recipient, + totalBytes, + maxBytes: SES_MAX_ATTACHMENT_TOTAL_BYTES, + }) + throw new Error( + `Attachments exceed maximum total size of ${SES_MAX_ATTACHMENT_TOTAL_BYTES} bytes`, + ) + } + try { - await client.send( - new SendEmailCommand({ + const hasAttachments = allowed.length > 0 + let command: SendEmailCommand + + if (hasAttachments) { + const rawMessage = await buildRawMimeMessage({ + fromAddress, + recipient, + cc, + replyTo, + subject, + htmlBody: body, + attachments: allowed, + }) + + command = new SendEmailCommand({ + FromEmailAddress: fromAddress, + Destination: { + ToAddresses: [recipient], + ...(cc?.length && { CcAddresses: cc }), + }, + Content: { Raw: { Data: rawMessage } }, + ...(replyTo && { ReplyToAddresses: [replyTo] }), + ...(appConfig.ses.configurationSet && { + ConfigurationSetName: appConfig.ses.configurationSet, + }), + }) + } else { + command = new SendEmailCommand({ FromEmailAddress: fromAddress, Destination: { ToAddresses: [recipient], @@ -87,17 +247,17 @@ export async function sendEmailViaSes({ Content: { Simple: { Subject: { Data: subject, Charset: 'UTF-8' }, - Body: { - Html: { Data: body, Charset: 'UTF-8' }, - }, + Body: { Html: { Data: body, Charset: 'UTF-8' } }, }, }, ...(replyTo && { ReplyToAddresses: [replyTo] }), ...(appConfig.ses.configurationSet && { ConfigurationSetName: appConfig.ses.configurationSet, }), - }), - ) + }) + } + + await client.send(command) incrementMetric('ses.email.sent') } catch (e) { logger.error('Error sending email via SES, please try again later.', { diff --git a/packages/frontend/src/components/AttachmentSuggestions/utils.ts b/packages/frontend/src/components/AttachmentSuggestions/utils.ts index a6504fbce..aa8df87d7 100644 --- a/packages/frontend/src/components/AttachmentSuggestions/utils.ts +++ b/packages/frontend/src/components/AttachmentSuggestions/utils.ts @@ -10,7 +10,13 @@ export const MAX_NUM_FILES = 10 const MAX_FILE_SIZE = 10 * MB // 10MB const MAX_TOTAL_FILE_SIZE = 10 * MB // 10MB +// Mirrors backend ACCEPTED_FILE_TYPES (helpers/s3.ts). This is the union +// of types allowed at upload time; the backend applies a stricter or +// looser provider-specific filter at send time: +// - Postman path: 35-type allowlist +// - SES path: denylist (executables/scripts only) export const ACCEPTED_FILE_TYPES = [ + // --- Postman-compatible types --- 'text/plain', // .txt, .asc 'video/x-msvideo', // .avi 'image/bmp', // .bmp @@ -40,6 +46,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 interface AttachmentConfigInput {