Files
GibSend/apps/web/src/server/service/campaign-service.ts
KM Koushik d497c29eba process camapign emails in queue (#104)
* process camapign emails in queue

* remove validity

* add bulk queue

* fix typo
2025-02-08 21:34:48 +11:00

393 lines
8.9 KiB
TypeScript

import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { db } from "../db";
import { createHash } from "crypto";
import { env } from "~/env";
import { Campaign, Contact, EmailStatus } from "@prisma/client";
import { validateDomainFromEmail } from "./domain-service";
import { EmailQueueService } from "./email-queue-service";
import { Queue, Worker } from "bullmq";
import { getRedis } from "../redis";
import {
CAMPAIGN_MAIL_PROCESSING_QUEUE,
DEFAULT_QUEUE_OPTIONS,
} from "../queue/queue-constants";
export async function sendCampaign(id: string) {
let campaign = await db.campaign.findUnique({
where: { id },
});
if (!campaign) {
throw new Error("Campaign not found");
}
if (!campaign.content) {
throw new Error("No content added for campaign");
}
let jsonContent: Record<string, any>;
try {
jsonContent = JSON.parse(campaign.content);
const renderer = new EmailRenderer(jsonContent);
const html = await renderer.render();
campaign = await db.campaign.update({
where: { id },
data: { html },
});
} catch (error) {
console.error(error);
throw new Error("Failed to parse campaign content");
}
if (!campaign.contactBookId) {
throw new Error("No contact book found for campaign");
}
const contactBook = await db.contactBook.findUnique({
where: { id: campaign.contactBookId },
include: {
contacts: {
where: {
subscribed: true,
},
},
},
});
if (!contactBook) {
throw new Error("Contact book not found");
}
if (!campaign.html) {
throw new Error("No HTML content for campaign");
}
await sendCampaignEmail(campaign, {
campaignId: campaign.id,
from: campaign.from,
subject: campaign.subject,
html: campaign.html,
replyTo: campaign.replyTo,
cc: campaign.cc,
bcc: campaign.bcc,
teamId: campaign.teamId,
contacts: contactBook.contacts,
});
await db.campaign.update({
where: { id },
data: { status: "SENT", total: contactBook.contacts.length },
});
}
export function createUnsubUrl(contactId: string, campaignId: string) {
const unsubId = `${contactId}-${campaignId}`;
const unsubHash = createHash("sha256")
.update(`${unsubId}-${env.NEXTAUTH_SECRET}`)
.digest("hex");
return `${env.NEXTAUTH_URL}/unsubscribe?id=${unsubId}&hash=${unsubHash}`;
}
export async function unsubscribeContact(id: string, hash: string) {
const [contactId, campaignId] = id.split("-");
if (!contactId || !campaignId) {
throw new Error("Invalid unsubscribe link");
}
// Verify the hash
const expectedHash = createHash("sha256")
.update(`${id}-${env.NEXTAUTH_SECRET}`)
.digest("hex");
if (hash !== expectedHash) {
throw new Error("Invalid unsubscribe link");
}
// Update the contact's subscription status
try {
const contact = await db.contact.findUnique({
where: { id: contactId },
});
if (!contact) {
throw new Error("Contact not found");
}
if (contact.subscribed) {
await db.contact.update({
where: { id: contactId },
data: { subscribed: false },
});
await db.campaign.update({
where: { id: campaignId },
data: {
unsubscribed: {
increment: 1,
},
},
});
}
return contact;
} catch (error) {
console.error("Error unsubscribing contact:", error);
throw new Error("Failed to unsubscribe contact");
}
}
export async function subscribeContact(id: string, hash: string) {
const [contactId, campaignId] = id.split("-");
if (!contactId || !campaignId) {
throw new Error("Invalid subscribe link");
}
// Verify the hash
const expectedHash = createHash("sha256")
.update(`${id}-${env.NEXTAUTH_SECRET}`)
.digest("hex");
if (hash !== expectedHash) {
throw new Error("Invalid subscribe link");
}
// Update the contact's subscription status
try {
const contact = await db.contact.findUnique({
where: { id: contactId },
});
if (!contact) {
throw new Error("Contact not found");
}
if (!contact.subscribed) {
await db.contact.update({
where: { id: contactId },
data: { subscribed: true },
});
await db.campaign.update({
where: { id: campaignId },
data: {
unsubscribed: {
decrement: 1,
},
},
});
}
return true;
} catch (error) {
console.error("Error subscribing contact:", error);
throw new Error("Failed to subscribe contact");
}
}
type CampainEmail = {
campaignId: string;
from: string;
subject: string;
html: string;
previewText?: string;
replyTo?: string[];
cc?: string[];
bcc?: string[];
teamId: number;
contacts: Array<Contact>;
};
type CampaignEmailJob = {
contact: Contact;
campaign: Campaign;
emailConfig: {
from: string;
subject: string;
replyTo?: string[];
cc?: string[];
bcc?: string[];
teamId: number;
campaignId: string;
previewText?: string;
domainId: number;
region: string;
};
};
async function processContactEmail(jobData: CampaignEmailJob) {
const { contact, campaign, emailConfig } = jobData;
const jsonContent = JSON.parse(campaign.content || "{}");
const renderer = new EmailRenderer(jsonContent);
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
const html = await renderer.render({
shouldReplaceVariableValues: true,
variableValues: {
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
},
linkValues: {
"{{unsend_unsubscribe_url}}": unsubscribeUrl,
},
});
// Create single email
const email = await db.email.create({
data: {
to: [contact.email],
replyTo: emailConfig.replyTo,
cc: emailConfig.cc,
bcc: emailConfig.bcc,
from: emailConfig.from,
subject: emailConfig.subject,
html,
text: emailConfig.previewText,
teamId: emailConfig.teamId,
campaignId: emailConfig.campaignId,
contactId: contact.id,
domainId: emailConfig.domainId,
},
});
// Queue email for sending
await EmailQueueService.queueEmail(
email.id,
emailConfig.region,
false,
unsubscribeUrl
);
}
export async function sendCampaignEmail(
campaign: Campaign,
emailData: CampainEmail
) {
const {
campaignId,
from,
subject,
replyTo,
cc,
bcc,
teamId,
contacts,
previewText,
} = emailData;
const domain = await validateDomainFromEmail(from, teamId);
console.log("Bulk queueing contacts");
await CampaignEmailService.queueBulkContacts(
contacts.map((contact) => ({
contact,
campaign,
emailConfig: {
from,
subject,
replyTo: replyTo
? Array.isArray(replyTo)
? replyTo
: [replyTo]
: undefined,
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
teamId,
campaignId,
previewText,
domainId: domain.id,
region: domain.region,
},
}))
);
}
export async function updateCampaignAnalytics(
campaignId: string,
emailStatus: EmailStatus
) {
const campaign = await db.campaign.findUnique({
where: { id: campaignId },
});
if (!campaign) {
throw new Error("Campaign not found");
}
const updateData: Record<string, any> = {};
switch (emailStatus) {
case EmailStatus.SENT:
updateData.sent = { increment: 1 };
break;
case EmailStatus.DELIVERED:
updateData.delivered = { increment: 1 };
break;
case EmailStatus.OPENED:
updateData.opened = { increment: 1 };
break;
case EmailStatus.CLICKED:
updateData.clicked = { increment: 1 };
break;
case EmailStatus.BOUNCED:
updateData.bounced = { increment: 1 };
break;
case EmailStatus.COMPLAINED:
updateData.complained = { increment: 1 };
break;
default:
break;
}
await db.campaign.update({
where: { id: campaignId },
data: updateData,
});
}
const CAMPAIGN_EMAIL_CONCURRENCY = 200;
class CampaignEmailService {
private static campaignQueue = new Queue(CAMPAIGN_MAIL_PROCESSING_QUEUE, {
connection: getRedis(),
});
static worker = new Worker(
CAMPAIGN_MAIL_PROCESSING_QUEUE,
async (job) => {
await processContactEmail(job.data);
},
{
connection: getRedis(),
concurrency: CAMPAIGN_EMAIL_CONCURRENCY,
}
);
static async queueContact(data: CampaignEmailJob) {
return await this.campaignQueue.add(
`contact-${data.contact.id}`,
data,
DEFAULT_QUEUE_OPTIONS
);
}
static async queueBulkContacts(data: CampaignEmailJob[]) {
return await this.campaignQueue.addBulk(
data.map((item) => ({
name: `contact-${item.contact.id}`,
data: item,
opts: {
...DEFAULT_QUEUE_OPTIONS,
},
}))
);
}
}