255 lines
7.0 KiB
TypeScript
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 });
|
|
}),
|
|
});
|