diff --git a/apps/web/src/app/(dashboard)/emails/email-list.tsx b/apps/web/src/app/(dashboard)/emails/email-list.tsx index 751c16e..ff1afae 100644 --- a/apps/web/src/app/(dashboard)/emails/email-list.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-list.tsx @@ -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"); diff --git a/apps/web/src/server/api/routers/email.ts b/apps/web/src/server/api/routers/email.ts index 6bf2fec..709fc8e 100644 --- a/apps/web/src/server/api/routers/email.ts +++ b/apps/web/src/server/api/routers/email.ts @@ -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 | 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; +}; + +const getBounceReasonFromParsed = ( + bounce: Partial, +): 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 }) => {