Skip to content

Commit 5423013

Browse files
authored
feat: include bounce reason in export (#226)
1 parent 0167d13 commit 5423013

2 files changed

Lines changed: 140 additions & 21 deletions

File tree

apps/web/src/app/(dashboard)/emails/email-list.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,27 @@ export default function EmailsList() {
122122
return /[",\r\n]/.test(safe) ? `"${safe}"` : safe;
123123
};
124124

125-
const header = ["To", "Status", "Subject", "Sent At"].join(",");
125+
const header = [
126+
"To",
127+
"Status",
128+
"Subject",
129+
"Sent At",
130+
"Bounce Type",
131+
"Bounce Subtype",
132+
"Bounce Reason",
133+
].join(",");
126134
const rows = resp.data.map((e) =>
127-
[e.to, e.status, e.subject, e.sentAt].map(escape).join(","),
135+
[
136+
e.to,
137+
e.status,
138+
e.subject,
139+
e.sentAt,
140+
e.bounceType,
141+
e.bounceSubType,
142+
e.bounceReason,
143+
]
144+
.map(escape)
145+
.join(","),
128146
);
129147
const csv = [header, ...rows].join("\n");
130148

apps/web/src/server/api/routers/email.ts

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Email, EmailStatus, Prisma } from "@prisma/client";
22
import { format, subDays } from "date-fns";
33
import { z } from "zod";
44
import { DEFAULT_QUERY_LIMIT } from "~/lib/constants";
5+
import { BOUNCE_ERROR_MESSAGES } from "~/lib/constants/ses-errors";
6+
import type { SesBounce } from "~/types/aws-types";
57

68
import {
79
createTRPCRouter,
@@ -13,6 +15,69 @@ import { cancelEmail, updateEmail } from "~/server/service/email-service";
1315

1416
const statuses = Object.values(EmailStatus) as [EmailStatus];
1517

18+
const ensureBounceObject = (
19+
data: Prisma.JsonValue,
20+
): Partial<SesBounce> | undefined => {
21+
const raw =
22+
typeof data === "string"
23+
? (() => {
24+
try {
25+
return JSON.parse(data);
26+
} catch {
27+
return undefined;
28+
}
29+
})()
30+
: data;
31+
if (!raw || typeof raw !== "object") return undefined;
32+
return raw as Partial<SesBounce>;
33+
};
34+
35+
const getBounceReasonFromParsed = (
36+
bounce: Partial<SesBounce>,
37+
): string | undefined => {
38+
const diagnostic = bounce.bouncedRecipients?.[0]?.diagnosticCode?.trim();
39+
if (diagnostic) return diagnostic;
40+
41+
const type = (bounce.bounceType ?? "").toString().trim() as
42+
| "Transient"
43+
| "Permanent"
44+
| "Undetermined"
45+
| "";
46+
const subtype = (bounce.bounceSubType ?? "")
47+
.toString()
48+
.trim()
49+
.replace(/\s+/g, "");
50+
51+
if (type === "Permanent") {
52+
const key = (
53+
["General", "NoEmail", "Suppressed", "OnAccountSuppressionList"].includes(
54+
subtype,
55+
)
56+
? subtype
57+
: "General"
58+
) as keyof typeof BOUNCE_ERROR_MESSAGES.Permanent;
59+
return BOUNCE_ERROR_MESSAGES.Permanent[key];
60+
}
61+
if (type === "Transient") {
62+
const key = (
63+
[
64+
"General",
65+
"MailboxFull",
66+
"MessageTooLarge",
67+
"ContentRejected",
68+
"AttachmentRejected",
69+
].includes(subtype)
70+
? subtype
71+
: "General"
72+
) as keyof typeof BOUNCE_ERROR_MESSAGES.Transient;
73+
return BOUNCE_ERROR_MESSAGES.Transient[key];
74+
}
75+
if (type === "Undetermined") {
76+
return BOUNCE_ERROR_MESSAGES.Undetermined;
77+
}
78+
return undefined;
79+
};
80+
1681
export const emailRouter = createTRPCRouter({
1782
emails: teamProcedure
1883
.input(
@@ -78,40 +143,76 @@ export const emailRouter = createTRPCRouter({
78143
subject: string;
79144
scheduledAt: Date | null;
80145
createdAt: Date;
146+
bounceData: Prisma.JsonValue | null;
81147
}>
82148
>`
83149
SELECT
84-
"to",
85-
"latestStatus",
86-
subject,
87-
"scheduledAt",
88-
"createdAt"
89-
FROM "Email"
90-
WHERE "teamId" = ${ctx.team.id}
91-
${input.status ? Prisma.sql`AND "latestStatus"::text = ${input.status}` : Prisma.sql``}
92-
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``}
93-
${input.apiId ? Prisma.sql`AND "apiId" = ${input.apiId}` : Prisma.sql``}
150+
e."to",
151+
e."latestStatus",
152+
e.subject,
153+
e."scheduledAt",
154+
e."createdAt",
155+
b.data as "bounceData"
156+
FROM "Email" e
157+
LEFT JOIN LATERAL (
158+
SELECT data
159+
FROM "EmailEvent"
160+
WHERE "emailId" = e.id AND "status" = 'BOUNCED'
161+
ORDER BY "createdAt" DESC
162+
LIMIT 1
163+
) b ON true
164+
WHERE e."teamId" = ${ctx.team.id}
165+
${
166+
input.status
167+
? Prisma.sql`AND e."latestStatus"::text = ${input.status}`
168+
: Prisma.sql``
169+
}
170+
${
171+
input.domain
172+
? Prisma.sql`AND e."domainId" = ${input.domain}`
173+
: Prisma.sql``
174+
}
175+
${
176+
input.apiId
177+
? Prisma.sql`AND e."apiId" = ${input.apiId}`
178+
: Prisma.sql``
179+
}
94180
${
95181
input.search
96182
? Prisma.sql`AND (
97-
"subject" ILIKE ${`%${input.search}%`}
183+
e."subject" ILIKE ${`%${input.search}%`}
98184
OR EXISTS (
99-
SELECT 1 FROM unnest("to") AS email
185+
SELECT 1 FROM unnest(e."to") AS email
100186
WHERE email ILIKE ${`%${input.search}%`}
101187
)
102188
)`
103189
: Prisma.sql``
104190
}
105-
ORDER BY "createdAt" DESC
191+
ORDER BY e."createdAt" DESC
106192
LIMIT 10000
107193
`;
108194

109-
return emails.map((email) => ({
110-
to: email.to.join("; "),
111-
status: email.latestStatus,
112-
subject: email.subject,
113-
sentAt: (email.scheduledAt ?? email.createdAt).toISOString(),
114-
}));
195+
return emails.map((email) => {
196+
const base = {
197+
to: email.to.join("; "),
198+
status: email.latestStatus,
199+
subject: email.subject,
200+
sentAt: (email.scheduledAt ?? email.createdAt).toISOString(),
201+
} as const;
202+
203+
if (email.latestStatus !== "BOUNCED" || !email.bounceData) {
204+
return { ...base, bounceType: undefined, bounceSubType: undefined, bounceReason: undefined };
205+
}
206+
207+
const bounce = ensureBounceObject(email.bounceData);
208+
const bounceType = bounce?.bounceType?.toString().trim() || undefined;
209+
const bounceSubType = bounce?.bounceSubType
210+
? bounce.bounceSubType.toString().trim().replace(/\s+/g, "")
211+
: undefined;
212+
const bounceReason = bounce ? getBounceReasonFromParsed(bounce) : undefined;
213+
214+
return { ...base, bounceType, bounceSubType, bounceReason };
215+
});
115216
}),
116217

117218
getEmail: emailProcedure.query(async ({ input }) => {

0 commit comments

Comments
 (0)