feat: include bounce reason in export (#226)

This commit is contained in:
KM Koushik
2025-09-11 07:09:13 +10:00
committed by GitHub
parent 0167d13ce8
commit 5423013b77
2 changed files with 140 additions and 21 deletions

View File

@@ -122,9 +122,27 @@ export default function EmailsList() {
return /[",\r\n]/.test(safe) ? `"${safe}"` : safe; return /[",\r\n]/.test(safe) ? `"${safe}"` : safe;
}; };
const header = ["To", "Status", "Subject", "Sent At"].join(","); const header = [
"To",
"Status",
"Subject",
"Sent At",
"Bounce Type",
"Bounce Subtype",
"Bounce Reason",
].join(",");
const rows = resp.data.map((e) => const rows = resp.data.map((e) =>
[e.to, e.status, e.subject, e.sentAt].map(escape).join(","), [
e.to,
e.status,
e.subject,
e.sentAt,
e.bounceType,
e.bounceSubType,
e.bounceReason,
]
.map(escape)
.join(","),
); );
const csv = [header, ...rows].join("\n"); const csv = [header, ...rows].join("\n");

View File

@@ -2,6 +2,8 @@ import { Email, EmailStatus, Prisma } from "@prisma/client";
import { format, subDays } from "date-fns"; import { format, subDays } from "date-fns";
import { z } from "zod"; import { z } from "zod";
import { DEFAULT_QUERY_LIMIT } from "~/lib/constants"; import { DEFAULT_QUERY_LIMIT } from "~/lib/constants";
import { BOUNCE_ERROR_MESSAGES } from "~/lib/constants/ses-errors";
import type { SesBounce } from "~/types/aws-types";
import { import {
createTRPCRouter, createTRPCRouter,
@@ -13,6 +15,69 @@ import { cancelEmail, updateEmail } from "~/server/service/email-service";
const statuses = Object.values(EmailStatus) as [EmailStatus]; const statuses = Object.values(EmailStatus) as [EmailStatus];
const ensureBounceObject = (
data: Prisma.JsonValue,
): Partial<SesBounce> | undefined => {
const raw =
typeof data === "string"
? (() => {
try {
return JSON.parse(data);
} catch {
return undefined;
}
})()
: data;
if (!raw || typeof raw !== "object") return undefined;
return raw as Partial<SesBounce>;
};
const getBounceReasonFromParsed = (
bounce: Partial<SesBounce>,
): string | undefined => {
const diagnostic = bounce.bouncedRecipients?.[0]?.diagnosticCode?.trim();
if (diagnostic) return diagnostic;
const type = (bounce.bounceType ?? "").toString().trim() as
| "Transient"
| "Permanent"
| "Undetermined"
| "";
const subtype = (bounce.bounceSubType ?? "")
.toString()
.trim()
.replace(/\s+/g, "");
if (type === "Permanent") {
const key = (
["General", "NoEmail", "Suppressed", "OnAccountSuppressionList"].includes(
subtype,
)
? subtype
: "General"
) as keyof typeof BOUNCE_ERROR_MESSAGES.Permanent;
return BOUNCE_ERROR_MESSAGES.Permanent[key];
}
if (type === "Transient") {
const key = (
[
"General",
"MailboxFull",
"MessageTooLarge",
"ContentRejected",
"AttachmentRejected",
].includes(subtype)
? subtype
: "General"
) as keyof typeof BOUNCE_ERROR_MESSAGES.Transient;
return BOUNCE_ERROR_MESSAGES.Transient[key];
}
if (type === "Undetermined") {
return BOUNCE_ERROR_MESSAGES.Undetermined;
}
return undefined;
};
export const emailRouter = createTRPCRouter({ export const emailRouter = createTRPCRouter({
emails: teamProcedure emails: teamProcedure
.input( .input(
@@ -78,40 +143,76 @@ export const emailRouter = createTRPCRouter({
subject: string; subject: string;
scheduledAt: Date | null; scheduledAt: Date | null;
createdAt: Date; createdAt: Date;
bounceData: Prisma.JsonValue | null;
}> }>
>` >`
SELECT SELECT
"to", e."to",
"latestStatus", e."latestStatus",
subject, e.subject,
"scheduledAt", e."scheduledAt",
"createdAt" e."createdAt",
FROM "Email" b.data as "bounceData"
WHERE "teamId" = ${ctx.team.id} FROM "Email" e
${input.status ? Prisma.sql`AND "latestStatus"::text = ${input.status}` : Prisma.sql``} LEFT JOIN LATERAL (
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``} SELECT data
${input.apiId ? Prisma.sql`AND "apiId" = ${input.apiId}` : Prisma.sql``} FROM "EmailEvent"
WHERE "emailId" = e.id AND "status" = 'BOUNCED'
ORDER BY "createdAt" DESC
LIMIT 1
) b ON true
WHERE e."teamId" = ${ctx.team.id}
${
input.status
? Prisma.sql`AND e."latestStatus"::text = ${input.status}`
: Prisma.sql``
}
${
input.domain
? Prisma.sql`AND e."domainId" = ${input.domain}`
: Prisma.sql``
}
${
input.apiId
? Prisma.sql`AND e."apiId" = ${input.apiId}`
: Prisma.sql``
}
${ ${
input.search input.search
? Prisma.sql`AND ( ? Prisma.sql`AND (
"subject" ILIKE ${`%${input.search}%`} e."subject" ILIKE ${`%${input.search}%`}
OR EXISTS ( OR EXISTS (
SELECT 1 FROM unnest("to") AS email SELECT 1 FROM unnest(e."to") AS email
WHERE email ILIKE ${`%${input.search}%`} WHERE email ILIKE ${`%${input.search}%`}
) )
)` )`
: Prisma.sql`` : Prisma.sql``
} }
ORDER BY "createdAt" DESC ORDER BY e."createdAt" DESC
LIMIT 10000 LIMIT 10000
`; `;
return emails.map((email) => ({ return emails.map((email) => {
const base = {
to: email.to.join("; "), to: email.to.join("; "),
status: email.latestStatus, status: email.latestStatus,
subject: email.subject, subject: email.subject,
sentAt: (email.scheduledAt ?? email.createdAt).toISOString(), sentAt: (email.scheduledAt ?? email.createdAt).toISOString(),
})); } as const;
if (email.latestStatus !== "BOUNCED" || !email.bounceData) {
return { ...base, bounceType: undefined, bounceSubType: undefined, bounceReason: undefined };
}
const bounce = ensureBounceObject(email.bounceData);
const bounceType = bounce?.bounceType?.toString().trim() || undefined;
const bounceSubType = bounce?.bounceSubType
? bounce.bounceSubType.toString().trim().replace(/\s+/g, "")
: undefined;
const bounceReason = bounce ? getBounceReasonFromParsed(bounce) : undefined;
return { ...base, bounceType, bounceSubType, bounceReason };
});
}), }),
getEmail: emailProcedure.query(async ({ input }) => { getEmail: emailProcedure.query(async ({ input }) => {