feat: add suppression list (#192)

This commit is contained in:
KM Koushik
2025-07-27 23:51:59 +10:00
committed by GitHub
parent a28f132428
commit e6dd8673b4
20 changed files with 2026 additions and 58 deletions

View File

@@ -10,6 +10,7 @@ import { templateRouter } from "./routers/template";
import { billingRouter } from "./routers/billing";
import { invitationRouter } from "./routers/invitiation";
import { dashboardRouter } from "./routers/dashboard";
import { suppressionRouter } from "./routers/suppression";
/**
* This is the primary router for your server.
@@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({
billing: billingRouter,
invitation: invitationRouter,
dashboard: dashboardRouter,
suppression: suppressionRouter,
});
// export type definition of API

View File

@@ -0,0 +1,147 @@
import { SuppressionReason } from "@prisma/client";
import { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import { SuppressionService } from "~/server/service/suppression-service";
export const suppressionRouter = createTRPCRouter({
// Get suppression list for team with pagination
getSuppressions: teamProcedure
.input(
z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
search: z.string().optional(),
reason: z.nativeEnum(SuppressionReason).optional().nullable(),
sortBy: z.enum(["email", "reason", "createdAt"]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
})
)
.query(async ({ ctx, input }) => {
const { page, limit, search, reason, sortBy, sortOrder } = input;
const result = await SuppressionService.getSuppressionList({
teamId: ctx.team.id,
page,
limit,
search,
reason,
sortBy,
sortOrder,
});
return {
suppressions: result.suppressions,
pagination: {
page,
limit,
totalCount: result.total,
totalPages: Math.ceil(result.total / limit),
hasNext: page * limit < result.total,
hasPrev: page > 1,
},
};
}),
// Add manual suppression
addSuppression: teamProcedure
.input(
z.object({
email: z.string().email(),
reason: z
.nativeEnum(SuppressionReason)
.default(SuppressionReason.MANUAL),
})
)
.mutation(async ({ ctx, input }) => {
return SuppressionService.addSuppression({
email: input.email,
teamId: ctx.team.id,
reason: input.reason,
});
}),
// Remove suppression
removeSuppression: teamProcedure
.input(
z.object({
email: z.string().email(),
})
)
.mutation(async ({ ctx, input }) => {
await SuppressionService.removeSuppression(input.email, ctx.team.id);
}),
// Bulk add suppressions
bulkAddSuppressions: teamProcedure
.input(
z.object({
emails: z.array(z.string().email()).max(1000),
reason: z
.nativeEnum(SuppressionReason)
.default(SuppressionReason.MANUAL),
})
)
.mutation(async ({ ctx, input }) => {
return SuppressionService.addMultipleSuppressions(
ctx.team.id,
input.emails,
input.reason
);
}),
// Check if email is suppressed
checkSuppression: teamProcedure
.input(
z.object({
email: z.string().email(),
})
)
.query(async ({ ctx, input }) => {
return SuppressionService.isEmailSuppressed(input.email, ctx.team.id);
}),
// Check multiple emails for suppression
checkMultipleSuppressions: teamProcedure
.input(
z.object({
emails: z.array(z.string().email()).max(100),
})
)
.query(async ({ ctx, input }) => {
return SuppressionService.checkMultipleEmails(input.emails, ctx.team.id);
}),
// Get suppression stats
getSuppressionStats: teamProcedure.query(async ({ ctx }) => {
return SuppressionService.getSuppressionStats(ctx.team.id);
}),
// Export suppressions (for download functionality)
exportSuppressions: teamProcedure
.input(
z.object({
reason: z.nativeEnum(SuppressionReason).optional().nullable(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
// Get all suppressions without pagination for export
const result = await SuppressionService.getSuppressionList({
teamId: ctx.team.id,
page: 1,
limit: 10000, // Large limit for export
search: input.search,
reason: input.reason,
sortBy: "createdAt",
sortOrder: "desc",
});
return result.suppressions.map((suppression) => ({
email: suppression.email,
reason: suppression.reason,
source: suppression.source,
createdAt: suppression.createdAt.toISOString(),
}));
}),
});

View File

@@ -18,6 +18,7 @@ import {
} from "../queue/queue-constants";
import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import { SuppressionService } from "./suppression-service";
export async function sendCampaign(id: string) {
let campaign = await db.campaign.findUnique({
@@ -253,6 +254,35 @@ async function processContactEmail(jobData: CampaignEmailJob) {
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
// Check for suppressed emails before processing
const toEmails = [contact.email];
const ccEmails = emailConfig.cc || [];
const bccEmails = emailConfig.bcc || [];
// Collect all unique emails to check for suppressions
const allEmailsToCheck = [
...new Set([...toEmails, ...ccEmails, ...bccEmails]),
];
const suppressionResults = await SuppressionService.checkMultipleEmails(
allEmailsToCheck,
emailConfig.teamId
);
// Filter each field separately
const filteredToEmails = toEmails.filter(
(email) => !suppressionResults[email]
);
const filteredCcEmails = ccEmails.filter(
(email) => !suppressionResults[email]
);
const filteredBccEmails = bccEmails.filter(
(email) => !suppressionResults[email]
);
// Check if the contact's email (TO recipient) is suppressed
const isContactSuppressed = filteredToEmails.length === 0;
const html = await renderer.render({
shouldReplaceVariableValues: true,
variableValues: {
@@ -265,13 +295,80 @@ async function processContactEmail(jobData: CampaignEmailJob) {
},
});
// Create single email
if (isContactSuppressed) {
// Create suppressed email record
logger.info(
{
contactEmail: contact.email,
campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId,
},
"Contact email is suppressed. Creating suppressed email record."
);
const email = await db.email.create({
data: {
to: toEmails,
replyTo: emailConfig.replyTo,
cc: ccEmails.length > 0 ? ccEmails : undefined,
bcc: bccEmails.length > 0 ? bccEmails : undefined,
from: emailConfig.from,
subject: emailConfig.subject,
html,
text: emailConfig.previewText,
teamId: emailConfig.teamId,
campaignId: emailConfig.campaignId,
contactId: contact.id,
domainId: emailConfig.domainId,
latestStatus: "SUPPRESSED",
},
});
await db.emailEvent.create({
data: {
emailId: email.id,
status: "SUPPRESSED",
data: {
error: "Contact email is suppressed. No email sent.",
},
},
});
return;
}
// Log if any CC/BCC emails were filtered out
if (ccEmails.length > filteredCcEmails.length) {
logger.info(
{
originalCc: ccEmails,
filteredCc: filteredCcEmails,
campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId,
},
"Some CC recipients were suppressed and filtered out from campaign email."
);
}
if (bccEmails.length > filteredBccEmails.length) {
logger.info(
{
originalBcc: bccEmails,
filteredBcc: filteredBccEmails,
campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId,
},
"Some BCC recipients were suppressed and filtered out from campaign email."
);
}
// Create email with filtered recipients
const email = await db.email.create({
data: {
to: [contact.email],
to: filteredToEmails,
replyTo: emailConfig.replyTo,
cc: emailConfig.cc,
bcc: emailConfig.bcc,
cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
from: emailConfig.from,
subject: emailConfig.subject,
html,

View File

@@ -5,6 +5,7 @@ import { EmailQueueService } from "./email-queue-service";
import { validateDomainFromEmail } from "./domain-service";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { logger } from "../logger/log";
import { SuppressionService } from "./suppression-service";
async function checkIfValidEmail(emailId: string) {
const email = await db.email.findUnique({
@@ -71,6 +72,95 @@ export async function sendEmail(
const domain = await validateDomainFromEmail(from, teamId);
// Check for suppressed emails before sending
const toEmails = Array.isArray(to) ? to : [to];
const ccEmails = cc ? (Array.isArray(cc) ? cc : [cc]) : [];
const bccEmails = bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : [];
// Collect all unique emails to check for suppressions
const allEmailsToCheck = [
...new Set([...toEmails, ...ccEmails, ...bccEmails]),
];
const suppressionResults = await SuppressionService.checkMultipleEmails(
allEmailsToCheck,
teamId
);
// Filter each field separately
const filteredToEmails = toEmails.filter(
(email) => !suppressionResults[email]
);
const filteredCcEmails = ccEmails.filter(
(email) => !suppressionResults[email]
);
const filteredBccEmails = bccEmails.filter(
(email) => !suppressionResults[email]
);
// Only block the email if all TO recipients are suppressed
if (filteredToEmails.length === 0) {
logger.info(
{
to,
teamId,
},
"All TO recipients are suppressed. No emails to send."
);
const email = await db.email.create({
data: {
to: toEmails,
from,
subject: subject as string,
teamId,
domainId: domain.id,
latestStatus: "SUPPRESSED",
apiId: apiKeyId,
text,
html,
cc: ccEmails.length > 0 ? ccEmails : undefined,
bcc: bccEmails.length > 0 ? bccEmails : undefined,
inReplyToId,
},
});
await db.emailEvent.create({
data: {
emailId: email.id,
status: "SUPPRESSED",
data: {
error: "All TO recipients are suppressed. No emails to send.",
},
},
});
return email;
}
// Log if any CC/BCC emails were filtered out
if (ccEmails.length > filteredCcEmails.length) {
logger.info(
{
originalCc: ccEmails,
filteredCc: filteredCcEmails,
teamId,
},
"Some CC recipients were suppressed and filtered out."
);
}
if (bccEmails.length > filteredBccEmails.length) {
logger.info(
{
originalBcc: bccEmails,
filteredBcc: filteredBccEmails,
teamId,
},
"Some BCC recipients were suppressed and filtered out."
);
}
if (templateId) {
const template = await db.template.findUnique({
where: { id: templateId },
@@ -131,7 +221,7 @@ export async function sendEmail(
const email = await db.email.create({
data: {
to: Array.isArray(to) ? to : [to],
to: filteredToEmails,
from,
subject: subject as string,
replyTo: replyTo
@@ -139,8 +229,8 @@ export async function sendEmail(
? replyTo
: [replyTo]
: undefined,
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
text,
html,
teamId,
@@ -267,17 +357,217 @@ export async function sendBulkEmails(
});
}
// Filter out suppressed emails
const emailChecks = await Promise.all(
emailContents.map(async (content, index) => {
const toEmails = Array.isArray(content.to) ? content.to : [content.to];
const ccEmails = content.cc
? Array.isArray(content.cc)
? content.cc
: [content.cc]
: [];
const bccEmails = content.bcc
? Array.isArray(content.bcc)
? content.bcc
: [content.bcc]
: [];
// Collect all unique emails to check for suppressions
const allEmailsToCheck = [
...new Set([...toEmails, ...ccEmails, ...bccEmails]),
];
const suppressionResults = await SuppressionService.checkMultipleEmails(
allEmailsToCheck,
content.teamId
);
// Filter each field separately
const filteredToEmails = toEmails.filter(
(email) => !suppressionResults[email]
);
const filteredCcEmails = ccEmails.filter(
(email) => !suppressionResults[email]
);
const filteredBccEmails = bccEmails.filter(
(email) => !suppressionResults[email]
);
// Only consider it suppressed if all TO recipients are suppressed
const hasSuppressedToEmails = filteredToEmails.length === 0;
return {
originalIndex: index,
content: {
...content,
to: filteredToEmails,
cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
},
suppressed: hasSuppressedToEmails,
suppressedEmails: toEmails.filter((email) => suppressionResults[email]),
suppressedCcEmails: ccEmails.filter(
(email) => suppressionResults[email]
),
suppressedBccEmails: bccEmails.filter(
(email) => suppressionResults[email]
),
};
})
);
const validEmails = emailChecks.filter((check) => !check.suppressed);
const suppressedEmailsInfo = emailChecks.filter((check) => check.suppressed);
// Log suppressed emails for reporting
if (suppressedEmailsInfo.length > 0) {
logger.info(
{
suppressedCount: suppressedEmailsInfo.length,
totalCount: emailContents.length,
suppressedEmails: suppressedEmailsInfo.map((info) => ({
to: info.content.to,
suppressedAddresses: info.suppressedEmails,
})),
},
"Filtered suppressed emails from bulk send"
);
}
// Update emailContents to only include valid emails
const filteredEmailContents = validEmails.map((check) => check.content);
// Create suppressed email records
const suppressedEmails = [];
for (const suppressedInfo of suppressedEmailsInfo) {
const originalContent = emailContents[suppressedInfo.originalIndex];
if (!originalContent) continue;
const {
to,
from,
subject: subjectFromApiCall,
templateId,
variables,
text,
html: htmlFromApiCall,
teamId,
attachments,
replyTo,
cc,
bcc,
scheduledAt,
apiKeyId,
inReplyToId,
} = originalContent;
let subject = subjectFromApiCall;
let html = htmlFromApiCall;
// Validate domain for suppressed email too
const domain = await validateDomainFromEmail(from, teamId);
// Process template if specified
if (templateId) {
const template = await db.template.findUnique({
where: { id: templateId },
});
if (template) {
const jsonContent = JSON.parse(template.content || "{}");
const renderer = new EmailRenderer(jsonContent);
subject = replaceVariables(template.subject || "", variables || {});
// {{}} for link replacements
const modifiedVariables = {
...variables,
...Object.keys(variables || {}).reduce(
(acc, key) => {
acc[`{{${key}}}`] = variables?.[key] || "";
return acc;
},
{} as Record<string, string>
),
};
html = await renderer.render({
shouldReplaceVariableValues: true,
variableValues: modifiedVariables,
});
}
}
const originalToEmails = Array.isArray(originalContent.to)
? originalContent.to
: [originalContent.to];
const originalCcEmails = originalContent.cc
? Array.isArray(originalContent.cc)
? originalContent.cc
: [originalContent.cc]
: [];
const originalBccEmails = originalContent.bcc
? Array.isArray(originalContent.bcc)
? originalContent.bcc
: [originalContent.bcc]
: [];
const email = await db.email.create({
data: {
to: originalToEmails,
from,
subject: subject as string,
replyTo: replyTo
? Array.isArray(replyTo)
? replyTo
: [replyTo]
: undefined,
cc: originalCcEmails.length > 0 ? originalCcEmails : undefined,
bcc: originalBccEmails.length > 0 ? originalBccEmails : undefined,
text,
html,
teamId,
domainId: domain.id,
attachments: attachments ? JSON.stringify(attachments) : undefined,
scheduledAt: scheduledAt ? new Date(scheduledAt) : undefined,
latestStatus: "SUPPRESSED",
apiId: apiKeyId,
inReplyToId,
},
});
await db.emailEvent.create({
data: {
emailId: email.id,
status: "SUPPRESSED",
data: {
error: "All TO recipients are suppressed. No emails to send.",
},
},
});
suppressedEmails.push({
email,
originalIndex: suppressedInfo.originalIndex,
});
}
if (filteredEmailContents.length === 0) {
// Return only suppressed emails if no valid emails to send
return suppressedEmails;
}
// Group emails by domain to minimize domain validations
const emailsByDomain = new Map<
string,
{
domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
emails: typeof emailContents;
emails: typeof filteredEmailContents;
}
>();
// First pass: validate domains and group emails
for (const content of emailContents) {
for (const content of filteredEmailContents) {
const { from } = content;
if (!emailsByDomain.has(from)) {
const domain = await validateDomainFromEmail(from, content.teamId);
@@ -316,6 +606,11 @@ export async function sendBulkEmails(
apiKeyId,
} = content;
// Find the original index for this email
const originalIndex =
validEmails.find((check) => check.content === content)?.originalIndex ??
-1;
let subject = subjectFromApiCall;
let html = htmlFromApiCall;
@@ -383,8 +678,8 @@ export async function sendBulkEmails(
? replyTo
: [replyTo]
: undefined,
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
cc: cc && cc.length > 0 ? cc : undefined,
bcc: bcc && bcc.length > 0 ? bcc : undefined,
text,
html,
teamId,
@@ -396,7 +691,7 @@ export async function sendBulkEmails(
},
});
createdEmails.push(email);
createdEmails.push({ email, originalIndex });
// Prepare queue job
queueJobs.push({
@@ -433,7 +728,7 @@ export async function sendBulkEmails(
createdEmails.map(async (email) => {
await db.emailEvent.create({
data: {
emailId: email.id,
emailId: email.email.id,
status: "FAILED",
data: {
error: error.toString(),
@@ -441,7 +736,7 @@ export async function sendBulkEmails(
},
});
await db.email.update({
where: { id: email.id },
where: { id: email.email.id },
data: { latestStatus: "FAILED" },
});
})
@@ -449,5 +744,10 @@ export async function sendBulkEmails(
throw error;
}
return createdEmails;
// Combine and sort all emails by original index to preserve order
const allEmails = [...suppressedEmails, ...createdEmails];
allEmails.sort((a, b) => a.originalIndex - b.originalIndex);
// Return just the email objects in the correct order
return allEmails.map((item) => item.email);
}

View File

@@ -1,4 +1,9 @@
import { EmailStatus, Prisma, UnsubscribeReason } from "@prisma/client";
import {
EmailStatus,
Prisma,
UnsubscribeReason,
SuppressionReason,
} from "@prisma/client";
import {
SesBounce,
SesClick,
@@ -19,6 +24,7 @@ import {
} from "../queue/queue-constants";
import { getChildLogger, logger, withLogger } from "../logger/log";
import { randomUUID } from "crypto";
import { SuppressionService } from "./suppression-service";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -101,6 +107,45 @@ export async function parseSesHook(data: SesEvent) {
mailStatus === EmailStatus.BOUNCED &&
(mailData as SesBounce).bounceType === "Permanent";
// Add emails to suppression list for hard bounces and complaints
if (isHardBounced || mailStatus === EmailStatus.COMPLAINED) {
const recipientEmails = Array.isArray(email.to) ? email.to : [email.to];
try {
await Promise.all(
recipientEmails.map((recipientEmail) =>
SuppressionService.addSuppression({
email: recipientEmail,
teamId: email.teamId,
reason: isHardBounced
? SuppressionReason.HARD_BOUNCE
: SuppressionReason.COMPLAINT,
source: email.id,
})
)
);
logger.info(
{
emailId: email.id,
recipients: recipientEmails,
reason: isHardBounced ? "HARD_BOUNCE" : "COMPLAINT",
},
"Added emails to suppression list due to bounce/complaint"
);
} catch (error) {
logger.error(
{
emailId: email.id,
recipients: recipientEmails,
error: error instanceof Error ? error.message : "Unknown error",
},
"Failed to add emails to suppression list"
);
// Don't throw error - continue processing the webhook
}
}
if (
[
"DELIVERED",

View File

@@ -0,0 +1,393 @@
import { SuppressionReason, SuppressionList } from "@prisma/client";
import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error";
import { logger } from "../logger/log";
export type AddSuppressionParams = {
email: string;
teamId: number;
reason: SuppressionReason;
source?: string;
};
export type GetSuppressionListParams = {
teamId: number;
page?: number;
limit?: number;
search?: string;
reason?: SuppressionReason | null;
sortBy?: "email" | "reason" | "createdAt";
sortOrder?: "asc" | "desc";
};
export type SuppressionListResult = {
suppressions: SuppressionList[];
total: number;
};
export class SuppressionService {
/**
* Add email to suppression list
*/
static async addSuppression(
params: AddSuppressionParams
): Promise<SuppressionList> {
const { email, teamId, reason, source } = params;
try {
const suppression = await db.suppressionList.upsert({
where: {
teamId_email: {
teamId,
email: email.toLowerCase().trim(),
},
},
create: {
email: email.toLowerCase().trim(),
teamId,
reason,
source,
},
update: {
reason,
source,
updatedAt: new Date(),
},
});
logger.info(
{
email,
teamId,
reason,
source,
suppressionId: suppression.id,
},
"Email added to suppression list"
);
return suppression;
} catch (error) {
logger.error(
{
email,
teamId,
reason,
source,
error: error instanceof Error ? error.message : "Unknown error",
},
"Failed to add email to suppression list"
);
throw new UnsendApiError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to add email to suppression list",
});
}
}
/**
* Check if email is suppressed for team
*/
static async isEmailSuppressed(
email: string,
teamId: number
): Promise<boolean> {
try {
const suppression = await db.suppressionList.findUnique({
where: {
teamId_email: {
teamId,
email: email.toLowerCase().trim(),
},
},
});
return !!suppression;
} catch (error) {
logger.error(
{
email,
teamId,
error: error instanceof Error ? error.message : "Unknown error",
},
"Failed to check email suppression status"
);
// In case of error, err on the side of caution and don't suppress
return false;
}
}
/**
* Remove email from suppression list
*/
static async removeSuppression(email: string, teamId: number): Promise<void> {
try {
const deleted = await db.suppressionList.delete({
where: {
teamId_email: {
teamId,
email: email.toLowerCase().trim(),
},
},
});
logger.info(
{
email,
teamId,
suppressionId: deleted.id,
},
"Email removed from suppression list"
);
} catch (error) {
// If the record doesn't exist, that's fine - it's already not suppressed
if (
error instanceof Error &&
error.message.includes("Record to delete does not exist")
) {
logger.debug(
{
email,
teamId,
},
"Attempted to remove non-existent suppression - already not suppressed"
);
return;
}
logger.error(
{
email,
teamId,
error: error instanceof Error ? error.message : "Unknown error",
},
"Failed to remove email from suppression list"
);
throw new UnsendApiError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to remove email from suppression list",
});
}
}
/**
* Get suppression list for team with pagination
*/
static async getSuppressionList(
params: GetSuppressionListParams
): Promise<SuppressionListResult> {
const {
teamId,
page = 1,
limit = 20,
search,
reason,
sortBy = "createdAt",
sortOrder = "desc",
} = params;
const offset = (page - 1) * limit;
const where = {
teamId,
...(search && {
email: {
contains: search,
mode: "insensitive" as const,
},
}),
...(reason && { reason }),
};
try {
const [suppressions, total] = await Promise.all([
db.suppressionList.findMany({
where,
skip: offset,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
db.suppressionList.count({ where }),
]);
return {
suppressions,
total,
};
} catch (error) {
logger.error(
{
teamId,
page,
limit,
search,
reason,
error: error instanceof Error ? error.message : "Unknown error",
},
"Failed to get suppression list"
);
throw new UnsendApiError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get suppression list",
});
}
}
/**
* Add multiple emails to suppression list
*/
static async addMultipleSuppressions(
teamId: number,
emails: string[],
reason: SuppressionReason
) {
// Remove duplicates by normalizing emails first, then using Set
const normalizedEmails = emails.map((email) => email.toLowerCase().trim());
const uniqueEmails = Array.from(new Set(normalizedEmails));
try {
// Process in batches to avoid overwhelming the database
const batchSize = 1000;
for (let i = 0; i < uniqueEmails.length; i += batchSize) {
const batch = uniqueEmails.slice(i, i + batchSize);
const alreadySuppressed = await db.suppressionList.findMany({
where: {
teamId,
email: { in: batch },
},
});
const emailsToAdd = batch.filter(
(email) => !alreadySuppressed.some((s) => s.email === email)
);
await db.suppressionList.createMany({
data: emailsToAdd.map((email) => ({
teamId,
email,
reason,
})),
});
}
logger.info(
{
originalCount: emails.length,
uniqueCount: uniqueEmails.length,
},
"Added multiple emails to suppression list"
);
} catch (error) {
logger.error(
{
originalCount: emails.length,
uniqueCount: uniqueEmails.length,
error: error instanceof Error ? error.message : "Unknown error",
},
"Failed to add multiple emails to suppression list"
);
throw new UnsendApiError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to add multiple emails to suppression list",
});
}
}
/**
* Get suppression statistics for a team
*/
static async getSuppressionStats(
teamId: number
): Promise<Record<SuppressionReason, number>> {
try {
const stats = await db.suppressionList.groupBy({
by: ["reason"],
where: { teamId },
_count: { _all: true },
});
const result: Record<SuppressionReason, number> = {
HARD_BOUNCE: 0,
COMPLAINT: 0,
MANUAL: 0,
};
stats.forEach((stat) => {
result[stat.reason] = stat._count._all;
});
return result;
} catch (error) {
logger.error(
{
teamId,
error: error instanceof Error ? error.message : "Unknown error",
},
"Failed to get suppression stats"
);
throw new UnsendApiError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get suppression stats",
});
}
}
/**
* Check multiple emails for suppression status
*/
static async checkMultipleEmails(
emails: string[],
teamId: number
): Promise<Record<string, boolean>> {
try {
const normalizedEmails = emails.map((email) =>
email.toLowerCase().trim()
);
const suppressions = await db.suppressionList.findMany({
where: {
teamId,
email: {
in: normalizedEmails,
},
},
select: {
email: true,
},
});
const suppressedEmails = new Set(suppressions.map((s) => s.email));
const result: Record<string, boolean> = {};
emails.forEach((email) => {
result[email] = suppressedEmails.has(email.toLowerCase().trim());
});
return result;
} catch (error) {
logger.error(
{
emailCount: emails.length,
teamId,
error: error instanceof Error ? error.message : "Unknown error",
},
"Failed to check multiple emails for suppression"
);
// In case of error, err on the side of caution and don't suppress any
const result: Record<string, boolean> = {};
emails.forEach((email) => {
result[email] = false;
});
return result;
}
}
}