diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/email-outbox-utils.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/email-outbox-utils.ts new file mode 100644 index 000000000..d048073ec --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/email-outbox-utils.ts @@ -0,0 +1,25 @@ +import type { AdminEmailOutbox } from "@hexclave/next"; + +export type EmailWithDeliveredAt = AdminEmailOutbox & { + deliveredAt?: Date | string | null, +}; + +export function hasDeliveredAt(email: AdminEmailOutbox): email is EmailWithDeliveredAt { + return "deliveredAt" in email; +} + +export function getRecipientDisplay(email: AdminEmailOutbox): string { + const to = email.to; + if (to.type === "user-primary-email") { + return `User: ${to.userId.slice(0, 8)}...`; + } + if (to.type === "user-custom-emails") { + return to.emails[0] ?? `User: ${to.userId.slice(0, 8)}...`; + } + return to.emails[0] ?? "No recipients"; +} + +export function getEmailTimestamp(email: AdminEmailOutbox): Date { + const deliveredAt = hasDeliveredAt(email) ? email.deliveredAt : undefined; + return deliveredAt != null ? new Date(deliveredAt) : email.scheduledAt; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx index d7845fa7f..b73d99f9e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx @@ -17,32 +17,9 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react import { useAdminApp, useProjectId } from "../use-admin-app"; import { DomainReputationCard } from "./domain-reputation-card"; import { STATUS_LABELS, computeEmailStats, getStatusBadgeColor } from "./email-status-utils"; +import { getRecipientDisplay, getEmailTimestamp } from "./email-outbox-utils"; import { StatsBar } from "./stats-bar"; -type EmailWithDeliveredAt = AdminEmailOutbox & { - deliveredAt?: Date | string | null, -}; - -function hasDeliveredAt(email: AdminEmailOutbox): email is EmailWithDeliveredAt { - return "deliveredAt" in email; -} - -function getRecipientDisplay(email: AdminEmailOutbox): string { - const to = email.to; - if (to.type === "user-primary-email") { - return `User: ${to.userId.slice(0, 8)}...`; - } - if (to.type === "user-custom-emails") { - return to.emails[0] ?? `User: ${to.userId.slice(0, 8)}...`; - } - return to.emails[0] ?? "No recipients"; -} - -function getEmailTimestamp(email: AdminEmailOutbox): Date { - const deliveredAt = hasDeliveredAt(email) ? email.deliveredAt : undefined; - return deliveredAt ? new Date(deliveredAt) : email.scheduledAt; -} - const emailColumns: DataGridColumnDef[] = [ { id: "recipient", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index 8e2cfbf26..908ba20f6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -63,6 +63,7 @@ import { useAdminApp } from "../../use-admin-app"; import { UserAnalyticsSection } from "./user-analytics"; import { UserPageTableSection } from "./user-page-table-section"; import { UserPaymentsSection } from "./user-payments"; +import { UserEmailsSection } from "./user-emails"; import dynamic from "next/dynamic"; // The session-replays page is ~2k LOC and pulls rrweb in via dynamic imports. @@ -1869,6 +1870,7 @@ type UserPageTabConfig = { const USER_PAGE_TABS = [ { id: "authentication", label: "Authentication", appId: "authentication" }, { id: "teams", label: "Teams", appId: "teams" }, + { id: "emails", label: "Emails", appId: "emails" }, { id: "payments", label: "Payments", appId: "payments" }, { id: "analytics", label: "Analytics", appId: "analytics" }, { id: "session-replays", label: "Session Replays", appId: "session-replays" }, @@ -1994,6 +1996,11 @@ function UserPage({ user }: { user: ServerUser }) { )} + {activeTab === "emails" && ( + }> + + + )} {activeTab === "payments" && ( }> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-emails.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-emails.tsx new file mode 100644 index 000000000..b86882833 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-emails.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { DesignBadge } from "@/components/design-components"; +import { useRouter } from "@/components/router"; +import { Skeleton, Typography } from "@/components/ui"; +import { EnvelopeSimpleIcon } from "@phosphor-icons/react"; +import type { DataGridColumnDef } from "@hexclave/dashboard-ui-components"; +import type { AdminEmailOutbox, ServerUser } from "@hexclave/next"; +import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; +import { urlString } from "@hexclave/shared/dist/utils/urls"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAdminApp, useProjectId } from "../../use-admin-app"; +import { UserPageTableSection } from "./user-page-table-section"; +import { STATUS_LABELS, computeEmailStats, getStatusBadgeColor } from "../../email-sent/email-status-utils"; +import { getRecipientDisplay, getEmailTimestamp } from "../../email-sent/email-outbox-utils"; +import { StatsBar } from "../../email-sent/stats-bar"; + +function isEmailForUser(email: AdminEmailOutbox, userId: string): boolean { + const to = email.to; + if (to.type === "user-primary-email" && to.userId === userId) return true; + if (to.type === "user-custom-emails" && to.userId === userId) return true; + return false; +} + +const emailColumns: DataGridColumnDef[] = [ + { + id: "subject", + header: "Subject", + width: 240, + flex: 1, + sortable: false, + renderCell: ({ row }) => { + if (!row.hasRendered) { + return Not yet rendered; + } + return {row.subject}; + }, + }, + { + id: "recipient", + header: "Recipient", + width: 200, + sortable: false, + renderCell: ({ row }) => ( + {getRecipientDisplay(row)} + ), + }, + { + id: "time", + header: "Time", + width: 160, + sortable: false, + renderCell: ({ row }) => ( + + {getEmailTimestamp(row).toLocaleString()} + + ), + }, + { + id: "status", + header: "Status", + width: 120, + sortable: false, + renderCell: ({ row }) => ( + + ), + }, +]; + +export function UserEmailsSection({ user }: { user: ServerUser }) { + const hexclaveAdminApp = useAdminApp(); + const projectId = useProjectId(); + const router = useRouter(); + const [emails, setEmails] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetches all pages of the outbox so client-side filtering by userId + // doesn't silently miss emails on later pages. + const refreshEmails = useCallback(async () => { + setLoading(true); + setError(null); + try { + const allEmails: AdminEmailOutbox[] = []; + let cursor: string | undefined; + do { + const result = await hexclaveAdminApp.listOutboxEmails(cursor != null ? { cursor } : undefined); + allEmails.push(...result.items); + cursor = result.nextCursor ?? undefined; + } while (cursor != null); + setEmails(allEmails); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load emails"); + } finally { + setLoading(false); + } + }, [hexclaveAdminApp]); + + useEffect(() => { + let cancelled = false; + runAsynchronouslyWithAlert(async () => { + await refreshEmails(); + if (cancelled) return; + }); + return () => { + cancelled = true; + }; + }, [refreshEmails]); + + const filtered = useMemo( + () => emails + .filter((e) => isEmailForUser(e, user.id)) + .sort((a, b) => getEmailTimestamp(b).getTime() - getEmailTimestamp(a).getTime()), + [emails, user.id], + ); + const stats = useMemo(() => computeEmailStats(filtered), [filtered]); + + if (loading) { + return ( +
+ + +
+ ); + } + + if (error != null) { + return ( +
+ + Failed to load emails + {error} +
+ ); + } + + return ( +
+ {filtered.length > 0 && ( +
+
+ {filtered.length} email{filtered.length !== 1 ? "s" : ""} +
+ +
+ )} + + email.id} + emptyLabel="No emails sent to this user" + paginated + onRowClick={(row) => { + router.push(urlString`/projects/${projectId}/email-viewer/${row.id}`); + }} + /> +
+ ); +}