From d88e77c67b110c8eba3685d4b3167e19b96fab4c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 17 Jun 2026 13:39:24 -0700 Subject: [PATCH] User ID filter for email outbox --- .../src/app/api/latest/emails/outbox/crud.tsx | 7 ++ .../users/[userId]/user-emails.tsx | 39 +++++------ .../api/v1/emails/outbox-api.test.ts | 66 +++++++++++++++++++ .../shared/src/interface/admin-interface.ts | 3 +- .../shared/src/interface/crud/email-outbox.ts | 2 +- .../apps/implementations/admin-app-impl.ts | 3 +- .../hexclave-app/apps/interfaces/admin-app.ts | 1 + 7 files changed, 96 insertions(+), 25 deletions(-) diff --git a/apps/backend/src/app/api/latest/emails/outbox/crud.tsx b/apps/backend/src/app/api/latest/emails/outbox/crud.tsx index 20539cdd2..a713061e0 100644 --- a/apps/backend/src/app/api/latest/emails/outbox/crud.tsx +++ b/apps/backend/src/app/api/latest/emails/outbox/crud.tsx @@ -296,6 +296,7 @@ export const emailOutboxCrudHandlers = createLazyProxy(() => createCrudHandlers( querySchema: yupObject({ status: yupString().optional(), simple_status: yupString().optional(), + user_id: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Filter for emails whose recipient is the given user ID." } }), limit: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: `The maximum number of items to return. Maximum allowed is ${MAX_LIMIT}` } }), cursor: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "The cursor to start the result set from (email ID)" } }), }), @@ -340,6 +341,12 @@ export const emailOutboxCrudHandlers = createLazyProxy(() => createCrudHandlers( if (query.simple_status) { where.simpleStatus = query.simple_status.toUpperCase().replace(/-/g, "_") as any; } + if (query.user_id) { + where.to = { + path: ["userId"], + equals: query.user_id, + }; + } const emails = await globalPrismaClient.emailOutbox.findMany({ where, 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 index b86882833..56c6e4396 100644 --- 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 @@ -15,13 +15,6 @@ import { STATUS_LABELS, computeEmailStats, getStatusBadgeColor } from "../../ema 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", @@ -75,45 +68,47 @@ export function UserEmailsSection({ user }: { user: ServerUser }) { 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 () => { + const refreshEmails = useCallback(async (isCancelled: () => boolean) => { setLoading(true); setError(null); try { const allEmails: AdminEmailOutbox[] = []; let cursor: string | undefined; do { - const result = await hexclaveAdminApp.listOutboxEmails(cursor != null ? { cursor } : undefined); + const result = await hexclaveAdminApp.listOutboxEmails({ + userId: user.id, + cursor, + }); + if (isCancelled()) return; allEmails.push(...result.items); cursor = result.nextCursor ?? undefined; } while (cursor != null); setEmails(allEmails); } catch (err) { + if (isCancelled()) return; setError(err instanceof Error ? err.message : "Failed to load emails"); } finally { + if (isCancelled()) return; setLoading(false); } - }, [hexclaveAdminApp]); + }, [hexclaveAdminApp, user.id]); useEffect(() => { let cancelled = false; runAsynchronouslyWithAlert(async () => { - await refreshEmails(); - if (cancelled) return; + await refreshEmails(() => cancelled); }); return () => { cancelled = true; }; }, [refreshEmails]); - const filtered = useMemo( - () => emails - .filter((e) => isEmailForUser(e, user.id)) + const sortedEmails = useMemo( + () => [...emails] .sort((a, b) => getEmailTimestamp(b).getTime() - getEmailTimestamp(a).getTime()), - [emails, user.id], + [emails], ); - const stats = useMemo(() => computeEmailStats(filtered), [filtered]); + const stats = useMemo(() => computeEmailStats(sortedEmails), [sortedEmails]); if (loading) { return ( @@ -136,10 +131,10 @@ export function UserEmailsSection({ user }: { user: ServerUser }) { return (
- {filtered.length > 0 && ( + {sortedEmails.length > 0 && (
- {filtered.length} email{filtered.length !== 1 ? "s" : ""} + {sortedEmails.length} email{sortedEmails.length !== 1 ? "s" : ""}
@@ -149,7 +144,7 @@ export function UserEmailsSection({ user }: { user: ServerUser }) { title="Sent Emails" urlStateKey="useremails" columns={emailColumns} - rows={filtered} + rows={sortedEmails} getRowId={(email) => email.id} emptyLabel="No emails sent to this user" paginated diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts index 794bb4c26..671f7929b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/outbox-api.test.ts @@ -189,6 +189,72 @@ describe("email outbox API", () => { expect(okResponse.body.items.every((e: any) => e.simple_status === "ok")).toBe(true); }); + it("should filter by recipient user_id", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Outbox Filter User Project", + config: { + email_config: testEmailConfig, + }, + }); + + const firstMailbox = backendContext.value.mailbox; + const firstUserResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: firstMailbox.emailAddress, + primary_email_verified: true, + }, + }); + expect(firstUserResponse.status).toBe(201); + const firstUserId = firstUserResponse.body.id; + + const secondMailbox = await bumpEmailAddress(); + const secondUserResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: secondMailbox.emailAddress, + primary_email_verified: true, + }, + }); + expect(secondUserResponse.status).toBe(201); + const secondUserId = secondUserResponse.body.id; + + await niceBackendFetch("/api/v1/emails/send-email", { + method: "POST", + accessType: "server", + body: { + user_ids: [firstUserId], + html: "

User filter first email

", + subject: "User Filter First Email", + notification_category_name: "Transactional", + }, + }); + await niceBackendFetch("/api/v1/emails/send-email", { + method: "POST", + accessType: "server", + body: { + user_ids: [secondUserId], + html: "

User filter second email

", + subject: "User Filter Second Email", + notification_category_name: "Transactional", + }, + }); + + await waitForOutboxEmailWithStatus("User Filter First Email", "sent"); + await waitForOutboxEmailWithStatus("User Filter Second Email", "sent"); + + const firstUserOutboxResponse = await niceBackendFetch(`/api/v1/emails/outbox?user_id=${firstUserId}`, { + method: "GET", + accessType: "server", + }); + expect(firstUserOutboxResponse.status).toBe(200); + expect(firstUserOutboxResponse.body.items).toHaveLength(1); + expect(firstUserOutboxResponse.body.items[0].subject).toBe("User Filter First Email"); + expect(firstUserOutboxResponse.body.items[0].to.user_id).toBe(firstUserId); + }); + it("should return empty list for project with no emails", async ({ expect }) => { await Project.createAndSwitch({ display_name: "Test Empty Outbox Project", diff --git a/packages/shared/src/interface/admin-interface.ts b/packages/shared/src/interface/admin-interface.ts index 4b6a48cd9..5710f165c 100644 --- a/packages/shared/src/interface/admin-interface.ts +++ b/packages/shared/src/interface/admin-interface.ts @@ -1091,10 +1091,11 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { return await response.json(); } - async listOutboxEmails(options?: { status?: string, simple_status?: string, limit?: number, cursor?: string }): Promise { + async listOutboxEmails(options?: { status?: string, simple_status?: string, user_id?: string, limit?: number, cursor?: string }): Promise { const qs = new URLSearchParams(); if (options?.status) qs.set('status', options.status); if (options?.simple_status) qs.set('simple_status', options.simple_status); + if (options?.user_id) qs.set('user_id', options.user_id); if (options?.limit !== undefined) qs.set('limit', options.limit.toString()); if (options?.cursor) qs.set('cursor', options.cursor); const response = await this.sendServerRequest( diff --git a/packages/shared/src/interface/crud/email-outbox.ts b/packages/shared/src/interface/crud/email-outbox.ts index 004d35ace..82380f8e6 100644 --- a/packages/shared/src/interface/crud/email-outbox.ts +++ b/packages/shared/src/interface/crud/email-outbox.ts @@ -254,7 +254,7 @@ export const emailOutboxCrud = createCrud({ serverList: { tags: ["Emails"], summary: "List email outbox", - description: "Lists all emails in the outbox with optional filtering by status or simple_status.", + description: "Lists all emails in the outbox with optional filtering by status, simple_status, or user_id.", }, }, }); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index 9bd973bc6..ff29c5009 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -1112,10 +1112,11 @@ export class _HexclaveAdminAppImplIncomplete { + async listOutboxEmails(options?: { status?: string, simpleStatus?: string, userId?: string, limit?: number, cursor?: string }): Promise<{ items: AdminEmailOutbox[], nextCursor: string | null }> { const response = await this._interface.listOutboxEmails({ status: options?.status, simple_status: options?.simpleStatus, + user_id: options?.userId, limit: options?.limit, cursor: options?.cursor, }); diff --git a/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts index e3b5bdadb..1d24954bb 100644 --- a/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts @@ -16,6 +16,7 @@ import { StackServerApp, StackServerAppConstructorOptions } from "./server-app"; export type EmailOutboxListOptions = { status?: string, simpleStatus?: string, + userId?: string, limit?: number, cursor?: string, };