From cf41caf0f980a011de2bb6ee607575d545ad55d6 Mon Sep 17 00:00:00 2001 From: m0nggh Date: Tue, 12 May 2026 09:20:05 +0800 Subject: [PATCH 1/3] add ses event parser and worker processor --- .../ses-test-events/ses-bounce-permanent.json | 12 ++ .../ses-test-events/ses-bounce-transient.json | 12 ++ .../ses-test-events/ses-complaint-abuse.json | 12 ++ .../ses-complaint-not-spam.json | 12 ++ .../__tests__/ses-event-parser.test.ts | 73 +++++++++++ .../backend/src/helpers/ses-event-parser.ts | 68 ++++++++++ .../src/workers/__tests__/ses-events.itest.ts | 105 +++++++++++++++ packages/backend/src/workers/ses-events.ts | 121 ++++++++++++++++++ 8 files changed, 415 insertions(+) create mode 100644 packages/backend/ses-test-events/ses-bounce-permanent.json create mode 100644 packages/backend/ses-test-events/ses-bounce-transient.json create mode 100644 packages/backend/ses-test-events/ses-complaint-abuse.json create mode 100644 packages/backend/ses-test-events/ses-complaint-not-spam.json create mode 100644 packages/backend/src/helpers/__tests__/ses-event-parser.test.ts create mode 100644 packages/backend/src/helpers/ses-event-parser.ts create mode 100644 packages/backend/src/workers/__tests__/ses-events.itest.ts create mode 100644 packages/backend/src/workers/ses-events.ts diff --git a/packages/backend/ses-test-events/ses-bounce-permanent.json b/packages/backend/ses-test-events/ses-bounce-permanent.json new file mode 100644 index 0000000000..9b474099f3 --- /dev/null +++ b/packages/backend/ses-test-events/ses-bounce-permanent.json @@ -0,0 +1,12 @@ +{ + "Type": "Notification", + "MessageId": "fixture-msg-001", + "TopicArn": "arn:aws:sns:ap-southeast-2:123456789012:ses-events", + "Subject": null, + "Message": "{\"eventType\":\"Bounce\",\"bounce\":{\"bounceType\":\"Permanent\",\"bounceSubType\":\"NoEmail\",\"bouncedRecipients\":[{\"emailAddress\":\"bounce@example.com\",\"action\":\"failed\",\"status\":\"5.1.1\",\"diagnosticCode\":\"smtp; 550 5.1.1 user unknown\"}],\"timestamp\":\"2026-05-11T10:00:00.000Z\",\"feedbackId\":\"feedback-001\"},\"mail\":{\"timestamp\":\"2026-05-11T09:59:00.000Z\",\"source\":\"noreply@plumber.gov.sg\",\"sourceArn\":\"arn:aws:ses:ap-southeast-2:123456789012:identity/plumber.gov.sg\",\"sendingAccountId\":\"123456789012\",\"messageId\":\"ses-msg-001\",\"destination\":[\"bounce@example.com\"]}}", + "Timestamp": "2026-05-11T10:00:01.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertURL": "https://sns.ap-southeast-2.amazonaws.com/SimpleNotificationService-example.pem", + "UnsubscribeURL": "https://sns.ap-southeast-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-southeast-2:123456789012:ses-events:example" +} diff --git a/packages/backend/ses-test-events/ses-bounce-transient.json b/packages/backend/ses-test-events/ses-bounce-transient.json new file mode 100644 index 0000000000..f03e0f3e6c --- /dev/null +++ b/packages/backend/ses-test-events/ses-bounce-transient.json @@ -0,0 +1,12 @@ +{ + "Type": "Notification", + "MessageId": "fixture-msg-002", + "TopicArn": "arn:aws:sns:ap-southeast-2:123456789012:ses-events", + "Subject": null, + "Message": "{\"eventType\":\"Bounce\",\"bounce\":{\"bounceType\":\"Transient\",\"bounceSubType\":\"MailboxFull\",\"bouncedRecipients\":[{\"emailAddress\":\"full@example.com\",\"action\":\"failed\",\"status\":\"4.2.2\",\"diagnosticCode\":\"smtp; 452 4.2.2 Mailbox full\"}],\"timestamp\":\"2026-05-11T10:00:00.000Z\",\"feedbackId\":\"feedback-002\"},\"mail\":{\"timestamp\":\"2026-05-11T09:59:00.000Z\",\"source\":\"noreply@plumber.gov.sg\",\"sendingAccountId\":\"123456789012\",\"messageId\":\"ses-msg-002\",\"destination\":[\"full@example.com\"]}}", + "Timestamp": "2026-05-11T10:00:01.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertURL": "https://sns.ap-southeast-2.amazonaws.com/SimpleNotificationService-example.pem", + "UnsubscribeURL": "https://sns.ap-southeast-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-southeast-2:123456789012:ses-events:example" +} diff --git a/packages/backend/ses-test-events/ses-complaint-abuse.json b/packages/backend/ses-test-events/ses-complaint-abuse.json new file mode 100644 index 0000000000..0e312809b4 --- /dev/null +++ b/packages/backend/ses-test-events/ses-complaint-abuse.json @@ -0,0 +1,12 @@ +{ + "Type": "Notification", + "MessageId": "fixture-msg-003", + "TopicArn": "arn:aws:sns:ap-southeast-2:123456789012:ses-events", + "Subject": null, + "Message": "{\"eventType\":\"Complaint\",\"complaint\":{\"complainedRecipients\":[{\"emailAddress\":\"complainer@example.com\"}],\"timestamp\":\"2026-05-11T10:00:00.000Z\",\"feedbackId\":\"feedback-003\",\"complaintSubType\":null,\"complaintFeedbackType\":\"abuse\"},\"mail\":{\"timestamp\":\"2026-05-11T09:59:00.000Z\",\"source\":\"noreply@plumber.gov.sg\",\"sendingAccountId\":\"123456789012\",\"messageId\":\"ses-msg-003\",\"destination\":[\"complainer@example.com\"]}}", + "Timestamp": "2026-05-11T10:00:01.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertURL": "https://sns.ap-southeast-2.amazonaws.com/SimpleNotificationService-example.pem", + "UnsubscribeURL": "https://sns.ap-southeast-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-southeast-2:123456789012:ses-events:example" +} diff --git a/packages/backend/ses-test-events/ses-complaint-not-spam.json b/packages/backend/ses-test-events/ses-complaint-not-spam.json new file mode 100644 index 0000000000..1dc558c7fd --- /dev/null +++ b/packages/backend/ses-test-events/ses-complaint-not-spam.json @@ -0,0 +1,12 @@ +{ + "Type": "Notification", + "MessageId": "fixture-msg-004", + "TopicArn": "arn:aws:sns:ap-southeast-2:123456789012:ses-events", + "Subject": null, + "Message": "{\"eventType\":\"Complaint\",\"complaint\":{\"complainedRecipients\":[{\"emailAddress\":\"notspam@example.com\"}],\"timestamp\":\"2026-05-11T10:00:00.000Z\",\"feedbackId\":\"feedback-004\",\"complaintSubType\":null,\"complaintFeedbackType\":\"not-spam\"},\"mail\":{\"timestamp\":\"2026-05-11T09:59:00.000Z\",\"source\":\"noreply@plumber.gov.sg\",\"sendingAccountId\":\"123456789012\",\"messageId\":\"ses-msg-004\",\"destination\":[\"notspam@example.com\"]}}", + "Timestamp": "2026-05-11T10:00:01.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertURL": "https://sns.ap-southeast-2.amazonaws.com/SimpleNotificationService-example.pem", + "UnsubscribeURL": "https://sns.ap-southeast-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-southeast-2:123456789012:ses-events:example" +} diff --git a/packages/backend/src/helpers/__tests__/ses-event-parser.test.ts b/packages/backend/src/helpers/__tests__/ses-event-parser.test.ts new file mode 100644 index 0000000000..c1fcd9e418 --- /dev/null +++ b/packages/backend/src/helpers/__tests__/ses-event-parser.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { describe, expect, it } from 'vitest' + +import { parseSqsMessage, SesEventType } from '@/helpers/ses-event-parser' + +function loadFixture(name: string): string { + return readFileSync( + resolve(__dirname, '../../../ses-test-events', name), + 'utf-8', + ) +} + +describe('ses-event-parser', () => { + describe('parseSqsMessage', () => { + it('should parse a permanent bounce event', () => { + const result = parseSqsMessage(loadFixture('ses-bounce-permanent.json')) + expect(result.eventType).toBe(SesEventType.Bounce) + expect(result.bounce).toBeDefined() + expect(result.bounce.bounceType).toBe('Permanent') + expect(result.bounce.bounceSubType).toBe('NoEmail') + expect(result.bounce.bouncedRecipients).toHaveLength(1) + expect(result.bounce.bouncedRecipients[0].emailAddress).toBe( + 'bounce@example.com', + ) + expect(result.mail.messageId).toBe('ses-msg-001') + }) + + it('should parse a transient bounce event', () => { + const result = parseSqsMessage(loadFixture('ses-bounce-transient.json')) + expect(result.eventType).toBe(SesEventType.Bounce) + expect(result.bounce.bounceType).toBe('Transient') + expect(result.bounce.bounceSubType).toBe('MailboxFull') + }) + + it('should parse a complaint event', () => { + const result = parseSqsMessage(loadFixture('ses-complaint-abuse.json')) + expect(result.eventType).toBe(SesEventType.Complaint) + expect(result.complaint).toBeDefined() + expect(result.complaint.complaintFeedbackType).toBe('abuse') + expect(result.complaint.complainedRecipients).toHaveLength(1) + expect(result.complaint.complainedRecipients[0].emailAddress).toBe( + 'complainer@example.com', + ) + }) + + it('should parse a not-spam complaint event', () => { + const result = parseSqsMessage(loadFixture('ses-complaint-not-spam.json')) + expect(result.eventType).toBe(SesEventType.Complaint) + expect(result.complaint.complaintFeedbackType).toBe('not-spam') + }) + + it('should throw on invalid JSON in SQS body', () => { + expect(() => parseSqsMessage('not json')).toThrow() + }) + + it('should throw on missing Message field in SNS envelope', () => { + expect(() => + parseSqsMessage(JSON.stringify({ Type: 'Notification' })), + ).toThrow('Missing Message field in SNS envelope') + }) + + it('should throw on missing eventType in SES event', () => { + const snsEnvelope = JSON.stringify({ + Type: 'Notification', + Message: JSON.stringify({ mail: {} }), + }) + expect(() => parseSqsMessage(snsEnvelope)).toThrow( + 'Missing eventType in SES event', + ) + }) + }) +}) diff --git a/packages/backend/src/helpers/ses-event-parser.ts b/packages/backend/src/helpers/ses-event-parser.ts new file mode 100644 index 0000000000..0d7b709db5 --- /dev/null +++ b/packages/backend/src/helpers/ses-event-parser.ts @@ -0,0 +1,68 @@ +export enum SesEventType { + Bounce = 'Bounce', + Complaint = 'Complaint', +} + +interface BouncedRecipient { + emailAddress: string + action?: string + status?: string + diagnosticCode?: string +} + +interface ComplainedRecipient { + emailAddress: string +} + +interface SesBounce { + bounceType: 'Permanent' | 'Transient' | 'Undetermined' + bounceSubType: string + bouncedRecipients: BouncedRecipient[] + timestamp: string + feedbackId: string +} + +interface SesComplaint { + complainedRecipients: ComplainedRecipient[] + timestamp: string + feedbackId: string + complaintFeedbackType?: string + complaintSubType?: string +} + +interface SesMail { + timestamp: string + source: string + sendingAccountId?: string + messageId: string + destination: string[] +} + +export interface SesEvent { + eventType: SesEventType + bounce?: SesBounce + complaint?: SesComplaint + mail: SesMail +} + +/** + * Parses an SQS message body containing an SNS-wrapped SES event. + * + * Message nesting: SQS body (string) -> SNS envelope (JSON with `Message` field) + * -> SES event (JSON string inside `Message`). + */ +export function parseSqsMessage(sqsBody: string): SesEvent { + const snsEnvelope = JSON.parse(sqsBody) + + if (!snsEnvelope.Message) { + throw new Error('Missing Message field in SNS envelope') + } + + const sesEvent = JSON.parse(snsEnvelope.Message) + + if (!sesEvent.eventType) { + throw new Error('Missing eventType in SES event') + } + + return sesEvent as SesEvent +} diff --git a/packages/backend/src/workers/__tests__/ses-events.itest.ts b/packages/backend/src/workers/__tests__/ses-events.itest.ts new file mode 100644 index 0000000000..a7eef11324 --- /dev/null +++ b/packages/backend/src/workers/__tests__/ses-events.itest.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { describe, expect, it } from 'vitest' + +import { parseSqsMessage } from '@/helpers/ses-event-parser' +import EmailSuppressionEntry from '@/models/email-suppression-entry' + +import { processSesEvent } from '../ses-events' + +function loadFixture(name: string): string { + return readFileSync( + resolve(__dirname, '../../../ses-test-events', name), + 'utf-8', + ) +} + +function makeJobData(fixtureName: string) { + return { + sesEvent: parseSqsMessage(loadFixture(fixtureName)), + sqsMessageId: `test-sqs-${fixtureName}`, + } +} + +describe('processSesEvent', () => { + it('should suppress email on permanent bounce', async () => { + await processSesEvent(makeJobData('ses-bounce-permanent.json')) + + const suppressed = await EmailSuppressionEntry.getSuppressedEmails([ + 'bounce@example.com', + ]) + expect(suppressed).toEqual(['bounce@example.com']) + + const row = await EmailSuppressionEntry.query().findOne({ + email: 'bounce@example.com', + }) + expect(row.reason).toBe('BOUNCE') + expect(row.reasonDetail).toBe('NoEmail') + expect(row.sesMessageId).toBe('ses-msg-001') + }) + + it('should NOT suppress email on transient bounce', async () => { + await processSesEvent(makeJobData('ses-bounce-transient.json')) + + const suppressed = await EmailSuppressionEntry.getSuppressedEmails([ + 'full@example.com', + ]) + expect(suppressed).toEqual([]) + }) + + it('should suppress email on abuse complaint', async () => { + await processSesEvent(makeJobData('ses-complaint-abuse.json')) + + const suppressed = await EmailSuppressionEntry.getSuppressedEmails([ + 'complainer@example.com', + ]) + expect(suppressed).toEqual(['complainer@example.com']) + + const row = await EmailSuppressionEntry.query().findOne({ + email: 'complainer@example.com', + }) + expect(row.reason).toBe('COMPLAINT') + expect(row.reasonDetail).toBe('abuse') + }) + + it('should auto-whitelist on not-spam complaint', async () => { + // First suppress the email + await EmailSuppressionEntry.upsertSuppression({ + email: 'notspam@example.com', + reason: 'COMPLAINT', + reasonDetail: 'abuse', + }) + + // Then process not-spam complaint + await processSesEvent(makeJobData('ses-complaint-not-spam.json')) + + const suppressed = await EmailSuppressionEntry.getSuppressedEmails([ + 'notspam@example.com', + ]) + expect(suppressed).toEqual([]) + + const row = await EmailSuppressionEntry.query().findOne({ + email: 'notspam@example.com', + }) + expect(row.lastWhitelistedAt).not.toBeNull() + }) + + it('should handle not-spam complaint for non-suppressed email gracefully', async () => { + await processSesEvent(makeJobData('ses-complaint-not-spam.json')) + + const rows = await EmailSuppressionEntry.query().where({ + email: 'notspam@example.com', + }) + expect(rows).toHaveLength(0) + }) + + it('should be idempotent — processing same bounce twice does not duplicate', async () => { + await processSesEvent(makeJobData('ses-bounce-permanent.json')) + await processSesEvent(makeJobData('ses-bounce-permanent.json')) + + const rows = await EmailSuppressionEntry.query().where({ + email: 'bounce@example.com', + }) + expect(rows).toHaveLength(1) + }) +}) diff --git a/packages/backend/src/workers/ses-events.ts b/packages/backend/src/workers/ses-events.ts new file mode 100644 index 0000000000..21467d7adb --- /dev/null +++ b/packages/backend/src/workers/ses-events.ts @@ -0,0 +1,121 @@ +import logger from '@/helpers/logger' +import { SesEvent, SesEventType } from '@/helpers/ses-event-parser' +import EmailSuppressionEntry from '@/models/email-suppression-entry' + +export interface SesEventJobData { + sesEvent: SesEvent + sqsMessageId: string +} + +/** + * Process a parsed SES event from the BullMQ queue. + * + * NOTE on recipient arrays: + * `bouncedRecipients` and `complainedRecipients` are arrays per the SES + * spec, but in practice each event we receive will only contain ONE + * recipient. This is because our SES sender (sendViaSes) calls + * SendEmailCommand with a single `ToAddresses` per call. The loops below + * still iterate to remain faithful to the spec and to be safe if Phase 2 + * ever introduces multi-recipient sends via SendBulkEmailCommand. + */ +export async function processSesEvent(data: SesEventJobData): Promise { + const { sesEvent, sqsMessageId } = data + const { eventType, mail } = sesEvent + + if (eventType === SesEventType.Bounce) { + // Defensive: an event tagged Bounce must carry a bounce payload. If it + // doesn't, surface it loudly rather than silently dropping the event. + if (!sesEvent.bounce) { + logger.error('Bounce event missing bounce payload', { + event: 'ses-malformed-event', + sqsMessageId, + messageId: mail?.messageId, + }) + return + } + + const { bounceType, bounceSubType, bouncedRecipients } = sesEvent.bounce + + if (bounceType === 'Permanent') { + for (const recipient of bouncedRecipients) { + await EmailSuppressionEntry.upsertSuppression({ + email: recipient.emailAddress, + reason: 'BOUNCE', + reasonDetail: bounceSubType, + sesMessageId: mail.messageId, + }) + + logger.info('Email suppressed due to permanent bounce', { + event: 'ses-email-suppressed', + email: recipient.emailAddress, + bounceType, + bounceSubType, + sesMessageId: mail.messageId, + sqsMessageId, + }) + } + } else { + // Transient / Undetermined — log only, do not suppress + logger.info('Transient bounce received — no suppression', { + event: 'ses-transient-bounce', + bounceType, + bounceSubType, + recipients: bouncedRecipients.map((r) => r.emailAddress), + sesMessageId: mail.messageId, + sqsMessageId, + }) + } + return + } + + if (eventType === SesEventType.Complaint) { + if (!sesEvent.complaint) { + logger.error('Complaint event missing complaint payload', { + event: 'ses-malformed-event', + sqsMessageId, + messageId: mail?.messageId, + }) + return + } + + const { complainedRecipients, complaintFeedbackType } = sesEvent.complaint + + if (complaintFeedbackType === 'not-spam') { + // Auto-whitelist: recipient marked the email as not-spam + const emails = complainedRecipients.map((r) => r.emailAddress) + const whitelisted = await EmailSuppressionEntry.whitelistEmails(emails) + + logger.info('Auto-whitelisted emails due to not-spam complaint', { + event: 'ses-auto-whitelist', + whitelisted, + sesMessageId: mail.messageId, + sqsMessageId, + }) + } else { + // abuse, fraud, virus, other, null — suppress + for (const recipient of complainedRecipients) { + await EmailSuppressionEntry.upsertSuppression({ + email: recipient.emailAddress, + reason: 'COMPLAINT', + reasonDetail: complaintFeedbackType ?? 'other', + sesMessageId: mail.messageId, + }) + + logger.info('Email suppressed due to complaint', { + event: 'ses-email-suppressed', + email: recipient.emailAddress, + complaintFeedbackType, + sesMessageId: mail.messageId, + sqsMessageId, + }) + } + } + return + } + + logger.warn('Unhandled SES event type', { + event: 'ses-unhandled-event', + eventType, + sqsMessageId, + }) +} From 79a7684653385096b06eb147371a38de80434ac1 Mon Sep 17 00:00:00 2001 From: m0nggh Date: Fri, 22 May 2026 19:08:25 +0800 Subject: [PATCH 2/3] move from worker to a helper function instead --- .../__tests__/process-ses-event.itest.ts} | 2 +- .../{workers/ses-events.ts => helpers/process-ses-event.ts} | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename packages/backend/src/{workers/__tests__/ses-events.itest.ts => helpers/__tests__/process-ses-event.itest.ts} (98%) rename packages/backend/src/{workers/ses-events.ts => helpers/process-ses-event.ts} (95%) diff --git a/packages/backend/src/workers/__tests__/ses-events.itest.ts b/packages/backend/src/helpers/__tests__/process-ses-event.itest.ts similarity index 98% rename from packages/backend/src/workers/__tests__/ses-events.itest.ts rename to packages/backend/src/helpers/__tests__/process-ses-event.itest.ts index a7eef11324..23906c4543 100644 --- a/packages/backend/src/workers/__tests__/ses-events.itest.ts +++ b/packages/backend/src/helpers/__tests__/process-ses-event.itest.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { parseSqsMessage } from '@/helpers/ses-event-parser' import EmailSuppressionEntry from '@/models/email-suppression-entry' -import { processSesEvent } from '../ses-events' +import { processSesEvent } from '../process-ses-event' function loadFixture(name: string): string { return readFileSync( diff --git a/packages/backend/src/workers/ses-events.ts b/packages/backend/src/helpers/process-ses-event.ts similarity index 95% rename from packages/backend/src/workers/ses-events.ts rename to packages/backend/src/helpers/process-ses-event.ts index 21467d7adb..127cdff45c 100644 --- a/packages/backend/src/workers/ses-events.ts +++ b/packages/backend/src/helpers/process-ses-event.ts @@ -2,13 +2,13 @@ import logger from '@/helpers/logger' import { SesEvent, SesEventType } from '@/helpers/ses-event-parser' import EmailSuppressionEntry from '@/models/email-suppression-entry' -export interface SesEventJobData { +export interface SesEventInput { sesEvent: SesEvent sqsMessageId: string } /** - * Process a parsed SES event from the BullMQ queue. + * Process a parsed SES event (called by the SQS consumer's handleMessage). * * NOTE on recipient arrays: * `bouncedRecipients` and `complainedRecipients` are arrays per the SES @@ -18,7 +18,7 @@ export interface SesEventJobData { * still iterate to remain faithful to the spec and to be safe if Phase 2 * ever introduces multi-recipient sends via SendBulkEmailCommand. */ -export async function processSesEvent(data: SesEventJobData): Promise { +export async function processSesEvent(data: SesEventInput): Promise { const { sesEvent, sqsMessageId } = data const { eventType, mail } = sesEvent From a1bb578c8d3b4f4b6823814ef95401df24c35204 Mon Sep 17 00:00:00 2001 From: m0nggh Date: Mon, 8 Jun 2026 23:59:35 +0800 Subject: [PATCH 3/3] enforce stricter schema for ses events --- .../__tests__/ses-event-parser.test.ts | 26 ++-- .../backend/src/helpers/process-ses-event.ts | 36 ++---- .../backend/src/helpers/ses-event-parser.ts | 116 ++++++++++-------- 3 files changed, 89 insertions(+), 89 deletions(-) diff --git a/packages/backend/src/helpers/__tests__/ses-event-parser.test.ts b/packages/backend/src/helpers/__tests__/ses-event-parser.test.ts index c1fcd9e418..89a3bcfb85 100644 --- a/packages/backend/src/helpers/__tests__/ses-event-parser.test.ts +++ b/packages/backend/src/helpers/__tests__/ses-event-parser.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs' import { resolve } from 'path' -import { describe, expect, it } from 'vitest' +import { assert, describe, expect, it } from 'vitest' import { parseSqsMessage, SesEventType } from '@/helpers/ses-event-parser' @@ -15,8 +15,7 @@ describe('ses-event-parser', () => { describe('parseSqsMessage', () => { it('should parse a permanent bounce event', () => { const result = parseSqsMessage(loadFixture('ses-bounce-permanent.json')) - expect(result.eventType).toBe(SesEventType.Bounce) - expect(result.bounce).toBeDefined() + assert(result.eventType === SesEventType.Bounce) // narrows the union expect(result.bounce.bounceType).toBe('Permanent') expect(result.bounce.bounceSubType).toBe('NoEmail') expect(result.bounce.bouncedRecipients).toHaveLength(1) @@ -28,15 +27,14 @@ describe('ses-event-parser', () => { it('should parse a transient bounce event', () => { const result = parseSqsMessage(loadFixture('ses-bounce-transient.json')) - expect(result.eventType).toBe(SesEventType.Bounce) + assert(result.eventType === SesEventType.Bounce) // narrows the union expect(result.bounce.bounceType).toBe('Transient') expect(result.bounce.bounceSubType).toBe('MailboxFull') }) it('should parse a complaint event', () => { const result = parseSqsMessage(loadFixture('ses-complaint-abuse.json')) - expect(result.eventType).toBe(SesEventType.Complaint) - expect(result.complaint).toBeDefined() + assert(result.eventType === SesEventType.Complaint) // narrows the union expect(result.complaint.complaintFeedbackType).toBe('abuse') expect(result.complaint.complainedRecipients).toHaveLength(1) expect(result.complaint.complainedRecipients[0].emailAddress).toBe( @@ -46,7 +44,7 @@ describe('ses-event-parser', () => { it('should parse a not-spam complaint event', () => { const result = parseSqsMessage(loadFixture('ses-complaint-not-spam.json')) - expect(result.eventType).toBe(SesEventType.Complaint) + assert(result.eventType === SesEventType.Complaint) // narrows the union expect(result.complaint.complaintFeedbackType).toBe('not-spam') }) @@ -57,7 +55,7 @@ describe('ses-event-parser', () => { it('should throw on missing Message field in SNS envelope', () => { expect(() => parseSqsMessage(JSON.stringify({ Type: 'Notification' })), - ).toThrow('Missing Message field in SNS envelope') + ).toThrow(/Message/) }) it('should throw on missing eventType in SES event', () => { @@ -65,9 +63,15 @@ describe('ses-event-parser', () => { Type: 'Notification', Message: JSON.stringify({ mail: {} }), }) - expect(() => parseSqsMessage(snsEnvelope)).toThrow( - 'Missing eventType in SES event', - ) + expect(() => parseSqsMessage(snsEnvelope)).toThrow(/eventType/) + }) + + it('should throw on an unhandled event type', () => { + const snsEnvelope = JSON.stringify({ + Type: 'Notification', + Message: JSON.stringify({ eventType: 'Delivery', mail: {} }), + }) + expect(() => parseSqsMessage(snsEnvelope)).toThrow(/eventType/) }) }) }) diff --git a/packages/backend/src/helpers/process-ses-event.ts b/packages/backend/src/helpers/process-ses-event.ts index 127cdff45c..9e9a2dcece 100644 --- a/packages/backend/src/helpers/process-ses-event.ts +++ b/packages/backend/src/helpers/process-ses-event.ts @@ -20,23 +20,16 @@ export interface SesEventInput { */ export async function processSesEvent(data: SesEventInput): Promise { const { sesEvent, sqsMessageId } = data - const { eventType, mail } = sesEvent - - if (eventType === SesEventType.Bounce) { - // Defensive: an event tagged Bounce must carry a bounce payload. If it - // doesn't, surface it loudly rather than silently dropping the event. - if (!sesEvent.bounce) { - logger.error('Bounce event missing bounce payload', { - event: 'ses-malformed-event', - sqsMessageId, - messageId: mail?.messageId, - }) - return - } + const { mail } = sesEvent + // sesEvent is a discriminated union on eventType (Bounce | Complaint), so the + // payload for each branch is guaranteed present — no defensive null checks + // needed, and the union is exhaustive. + if (sesEvent.eventType === SesEventType.Bounce) { const { bounceType, bounceSubType, bouncedRecipients } = sesEvent.bounce if (bounceType === 'Permanent') { + // TODO: add micro-optimisation for upsertSuppression to blacklist multiple recipient emails in phase 2 for (const recipient of bouncedRecipients) { await EmailSuppressionEntry.upsertSuppression({ email: recipient.emailAddress, @@ -68,16 +61,7 @@ export async function processSesEvent(data: SesEventInput): Promise { return } - if (eventType === SesEventType.Complaint) { - if (!sesEvent.complaint) { - logger.error('Complaint event missing complaint payload', { - event: 'ses-malformed-event', - sqsMessageId, - messageId: mail?.messageId, - }) - return - } - + if (sesEvent.eventType === SesEventType.Complaint) { const { complainedRecipients, complaintFeedbackType } = sesEvent.complaint if (complaintFeedbackType === 'not-spam') { @@ -112,10 +96,4 @@ export async function processSesEvent(data: SesEventInput): Promise { } return } - - logger.warn('Unhandled SES event type', { - event: 'ses-unhandled-event', - eventType, - sqsMessageId, - }) } diff --git a/packages/backend/src/helpers/ses-event-parser.ts b/packages/backend/src/helpers/ses-event-parser.ts index 0d7b709db5..53ba0ad2e1 100644 --- a/packages/backend/src/helpers/ses-event-parser.ts +++ b/packages/backend/src/helpers/ses-event-parser.ts @@ -1,68 +1,86 @@ +import { z } from 'zod' + export enum SesEventType { Bounce = 'Bounce', Complaint = 'Complaint', } -interface BouncedRecipient { - emailAddress: string - action?: string - status?: string - diagnosticCode?: string -} +// SES omits-or-nulls optional fields, so accept both null and undefined. +const optionalString = z.string().nullish() -interface ComplainedRecipient { - emailAddress: string -} +const bouncedRecipientSchema = z.object({ + emailAddress: z.string(), + action: optionalString, + status: optionalString, + diagnosticCode: optionalString, +}) -interface SesBounce { - bounceType: 'Permanent' | 'Transient' | 'Undetermined' - bounceSubType: string - bouncedRecipients: BouncedRecipient[] - timestamp: string - feedbackId: string -} +const complainedRecipientSchema = z.object({ + emailAddress: z.string(), +}) -interface SesComplaint { - complainedRecipients: ComplainedRecipient[] - timestamp: string - feedbackId: string - complaintFeedbackType?: string - complaintSubType?: string -} +const sesBounceSchema = z.object({ + bounceType: z.enum(['Permanent', 'Transient', 'Undetermined']), + bounceSubType: z.string(), + bouncedRecipients: z.array(bouncedRecipientSchema), + timestamp: z.string(), + feedbackId: z.string(), +}) -interface SesMail { - timestamp: string - source: string - sendingAccountId?: string - messageId: string - destination: string[] -} +const sesComplaintSchema = z.object({ + complainedRecipients: z.array(complainedRecipientSchema), + timestamp: z.string(), + feedbackId: z.string(), + complaintFeedbackType: optionalString, + complaintSubType: optionalString, +}) -export interface SesEvent { - eventType: SesEventType - bounce?: SesBounce - complaint?: SesComplaint - mail: SesMail -} +const sesMailSchema = z.object({ + timestamp: z.string(), + source: z.string(), + sendingAccountId: optionalString, + messageId: z.string(), + destination: z.array(z.string()), +}) + +const bounceEventSchema = z.object({ + eventType: z.literal(SesEventType.Bounce), + bounce: sesBounceSchema, + mail: sesMailSchema, +}) + +const complaintEventSchema = z.object({ + eventType: z.literal(SesEventType.Complaint), + complaint: sesComplaintSchema, + mail: sesMailSchema, +}) + +// The SQS queue's SNS subscription is filtered to Bounce + Complaint only, so +// the tagged union is exhaustive. Any other eventType is unexpected and fails +// validation here (surfaced by the consumer's error handling) rather than +// being silently accepted. +const sesEventSchema = z.discriminatedUnion('eventType', [ + bounceEventSchema, + complaintEventSchema, +]) + +// SNS wraps the SES event JSON as a string in its `Message` field. +const snsEnvelopeSchema = z.object({ + Message: z.string(), +}) + +export type SesEvent = z.infer /** * Parses an SQS message body containing an SNS-wrapped SES event. * * Message nesting: SQS body (string) -> SNS envelope (JSON with `Message` field) * -> SES event (JSON string inside `Message`). + * + * Throws if the body isn't valid JSON (SyntaxError) or the payload doesn't + * match the expected shape (ZodError) — callers handle/log the failure. */ export function parseSqsMessage(sqsBody: string): SesEvent { - const snsEnvelope = JSON.parse(sqsBody) - - if (!snsEnvelope.Message) { - throw new Error('Missing Message field in SNS envelope') - } - - const sesEvent = JSON.parse(snsEnvelope.Message) - - if (!sesEvent.eventType) { - throw new Error('Missing eventType in SES event') - } - - return sesEvent as SesEvent + const { Message } = snsEnvelopeSchema.parse(JSON.parse(sqsBody)) + return sesEventSchema.parse(JSON.parse(Message)) }