mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Fix email outbox pagination
This commit is contained in:
parent
50ffd373a1
commit
90ac480f43
@ -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 }) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user