feat: add suppression list (#192)
This commit is contained in:
@@ -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
|
||||
|
147
apps/web/src/server/api/routers/suppression.ts
Normal file
147
apps/web/src/server/api/routers/suppression.ts
Normal 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(),
|
||||
}));
|
||||
}),
|
||||
});
|
@@ -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,
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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",
|
||||
|
393
apps/web/src/server/service/suppression-service.ts
Normal file
393
apps/web/src/server/service/suppression-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user