feat: include bounce reason in export (#226)
This commit is contained in:
@@ -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");
|
||||
|
||||
|
@@ -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 }) => {
|
||||
|
Reference in New Issue
Block a user