Fix email outbox pagination

This commit is contained in:
Konstantin Wohlwend 2026-01-08 10:28:06 -08:00
parent 50ffd373a1
commit 90ac480f43
8 changed files with 415 additions and 98 deletions

View File

@ -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 }) => {

View File

@ -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<AdminEmailOutbox[]>([]);
@ -596,77 +598,122 @@ export default function PageClient() {
const [selectedEmail, setSelectedEmail] = useState<AdminEmailOutbox | null>(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<string | null>(null);
// Store cursors for each page so we can navigate backwards
const cursorHistory = useRef<Map<number, string | undefined>>(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<AdminEmailOutbox>[] = [
{
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 (
<div className="max-w-[200px] truncate">
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 (
<TableRow
key={email.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => {
setSelectedEmail(email);
setDetailSheetOpen(true);
}}
>
<TableCell className="max-w-[200px]">
<div className="truncate">
<SimpleTooltip tooltip={subject || "Not rendered yet"}>
<span>{subject || <span className="text-muted-foreground italic">Pending</span>}</span>
</SimpleTooltip>
</div>
);
},
},
{
accessorKey: "to",
header: "Recipient",
cell: ({ row }) => {
const display = getRecipientDisplay(row.original);
return (
<div className="max-w-[150px] truncate">
<SimpleTooltip tooltip={display}>
<span className="text-sm">{display}</span>
</TableCell>
<TableCell className="max-w-[150px]">
<div className="truncate">
<SimpleTooltip tooltip={recipientDisplay}>
<span className="text-sm">{recipientDisplay}</span>
</SimpleTooltip>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const email = row.original;
const paused = isEmailPaused(email);
return (
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant={getStatusBadgeVariant(email.simpleStatus)}>
{STATUS_LABELS[email.status]}
@ -677,48 +724,30 @@ export default function PageClient() {
</SimpleTooltip>
)}
</div>
);
},
},
{
accessorKey: "scheduledAt",
header: "Scheduled",
cell: ({ row }) => {
const date = row.original.scheduledAt;
return (
<SimpleTooltip tooltip={date.toLocaleString()}>
<span className="text-sm text-muted-foreground">{fromNow(date)}</span>
</TableCell>
<TableCell>
<SimpleTooltip tooltip={email.scheduledAt.toLocaleString()}>
<span className="text-sm text-muted-foreground">{fromNow(email.scheduledAt)}</span>
</SimpleTooltip>
);
},
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
const date = row.original.createdAt;
return (
<SimpleTooltip tooltip={date.toLocaleString()}>
<span className="text-sm text-muted-foreground">{fromNow(date)}</span>
</TableCell>
<TableCell>
<SimpleTooltip tooltip={email.createdAt.toLocaleString()}>
<span className="text-sm text-muted-foreground">{fromNow(email.createdAt)}</span>
</SimpleTooltip>
);
},
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<EmailActions email={row.original} onRefresh={loadEmails} />
),
},
];
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<EmailActions email={email} onRefresh={handleRefresh} />
</TableCell>
</TableRow>
);
}), [emails, handleRefresh]);
return (
<PageLayout
title="Email Outbox"
description="View and manage scheduled and sent emails"
actions={
<Button onClick={() => runAsynchronouslyWithAlert(loadEmails)} variant="outline">
<Button onClick={() => runAsynchronouslyWithAlert(handleRefresh)} variant="outline">
Refresh
</Button>
}
@ -770,16 +799,35 @@ export default function PageClient() {
<Typography className="text-muted-foreground">No emails found</Typography>
</div>
) : (
<DataTable
data={emails}
columns={columns}
defaultColumnFilters={[]}
defaultSorting={[{ id: "createdAt", desc: true }]}
onRowClick={(email) => {
setSelectedEmail(email);
setDetailSheetOpen(true);
}}
/>
<div className="flex flex-col">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Subject</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Status</TableHead>
<TableHead>Scheduled</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{emailTableRows}
</TableBody>
</Table>
</div>
<PaginationControls
page={page}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
hasNextPage={nextCursor !== null}
hasPreviousPage={page > 1}
onPageSizeChange={handlePageSizeChange}
onPreviousPage={handlePreviousPage}
onNextPage={handleNextPage}
/>
</div>
)}
</SettingCard>
@ -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);

View File

@ -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 (
<Container>
<Subject value="Pagination Test Email" />
<NotificationCategory value="Transactional" />
<div>Test</div>
</Container>
);
}
`;
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 (
<Container>
<Subject value="Ordering Test Email" />
<NotificationCategory value="Transactional" />
<div>Test</div>
</Container>
);
}
`;
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);
});
});

View File

@ -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 }) => {

View File

@ -626,10 +626,12 @@ export class StackAdminInterface extends StackServerInterface {
// Email Outbox methods
async listOutboxEmails(options?: { status?: string, simple_status?: string }): Promise<EmailOutboxCrud["Server"]["List"]> {
async listOutboxEmails(options?: { status?: string, simple_status?: string, limit?: number, cursor?: string }): Promise<EmailOutboxCrud["Server"]["List"]> {
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' },

View File

@ -812,12 +812,17 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
return result as AdminEmailOutbox;
}
async listOutboxEmails(options?: { status?: string, simpleStatus?: string }): Promise<AdminEmailOutbox[]> {
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<AdminEmailOutbox> {

View File

@ -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<HasTokenStore extends boolean = boolean, ProjectId ext
refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string }): Promise<void>,
// Email Outbox methods
listOutboxEmails(options?: EmailOutboxListOptions): Promise<AdminEmailOutbox[]>,
listOutboxEmails(options?: EmailOutboxListOptions): Promise<EmailOutboxListResult>,
getOutboxEmail(id: string): Promise<AdminEmailOutbox>,
updateOutboxEmail(id: string, options: EmailOutboxUpdateOptions): Promise<AdminEmailOutbox>,
pauseOutboxEmail(id: string): Promise<AdminEmailOutbox>,

View File

@ -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";