From 585cd23ba2e1d972da94264cef69999c547e9e19 Mon Sep 17 00:00:00 2001 From: Manoj Naik <68473696+ManojINaik@users.noreply.github.com> Date: Tue, 17 Feb 2026 02:19:03 +0530 Subject: [PATCH] fix: sync suppression list removal with AWS SES (closes #324) (#331) * 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) * 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. * 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/aws/ses.ts | 34 +++++++++++ .../src/server/service/suppression-service.ts | 60 +++++++++++++++++-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 1b3296b..0fda94c 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 16c59e1..5c7d146 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,71 @@ 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) + ) + ); + + // Check for failures - deleteFromSesSuppressionList returns false on error + const failures = results.filter( + (r) => + r.status === "rejected" || + (r.status === "fulfilled" && r.value === false) + ); + 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 +199,7 @@ export class SuppressionService { ) { logger.debug( { - email, + email: normalizedEmail, teamId, }, "Attempted to remove non-existent suppression - already not suppressed" @@ -159,7 +209,7 @@ export class SuppressionService { logger.error( { - email, + email: normalizedEmail, teamId, error: error instanceof Error ? error.message : "Unknown error", },