diff --git a/apps/web/app/(app)/[emailAccountId]/stats/CombinedStatsChart.tsx b/apps/web/app/(app)/[emailAccountId]/stats/CombinedStatsChart.tsx index 3f005a5c8f..2da8c53a64 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/CombinedStatsChart.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/CombinedStatsChart.tsx @@ -4,7 +4,7 @@ import useSWRImmutable from "swr/immutable"; import type { StatsByDayResponse, StatsByDayQuery, -} from "@/app/api/user/stats/day/route"; +} from "@/app/api/user/stats/day/controller"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; diff --git a/apps/web/app/(app)/[emailAccountId]/stats/DetailedStats.tsx b/apps/web/app/(app)/[emailAccountId]/stats/DetailedStats.tsx index 1fd11eb26a..1c4377e152 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/DetailedStats.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/DetailedStats.tsx @@ -11,7 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { StatsByWeekResponse, StatsByWeekParams, -} from "@/app/api/user/stats/by-period/route"; +} from "@/app/api/user/stats/by-period/controller"; import { DetailedStatsFilter } from "@/app/(app)/[emailAccountId]/stats/DetailedStatsFilter"; import { getDateRangeParams } from "@/app/(app)/[emailAccountId]/stats/params"; diff --git a/apps/web/app/(app)/[emailAccountId]/stats/StatsChart.tsx b/apps/web/app/(app)/[emailAccountId]/stats/StatsChart.tsx index 4a6bd89007..dc177a79f4 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/StatsChart.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/StatsChart.tsx @@ -3,7 +3,7 @@ import useSWR from "swr"; import type { StatsByDayQuery, StatsByDayResponse, -} from "@/app/api/user/stats/day/route"; +} from "@/app/api/user/stats/day/controller"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; diff --git a/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx b/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx index 1f99ddc90a..d8ef7d8096 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx @@ -13,7 +13,7 @@ import { import type { StatsByWeekParams, StatsByWeekResponse, -} from "@/app/api/user/stats/by-period/route"; +} from "@/app/api/user/stats/by-period/controller"; import { getDateRangeParams } from "./params"; import { formatStat } from "@/utils/stats"; import { StatsCards } from "@/components/StatsCards"; diff --git a/apps/web/app/api/user/stats/by-period/controller.ts b/apps/web/app/api/user/stats/by-period/controller.ts new file mode 100644 index 0000000000..3a18074189 --- /dev/null +++ b/apps/web/app/api/user/stats/by-period/controller.ts @@ -0,0 +1,124 @@ +import format from "date-fns/format"; +import { z } from "zod"; +import sumBy from "lodash/sumBy"; +import { zodPeriod } from "@inboxzero/tinybird"; +import prisma from "@/utils/prisma"; +import { Prisma } from "@prisma/client"; + +export const statsByWeekParams = z.object({ + period: zodPeriod, + fromDate: z.coerce.number().nullish(), + toDate: z.coerce.number().nullish(), +}); +export type StatsByWeekParams = z.infer; +export type StatsByWeekResponse = Awaited>; + +export async function getStatsByPeriod( + options: StatsByWeekParams & { + emailAccountId: string; + }, +) { + // Get all stats in a single query + const stats = await getEmailStatsByPeriod(options); + + // Transform stats to match the expected format + const formattedStats = stats.map((stat) => { + const startOfPeriodFormatted = format(stat.startOfPeriod, "LLL dd, y"); + + return { + startOfPeriod: startOfPeriodFormatted, + All: Number(stat.totalCount), + Sent: Number(stat.sentCount), + Read: Number(stat.readCount), + Unread: Number(stat.unread), + Unarchived: Number(stat.inboxCount), + Archived: Number(stat.notInbox), + }; + }); + + // Calculate totals + const totalAll = sumBy(stats, (stat) => Number(stat.totalCount)); + const totalInbox = sumBy(stats, (stat) => Number(stat.inboxCount)); + const totalRead = sumBy(stats, (stat) => Number(stat.readCount)); + const totalSent = sumBy(stats, (stat) => Number(stat.sentCount)); + + return { + result: formattedStats, + allCount: totalAll, + inboxCount: totalInbox, + readCount: totalRead, + sentCount: totalSent, + }; +} + +async function getEmailStatsByPeriod( + options: StatsByWeekParams & { emailAccountId: string }, +) { + const { period, fromDate, toDate, emailAccountId } = options; + + // Build date conditions without starting with AND + const dateConditions: Prisma.Sql[] = []; + if (fromDate) { + dateConditions.push(Prisma.sql`date >= ${new Date(fromDate)}`); + } + if (toDate) { + dateConditions.push(Prisma.sql`date <= ${new Date(toDate)}`); + } + + const dateFormat = + period === "day" + ? "YYYY-MM-DD" + : period === "week" + ? "YYYY-WW" + : period === "month" + ? "YYYY-MM" + : "YYYY"; + + // Using raw query with properly typed parameters + type StatsResult = { + period_group: string; + startOfPeriod: Date; + totalCount: bigint; + inboxCount: bigint; + readCount: bigint; + sentCount: bigint; + unread: bigint; + notInbox: bigint; + }; + + // Create WHERE clause properly + const whereClause = Prisma.sql`WHERE "emailAccountId" = ${emailAccountId}`; + const dateClause = + dateConditions.length > 0 + ? Prisma.sql` AND ${Prisma.join(dateConditions, " AND ")}` + : Prisma.sql``; + + // Convert period and dateFormat to string literals in PostgreSQL + return prisma.$queryRaw` + WITH stats AS ( + SELECT + TO_CHAR(date, ${Prisma.raw(`'${dateFormat}'`)}) AS period_group, + DATE_TRUNC(${Prisma.raw(`'${period}'`)}, date) AS start_of_period, + COUNT(*) AS total_count, + SUM(CASE WHEN inbox = true THEN 1 ELSE 0 END) AS inbox_count, + SUM(CASE WHEN inbox = false THEN 1 ELSE 0 END) AS not_inbox, + SUM(CASE WHEN read = true THEN 1 ELSE 0 END) AS read_count, + SUM(CASE WHEN read = false THEN 1 ELSE 0 END) AS unread, + SUM(CASE WHEN sent = true THEN 1 ELSE 0 END) AS sent_count + FROM "EmailMessage" + ${whereClause}${dateClause} + GROUP BY period_group, start_of_period + ORDER BY start_of_period + ) + SELECT + period_group, + start_of_period AS "startOfPeriod", + total_count AS "totalCount", + inbox_count AS "inboxCount", + not_inbox AS "notInbox", + read_count AS "readCount", + unread, + sent_count AS "sentCount" + FROM stats + `; +} diff --git a/apps/web/app/api/user/stats/by-period/route.ts b/apps/web/app/api/user/stats/by-period/route.ts index 23023d6eaa..3304c36a81 100644 --- a/apps/web/app/api/user/stats/by-period/route.ts +++ b/apps/web/app/api/user/stats/by-period/route.ts @@ -1,129 +1,9 @@ import { NextResponse } from "next/server"; -import format from "date-fns/format"; -import { z } from "zod"; -import sumBy from "lodash/sumBy"; -import { zodPeriod } from "@inboxzero/tinybird"; import { withEmailAccount } from "@/utils/middleware"; -import prisma from "@/utils/prisma"; -import { Prisma } from "@prisma/client"; - -const statsByWeekParams = z.object({ - period: zodPeriod, - fromDate: z.coerce.number().nullish(), - toDate: z.coerce.number().nullish(), -}); -export type StatsByWeekParams = z.infer; -export type StatsByWeekResponse = Awaited>; - -async function getEmailStatsByPeriod( - options: StatsByWeekParams & { emailAccountId: string }, -) { - const { period, fromDate, toDate, emailAccountId } = options; - - // Build date conditions without starting with AND - const dateConditions: Prisma.Sql[] = []; - if (fromDate) { - dateConditions.push(Prisma.sql`date >= ${new Date(fromDate)}`); - } - if (toDate) { - dateConditions.push(Prisma.sql`date <= ${new Date(toDate)}`); - } - - const dateFormat = - period === "day" - ? "YYYY-MM-DD" - : period === "week" - ? "YYYY-WW" - : period === "month" - ? "YYYY-MM" - : "YYYY"; - - // Using raw query with properly typed parameters - type StatsResult = { - period_group: string; - startOfPeriod: Date; - totalCount: bigint; - inboxCount: bigint; - readCount: bigint; - sentCount: bigint; - unread: bigint; - notInbox: bigint; - }; - - // Create WHERE clause properly - const whereClause = Prisma.sql`WHERE "emailAccountId" = ${emailAccountId}`; - const dateClause = - dateConditions.length > 0 - ? Prisma.sql` AND ${Prisma.join(dateConditions, " AND ")}` - : Prisma.sql``; - - // Convert period and dateFormat to string literals in PostgreSQL - return prisma.$queryRaw` - WITH stats AS ( - SELECT - TO_CHAR(date, ${Prisma.raw(`'${dateFormat}'`)}) AS period_group, - DATE_TRUNC(${Prisma.raw(`'${period}'`)}, date) AS start_of_period, - COUNT(*) AS total_count, - SUM(CASE WHEN inbox = true THEN 1 ELSE 0 END) AS inbox_count, - SUM(CASE WHEN inbox = false THEN 1 ELSE 0 END) AS not_inbox, - SUM(CASE WHEN read = true THEN 1 ELSE 0 END) AS read_count, - SUM(CASE WHEN read = false THEN 1 ELSE 0 END) AS unread, - SUM(CASE WHEN sent = true THEN 1 ELSE 0 END) AS sent_count - FROM "EmailMessage" - ${whereClause}${dateClause} - GROUP BY period_group, start_of_period - ORDER BY start_of_period - ) - SELECT - period_group, - start_of_period AS "startOfPeriod", - total_count AS "totalCount", - inbox_count AS "inboxCount", - not_inbox AS "notInbox", - read_count AS "readCount", - unread, - sent_count AS "sentCount" - FROM stats - `; -} - -async function getStatsByPeriod( - options: StatsByWeekParams & { - emailAccountId: string; - }, -) { - // Get all stats in a single query - const stats = await getEmailStatsByPeriod(options); - - // Transform stats to match the expected format - const formattedStats = stats.map((stat) => { - const startOfPeriodFormatted = format(stat.startOfPeriod, "LLL dd, y"); - - return { - startOfPeriod: startOfPeriodFormatted, - All: Number(stat.totalCount), - Sent: Number(stat.sentCount), - Read: Number(stat.readCount), - Unread: Number(stat.unread), - Unarchived: Number(stat.inboxCount), - Archived: Number(stat.notInbox), - }; - }); - - // Calculate totals - const totalAll = sumBy(stats, (stat) => Number(stat.totalCount)); - const totalInbox = sumBy(stats, (stat) => Number(stat.inboxCount)); - const totalRead = sumBy(stats, (stat) => Number(stat.readCount)); - const totalSent = sumBy(stats, (stat) => Number(stat.sentCount)); - - return { - result: formattedStats, - allCount: totalAll, - inboxCount: totalInbox, - readCount: totalRead, - sentCount: totalSent, - }; -} +import { + getStatsByPeriod, + statsByWeekParams, +} from "@/app/api/user/stats/by-period/controller"; export const GET = withEmailAccount(async (request) => { const emailAccountId = request.auth.emailAccountId; diff --git a/apps/web/app/api/user/stats/day/controller.ts b/apps/web/app/api/user/stats/day/controller.ts new file mode 100644 index 0000000000..e64acb6250 --- /dev/null +++ b/apps/web/app/api/user/stats/day/controller.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import type { gmail_v1 } from "@googleapis/gmail"; +import { dateToSeconds } from "@/utils/date"; +import { getMessages } from "@/utils/gmail/message"; + +export const statsByDayQuery = z.object({ + type: z.enum(["inbox", "sent", "archived"]), +}); +export type StatsByDayQuery = z.infer; +export type StatsByDayResponse = Awaited< + ReturnType +>; + +const DAYS = 7; + +export async function getPastSevenDayStats( + options: { + emailAccountId: string; + gmail: gmail_v1.Gmail; + } & StatsByDayQuery, +) { + const today = new Date(); + const sevenDaysAgo = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - (DAYS - 1), // include today in stats + ); + // const cachedStats = await getAllStats({ email }) + + const lastSevenDaysCountsArray = await Promise.all( + Array.from({ length: DAYS }, (_, i) => { + const date = new Date(sevenDaysAgo); + date.setDate(date.getDate() + i); + return date; + }).map(async (date) => { + const dateString = `${date.getDate()}/${date.getMonth() + 1}`; + + // let count = cachedStats?.[dateString] + let count: number | undefined = undefined; + + if (typeof count !== "number") { + const query = getQuery(options.type, date); + + const messages = await getMessages(options.gmail, { + query, + maxResults: 500, + }); + + count = messages.messages?.length || 0; + } + + return { + date: dateString, + Emails: count, + }; + }), + ); + + return lastSevenDaysCountsArray; +} + +function getQuery(type: StatsByDayQuery["type"], date: Date) { + const startOfDayInSeconds = dateToSeconds(date); + const endOfDayInSeconds = startOfDayInSeconds + 86400; + + const dateRange = `after:${startOfDayInSeconds} before:${endOfDayInSeconds}`; + + switch (type) { + case "inbox": + return `in:inbox ${dateRange}`; + case "sent": + return `in:sent ${dateRange}`; + case "archived": + return `-in:inbox -in:sent ${dateRange}`; + } +} diff --git a/apps/web/app/api/user/stats/day/route.ts b/apps/web/app/api/user/stats/day/route.ts index 0fa9ef9966..5b7a44c589 100644 --- a/apps/web/app/api/user/stats/day/route.ts +++ b/apps/web/app/api/user/stats/day/route.ts @@ -1,82 +1,10 @@ -import { z } from "zod"; import { NextResponse } from "next/server"; -import type { gmail_v1 } from "@googleapis/gmail"; import { withEmailAccount } from "@/utils/middleware"; -import { dateToSeconds } from "@/utils/date"; -import { getMessages } from "@/utils/gmail/message"; import { getGmailClientForEmail } from "@/utils/account"; - -const statsByDayQuery = z.object({ - type: z.enum(["inbox", "sent", "archived"]), -}); -export type StatsByDayQuery = z.infer; -export type StatsByDayResponse = Awaited< - ReturnType ->; - -const DAYS = 7; - -async function getPastSevenDayStats( - options: { - emailAccountId: string; - gmail: gmail_v1.Gmail; - } & StatsByDayQuery, -) { - const today = new Date(); - const sevenDaysAgo = new Date( - today.getFullYear(), - today.getMonth(), - today.getDate() - (DAYS - 1), // include today in stats - ); - // const cachedStats = await getAllStats({ email }) - - const lastSevenDaysCountsArray = await Promise.all( - Array.from({ length: DAYS }, (_, i) => { - const date = new Date(sevenDaysAgo); - date.setDate(date.getDate() + i); - return date; - }).map(async (date) => { - const dateString = `${date.getDate()}/${date.getMonth() + 1}`; - - // let count = cachedStats?.[dateString] - let count: number | undefined = undefined; - - if (typeof count !== "number") { - const query = getQuery(options.type, date); - - const messages = await getMessages(options.gmail, { - query, - maxResults: 500, - }); - - count = messages.messages?.length || 0; - } - - return { - date: dateString, - Emails: count, - }; - }), - ); - - return lastSevenDaysCountsArray; -} - -function getQuery(type: StatsByDayQuery["type"], date: Date) { - const startOfDayInSeconds = dateToSeconds(date); - const endOfDayInSeconds = startOfDayInSeconds + 86400; - - const dateRange = `after:${startOfDayInSeconds} before:${endOfDayInSeconds}`; - - switch (type) { - case "inbox": - return `in:inbox ${dateRange}`; - case "sent": - return `in:sent ${dateRange}`; - case "archived": - return `-in:inbox -in:sent ${dateRange}`; - } -} +import { + getPastSevenDayStats, + statsByDayQuery, +} from "@/app/api/user/stats/day/controller"; export const GET = withEmailAccount(async (request) => { const emailAccountId = request.auth.emailAccountId; diff --git a/apps/web/app/api/v1/openapi/route.ts b/apps/web/app/api/v1/openapi/route.ts index 935f7ca9d8..328e070ca1 100644 --- a/apps/web/app/api/v1/openapi/route.ts +++ b/apps/web/app/api/v1/openapi/route.ts @@ -13,6 +13,12 @@ import { replyTrackerQuerySchema, replyTrackerResponseSchema, } from "@/app/api/v1/reply-tracker/validation"; +import { + summaryStatsQuerySchema, + summaryStatsResponseSchema, + dayStatsQuerySchema, + dayStatsResponseSchema, +} from "@/app/api/v1/stats/validation"; import { API_KEY_HEADER } from "@/utils/api-auth"; extendZodWithOpenApi(z); @@ -72,6 +78,48 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/stats/summary", + description: + "Get email summary statistics with totals and time-based breakdowns", + security: [{ ApiKeyAuth: [] }], + request: { + query: summaryStatsQuerySchema, + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: summaryStatsResponseSchema, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/stats/day", + description: + "Get daily email statistics for the past 7 days by type (inbox, sent, archived)", + security: [{ ApiKeyAuth: [] }], + request: { + query: dayStatsQuerySchema, + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: dayStatsResponseSchema, + }, + }, + }, + }, +}); + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const customHost = searchParams.get("host"); diff --git a/apps/web/app/api/v1/stats/day/route.ts b/apps/web/app/api/v1/stats/day/route.ts new file mode 100644 index 0000000000..f887e3dad0 --- /dev/null +++ b/apps/web/app/api/v1/stats/day/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { withError } from "@/utils/middleware"; +import { createScopedLogger } from "@/utils/logger"; +import { dayStatsQuerySchema, type DayStatsResponse } from "../validation"; +import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth"; +import { getEmailAccountId } from "@/app/api/v1/helpers"; +import { getPastSevenDayStats } from "@/app/api/user/stats/day/controller"; + +const logger = createScopedLogger("api/v1/stats/day"); + +export const GET = withError(async (request) => { + const { gmail, userId, accountId } = + await validateApiKeyAndGetGmailClient(request); + + const { searchParams } = new URL(request.url); + const queryResult = dayStatsQuerySchema.safeParse( + Object.fromEntries(searchParams), + ); + + if (!queryResult.success) { + return NextResponse.json( + { error: "Invalid query parameters" }, + { status: 400 }, + ); + } + + const emailAccountId = await getEmailAccountId({ + email: queryResult.data.email, + accountId, + userId, + }); + + if (!emailAccountId) { + return NextResponse.json( + { error: "Email account not found" }, + { status: 400 }, + ); + } + + try { + const result: DayStatsResponse = await getPastSevenDayStats({ + type: queryResult.data.type, + gmail, + emailAccountId, + }); + + return NextResponse.json(result); + } catch (error) { + logger.error("Error retrieving day statistics", { + userId, + emailAccountId, + type: queryResult.data.type, + error, + }); + return NextResponse.json( + { error: "Failed to retrieve day statistics" }, + { status: 500 }, + ); + } +}); diff --git a/apps/web/app/api/v1/stats/summary/route.ts b/apps/web/app/api/v1/stats/summary/route.ts new file mode 100644 index 0000000000..85898857d0 --- /dev/null +++ b/apps/web/app/api/v1/stats/summary/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { withError } from "@/utils/middleware"; +import { createScopedLogger } from "@/utils/logger"; +import { + summaryStatsQuerySchema, + type SummaryStatsResponse, +} from "../validation"; +import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth"; +import { getEmailAccountId } from "@/app/api/v1/helpers"; +import { getStatsByPeriod } from "@/app/api/user/stats/by-period/controller"; + +const logger = createScopedLogger("api/v1/stats/summary"); + +export const GET = withError(async (request) => { + const { userId, accountId } = await validateApiKeyAndGetGmailClient(request); + + const { searchParams } = new URL(request.url); + const queryResult = summaryStatsQuerySchema.safeParse( + Object.fromEntries(searchParams), + ); + + if (!queryResult.success) { + return NextResponse.json( + { error: "Invalid query parameters" }, + { status: 400 }, + ); + } + + const emailAccountId = await getEmailAccountId({ + email: queryResult.data.email, + accountId, + userId, + }); + + if (!emailAccountId) { + return NextResponse.json( + { error: "Email account not found" }, + { status: 400 }, + ); + } + + try { + const result: SummaryStatsResponse = await getStatsByPeriod({ + period: queryResult.data.period, + fromDate: queryResult.data.fromDate, + toDate: queryResult.data.toDate, + emailAccountId, + }); + + return NextResponse.json(result); + } catch (error) { + logger.error("Error retrieving summary statistics", { + userId, + emailAccountId, + error, + }); + return NextResponse.json( + { error: "Failed to retrieve summary statistics" }, + { status: 500 }, + ); + } +}); diff --git a/apps/web/app/api/v1/stats/validation.ts b/apps/web/app/api/v1/stats/validation.ts new file mode 100644 index 0000000000..5fdb84e926 --- /dev/null +++ b/apps/web/app/api/v1/stats/validation.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { zodPeriod } from "@inboxzero/tinybird"; + +export const statsQuerySchema = z.object({ + fromDate: z.coerce + .number() + .optional() + .describe("Start date as Unix timestamp"), + toDate: z.coerce.number().optional().describe("End date as Unix timestamp"), + email: z + .string() + .optional() + .describe( + "Email account to get stats for (if not provided, uses the first account)", + ), +}); + +// Summary stats schema +export const summaryStatsQuerySchema = z.object({ + period: zodPeriod + .optional() + .default("week") + .describe("Time period for aggregation"), + fromDate: z.coerce + .number() + .optional() + .describe("Start date as Unix timestamp"), + toDate: z.coerce.number().optional().describe("End date as Unix timestamp"), + email: z + .string() + .optional() + .describe( + "Email account to get stats for (if not provided, uses the first account)", + ), +}); + +export const summaryStatsResponseSchema = z.object({ + result: z + .array( + z.object({ + startOfPeriod: z.string().describe("Start date of the period"), + All: z.number().describe("Total emails"), + Sent: z.number().describe("Emails sent"), + Read: z.number().describe("Emails read"), + Unread: z.number().describe("Emails unread"), + Unarchived: z.number().describe("Emails in inbox"), + Archived: z.number().describe("Emails archived"), + }), + ) + .describe("Statistics broken down by time period"), + allCount: z.number().describe("Total count of all emails"), + inboxCount: z.number().describe("Total count of emails in inbox"), + readCount: z.number().describe("Total count of read emails"), + sentCount: z.number().describe("Total count of sent emails"), +}); + +// Day stats schema +export const dayStatsQuerySchema = z.object({ + type: z + .enum(["sent", "archived", "inbox"]) + .describe("Type of email statistics to retrieve"), + email: z + .string() + .optional() + .describe( + "Email account to get stats for (if not provided, uses the first account)", + ), +}); + +export const dayStatsResponseSchema = z + .array( + z.object({ + date: z.string().describe("Date in YYYY-MM-DD format"), + Emails: z.number().describe("Number of emails for this date"), + }), + ) + .describe("Daily email statistics for the past 7 days"); + +export type SummaryStatsResponse = z.infer; +export type DayStatsResponse = z.infer;