feat: add email export option (#212)

This commit is contained in:
KM Koushik
2025-09-11 05:36:57 +10:00
committed by GitHub
parent 71b9150a9b
commit cd40de0407
2 changed files with 108 additions and 2 deletions

View File

@@ -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 (
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-between">
<div className="flex justify-between items-center">
<Input
placeholder="Search by subject or email"
className="w-[350px] mr-4"
@@ -181,6 +226,14 @@ export default function EmailsList() {
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={handleExport}
disabled={exportQuery.isFetching}
>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</div>
</div>
<div className="flex flex-col rounded-xl border shadow">

View File

@@ -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: {