User ID filter for email outbox

This commit is contained in:
Konstantin Wohlwend 2026-06-17 13:39:24 -07:00
parent 70d90494bc
commit d88e77c67b
7 changed files with 96 additions and 25 deletions

View File

@ -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,

View File

@ -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<AdminEmailOutbox>[] = [
{
id: "subject",
@ -75,45 +68,47 @@ export function UserEmailsSection({ user }: { user: ServerUser }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-4">
{filtered.length > 0 && (
{sortedEmails.length > 0 && (
<div className="py-1">
<div className="mb-2 text-sm text-center">
<span className="font-medium">{filtered.length} email{filtered.length !== 1 ? "s" : ""}</span>
<span className="font-medium">{sortedEmails.length} email{sortedEmails.length !== 1 ? "s" : ""}</span>
</div>
<StatsBar data={stats} />
</div>
@ -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

View File

@ -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: "<p>User filter first email</p>",
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: "<p>User filter second email</p>",
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",

View File

@ -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<EmailOutboxCrud["Server"]["List"]> {
async listOutboxEmails(options?: { status?: string, simple_status?: string, user_id?: 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?.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(

View File

@ -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.",
},
},
});

View File

@ -1112,10 +1112,11 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
return result as AdminEmailOutbox;
}
async listOutboxEmails(options?: { status?: string, simpleStatus?: string, limit?: number, cursor?: string }): Promise<{ items: AdminEmailOutbox[], nextCursor: string | null }> {
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,
});

View File

@ -16,6 +16,7 @@ import { StackServerApp, StackServerAppConstructorOptions } from "./server-app";
export type EmailOutboxListOptions = {
status?: string,
simpleStatus?: string,
userId?: string,
limit?: number,
cursor?: string,
};