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.
This commit is contained in:
Manoj Naik
2026-02-17 02:19:03 +05:30
committed by GitHub
parent ed4a429a1d
commit 585cd23ba2
2 changed files with 89 additions and 5 deletions
+34
View File
@@ -11,6 +11,7 @@ import {
GetAccountCommand, GetAccountCommand,
CreateTenantResourceAssociationCommand, CreateTenantResourceAssociationCommand,
DeleteTenantResourceAssociationCommand, DeleteTenantResourceAssociationCommand,
DeleteSuppressedDestinationCommand,
} from "@aws-sdk/client-sesv2"; } from "@aws-sdk/client-sesv2";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
import { generateKeyPairSync } from "crypto"; import { generateKeyPairSync } from "crypto";
@@ -310,3 +311,36 @@ export async function addWebhookConfiguration(
const response = await sesClient.send(command); const response = await sesClient.send(command);
return response.$metadata.httpStatusCode === 200; 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<boolean> {
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;
}
}
@@ -2,6 +2,7 @@ import { SuppressionReason, SuppressionList } from "@prisma/client";
import { db } from "../db"; import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error"; import { UnsendApiError } from "~/server/public-api/api-error";
import { logger } from "../logger/log"; import { logger } from "../logger/log";
import { deleteFromSesSuppressionList } from "../aws/ses";
export type AddSuppressionParams = { export type AddSuppressionParams = {
email: string; 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<void> { static async removeSuppression(email: string, teamId: number): Promise<void> {
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 { try {
const deleted = await db.suppressionList.delete({ const deleted = await db.suppressionList.delete({
where: { where: {
teamId_email: { teamId_email: {
teamId, teamId,
email: email.toLowerCase().trim(), email: normalizedEmail,
}, },
}, },
}); });
logger.info( logger.info(
{ {
email, email: normalizedEmail,
teamId, teamId,
suppressionId: deleted.id, suppressionId: deleted.id,
}, },
@@ -149,7 +199,7 @@ export class SuppressionService {
) { ) {
logger.debug( logger.debug(
{ {
email, email: normalizedEmail,
teamId, teamId,
}, },
"Attempted to remove non-existent suppression - already not suppressed" "Attempted to remove non-existent suppression - already not suppressed"
@@ -159,7 +209,7 @@ export class SuppressionService {
logger.error( logger.error(
{ {
email, email: normalizedEmail,
teamId, teamId,
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : "Unknown error",
}, },