From 049b50d30d529778c21b740758b3b971674f5593 Mon Sep 17 00:00:00 2001 From: Manoj Naik Date: Tue, 30 Dec 2025 20:37:32 +0530 Subject: [PATCH 1/3] fix: sync suppression list removal with AWS SES (closes #324) When removing an email from the suppression list, now also removes it from AWS SES account-level suppression list across all regions where the team has domains configured. - Add deleteFromSesSuppressionList helper to ses.ts - Update removeSuppression to query team domains for unique regions - Use best-effort pattern: AWS failures don't block local DB deletion - Handle NotFoundException gracefully (email not in SES list) --- apps/web/src/server/aws/ses.ts | 34 ++++++++++++ .../src/server/service/suppression-service.ts | 55 +++++++++++++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 1b3296b8..0fda94c8 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -11,6 +11,7 @@ import { GetAccountCommand, CreateTenantResourceAssociationCommand, DeleteTenantResourceAssociationCommand, + DeleteSuppressedDestinationCommand, } from "@aws-sdk/client-sesv2"; import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import { generateKeyPairSync } from "crypto"; @@ -310,3 +311,36 @@ export async function addWebhookConfiguration( const response = await sesClient.send(command); return response.$metadata.httpStatusCode === 200; } + +/** + * Remove email from AWS SES account-level suppression list + * Returns true if successful or email wasn't suppressed, false on error + */ +export async function deleteFromSesSuppressionList( + email: string, + region: string +): Promise { + const sesClient = getSesClient(region); + try { + const command = new DeleteSuppressedDestinationCommand({ + EmailAddress: email, + }); + await sesClient.send(command); + logger.info({ email, region }, "Removed email from SES suppression list"); + return true; + } catch (error: any) { + // NotFoundException means email wasn't in SES suppression list - that's fine + if (error.name === "NotFoundException") { + logger.debug( + { email, region }, + "Email not in SES suppression list (already removed or never added)" + ); + return true; + } + logger.error( + { email, region, error: error.message }, + "Failed to remove email from SES suppression list" + ); + return false; + } +} diff --git a/apps/web/src/server/service/suppression-service.ts b/apps/web/src/server/service/suppression-service.ts index 16c59e12..09df8000 100644 --- a/apps/web/src/server/service/suppression-service.ts +++ b/apps/web/src/server/service/suppression-service.ts @@ -2,6 +2,7 @@ import { SuppressionReason, SuppressionList } from "@prisma/client"; import { db } from "../db"; import { UnsendApiError } from "~/server/public-api/api-error"; import { logger } from "../logger/log"; +import { deleteFromSesSuppressionList } from "../aws/ses"; export type AddSuppressionParams = { email: string; @@ -120,22 +121,66 @@ export class SuppressionService { } /** - * Remove email from suppression list + * Remove email from suppression list (both local DB and AWS SES) */ static async removeSuppression(email: string, teamId: number): Promise { + const normalizedEmail = email.toLowerCase().trim(); + + // Get all unique regions from team's domains for AWS SES cleanup + try { + const teamDomains = await db.domain.findMany({ + where: { teamId }, + select: { region: true }, + }); + const uniqueRegions = [...new Set(teamDomains.map((d) => d.region))]; + + // Attempt to remove from AWS SES in all regions (best effort, don't throw) + if (uniqueRegions.length > 0) { + const results = await Promise.allSettled( + uniqueRegions.map((region) => + deleteFromSesSuppressionList(normalizedEmail, region) + ) + ); + + const failures = results.filter((r) => r.status === "rejected"); + if (failures.length > 0) { + logger.warn( + { + email: normalizedEmail, + teamId, + failedRegions: failures.length, + totalRegions: uniqueRegions.length, + }, + "Some AWS SES regions failed during suppression removal" + ); + } + } + } catch (error) { + // AWS SES cleanup failure should not block local DB deletion + logger.error( + { + email: normalizedEmail, + teamId, + error: error instanceof Error ? error.message : "Unknown error", + }, + "Failed to cleanup AWS SES suppression (continuing with local deletion)" + ); + } + + // Delete from local database try { const deleted = await db.suppressionList.delete({ where: { teamId_email: { teamId, - email: email.toLowerCase().trim(), + email: normalizedEmail, }, }, }); logger.info( { - email, + email: normalizedEmail, teamId, suppressionId: deleted.id, }, @@ -149,7 +194,7 @@ export class SuppressionService { ) { logger.debug( { - email, + email: normalizedEmail, teamId, }, "Attempted to remove non-existent suppression - already not suppressed" @@ -159,7 +204,7 @@ export class SuppressionService { logger.error( { - email, + email: normalizedEmail, teamId, error: error instanceof Error ? error.message : "Unknown error", }, From c63400f4c10cfbf1cebf7e03caead01baa09aaa9 Mon Sep 17 00:00:00 2001 From: Manoj Naik Date: Tue, 30 Dec 2025 20:46:29 +0530 Subject: [PATCH 2/3] fix: correct failure detection logic for SES suppression removal deleteFromSesSuppressionList returns false on error (never throws), so check for fulfilled promises with value === false instead of rejected status. --- apps/web/src/server/service/suppression-service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/server/service/suppression-service.ts b/apps/web/src/server/service/suppression-service.ts index 09df8000..c201078d 100644 --- a/apps/web/src/server/service/suppression-service.ts +++ b/apps/web/src/server/service/suppression-service.ts @@ -142,7 +142,10 @@ export class SuppressionService { ) ); - const failures = results.filter((r) => r.status === "rejected"); + // Check for failures - deleteFromSesSuppressionList returns false on error + const failures = results.filter( + (r) => r.status === "fulfilled" && r.value === false + ); if (failures.length > 0) { logger.warn( { From 698ee27dd15460a9a6b98e42e3cf9262421e36f3 Mon Sep 17 00:00:00 2001 From: Manoj Naik Date: Tue, 30 Dec 2025 21:12:34 +0530 Subject: [PATCH 3/3] fix: account for rejected promises in SES suppression removal Updated the filter logic for Promise.allSettled to include 'rejected' status as well as 'fulfilled' with a 'false' value. This ensures that any errors occurring before the try block in deleteFromSesSuppressionList are correctly caught and logged. --- apps/web/src/server/service/suppression-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/server/service/suppression-service.ts b/apps/web/src/server/service/suppression-service.ts index c201078d..5c7d1468 100644 --- a/apps/web/src/server/service/suppression-service.ts +++ b/apps/web/src/server/service/suppression-service.ts @@ -144,7 +144,9 @@ export class SuppressionService { // Check for failures - deleteFromSesSuppressionList returns false on error const failures = results.filter( - (r) => r.status === "fulfilled" && r.value === false + (r) => + r.status === "rejected" || + (r.status === "fulfilled" && r.value === false) ); if (failures.length > 0) { logger.warn(