* 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:
@@ -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<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 { 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<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 {
|
||||
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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user