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;
};
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) =>
[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");

View File

@@ -2,6 +2,8 @@ import { Email, EmailStatus, Prisma } from "@prisma/client";
import { format, subDays } from "date-fns";
import { z } from "zod";
import { DEFAULT_QUERY_LIMIT } from "~/lib/constants";
import { BOUNCE_ERROR_MESSAGES } from "~/lib/constants/ses-errors";
import type { SesBounce } from "~/types/aws-types";
import {
createTRPCRouter,
@@ -13,6 +15,69 @@ import { cancelEmail, updateEmail } from "~/server/service/email-service";
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({
emails: teamProcedure
.input(
@@ -78,40 +143,76 @@ export const emailRouter = createTRPCRouter({
subject: string;
scheduledAt: Date | null;
createdAt: Date;
bounceData: Prisma.JsonValue | null;
}>
>`
SELECT
"to",
"latestStatus",
subject,
"scheduledAt",
"createdAt"
FROM "Email"
WHERE "teamId" = ${ctx.team.id}
${input.status ? Prisma.sql`AND "latestStatus"::text = ${input.status}` : Prisma.sql``}
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``}
${input.apiId ? Prisma.sql`AND "apiId" = ${input.apiId}` : Prisma.sql``}
e."to",
e."latestStatus",
e.subject,
e."scheduledAt",
e."createdAt",
b.data as "bounceData"
FROM "Email" e
LEFT JOIN LATERAL (
SELECT data
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
? Prisma.sql`AND (
"subject" ILIKE ${`%${input.search}%`}
e."subject" ILIKE ${`%${input.search}%`}
OR EXISTS (
SELECT 1 FROM unnest("to") AS email
SELECT 1 FROM unnest(e."to") AS email
WHERE email ILIKE ${`%${input.search}%`}
)
)`
: Prisma.sql``
}
ORDER BY "createdAt" DESC
ORDER BY e."createdAt" DESC
LIMIT 10000
`;
return emails.map((email) => ({
to: email.to.join("; "),
status: email.latestStatus,
subject: email.subject,
sentAt: (email.scheduledAt ?? email.createdAt).toISOString(),
}));
return emails.map((email) => {
const base = {
to: email.to.join("; "),
status: email.latestStatus,
subject: email.subject,
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 }) => {