Merge branch 'dev' into this-line-platform

This commit is contained in:
Zai Shi 2025-03-12 18:07:00 -07:00
commit 8401b01586
10 changed files with 507 additions and 223 deletions

View File

@ -0,0 +1,42 @@
import { prismaClient } from "@/prisma-client";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { SentEmail } from "@prisma/client";
import { InternalEmailsCrud, internalEmailsCrud } from "@stackframe/stack-shared/dist/interface/crud/emails";
import { projectIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
function prismaModelToCrud(prismaModel: SentEmail): InternalEmailsCrud["Admin"]["Read"] {
return {
id: prismaModel.id,
subject: prismaModel.subject,
sent_at_millis: prismaModel.createdAt.getTime(),
to: prismaModel.to,
sender_config: prismaModel.senderConfig,
error: prismaModel.error,
};
}
export const internalEmailsCrudHandlers = createLazyProxy(() => createCrudHandlers(internalEmailsCrud, {
paramsSchema: yupObject({
projectId: projectIdSchema.defined(),
}),
onList: async ({ params }) => {
const emails = await prismaClient.sentEmail.findMany({
where: {
tenancy: {
projectId: params.projectId,
},
},
orderBy: {
createdAt: 'desc',
},
});
return {
items: emails.map(x => prismaModelToCrud(x)),
is_paginated: false,
};
}
}));

View File

@ -0,0 +1,3 @@
import { internalEmailsCrudHandlers } from "./crud";
export const GET = internalEmailsCrudHandlers.listHandler;

View File

@ -5,16 +5,18 @@ import { InputField, SelectField } from "@/components/form-fields";
import { useRouter } from "@/components/router";
import { SettingCard, SettingText } from "@/components/settings";
import { getPublicEnvVar } from "@/lib/env";
import { AdminEmailConfig, AdminProject } from "@stackframe/stack";
import { AdminEmailConfig, AdminProject, AdminSentEmail } from "@stackframe/stack";
import { Reader } from "@stackframe/stack-emails/dist/editor/email-builder/index";
import { EMAIL_TEMPLATES_METADATA, convertEmailSubjectVariables, convertEmailTemplateMetadataExampleValues, convertEmailTemplateVariables, validateEmailTemplateContent } from "@stackframe/stack-emails/dist/utils";
import { EmailTemplateType } from "@stackframe/stack-shared/dist/interface/crud/email-templates";
import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects";
import { ActionCell, ActionDialog, Alert, AlertDescription, AlertTitle, Button, Card, SimpleTooltip, Typography, useToast } from "@stackframe/stack-ui";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { ActionCell, ActionDialog, Alert, AlertDescription, AlertTitle, Button, Card, DataTable, SimpleTooltip, Typography, useToast } from "@stackframe/stack-ui";
import { ColumnDef } from "@tanstack/react-table";
import { AlertCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import * as yup from "yup";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
@ -110,6 +112,9 @@ export default function PageClient() {
</Card>
))}
</SettingCard>
<SettingCard title="Email Log" description="Manage email sending history">
<EmailSendDataTable />
</SettingCard>
<ActionDialog
open={sharedSmtpWarningDialogOpen !== null}
@ -133,6 +138,60 @@ export default function PageClient() {
);
}
const emailTableColumns: ColumnDef<AdminSentEmail>[] = [
{ accessorKey: 'recipient', header: 'Recipient' },
{ accessorKey: 'subject', header: 'Subject' },
{ accessorKey: 'sentAt', header: 'Sent At', cell: ({ row }) => {
const date = row.original.sentAt;
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
},
{ accessorKey: 'status', header: 'Status', cell: ({ row }) => {
return row.original.error ? (
<div className="text-red-500">Failed</div>
) : (
<div className="text-green-500">Sent</div>
);
} },
];
function EmailSendDataTable() {
const stackAdminApp = useAdminApp();
const [emailLogs, setEmailLogs] = useState<AdminSentEmail[]>([]);
const [loading, setLoading] = useState(true);
// Fetch email logs when component mounts
useEffect(() => {
runAsynchronously(async () => {
setLoading(true);
try {
const emails = await stackAdminApp.listSentEmails();
setEmailLogs(emails);
} catch (error) {
console.error("Failed to fetch email logs:", error);
} finally {
setLoading(false);
}
});
}, [stackAdminApp]);
if (loading) {
return (
<div className="flex justify-center py-4">
<Typography>Loading email logs...</Typography>
</div>
);
}
return <DataTable
data={emailLogs}
defaultColumnFilters={[]}
columns={emailTableColumns}
defaultSorting={[{ id: 'sentAt', desc: true }]}
/>;
}
function EmailPreview(props: { content: any, type: EmailTemplateType }) {
const project = useAdminApp().useProject();
const [valid, document] = useMemo(() => {

View File

@ -1,6 +1,7 @@
import { InternalSession } from "../sessions";
import { ApiKeysCrud } from "./crud/api-keys";
import { EmailTemplateCrud, EmailTemplateType } from "./crud/email-templates";
import { InternalEmailsCrud } from "./crud/emails";
import { ProjectsCrud } from "./crud/projects";
import { SvixTokenCrud } from "./crud/svix-token";
import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions";
@ -247,4 +248,11 @@ export class StackAdminInterface extends StackServerInterface {
}, null);
return await response.json();
}
async listSentEmails(): Promise<InternalEmailsCrud["Admin"]["List"]> {
const response = await this.sendAdminRequest("/internal/emails", {
method: "GET",
}, null);
return await response.json();
}
}

View File

@ -0,0 +1,17 @@
import { createCrud, CrudTypeOf } from "../../crud";
import * as fieldSchema from "../../schema-fields";
export const sentEmailReadSchema = fieldSchema.yupObject({
id: fieldSchema.yupString().defined(),
subject: fieldSchema.yupString().defined(),
sent_at_millis: fieldSchema.yupNumber().defined(),
to: fieldSchema.yupArray(fieldSchema.yupString().defined()),
sender_config: fieldSchema.yupMixed().nullable().defined(),
error: fieldSchema.yupMixed().nullable().optional(),
}).defined();
export const internalEmailsCrud = createCrud({
clientReadSchema: sentEmailReadSchema,
});
export type InternalEmailsCrud = CrudTypeOf<typeof internalEmailsCrud>;

View File

@ -17,6 +17,7 @@ import { StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/ad
import { clientVersion, createCache, getBaseUrl, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey } from "./common";
import { _StackServerAppImplIncomplete } from "./server-app-impl";
import { AdminSentEmail } from "../..";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
@ -336,4 +337,16 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
return Result.error({ errorMessage: response.error_message ?? throwErr("Email test error not specified") });
}
}
async listSentEmails(): Promise<AdminSentEmail[]> {
const response = await this._interface.listSentEmails();
return response.items.map((email) => ({
id: email.id,
to: email.to ?? [],
subject: email.subject,
recipient: email.to?.[0] ?? "",
sentAt: new Date(email.sent_at_millis),
error: email.error,
}));
}
}

View File

@ -3,6 +3,7 @@ import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { ApiKey, ApiKeyCreateOptions, ApiKeyFirstView } from "../../api-keys";
import { AsyncStoreProperty, EmailConfig } from "../../common";
import { AdminSentEmail } from "../../email";
import { AdminEmailTemplate, AdminEmailTemplateUpdateOptions } from "../../email-templates";
import { AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions } from "../../permissions";
import { AdminProject } from "../../projects";
@ -48,6 +49,8 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
recipientEmail: string,
emailConfig: EmailConfig,
}): Promise<Result<undefined, { errorMessage: string }>>,
listSentEmails(): Promise<AdminSentEmail[]>,
}
& StackServerApp<HasTokenStore, ProjectId>
);

View File

@ -0,0 +1,8 @@
export type AdminSentEmail = {
id: string,
to: string[],
subject: string,
recipient: string, // We'll derive this from to[0] for display
sentAt: Date, // We'll derive this from sent_at_millis for display
error?: unknown,
}

View File

@ -42,6 +42,10 @@ export type {
ContactChannel
} from "./contact-channels";
export type {
AdminSentEmail
} from "./email";
export type {
AdminTeamPermission,
AdminTeamPermissionDefinition,

File diff suppressed because it is too large Load Diff