diff --git a/apps/web/src/app/(dashboard)/admin/email-analytics/constants.ts b/apps/web/src/app/(dashboard)/admin/email-analytics/constants.ts
new file mode 100644
index 0000000..9bc22f8
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/email-analytics/constants.ts
@@ -0,0 +1,4 @@
+export const timeframeOptions = [
+ { label: "Today", value: "today" },
+ { label: "This month", value: "thisMonth" },
+] as const;
diff --git a/apps/web/src/app/(dashboard)/admin/email-analytics/page.tsx b/apps/web/src/app/(dashboard)/admin/email-analytics/page.tsx
new file mode 100644
index 0000000..2864d8e
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/admin/email-analytics/page.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card";
+import { Label } from "@usesend/ui/src/label";
+import { Switch } from "@usesend/ui/src/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@usesend/ui/src/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@usesend/ui/src/table";
+import Spinner from "@usesend/ui/src/spinner";
+import { api } from "~/trpc/react";
+import { isCloud } from "~/utils/common";
+import { timeframeOptions } from "./constants";
+
+export default function AdminEmailAnalyticsPage() {
+ const isCloudEnv = isCloud();
+ const [timeframe, setTimeframe] = useState<
+ (typeof timeframeOptions)[number]["value"]
+ >("today");
+ const [paidOnly, setPaidOnly] = useState(false);
+
+ const analyticsQuery = api.admin.getEmailAnalytics.useQuery(
+ {
+ timeframe,
+ paidOnly,
+ },
+ { keepPreviousData: true, enabled: isCloudEnv }
+ );
+
+ const data = analyticsQuery.data;
+
+ const totals = data?.totals ?? {
+ sent: 0,
+ delivered: 0,
+ opened: 0,
+ clicked: 0,
+ bounced: 0,
+ complained: 0,
+ hardBounced: 0,
+ };
+
+ const rows = useMemo(() => data?.rows ?? [], [data]);
+
+ if (!isCloudEnv) {
+ return (
+
+ Email analytics are available only in the cloud deployment.
+
+ );
+ }
+
+ return (
+
+
Email analytics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Usage by team
+ {data ? (
+
+ Since {data.timeframe === "today" ? "today" : data.periodStart}
+
+ ) : null}
+
+ {analyticsQuery.isLoading ? (
+
+ ) : null}
+
+
+
+
+
+ Team
+ Plan
+ Sent
+ Delivered
+ Opened
+ Clicked
+ Bounced
+ Complained
+ Hard bounced
+
+
+
+ {analyticsQuery.isLoading ? (
+
+
+
+
+
+ ) : rows.length === 0 ? (
+
+
+ No email activity found for this period.
+
+
+ ) : (
+ rows.map((row) => (
+
+ {row.name}
+ {row.plan}
+ {row.sent}
+ {row.delivered}
+ {row.opened}
+ {row.clicked}
+ {row.bounced}
+ {row.complained}
+ {row.hardBounced}
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
+
+function SummaryCard({ label, value }: { label: string; value: number }) {
+ return (
+
+
+
+ {label}
+
+
+
+ {value.toLocaleString()}
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/admin/layout.tsx b/apps/web/src/app/(dashboard)/admin/layout.tsx
index 7d53f7d..86a0013 100644
--- a/apps/web/src/app/(dashboard)/admin/layout.tsx
+++ b/apps/web/src/app/(dashboard)/admin/layout.tsx
@@ -20,6 +20,11 @@ export default function AdminLayout({
Teams
) : null}
+ {isCloud() ? (
+
+ Email analytics
+
+ ) : null}
{isCloud() ? (
Waitlist
diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts
index 52b826f..fe2c8dc 100644
--- a/apps/web/src/server/api/routers/admin.ts
+++ b/apps/web/src/server/api/routers/admin.ts
@@ -1,3 +1,4 @@
+import { Prisma, type Plan } from "@prisma/client";
import { z } from "zod";
import { env } from "~/env";
@@ -289,4 +290,91 @@ export const adminRouter = createTRPCRouter({
return updatedTeam;
}),
+
+ getEmailAnalytics: adminProcedure
+ .input(
+ z.object({
+ timeframe: z.enum(["today", "thisMonth"]),
+ paidOnly: z.boolean().optional(),
+ })
+ )
+ .query(async ({ input }) => {
+ const timeframe = input.timeframe;
+ const paidOnly = input.paidOnly ?? false;
+
+ const now = new Date();
+ const today = now.toISOString().slice(0, 10);
+ const monthStartDate = new Date(
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)
+ );
+ const monthStart = monthStartDate.toISOString().slice(0, 10);
+
+ type EmailAnalyticsRow = {
+ teamId: number;
+ name: string;
+ plan: Plan;
+ sent: number;
+ delivered: number;
+ opened: number;
+ clicked: number;
+ bounced: number;
+ complained: number;
+ hardBounced: number;
+ };
+
+ const rows = await db.$queryRaw>`
+ SELECT
+ d."teamId" AS "teamId",
+ t."name" AS name,
+ t."plan" AS plan,
+ SUM(d.sent)::integer AS sent,
+ SUM(d.delivered)::integer AS delivered,
+ SUM(d.opened)::integer AS opened,
+ SUM(d.clicked)::integer AS clicked,
+ SUM(d.bounced)::integer AS bounced,
+ SUM(d.complained)::integer AS complained,
+ SUM(d."hardBounced")::integer AS "hardBounced"
+ FROM "DailyEmailUsage" d
+ INNER JOIN "Team" t ON t.id = d."teamId"
+ WHERE 1 = 1
+ ${
+ timeframe === "today"
+ ? Prisma.sql`AND d."date" = ${today}`
+ : Prisma.sql`AND d."date" >= ${monthStart}`
+ }
+ ${paidOnly ? Prisma.sql`AND t."plan" = 'BASIC'` : Prisma.sql``}
+ GROUP BY d."teamId", t."name", t."plan"
+ ORDER BY sent DESC
+ `;
+
+ const totals = rows.reduce(
+ (acc, row) => {
+ acc.sent += row.sent;
+ acc.delivered += row.delivered;
+ acc.opened += row.opened;
+ acc.clicked += row.clicked;
+ acc.bounced += row.bounced;
+ acc.complained += row.complained;
+ acc.hardBounced += row.hardBounced;
+ return acc;
+ },
+ {
+ sent: 0,
+ delivered: 0,
+ opened: 0,
+ clicked: 0,
+ bounced: 0,
+ complained: 0,
+ hardBounced: 0,
+ }
+ );
+
+ return {
+ rows,
+ totals,
+ timeframe,
+ paidOnly,
+ periodStart: timeframe === "today" ? today : monthStart,
+ };
+ }),
});