Files
GibSend/apps/web/src/server/api/routers/email.ts
T
2025-09-11 07:09:13 +10:00

255 lines
7.0 KiB
TypeScript

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,
emailProcedure,
teamProcedure,
} from "~/server/api/trpc";
import { db } from "~/server/db";
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(
z.object({
page: z.number().optional(),
status: z.enum(statuses).optional().nullable(),
domain: z.number().optional(),
search: z.string().optional().nullable(),
apiId: z.number().optional(),
}),
)
.query(async ({ ctx, input }) => {
const page = input.page || 1;
const limit = DEFAULT_QUERY_LIMIT;
const offset = (page - 1) * limit;
const emails = await db.$queryRaw<Array<Email>>`
SELECT
id,
"createdAt",
"latestStatus",
subject,
"to",
"scheduledAt"
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``}
${
input.search
? Prisma.sql`AND (
"subject" ILIKE ${`%${input.search}%`}
OR EXISTS (
SELECT 1 FROM unnest("to") AS email
WHERE email ILIKE ${`%${input.search}%`}
)
)`
: Prisma.sql``
}
ORDER BY "createdAt" DESC
LIMIT ${DEFAULT_QUERY_LIMIT}
OFFSET ${offset}
`;
return { emails };
}),
exportEmails: teamProcedure
.input(
z.object({
status: z.enum(statuses).optional().nullable(),
domain: z.number().optional(),
search: z.string().optional().nullable(),
apiId: z.number().optional(),
}),
)
.query(async ({ ctx, input }) => {
const emails = await db.$queryRaw<
Array<{
to: string[];
latestStatus: EmailStatus;
subject: string;
scheduledAt: Date | null;
createdAt: Date;
bounceData: Prisma.JsonValue | null;
}>
>`
SELECT
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 (
e."subject" ILIKE ${`%${input.search}%`}
OR EXISTS (
SELECT 1 FROM unnest(e."to") AS email
WHERE email ILIKE ${`%${input.search}%`}
)
)`
: Prisma.sql``
}
ORDER BY e."createdAt" DESC
LIMIT 10000
`;
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 }) => {
const email = await db.email.findUnique({
where: {
id: input.id,
},
select: {
emailEvents: {
orderBy: {
status: "desc",
},
},
id: true,
createdAt: true,
latestStatus: true,
subject: true,
to: true,
from: true,
domainId: true,
text: true,
html: true,
scheduledAt: true,
},
});
return email;
}),
cancelEmail: emailProcedure.mutation(async ({ input }) => {
await cancelEmail(input.id);
}),
updateEmailScheduledAt: emailProcedure
.input(z.object({ scheduledAt: z.string().datetime() }))
.mutation(async ({ input }) => {
await updateEmail(input.id, { scheduledAt: input.scheduledAt });
}),
});