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;
|
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");
|
||||||
|
|
||||||
|
@@ -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) => {
|
||||||
to: email.to.join("; "),
|
const base = {
|
||||||
status: email.latestStatus,
|
to: email.to.join("; "),
|
||||||
subject: email.subject,
|
status: email.latestStatus,
|
||||||
sentAt: (email.scheduledAt ?? email.createdAt).toISOString(),
|
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 }) => {
|
getEmail: emailProcedure.query(async ({ input }) => {
|
||||||
|
Reference in New Issue
Block a user