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 752574920..e9103fd39 100644 --- a/apps/backend/src/app/api/latest/emails/outbox/crud.tsx +++ b/apps/backend/src/app/api/latest/emails/outbox/crud.tsx @@ -260,6 +260,8 @@ function prismaModelToCrud(prismaModel: EmailOutbox): EmailOutboxCrud["Server"][ throw new StackAssertionError(`Unknown email outbox status: ${status}`, { status }); } +const MAX_LIMIT = 100; + export const emailOutboxCrudHandlers = createLazyProxy(() => createCrudHandlers(emailOutboxCrud, { paramsSchema: yupObject({ id: yupString().uuid().optional(), @@ -267,6 +269,8 @@ export const emailOutboxCrudHandlers = createLazyProxy(() => createCrudHandlers( querySchema: yupObject({ status: yupString().optional(), simple_status: yupString().optional(), + 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)" } }), }), onRead: async ({ auth, params }) => { if (!params.id) { @@ -289,6 +293,15 @@ export const emailOutboxCrudHandlers = createLazyProxy(() => createCrudHandlers( return prismaModelToCrud(email); }, onList: async ({ auth, query }) => { + // Parse and validate limit + const parsedLimit = query.limit ? parseInt(query.limit, 10) : MAX_LIMIT; + if (isNaN(parsedLimit) || parsedLimit < 1) { + throw new StatusError(400, "Invalid limit parameter"); + } + if (parsedLimit > MAX_LIMIT) { + throw new StatusError(400, `Limit cannot exceed ${MAX_LIMIT}`); + } + const where: Prisma.EmailOutboxWhereInput = { tenancyId: auth.tenancy.id, }; @@ -304,17 +317,35 @@ export const emailOutboxCrudHandlers = createLazyProxy(() => createCrudHandlers( const emails = await globalPrismaClient.emailOutbox.findMany({ where, orderBy: [ - { finishedSendingAt: "desc" }, - { scheduledAtIfNotYetQueued: "desc" }, + // Emails with finishedSendingAt come first (most recent first), then nulls + { finishedSendingAt: { sort: "desc", nulls: "last" } }, + // For not-yet-sent emails: scheduled ones come first (most recent first), then nulls + { scheduledAtIfNotYetQueued: { sort: "desc", nulls: "last" } }, { priority: "asc" }, { id: "asc" }, ], - take: 100, + // +1 to check if there's a next page + take: parsedLimit + 1, + ...query.cursor ? { + cursor: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: query.cursor, + }, + }, + } : {}, }); + const hasMore = emails.length > parsedLimit; + const resultEmails = hasMore ? emails.slice(0, parsedLimit) : emails; + const nextCursor = hasMore ? emails[parsedLimit].id : null; + return { - items: emails.map(prismaModelToCrud), - is_paginated: false, + items: resultEmails.map(prismaModelToCrud), + is_paginated: true, + pagination: { + next_cursor: nextCursor, + }, }; }, onUpdate: async ({ auth, params, data }) => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page-client.tsx index 750be057d..28eab8e7a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page-client.tsx @@ -1,14 +1,14 @@ "use client"; +import { PaginationControls } from "@/components/data-table/common/pagination"; import { SettingCard } from "@/components/settings"; -import { ActionDialog, Badge, Button, DataTable, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SimpleTooltip, Switch, Typography, useToast } from "@/components/ui"; +import { ActionDialog, Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SimpleTooltip, Switch, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography, useToast } from "@/components/ui"; import { cn } from "@/lib/utils"; import { DotsThreeIcon, PauseIcon, PlayIcon, XCircleIcon } from "@phosphor-icons/react"; import { AdminEmailOutbox, AdminEmailOutboxSimpleStatus, AdminEmailOutboxStatus } from "@stackframe/stack"; import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { ColumnDef } from "@tanstack/react-table"; -import { useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; @@ -587,6 +587,8 @@ function EmailDetailSheet({ ); } +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + export default function PageClient() { const stackAdminApp = useAdminApp(); const [emails, setEmails] = useState([]); @@ -596,77 +598,122 @@ export default function PageClient() { const [selectedEmail, setSelectedEmail] = useState(null); const [detailSheetOpen, setDetailSheetOpen] = useState(false); - const loadEmails = async () => { + // Pagination state + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [nextCursor, setNextCursor] = useState(null); + // Store cursors for each page so we can navigate backwards + const cursorHistory = useRef>(new Map([[1, undefined]])); + + const loadEmails = useCallback(async (cursor?: string, limit?: number) => { setLoading(true); try { - const options: { status?: string, simpleStatus?: string } = {}; + const options: { status?: string, simpleStatus?: string, cursor?: string, limit?: number } = { + limit: limit ?? pageSize, + }; if (statusFilter !== "all") { options.status = statusFilter; } if (simpleStatusFilter !== "all") { options.simpleStatus = simpleStatusFilter; } + if (cursor) { + options.cursor = cursor; + } const result = await stackAdminApp.listOutboxEmails(options); - setEmails(result); + setEmails(result.items); + setNextCursor(result.nextCursor); } finally { setLoading(false); } - }; + }, [stackAdminApp, statusFilter, simpleStatusFilter, pageSize]); // Load emails on mount useState(() => { runAsynchronouslyWithAlert(loadEmails); }); - // Reload when filters change + // Reset pagination and reload when filters change const handleFilterChange = (newStatusFilter: string, newSimpleStatusFilter: string) => { setStatusFilter(newStatusFilter); setSimpleStatusFilter(newSimpleStatusFilter); - // Trigger reload + setPage(1); + cursorHistory.current = new Map([[1, undefined]]); + // Trigger reload - use setTimeout to ensure state is updated setTimeout(() => { - runAsynchronouslyWithAlert(loadEmails); + runAsynchronouslyWithAlert(async () => { + const options: { status?: string, simpleStatus?: string, limit?: number } = { limit: pageSize }; + if (newStatusFilter !== "all") { + options.status = newStatusFilter; + } + if (newSimpleStatusFilter !== "all") { + options.simpleStatus = newSimpleStatusFilter; + } + const result = await stackAdminApp.listOutboxEmails(options); + setEmails(result.items); + setNextCursor(result.nextCursor); + }); }, 0); }; - const columns: ColumnDef[] = [ - { - accessorKey: "subject", - header: "Subject", - cell: ({ row }) => { - const email = row.original; - // Subject is only available after rendering - check if it's a rendered status - const subject = "subject" in email ? email.subject : undefined; - return ( -
+ const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setPage(1); + cursorHistory.current = new Map([[1, undefined]]); + runAsynchronouslyWithAlert(() => loadEmails(undefined, newPageSize)); + }; + + const handleNextPage = () => { + if (!nextCursor) return; + const newPage = page + 1; + cursorHistory.current.set(newPage, nextCursor); + setPage(newPage); + runAsynchronouslyWithAlert(() => loadEmails(nextCursor)); + }; + + const handlePreviousPage = () => { + if (page <= 1) return; + const newPage = page - 1; + const cursor = cursorHistory.current.get(newPage); + setPage(newPage); + runAsynchronouslyWithAlert(() => loadEmails(cursor)); + }; + + const handleRefresh = useCallback(async () => { + const cursor = cursorHistory.current.get(page); + await loadEmails(cursor); + }, [loadEmails, page]); + + // Memoize to avoid re-creating on every render + const emailTableRows = useMemo(() => emails.map((email) => { + const subject = "subject" in email ? email.subject : undefined; + const recipientDisplay = getRecipientDisplay(email); + const paused = isEmailPaused(email); + + return ( + { + setSelectedEmail(email); + setDetailSheetOpen(true); + }} + > + +
{subject || Pending}
- ); - }, - }, - { - accessorKey: "to", - header: "Recipient", - cell: ({ row }) => { - const display = getRecipientDisplay(row.original); - return ( -
- - {display} + + +
+ + {recipientDisplay}
- ); - }, - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => { - const email = row.original; - const paused = isEmailPaused(email); - - return ( +
+
{STATUS_LABELS[email.status]} @@ -677,48 +724,30 @@ export default function PageClient() { )}
- ); - }, - }, - { - accessorKey: "scheduledAt", - header: "Scheduled", - cell: ({ row }) => { - const date = row.original.scheduledAt; - return ( - - {fromNow(date)} +
+ + + {fromNow(email.scheduledAt)} - ); - }, - }, - { - accessorKey: "createdAt", - header: "Created", - cell: ({ row }) => { - const date = row.original.createdAt; - return ( - - {fromNow(date)} + + + + {fromNow(email.createdAt)} - ); - }, - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( - - ), - }, - ]; + + e.stopPropagation()}> + + + + ); + }), [emails, handleRefresh]); return ( runAsynchronouslyWithAlert(loadEmails)} variant="outline"> + } @@ -770,16 +799,35 @@ export default function PageClient() { No emails found
) : ( - { - setSelectedEmail(email); - setDetailSheetOpen(true); - }} - /> +
+
+ + + + Subject + Recipient + Status + Scheduled + Created + + + + + {emailTableRows} + +
+
+ 1} + onPageSizeChange={handlePageSizeChange} + onPreviousPage={handlePreviousPage} + onNextPage={handleNextPage} + /> +
)} @@ -799,7 +847,7 @@ export default function PageClient() { } }} onRefresh={async () => { - await loadEmails(); + await handleRefresh(); // Update selected email with fresh data if (selectedEmail) { const updated = emails.find(e => e.id === selectedEmail.id); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts index 64ec5b7b5..afa46832b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts @@ -1651,3 +1651,221 @@ describe("theme and template deletion after scheduling", () => { }); }); +describe("email outbox pagination", () => { + it("should paginate email outbox results with cursor", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Pagination Project", + config: { + email_config: testEmailConfig, + }, + }); + + // Create a draft for sending emails + const templateSource = deindent` + import { Container } from "@react-email/components"; + import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + + export function EmailTemplate({ user, project }) { + return ( + + + +
Test
+
+ ); + } + `; + + const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", { + method: "POST", + accessType: "admin", + body: { + display_name: "Pagination Draft", + tsx_source: templateSource, + theme_id: false, + }, + }); + expect(createDraftResponse.status).toBe(200); + const draftId = createDraftResponse.body.id; + + // Create 5 users + const userIds: string[] = []; + for (let i = 0; i < 5; i++) { + const email = `pagination-test-${i}@example.com`; + const createUserResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: email, + primary_email_verified: true, + }, + }); + expect(createUserResponse.status).toBe(201); + userIds.push(createUserResponse.body.id); + } + + // Send emails to all users + const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", { + method: "POST", + accessType: "server", + body: { + user_ids: userIds, + draft_id: draftId, + }, + }); + expect(sendResponse.status).toBe(200); + + // Test pagination with limit=2 + const page1Response = await niceBackendFetch("/api/v1/emails/outbox?limit=2", { + method: "GET", + accessType: "server", + }); + expect(page1Response.status).toBe(200); + expect(page1Response.body.items.length).toBe(2); + expect(page1Response.body.is_paginated).toBe(true); + expect(page1Response.body.pagination.next_cursor).not.toBeNull(); + + // Get next page using cursor + const cursor = page1Response.body.pagination.next_cursor; + const page2Response = await niceBackendFetch(`/api/v1/emails/outbox?limit=2&cursor=${cursor}`, { + method: "GET", + accessType: "server", + }); + expect(page2Response.status).toBe(200); + expect(page2Response.body.items.length).toBe(2); + + // Verify items on page 2 are different from page 1 + const page1Ids = new Set(page1Response.body.items.map((e: { id: string }) => e.id)); + const page2Ids = page2Response.body.items.map((e: { id: string }) => e.id); + for (const id of page2Ids) { + expect(page1Ids.has(id)).toBe(false); + } + + // Get page 3 + const cursor2 = page2Response.body.pagination.next_cursor; + const page3Response = await niceBackendFetch(`/api/v1/emails/outbox?limit=2&cursor=${cursor2}`, { + method: "GET", + accessType: "server", + }); + expect(page3Response.status).toBe(200); + expect(page3Response.body.items.length).toBe(1); // Only 1 remaining + + // No more pages + expect(page3Response.body.pagination.next_cursor).toBeNull(); + }); + + it("should reject limit greater than 100", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Limit Project", + config: { + email_config: testEmailConfig, + }, + }); + + const response = await niceBackendFetch("/api/v1/emails/outbox?limit=101", { + method: "GET", + accessType: "server", + }); + expect(response.status).toBe(400); + }); + + it("should order emails with finishedSendingAt first (nulls last)", async ({ expect }) => { + await Project.createAndSwitch({ + display_name: "Test Ordering Project", + config: { + email_config: testEmailConfig, + }, + }); + + // Create a slow-rendering draft (so we have time to pause it) + const templateSource = deindent` + import { Container } from "@react-email/components"; + import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + + // Artificial delay to make the email slow to render + const startTime = performance.now(); + while (performance.now() - startTime < 200) { + // Busy wait + } + + export function EmailTemplate({ user, project }) { + return ( + + + +
Test
+
+ ); + } + `; + + const createDraftResponse = await niceBackendFetch("/api/v1/internal/email-drafts", { + method: "POST", + accessType: "admin", + body: { + display_name: "Ordering Draft", + tsx_source: templateSource, + theme_id: false, + }, + }); + expect(createDraftResponse.status).toBe(200); + const draftId = createDraftResponse.body.id; + + // Create user + const mailbox = backendContext.value.mailbox; + const createUserResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: mailbox.emailAddress, + primary_email_verified: true, + }, + }); + expect(createUserResponse.status).toBe(201); + const userId = createUserResponse.body.id; + + // Send 2 emails to the user and wait for them to be sent + for (let i = 0; i < 2; i++) { + const sendResponse = await niceBackendFetch("/api/v1/emails/send-email", { + method: "POST", + accessType: "server", + body: { + user_ids: [userId], + draft_id: draftId, + }, + }); + expect(sendResponse.status).toBe(200); + } + + // Wait for email processing - they should be sent + await wait(5_000); + + // Verify at least one email was sent + const listResponse = await niceBackendFetch("/api/v1/emails/outbox", { + method: "GET", + accessType: "server", + }); + expect(listResponse.status).toBe(200); + const emails = listResponse.body.items.filter((e: { subject?: string }) => + e.subject === "Ordering Test Email" + ); + + // Check ordering: finished emails should come before non-finished ones + // Statuses that have finishedSendingAt set (email has completed processing) + const finishedStatuses = new Set(["sent", "opened", "clicked", "marked-as-spam", "server-error", "bounced", "delivery-delayed", "skipped"]); + let foundNonFinished = false; + for (const email of emails) { + const hasFinished = finishedStatuses.has(email.status); + if (!hasFinished) { + foundNonFinished = true; + } else if (foundNonFinished) { + // We found a finished email after a non-finished email - wrong ordering + expect.fail(`Wrong ordering: found '${email.status}' after non-finished emails`); + } + } + // We should have at least one finished email + const hasSentEmails = emails.some((e: { status: string }) => finishedStatuses.has(e.status)); + expect(hasSentEmails).toBe(true); + }); +}); + 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 8cfa93509..c01855d3a 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 @@ -109,7 +109,7 @@ describe("email outbox API", () => { }); expect(listResponse.status).toBe(200); expect(listResponse.body.items.length).toBeGreaterThanOrEqual(1); - expect(listResponse.body.is_paginated).toBe(false); + expect(listResponse.body.is_paginated).toBe(true); }); it("should filter by status", async ({ expect }) => { diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index e1c46dad8..9c929660c 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -626,10 +626,12 @@ export class StackAdminInterface extends StackServerInterface { // Email Outbox methods - async listOutboxEmails(options?: { status?: string, simple_status?: string }): Promise { + async listOutboxEmails(options?: { status?: string, simple_status?: 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?.limit !== undefined) qs.set('limit', options.limit.toString()); + if (options?.cursor) qs.set('cursor', options.cursor); const response = await this.sendServerRequest( `/emails/outbox${qs.size ? `?${qs.toString()}` : ''}`, { method: 'GET' }, diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index e929c7772..2b6d05482 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -812,12 +812,17 @@ export class _StackAdminAppImplIncomplete { + async listOutboxEmails(options?: { status?: string, simpleStatus?: string, limit?: number, cursor?: string }): Promise<{ items: AdminEmailOutbox[], nextCursor: string | null }> { const response = await this._interface.listOutboxEmails({ status: options?.status, simple_status: options?.simpleStatus, + limit: options?.limit, + cursor: options?.cursor, }); - return response.items.map((item) => this._emailOutboxCrudToAdmin(item)); + return { + items: response.items.map((item) => this._emailOutboxCrudToAdmin(item)), + nextCursor: response.pagination?.next_cursor ?? null, + }; } async getOutboxEmail(id: string): Promise { diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 5d02d2bd7..f721e4eca 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -13,6 +13,13 @@ import { StackServerApp, StackServerAppConstructorOptions } from "./server-app"; export type EmailOutboxListOptions = { status?: string, simpleStatus?: string, + limit?: number, + cursor?: string, +}; + +export type EmailOutboxListResult = { + items: AdminEmailOutbox[], + nextCursor: string | null, }; export type EmailOutboxUpdateOptions = { @@ -103,7 +110,7 @@ export type StackAdminApp, // Email Outbox methods - listOutboxEmails(options?: EmailOutboxListOptions): Promise, + listOutboxEmails(options?: EmailOutboxListOptions): Promise, getOutboxEmail(id: string): Promise, updateOutboxEmail(id: string, options: EmailOutboxUpdateOptions): Promise, pauseOutboxEmail(id: string): Promise, diff --git a/packages/template/src/lib/stack-app/index.ts b/packages/template/src/lib/stack-app/index.ts index d423c7a44..c947c64ad 100644 --- a/packages/template/src/lib/stack-app/index.ts +++ b/packages/template/src/lib/stack-app/index.ts @@ -12,6 +12,12 @@ export type { StackServerAppConstructorOptions } from "./apps"; +export type { + EmailOutboxListOptions, + EmailOutboxListResult, + EmailOutboxUpdateOptions +} from "./apps/interfaces/admin-app"; + export type { ProjectConfig } from "./project-configs";