mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-27 21:01:03 +08:00
User ID filter for email outbox
This commit is contained in:
parent
70d90494bc
commit
d88e77c67b
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -16,6 +16,7 @@ import { StackServerApp, StackServerAppConstructorOptions } from "./server-app";
|
||||
export type EmailOutboxListOptions = {
|
||||
status?: string,
|
||||
simpleStatus?: string,
|
||||
userId?: string,
|
||||
limit?: number,
|
||||
cursor?: string,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user