From cd40de04076ffdbb9373c39faaa04a4789cabaf5 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Thu, 11 Sep 2025 05:36:57 +1000 Subject: [PATCH] feat: add email export option (#212) --- .../src/app/(dashboard)/emails/email-list.tsx | 55 ++++++++++++++++++- apps/web/src/server/api/routers/email.ts | 55 ++++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/emails/email-list.tsx b/apps/web/src/app/(dashboard)/emails/email-list.tsx index 645b283..751c16e 100644 --- a/apps/web/src/app/(dashboard)/emails/email-list.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-list.tsx @@ -16,6 +16,7 @@ import { MailSearch, MailWarning, MailX, + Download, } from "lucide-react"; import { formatDate, formatDistanceToNow } from "date-fns"; import { EmailStatus } from "@prisma/client"; @@ -74,6 +75,16 @@ export default function EmailsList() { apiId: apiId, }); + const exportQuery = api.email.exportEmails.useQuery( + { + status: status?.toUpperCase() as EmailStatus, + domain: domainId, + search, + apiId: apiId, + }, + { enabled: false }, + ); + const { data: domainsQuery } = api.domain.domains.useQuery(); const { data: apiKeysQuery } = api.apiKey.getApiKeys.useQuery(); @@ -99,9 +110,43 @@ export default function EmailsList() { setSearch(value); }, 1000); + const handleExport = async () => { + try { + const resp = await exportQuery.refetch(); + if (!resp.data) return; + + const escape = (val: unknown) => { + const s = String(val ?? ""); + const startsRisky = /^\s*[=+\-@]/.test(s); + const safe = (startsRisky ? "'" : "") + s.replace(/"/g, '""'); + return /[",\r\n]/.test(safe) ? `"${safe}"` : safe; + }; + + const header = ["To", "Status", "Subject", "Sent At"].join(","); + const rows = resp.data.map((e) => + [e.to, e.status, e.subject, e.sentAt].map(escape).join(","), + ); + const csv = [header, ...rows].join("\n"); + + const blob = new Blob(["\uFEFF" + csv], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `emails-${new Date().toISOString().split("T")[0]}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + console.error("Export failed", err); + } + }; + return (
-
+
+
diff --git a/apps/web/src/server/api/routers/email.ts b/apps/web/src/server/api/routers/email.ts index 28391f9..6bf2fec 100644 --- a/apps/web/src/server/api/routers/email.ts +++ b/apps/web/src/server/api/routers/email.ts @@ -22,7 +22,7 @@ export const emailRouter = createTRPCRouter({ domain: z.number().optional(), search: z.string().optional().nullable(), apiId: z.number().optional(), - }) + }), ) .query(async ({ ctx, input }) => { const page = input.page || 1; @@ -61,6 +61,59 @@ export const emailRouter = createTRPCRouter({ 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; + }> + >` + 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``} + ${ + 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 10000 + `; + + return emails.map((email) => ({ + to: email.to.join("; "), + status: email.latestStatus, + subject: email.subject, + sentAt: (email.scheduledAt ?? email.createdAt).toISOString(), + })); + }), + getEmail: emailProcedure.query(async ({ input }) => { const email = await db.email.findUnique({ where: {