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 | 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( 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>` 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 }); }), });