feat: add daily email usage (#97)

* add daily email usage

* remove console
This commit is contained in:
KM Koushik
2025-02-02 07:57:49 +11:00
committed by GitHub
parent 6b9696e715
commit f60c66acbe
9 changed files with 283 additions and 158 deletions

View File

@@ -68,108 +68,85 @@ export const emailRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
const { team } = ctx;
const days = input.days !== 7 ? 30 : 7;
const daysInMs = days * 24 * 60 * 60 * 1000;
const rawEmailStatusCounts = await db.email.findMany({
where: {
teamId: team.id,
createdAt: {
gt: new Date(Date.now() - daysInMs),
},
},
select: {
latestStatus: true,
createdAt: true,
},
});
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const isoStartDate = startDate.toISOString().split("T")[0];
const totalCount = rawEmailStatusCounts.length;
type DailyEmailUsage = {
date: string;
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
};
const emailStatusCounts = rawEmailStatusCounts.reduce(
(acc, cur) => {
acc[cur.latestStatus] = {
count: (acc[cur.latestStatus]?.count || 0) + 1,
percentage: Number(
(
(((acc[cur.latestStatus]?.count || 0) + 1) / totalCount) *
100
).toFixed(0)
),
};
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
SELECT
date,
sent,
delivered,
opened,
clicked,
bounced,
complained
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" >= ${isoStartDate}
ORDER BY "date" ASC
`;
// Fill in any missing dates with 0 values
const filledResult: DailyEmailUsage[] = [];
const endDateObj = new Date();
for (let i = days; i > -1; i--) {
const dateStr = subDays(endDateObj, i)
.toISOString()
.split("T")[0] as string;
const existingData = result.find((r) => r.date === dateStr);
if (existingData) {
filledResult.push({
...existingData,
date: format(dateStr, "MMM dd"),
});
} else {
filledResult.push({
date: format(dateStr, "MMM dd"),
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
});
}
}
const totalCounts = result.reduce(
(acc, curr) => {
acc.sent += curr.sent;
acc.delivered += curr.delivered;
acc.opened += curr.opened;
acc.clicked += curr.clicked;
acc.bounced += curr.bounced;
acc.complained += curr.complained;
return acc;
},
{
DELIVERED: { count: 0, percentage: 0 },
COMPLAINED: { count: 0, percentage: 0 },
OPENED: { count: 0, percentage: 0 },
CLICKED: { count: 0, percentage: 0 },
BOUNCED: { count: 0, percentage: 0 },
} as Record<EmailStatus, { count: number; percentage: number }>
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
}
);
const dateRecord: Record<
string,
Record<
"DELIVERED" | "COMPLAINED" | "OPENED" | "CLICKED" | "BOUNCED",
number
>
> = {};
const currentDate = new Date();
for (let i = 0; i < (input.days || 7); i++) {
const actualDate = subDays(currentDate, i);
dateRecord[format(actualDate, "MMM dd")] = {
DELIVERED: 0,
COMPLAINED: 0,
OPENED: 0,
CLICKED: 0,
BOUNCED: 0,
};
}
const _emailDailyStatusCounts = rawEmailStatusCounts.reduce(
(acc, { latestStatus, createdAt }) => {
const day = format(createdAt, "MMM dd");
if (
!day ||
![
"DELIVERED",
"COMPLAINED",
"OPENED",
"CLICKED",
"BOUNCED",
].includes(latestStatus)
) {
return acc;
}
if (!acc[day]) {
return acc;
}
acc[day]![
latestStatus as
| "DELIVERED"
| "COMPLAINED"
| "OPENED"
| "CLICKED"
| "BOUNCED"
]++;
return acc;
},
dateRecord
);
const emailDailyStatusCounts = Object.entries(_emailDailyStatusCounts)
.reverse()
.map(([date, counts]) => ({
name: date,
...counts,
}));
return { emailStatusCounts, totalCount, emailDailyStatusCounts };
return { result: filledResult, totalCounts };
}),
getEmail: emailProcedure.query(async ({ input }) => {

View File

@@ -162,15 +162,14 @@ export async function sendEmailThroughSes({
...(unsubUrl
? [
{ Name: "List-Unsubscribe", Value: `<${unsubUrl}>` },
{ Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click" },
{
Name: "List-Unsubscribe-Post",
Value: "List-Unsubscribe=One-Click",
},
]
: []
),
: []),
// Spread in the precedence header if present
...(isBulk
? [{ Name: "Precedence", Value: "bulk" }]
: []
),
...(isBulk ? [{ Name: "Precedence", Value: "bulk" }] : []),
],
},
},
@@ -216,7 +215,8 @@ export async function sendEmailWithAttachments({
rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`;
rawEmail += cc && cc.length ? `Cc: ${cc.join(", ")}\n` : "";
rawEmail += bcc && bcc.length ? `Bcc: ${bcc.join(", ")}\n` : "";
rawEmail += replyTo && replyTo.length ? `Reply-To: ${replyTo.join(", ")}\n` : "";
rawEmail +=
replyTo && replyTo.length ? `Reply-To: ${replyTo.join(", ")}\n` : "";
rawEmail += `Subject: ${subject}\n`;
rawEmail += `MIME-Version: 1.0\n`;
rawEmail += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
@@ -266,7 +266,7 @@ export async function addWebhookConfiguration(
configName: string,
topicArn: string,
eventTypes: EventType[],
region: string,
region: string
) {
const sesClient = getSesClient(region);

View File

@@ -3,6 +3,8 @@ import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
import { db } from "../db";
import { updateCampaignAnalytics } from "./campaign-service";
import { env } from "~/env";
import { getRedis } from "../redis";
import { Queue, Worker } from "bullmq";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -45,6 +47,49 @@ export async function parseSesHook(data: SesEvent) {
WHERE id = ${email.id}
`;
// Update daily email usage statistics
const today = new Date().toISOString().split("T")[0] as string; // Format: YYYY-MM-DD
if (
[
"DELIVERED",
"OPENED",
"CLICKED",
"BOUNCED",
"COMPLAINED",
"SENT",
].includes(mailStatus)
) {
const updateField = mailStatus.toLowerCase();
await db.dailyEmailUsage.upsert({
where: {
teamId_domainId_date_type: {
teamId: email.teamId,
domainId: email.domainId ?? 0,
date: today,
type: email.campaignId ? "MARKETING" : "TRANSACTIONAL",
},
},
create: {
teamId: email.teamId,
domainId: email.domainId ?? 0,
date: today,
type: email.campaignId ? "MARKETING" : "TRANSACTIONAL",
delivered: updateField === "delivered" ? 1 : 0,
opened: updateField === "opened" ? 1 : 0,
clicked: updateField === "clicked" ? 1 : 0,
bounced: updateField === "bounced" ? 1 : 0,
complained: updateField === "complained" ? 1 : 0,
},
update: {
[updateField]: {
increment: 1,
},
},
});
}
if (email.campaignId) {
if (
mailStatus !== "CLICKED" ||
@@ -109,3 +154,28 @@ function getEmailData(data: SesEvent) {
return data[eventType.toLowerCase() as SesEventDataKey];
}
}
export class SesHookParser {
private static sesHookQueue = new Queue("ses-web-hook", {
connection: getRedis(),
});
private static worker = new Worker(
"ses-web-hook",
async (job) => {
await this.execute(job.data);
},
{
connection: getRedis(),
concurrency: 200,
}
);
private static async execute(event: SesEvent) {
await parseSesHook(event);
}
static async queue(data: { event: SesEvent; messageId: string }) {
return await this.sesHookQueue.add(data.messageId, data.event);
}
}