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