Merge dev into added_docs

This commit is contained in:
Madison 2025-07-08 21:35:16 -05:00
commit bf759151d8
38 changed files with 5430 additions and 155 deletions

View File

@ -13,7 +13,7 @@ concurrency:
jobs:
docker:
runs-on: ubuntu-latest
runs-on: ubicloud-standard-8
steps:
- uses: actions/checkout@v3

View File

@ -53,3 +53,4 @@ STACK_ACCESS_TOKEN_EXPIRATION_TIME=# enter the expiration time for the access to
STACK_SETUP_ADMIN_GITHUB_ID=# enter the account ID of the admin user here, and after running the seed script they will be able to access the internal project in the Stack dashboard. Optional, don't specify it for default value
OTEL_EXPORTER_OTLP_ENDPOINT=# enter the OpenTelemetry endpoint here. Optional, default is `http://localhost:4318`
STACK_INTEGRATION_CLIENTS_CONFIG=# a list of oidc-provider clients for integrations. If not provided, disables integrations
STACK_FREESTYLE_API_KEY=# enter you freestyle.sh api key

View File

@ -43,3 +43,4 @@ STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING=yes
STACK_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": "neon-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize", "http://localhost:30000/api/v2/auth/authorize"]}, {"client_id": "custom-local", "client_secret": "custom-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize", "http://localhost:30000/api/v2/auth/authorize"]}]
CRON_SECRET=mock_cron_secret
STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key

View File

@ -60,6 +60,7 @@
"@vercel/otel": "^1.10.4",
"bcrypt": "^5.1.1",
"dotenv-cli": "^7.3.0",
"freestyle-sandboxes": "^0.0.92",
"jose": "^5.2.2",
"json-diff": "^1.0.6",
"next": "15.2.3",

View File

@ -0,0 +1,48 @@
import { EMAIL_THEMES, renderEmailWithTheme } from "@/lib/email-themes";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { captureError, StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
export const POST = createSmartRouteHandler({
metadata: {
summary: "Render email theme",
description: "Renders HTML content using the specified email theme",
tags: ["Emails"],
},
request: yupObject({
auth: yupObject({
type: yupString().oneOf(["admin"]).defined(),
}).nullable(),
body: yupObject({
theme: yupString().oneOf(Object.keys(EMAIL_THEMES) as (keyof typeof EMAIL_THEMES)[]).defined(),
preview_html: yupString().defined(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
html: yupString().defined(),
}).defined(),
}),
async handler({ body }) {
if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
}
const result = await renderEmailWithTheme(body.preview_html, body.theme);
if ("error" in result) {
captureError('render-email', new StackAssertionError("Error rendering email with theme", { result }));
throw new KnownErrors.EmailRenderingError(result.error);
}
return {
statusCode: 200,
bodyType: "json",
body: {
html: result.html,
},
};
},
});

View File

@ -1,11 +1,19 @@
import { renderEmailWithTheme } from "@/lib/email-themes";
import { getEmailConfig, sendEmail } from "@/lib/emails";
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { getUser } from "../../users/crud";
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
type UserResult = {
user_id: string,
user_email?: string,
success: boolean,
error?: string,
};
export const POST = createSmartRouteHandler({
metadata: {
@ -17,7 +25,7 @@ export const POST = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
user_id: yupString().defined(),
user_ids: yupArray(yupString().defined()).defined(),
html: yupString().defined(),
subject: yupString().defined(),
notification_category_name: yupString().defined(),
@ -28,60 +36,108 @@ export const POST = createSmartRouteHandler({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
user_email: yupString().defined(),
results: yupArray(yupObject({
user_id: yupString().defined(),
user_email: yupString().optional(),
success: yupBoolean().defined(),
error: yupString().optional(),
})).defined(),
}).defined(),
}),
handler: async ({ body, auth }) => {
if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
}
if (auth.tenancy.config.email_config.type === "shared") {
throw new StatusError(400, "Cannot send custom emails when using shared email config");
}
const user = await getUser({ userId: body.user_id, tenancyId: auth.tenancy.id });
if (!user) {
throw new StatusError(404, "User not found");
}
if (!user.primary_email) {
throw new StatusError(400, "User does not have a primary email");
}
const emailConfig = await getEmailConfig(auth.tenancy);
const notificationCategory = getNotificationCategoryByName(body.notification_category_name);
if (!notificationCategory) {
throw new StatusError(404, "Notification category not found");
}
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.id, notificationCategory.id);
if (!isNotificationEnabled) {
throw new StatusError(400, "User has disabled notifications for this category");
}
let html = body.html;
if (notificationCategory.can_disable) {
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
tenancy: auth.tenancy,
method: {},
data: {
user_id: user.id,
notification_category_id: notificationCategory.id,
const users = await prismaClient.projectUser.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: {
in: body.user_ids,
},
callbackUrl: undefined
});
const unsubscribeLink = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
unsubscribeLink.pathname = "/api/v1/emails/unsubscribe-link";
unsubscribeLink.searchParams.set("code", code);
html += `<br /><a href="${unsubscribeLink.toString()}">Click here to unsubscribe</a>`;
},
include: {
contactChannels: true,
},
});
const userMap = new Map(users.map(user => [user.projectUserId, user]));
const userSendErrors: Map<string, string> = new Map();
const userPrimaryEmails: Map<string, string> = new Map();
for (const userId of body.user_ids) {
const user = userMap.get(userId);
if (!user) {
userSendErrors.set(userId, "User not found");
continue;
}
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.projectUserId, notificationCategory.id);
if (!isNotificationEnabled) {
userSendErrors.set(userId, "User has disabled notifications for this category");
continue;
}
const primaryEmail = user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value;
if (!primaryEmail) {
userSendErrors.set(userId, "User does not have a primary email");
continue;
}
userPrimaryEmails.set(userId, primaryEmail);
let unsubscribeLink: string | null = null;
if (notificationCategory.can_disable) {
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
tenancy: auth.tenancy,
method: {},
data: {
user_id: user.projectUserId,
notification_category_id: notificationCategory.id,
},
callbackUrl: undefined
});
const unsubUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
unsubUrl.pathname = "/api/v1/emails/unsubscribe-link";
unsubUrl.searchParams.set("code", code);
unsubscribeLink = unsubUrl.toString();
}
const renderedEmail = await renderEmailWithTheme(body.html, auth.tenancy.config.email_theme, unsubscribeLink);
if ("error" in renderedEmail) {
userSendErrors.set(userId, "There was an error rendering the email");
continue;
}
try {
await sendEmail({
tenancyId: auth.tenancy.id,
emailConfig,
to: primaryEmail,
subject: body.subject,
html: renderedEmail.html,
text: renderedEmail.text,
});
} catch {
userSendErrors.set(userId, "Failed to send email");
}
}
await sendEmail({
tenancyId: auth.tenancy.id,
emailConfig: await getEmailConfig(auth.tenancy),
to: user.primary_email,
subject: body.subject,
html,
});
const results: UserResult[] = body.user_ids.map((userId) => ({
user_id: userId,
user_email: userPrimaryEmails.get(userId),
success: !userSendErrors.has(userId),
error: userSendErrors.get(userId),
}));
return {
statusCode: 200,
bodyType: 'json',
body: {
user_email: user.primary_email,
},
body: { results },
};
},
});

View File

@ -394,6 +394,7 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza
sender_name: renderedConfig.emails.server.senderName,
sender_email: renderedConfig.emails.server.senderEmail,
},
email_theme: renderedConfig.emails.theme,
team_creator_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.teamCreator)
.filter(([_, perm]) => perm)

View File

@ -0,0 +1,77 @@
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { FreestyleSandboxes } from 'freestyle-sandboxes';
export async function renderEmailWithTheme(
htmlContent: string,
theme: keyof typeof EMAIL_THEMES,
unsubscribeLink: string | null = null,
) {
const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");
const unsubscribeLinkHtml = unsubscribeLink ? `<br /><br /><a href="${unsubscribeLink}">Click here to unsubscribe</a>` : "";
if (["development", "test"].includes(getNodeEnvironment()) && apiKey === "mock_stack_freestyle_key") {
return {
html: `<div>Mock api key detected, returning mock data ${unsubscribeLinkHtml}</div>`,
text: "Mock api key detected, returning mock data",
};
}
const freestyle = new FreestyleSandboxes({ apiKey });
const TemplateComponent = EMAIL_THEMES[theme];
const script = deindent`
import React from 'react';
import { render, Html, Tailwind, Body } from '@react-email/components';
${TemplateComponent}
export default async () => {
const Email = <EmailTheme>${htmlContent + unsubscribeLinkHtml}</EmailTheme>
return {
html: await render(Email),
text: await render(Email, { plainText: true }),
};
}
`;
const nodeModules = {
"@react-email/components": "0.1.1",
};
const output = await freestyle.executeScript(script, { nodeModules });
if ("error" in output) {
return Result.error(output.error as string);
}
return output.result as { html: string, text: string };
}
const LightEmailTheme = `function EmailTheme({ children }: { children: React.ReactNode }) {
return (
<Html>
<Tailwind>
<Body>
<div className="bg-white text-slate-800 p-4 rounded-lg max-w-[600px] mx-auto leading-relaxed">
{children}
</div>
</Body>
</Tailwind>
</Html>
);
}`;
const DarkEmailTheme = `function EmailTheme({ children }: { children: React.ReactNode }) {
return (
<Html>
<Tailwind>
<Body>
<div className="bg-slate-900 text-slate-100 p-4 rounded-lg max-w-[600px] mx-auto leading-relaxed">
{children}
</div>
</Body>
</Tailwind>
</Html>
);
}`;
export const EMAIL_THEMES = {
'default-light': LightEmailTheme,
'default-dark': DarkEmailTheme,
} as const;

View File

@ -174,6 +174,7 @@ export async function createOrUpdateProject(
senderName: dataOptions.email_config.sender_name,
senderEmail: dataOptions.email_config.sender_email,
} satisfies OrganizationRenderedConfig['emails']['server'] : undefined,
'emails.theme': dataOptions.email_theme,
// ======================= rbac =======================
'rbac.defaultPermissions.teamMember': translateDefaultPermissions(dataOptions.team_member_default_permissions),
'rbac.defaultPermissions.teamCreator': translateDefaultPermissions(dataOptions.team_creator_default_permissions),

View File

@ -23,8 +23,8 @@ export class GithubProvider extends OAuthBaseProvider {
baseScope: "user:email",
// GitHub token does not expire except for lack of use in a year
// We set a default of 1 year
// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation#token-expired-due-to-lack-of-use=
defaultAccessTokenExpiresInMillis: 1000 * 60 * 60 * 24 * 365,
// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation#user-token-expired-due-to-github-app-configuration
defaultAccessTokenExpiresInMillis: 1000 * 60 * 60 * 8, // 8 hours
...options,
}));
}

View File

@ -11,3 +11,4 @@ NEXT_PUBLIC_STACK_SVIX_SERVER_URL=# For prod, leave it empty. For local developm
# Misc, optional
NEXT_PUBLIC_STACK_HEAD_TAGS='[{ "tagName": "script", "attributes": {}, "innerHTML": "// insert head tags here" }]'
STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translation provider here, for example: de-DE. Only works during development, not in production. Optional, by default don't translate
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'

View File

@ -0,0 +1,134 @@
"use client";
import { SettingCard } from "@/components/settings";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { ActionDialog, Button, Card, Separator, Typography } from "@stackframe/stack-ui";
import { Check } from "lucide-react";
import { useState } from "react";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
type ThemeType = 'default-light' | 'default-dark';
type Theme = {
id: ThemeType,
name: string,
};
const themes: Theme[] = [
{
id: 'default-light',
name: 'Light Theme',
},
{
id: 'default-dark',
name: 'Dark Theme',
},
];
export default function PageClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const activeTheme = project.config.emailTheme;
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogSelectedTheme, setDialogSelectedTheme] = useState<ThemeType>(activeTheme);
const handleThemeSelect = (themeId: ThemeType) => {
setDialogSelectedTheme(themeId);
};
const handleSaveTheme = async () => {
await project.update({
config: { emailTheme: dialogSelectedTheme }
});
};
const handleOpenDialog = () => {
setDialogSelectedTheme(activeTheme);
setDialogOpen(true);
};
const selectedThemeData = themes.find(t => t.id === activeTheme) ?? throwErr(`Unknown theme ${activeTheme}`, { activeTheme });
return (
<PageLayout title="Email Themes" description="Customize email themes for your project">
<SettingCard
title="Active Theme"
description={`Currently using ${selectedThemeData.name}`}
>
<ThemePreview themeId={activeTheme} />
<ActionDialog
trigger={<Button onClick={handleOpenDialog} className="ml-auto w-min">Set Theme</Button>}
open={dialogOpen}
onOpenChange={setDialogOpen}
title="Select Email Theme"
cancelButton
okButton={{
label: "Save Theme",
onClick: handleSaveTheme
}}
>
<div className="space-y-4">
{themes.map((theme) => (
<ThemeOption
key={theme.id}
theme={theme}
isSelected={dialogSelectedTheme === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</ActionDialog>
</SettingCard>
</PageLayout>
);
}
function ThemeOption({
theme,
isSelected,
onSelect
}: {
theme: Theme,
isSelected: boolean,
onSelect: (themeId: ThemeType) => void,
}) {
return (
<Card
className="cursor-pointer hover:ring-1 transition-all"
onClick={() => onSelect(theme.id)}
>
<div className="p-4 pb-3">
<div className="flex items-center justify-between">
<Typography className="font-medium text-lg">{theme.name}</Typography>
{isSelected && (
<div className="bg-blue-500 text-white rounded-full w-6 h-6 p-1 flex items-center justify-center">
<Check />
</div>
)}
</div>
<Separator className="my-3" />
<ThemePreview themeId={theme.id} />
</div>
</Card>
);
}
function ThemePreview({ themeId }: { themeId: ThemeType }) {
const previewEmailHtml = deindent`
<div>
<h2 className="mb-4 text-2xl font-bold">
Header text
</h2>
<p className="mb-4">
Body text content with some additional information.
</p>
</div>
`;
const stackAdminApp = useAdminApp();
const previewHtml = stackAdminApp.useEmailThemePreview(themeId, previewEmailHtml);
return (
<iframe srcDoc={previewHtml} className="mx-auto pointer-events-none" />
);
}

View File

@ -0,0 +1,11 @@
import PageClient from "./page-client";
export const metadata = {
title: "Email Themes",
};
export default function Page() {
return (
<PageClient />
);
}

View File

@ -1,8 +1,8 @@
"use client";
import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table";
import { FormDialog } from "@/components/form-dialog";
import { InputField, SelectField, TextAreaField } from "@/components/form-fields";
import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table";
import { SettingCard, SettingText } from "@/components/settings";
import { getPublicEnvVar } from "@/lib/env";
import { AdminEmailConfig, AdminProject, AdminSentEmail, ServerUser, UserAvatar } from "@stackframe/stack";
@ -333,40 +333,89 @@ function SendEmailDialog(props: {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [sharedSmtpDialogOpen, setSharedSmtpDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<ServerUser | null>(null);
const [selectedUsers, setSelectedUsers] = useState<ServerUser[]>([]);
const [stage, setStage] = useState<'recipients' | 'data'>('recipients');
const handleSend = async (formData: { subject: string, content: string, notificationCategoryName: string }) => {
if (!selectedUser) {
const handleSend = async (formData: { subject?: string, content?: string, notificationCategoryName?: string }) => {
if (!formData.subject || !formData.content || !formData.notificationCategoryName) {
// Should never happen. These fields are only optional during recipient stage.
throwErr("Missing required fields", { formData });
}
await stackAdminApp.sendEmail({
userIds: selectedUsers.map(user => user.id),
subject: formData.subject,
content: formData.content,
notificationCategoryName: formData.notificationCategoryName,
});
setSelectedUsers([]);
setStage('recipients');
toast({
title: "Email sent",
description: "Email was successfully sent",
variant: 'success',
});
};
const handleNext = async () => {
if (selectedUsers.length === 0) {
toast({
title: "No recipients selected",
description: "Please select at least one recipient to send the email.",
variant: "destructive",
});
return "prevent-close-and-prevent-reset";
return "prevent-close" as const;
}
try {
await stackAdminApp.sendEmail({
userId: selectedUser.id,
subject: formData.subject,
content: formData.content,
notificationCategoryName: formData.notificationCategoryName,
});
} catch (error) {
toast({
title: "Error sending email",
description: "The email could not be sent. The user may have unsubscribed from this notification category.",
variant: "destructive",
});
return;
}
setSelectedUser(null);
toast({
title: "Email sent",
description: "Email was successfully sent.",
variant: 'success',
});
setStage('data');
return "prevent-close" as const;
};
const handleBack = async () => {
setStage('recipients');
return "prevent-close" as const;
};
const handleClose = () => {
setOpen(false);
setStage('recipients');
setSelectedUsers([]);
};
const renderRecipientsBar = () => (
<div className="mb-4">
<Typography className="font-medium mb-2">Recipients</Typography>
<TooltipProvider>
<div className="flex flex-wrap gap-2 mb-4">
{selectedUsers.map((user) => (
<div key={user.id} className="relative group">
<Tooltip>
<TooltipTrigger>
<UserAvatar user={user} size={32} />
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="max-w-60 text-center text-wrap whitespace-pre-wrap">
{user.primaryEmail}
</div>
</TooltipContent>
</Tooltip>
{stage === 'recipients' && (
<Button
size="sm"
variant="ghost"
className="absolute -top-2 -right-2 h-4 w-4 rounded-full p-0 hover:bg-red-100 opacity-0 group-hover:opacity-100"
onClick={() => setSelectedUsers(users => users.filter(u => u.id !== user.id))}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
</TooltipProvider>
</div>
);
return (
<>
<div
@ -396,67 +445,70 @@ function SendEmailDialog(props: {
</ActionDialog>
<FormDialog
open={open}
onClose={() => setOpen(false)}
onClose={handleClose}
title="Send Email"
cancelButton
okButton={{ label: "Send" }}
onSubmit={handleSend}
formSchema={yup.object({
subject: yup.string().defined(),
content: yup.string().defined(),
notificationCategoryName: yup.string().oneOf(['Transactional', 'Marketing']).label("notification category").defined(),
})}
cancelButton={stage === "recipients" ?
{ label: 'Cancel', onClick: async () => handleClose() } :
{ label: 'Back', onClick: handleBack }
}
okButton={stage === 'recipients' ?
{ label: 'Next' } :
{ label: 'Send' }
}
onSubmit={stage === 'recipients' ? handleNext : handleSend}
formSchema={stage === "recipients" ?
yup.object({
subject: yup.string().optional(),
content: yup.string().optional(),
notificationCategoryName: yup.string().optional(),
}) :
yup.object({
subject: yup.string().defined(),
content: yup.string().defined(),
notificationCategoryName: yup.string().oneOf(['Transactional', 'Marketing']).label("notification category").defined(),
})
}
render={(form) => (
<>
<div className="mb-4">
<Typography className="font-medium mb-2">Recipient</Typography>
<TooltipProvider>
<div className="flex flex-wrap gap-2 mb-4">
{selectedUser && (
<Tooltip key={selectedUser.id}>
<TooltipTrigger>
<UserAvatar user={selectedUser} size={32} />
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="max-w-60 text-center text-wrap whitespace-pre-wrap">
{selectedUser.primaryEmail}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
{renderRecipientsBar()}
{stage === 'recipients' ? (
<TeamMemberSearchTable
action={(user) => (
<Button
size="sm"
variant="outline"
onClick={() => setSelectedUser(user)}
disabled={selectedUser?.id === user.id}
onClick={() => setSelectedUsers(users =>
users.some(u => u.id === user.id)
? users.filter(u => u.id !== user.id)
: [...users, user]
)}
>
{selectedUser?.id === user.id ? 'Selected' : 'Select'}
{selectedUsers.some(u => u.id === user.id) ? 'Remove' : 'Add'}
</Button>
)}
/>
</div>
<InputField label="Subject" name="subject" control={form.control} type="text" required />
{/* TODO: fetch notification categories here instead of hardcoding these two */}
<SelectField
label="Notification Category"
name="notificationCategoryName"
control={form.control}
options={[
{ label: "Transactional", value: 'Transactional' },
{ label: "Marketing", value: 'Marketing' },
]}
/>
<TextAreaField
label="Email Content"
name="content"
control={form.control}
rows={10}
required
/>
) : (
<>
<InputField label="Subject" name="subject" control={form.control} type="text" required />
{/* TODO: fetch notification categories here instead of hardcoding these two */}
<SelectField
label="Notification Category"
name="notificationCategoryName"
control={form.control}
options={[
{ label: "Transactional", value: 'Transactional' },
{ label: "Marketing", value: 'Marketing' },
]}
/>
<TextAreaField
label="Email Content"
name="content"
control={form.control}
rows={10}
required
/>
</>
)}
</>
)}
/>

View File

@ -32,13 +32,14 @@ import {
LucideIcon,
Mail,
Menu,
Palette,
Settings,
Settings2,
ShieldEllipsis,
SquarePen,
User,
Users,
Webhook,
SquarePen
} from "lucide-react";
import { useTheme } from "next-themes";
import { usePathname } from "next/navigation";
@ -58,6 +59,7 @@ type Item = {
icon: LucideIcon,
regex: RegExp,
type: 'item',
requiresDevFeatureFlag?: boolean,
};
type Hidden = {
@ -183,6 +185,14 @@ const navigationItems: (Label | Item | Hidden)[] = [
icon: SquarePen,
type: 'item'
},
{
name: "Themes",
href: "/email-themes",
regex: /^\/projects\/[^\/]+\/email-themes$/,
icon: Palette,
type: 'item',
requiresDevFeatureFlag: true,
},
{
name: "Configuration",
type: 'label'
@ -277,7 +287,7 @@ function UserBreadcrumbItem(props: { userId: string }) {
}
}
function NavItem({ item, href, onClick }: { item: Item, href: string, onClick?: () => void}) {
function NavItem({ item, href, onClick }: { item: Item, href: string, onClick?: () => void }) {
const pathname = usePathname();
const selected = useMemo(() => {
let pathnameWithoutTrailingSlash = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
@ -320,13 +330,19 @@ function SidebarContent({ projectId, onNavigate }: { projectId: string, onNaviga
{item.name}
</Typography>;
} else if (item.type === 'item') {
if (
item.requiresDevFeatureFlag &&
!JSON.parse(getPublicEnvVar("NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS") || "[]").includes(projectId)
) {
return null;
}
return <div key={index} className="flex px-2">
<NavItem item={item} onClick={onNavigate} href={`/projects/${projectId}${item.href}`}/>
<NavItem item={item} onClick={onNavigate} href={`/projects/${projectId}${item.href}`} />
</div>;
}
})}
<div className="flex-grow"/>
<div className="flex-grow" />
<div className="py-2 px-2 flex">
<NavItem
@ -426,7 +442,7 @@ function HeaderBreadcrumb({
{name.item}
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator/>
<BreadcrumbSeparator />
</Fragment> :
<BreadcrumbPage key={index}>
<Link href={name.href}>
@ -481,7 +497,7 @@ export default function SidebarLayout(props: { projectId: string, children?: Rea
/>
{getPublicEnvVar("NEXT_PUBLIC_STACK_EMULATOR_ENABLED") === "true" ?
<ThemeToggle /> :
<UserButton colorModeToggle={() => setTheme(resolvedTheme === 'light' ? 'dark' : 'light')}/>
<UserButton colorModeToggle={() => setTheme(resolvedTheme === 'light' ? 'dark' : 'light')} />
}
</div>
</div>

View File

@ -17,6 +17,7 @@ const _inlineEnvVars = {
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY,
NEXT_PUBLIC_STACK_URL: process.env.NEXT_PUBLIC_STACK_URL,
NEXT_PUBLIC_STACK_INBUCKET_WEB_URL: process.env.NEXT_PUBLIC_STACK_INBUCKET_WEB_URL,
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: process.env.NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS,
} as const;
// This will be replaced with the actual env vars after a docker build
@ -37,6 +38,7 @@ const _postBuildEnvVars = {
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY",
NEXT_PUBLIC_STACK_URL: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_URL",
NEXT_PUBLIC_STACK_INBUCKET_WEB_URL: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_INBUCKET_WEB_URL",
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS",
} satisfies typeof _inlineEnvVars;
// If this is not replaced with "true", then we will not use inline env vars

View File

@ -52,6 +52,7 @@ it("should be able to provision a new project if client details are correct", as
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [
{ "id": "github" },
{ "id": "google" },

View File

@ -106,6 +106,7 @@ it("lists oauth providers", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [{ "id": "google" }],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",

View File

@ -52,6 +52,7 @@ it("should be able to provision a new project if neon client details are correct
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [
{ "id": "github" },
{ "id": "google" },

View File

@ -78,6 +78,7 @@ it("creates a new project", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -128,6 +129,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"credential_enabled": false,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": true,
"oauth_account_merge_strategy": "link_method",
@ -175,6 +177,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [{ "id": "google" }],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -224,6 +227,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -282,6 +286,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"type": "standard",
"username": "test username",
},
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -342,6 +347,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
},
],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -385,6 +391,7 @@ it("lists the current projects after creating a new project", async ({ expect })
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -408,3 +415,126 @@ it("lists the current projects after creating a new project", async ({ expect })
}
`);
});
it("verifies email_theme update persists", async ({ expect }) => {
await Project.createAndSwitch();
const patchResponse = await niceBackendFetch("/api/v1/internal/projects/current", {
method: "PATCH",
accessType: "admin",
body: {
config: {
email_theme: "default-dark"
}
}
});
expect(patchResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"config": {
"allow_localhost": true,
"allow_team_api_keys": false,
"allow_user_api_keys": false,
"client_team_creation_enabled": false,
"client_user_deletion_enabled": false,
"create_team_on_sign_up": false,
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-dark",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "team_admin" }],
"team_member_default_permissions": [{ "id": "team_member" }],
"user_default_permissions": [],
},
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"id": "<stripped UUID>",
"is_production_mode": false,
"user_count": 0,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
const response = await niceBackendFetch("/api/v1/internal/projects/current", {
accessType: "admin"
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"config": {
"allow_localhost": true,
"allow_team_api_keys": false,
"allow_user_api_keys": false,
"client_team_creation_enabled": false,
"client_user_deletion_enabled": false,
"create_team_on_sign_up": false,
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-dark",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "team_admin" }],
"team_member_default_permissions": [{ "id": "team_member" }],
"user_default_permissions": [],
},
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"id": "<stripped UUID>",
"is_production_mode": false,
"user_count": 0,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("gives an error when updating email_theme with an invalid value", async ({ expect }) => {
await Project.createAndSwitch();
const response = await niceBackendFetch("/api/v1/internal/projects/current", {
method: "PATCH",
accessType: "admin",
body: {
config: {
email_theme: "some-invalid-theme",
}
}
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on PATCH /api/v1/internal/projects/current:
- body.config.email_theme must be one of the following values: default-light, default-dark
\`,
},
"error": deindent\`
Request validation failed on PATCH /api/v1/internal/projects/current:
- body.config.email_theme must be one of the following values: default-light, default-dark
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});

View File

@ -223,6 +223,7 @@ it("can customize default user permissions", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",

View File

@ -83,6 +83,7 @@ it("creates and updates the basic project information of a project", async ({ ex
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -130,6 +131,7 @@ it("updates the basic project configuration", async ({ expect }) => {
"credential_enabled": false,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": true,
"oauth_account_merge_strategy": "link_method",
@ -182,6 +184,7 @@ it("updates the project domains configuration", async ({ expect }) => {
},
],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -240,6 +243,7 @@ it("updates the project domains configuration", async ({ expect }) => {
},
],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -292,6 +296,7 @@ it("should allow insecure HTTP connections if insecureHttp is true", async ({ ex
},
],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -387,6 +392,7 @@ it("updates the project email configuration", async ({ expect }) => {
"type": "standard",
"username": "test username",
},
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -444,6 +450,7 @@ it("updates the project email configuration", async ({ expect }) => {
"type": "standard",
"username": "test username2",
},
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -487,6 +494,7 @@ it("updates the project email configuration", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -530,6 +538,7 @@ it("updates the project email configuration", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -587,6 +596,7 @@ it("updates the project email configuration", async ({ expect }) => {
"type": "standard",
"username": "control group",
},
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -751,6 +761,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [{ "id": "google" }],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -800,6 +811,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [{ "id": "google" }],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -851,6 +863,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [{ "id": "google" }],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -901,6 +914,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [{ "id": "spotify" }],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
@ -956,6 +970,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [
{ "id": "google" },
{ "id": "spotify" },
@ -1018,6 +1033,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [
{ "id": "google" },
{ "id": "spotify" },
@ -1346,6 +1362,7 @@ it("should increment and decrement userCount when a user is added to a project",
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": true,
"oauth_account_merge_strategy": "link_method",

View File

@ -21,7 +21,7 @@ describe("invalid requests", () => {
method: "POST",
accessType: "client",
body: {
user_id: randomUUID(),
user_ids: [randomUUID()],
html: "<p>Test email</p>",
subject: "Test Subject",
}
@ -49,20 +49,21 @@ describe("invalid requests", () => {
`);
});
it("should return 404 when user is not found", async ({ expect }) => {
it("should return 200 with user not found error in results", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
email_config: testEmailConfig,
},
});
const userId = randomUUID();
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: randomUUID(),
user_ids: [userId],
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Marketing",
@ -71,8 +72,16 @@ describe("invalid requests", () => {
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": "User not found",
"status": 200,
"body": {
"results": [
{
"error": "User not found",
"success": false,
"user_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
@ -92,7 +101,7 @@ describe("invalid requests", () => {
method: "POST",
accessType: "server",
body: {
user_id: createUserResponse.body.id,
user_ids: [createUserResponse.body.id],
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Marketing",
@ -128,7 +137,7 @@ describe("invalid requests", () => {
method: "POST",
accessType: "server",
body: {
user_id: createUserResponse.body.id,
user_ids: [createUserResponse.body.id],
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Invalid",
@ -145,7 +154,7 @@ describe("invalid requests", () => {
});
});
it("should return 400 when user has disabled notifications for the category", async ({ expect }) => {
it("should return 200 with disabled notifications error in results when user has disabled notifications for the category", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
@ -181,7 +190,7 @@ it("should return 400 when user has disabled notifications for the category", as
method: "POST",
accessType: "server",
body: {
user_id: user.userId,
user_ids: [user.userId],
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Marketing",
@ -190,14 +199,22 @@ it("should return 400 when user has disabled notifications for the category", as
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "User has disabled notifications for this category",
"status": 200,
"body": {
"results": [
{
"error": "User has disabled notifications for this category",
"success": false,
"user_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should return 400 when user does not have a primary email", async ({ expect }) => {
it("should return 200 with no primary email error in results when user does not have a primary email", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
@ -217,7 +234,7 @@ it("should return 400 when user does not have a primary email", async ({ expect
method: "POST",
accessType: "server",
body: {
user_id: createUserResponse.body.id,
user_ids: [createUserResponse.body.id],
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Marketing",
@ -226,8 +243,16 @@ it("should return 400 when user does not have a primary email", async ({ expect
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "User does not have a primary email",
"status": 200,
"body": {
"results": [
{
"error": "User does not have a primary email",
"success": false,
"user_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
@ -247,7 +272,7 @@ it("should return 200 and send email successfully", async ({ expect }) => {
method: "POST",
accessType: "server",
body: {
user_id: user.userId,
user_ids: [user.userId],
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
subject: "Custom Test Email Subject",
notification_category_name: "Marketing",
@ -258,7 +283,15 @@ it("should return 200 and send email successfully", async ({ expect }) => {
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
"body": {
"results": [
{
"success": true,
"user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com",
"user_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
@ -267,6 +300,75 @@ it("should return 200 and send email successfully", async ({ expect }) => {
const messages = await user.mailbox.fetchMessages();
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
expect(sentEmail).toBeDefined();
expect(sentEmail!.body?.html).toContain("<h1>Test Email</h1>");
expect(sentEmail!.body?.html).toContain("<p>This is a test email with HTML content.</p>");
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"http://localhost:8102/api/v1/emails/unsubscribe-link?code=%3Cstripped+query+param%3E"`);
});
it("should handle mixed results for multiple users", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Mixed Results Project",
config: {
email_config: testEmailConfig,
},
});
const userWithDisabledNotifications = await User.create();
await niceBackendFetch(`/api/v1/emails/notification-preference/${userWithDisabledNotifications.userId}/4f6f8873-3d04-46bd-8bef-18338b1a1b4c`, {
method: "PATCH",
accessType: "server",
body: {
enabled: false,
},
});
const nonExistentUserId = randomUUID();
const successfulUser = await User.create();
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_ids: [userWithDisabledNotifications.userId, nonExistentUserId, successfulUser.userId],
html: "<h1>Bulk Test Email</h1><p>This is a bulk test email.</p>",
subject: "Bulk Test Email Subject",
notification_category_name: "Marketing",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"results": [
{
"error": "User has disabled notifications for this category",
"success": false,
"user_id": "<stripped UUID>",
},
{
"error": "User not found",
"success": false,
"user_id": "<stripped UUID>",
},
{
"success": true,
"user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com",
"user_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
// Verify only the successful user received the email
const successfulUserMessages = await successfulUser.mailbox.fetchMessages();
const sentEmail = successfulUserMessages.find(msg => msg.subject === "Bulk Test Email Subject");
expect(sentEmail).toBeDefined();
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"http://localhost:8102/api/v1/emails/unsubscribe-link?code=%3Cstripped+query+param%3E"`);
// Verify the user with disabled notifications did not receive the email
const disabledUserMessages = await userWithDisabledNotifications.mailbox.fetchMessages();
const disabledUserEmail = disabledUserMessages.find(msg => msg.subject === "Bulk Test Email Subject");
expect(disabledUserEmail).toBeUndefined();
});

View File

@ -241,6 +241,7 @@ it("can customize default team permissions", async ({ expect }) => {
"credential_enabled": true,
"domains": [],
"email_config": { "type": "shared" },
"email_theme": "default-light",
"enabled_oauth_providers": [],
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",

View File

@ -23,7 +23,7 @@ it("unsubscribe link should be sent and update notification preference", async (
method: "POST",
accessType: "server",
body: {
user_id: user.userId,
user_ids: [user.userId],
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
subject: "Custom Test Email Subject",
notification_category_name: "Marketing",
@ -34,7 +34,15 @@ it("unsubscribe link should be sent and update notification preference", async (
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
"body": {
"results": [
{
"success": true,
"user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com",
"user_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
@ -43,7 +51,7 @@ it("unsubscribe link should be sent and update notification preference", async (
const messages = await user.mailbox.fetchMessages();
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
expect(sentEmail).toBeDefined();
expect(sentEmail!.body?.html).toMatch(/<h1>Test Email<\/h1><p>This is a test email with HTML content\.<\/p><br \/><a href="http:\/\/localhost:8102\/api\/v1\/emails\/unsubscribe-link\?code=[a-zA-Z0-9]+">Click here to unsubscribe<\/a>/);
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"http://localhost:8102/api/v1/emails/unsubscribe-link?code=%3Cstripped+query+param%3E"`);
// Extract the unsubscribe link and fetch it
const unsubscribeLinkMatch = sentEmail!.body?.html.match(/href="([^"]+)"/);
@ -110,7 +118,7 @@ it("unsubscribe link should not be sent for emails with transactional notificati
method: "POST",
accessType: "server",
body: {
user_id: user.userId,
user_ids: [user.userId],
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
subject: "Custom Test Email Subject",
notification_category_name: "Transactional",
@ -121,7 +129,15 @@ it("unsubscribe link should not be sent for emails with transactional notificati
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
"body": {
"results": [
{
"success": true,
"user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com",
"user_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
@ -129,5 +145,5 @@ it("unsubscribe link should not be sent for emails with transactional notificati
const messages = await user.mailbox.fetchMessages();
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
expect(sentEmail).toBeDefined();
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"<h1>Test Email</h1><p>This is a test email with HTML content.</p>\\n"`);
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"<div>Mock api key detected, returning mock data </div>"`);
});

View File

@ -135,6 +135,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
senderName: schemaFields.emailSenderNameSchema.optional().nonEmpty(),
senderEmail: schemaFields.emailSenderEmailSchema.optional().nonEmpty(),
}),
theme: schemaFields.emailThemeSchema.optional(),
}).optional()),
domains: branchConfigSchema.getNested("domains").concat(yupObject({
@ -219,6 +220,7 @@ export const organizationConfigDefaults = {
server: {
isShared: true,
},
theme: 'default-light',
},
} satisfies DeepReplaceAllowFunctionsForObjects<OrganizationConfigStrippedNormalizedOverride>;

View File

@ -303,7 +303,7 @@ export class StackAdminInterface extends StackServerInterface {
}
async sendEmail(options: {
user_id: string,
user_ids: string[],
subject: string,
html: string,
notification_category_name: string,
@ -336,4 +336,18 @@ export class StackAdminInterface extends StackServerInterface {
null,
);
}
async renderEmailThemePreview(theme: string, content: string): Promise<{ html: string }> {
const response = await this.sendAdminRequest(`/emails/render-email`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
theme,
preview_html: content,
}),
}, null);
return await response.json();
}
}

View File

@ -81,6 +81,7 @@ export const projectsCrudAdminReadSchema = yupObject({
enabled_oauth_providers: yupArray(enabledOAuthProviderSchema.defined()).defined().meta({ openapiField: { hidden: true } }),
domains: yupArray(domainSchema.defined()).defined(),
email_config: emailConfigSchema.defined(),
email_theme: schemaFields.emailThemeSchema.defined(),
create_team_on_sign_up: schemaFields.projectCreateTeamOnSignUpSchema.defined(),
team_creator_default_permissions: yupArray(teamPermissionSchema.defined()).defined(),
team_member_default_permissions: yupArray(teamPermissionSchema.defined()).defined(),
@ -121,6 +122,7 @@ export const projectsCrudAdminUpdateSchema = yupObject({
allow_user_api_keys: schemaFields.yupBoolean().optional(),
allow_team_api_keys: schemaFields.yupBoolean().optional(),
email_config: emailConfigSchema.optional().default(undefined),
email_theme: schemaFields.emailThemeSchema.optional(),
domains: yupArray(domainSchema.defined()).optional().default(undefined),
oauth_providers: yupArray(oauthProviderSchema.defined()).optional().default(undefined),
create_team_on_sign_up: schemaFields.projectCreateTeamOnSignUpSchema.optional(),

View File

@ -1367,6 +1367,17 @@ const PermissionIdAlreadyExists = createKnownErrorConstructor(
(json: any) => [json.permission_id] as const,
);
const EmailRenderingError = createKnownErrorConstructor(
KnownError,
"EMAIL_RENDERING_ERROR",
(error: string) => [
400,
`Failed to render email with theme: ${error}`,
{ error },
] as const,
(json: any) => [json.error] as const,
);
export type KnownErrors = {
[K in keyof typeof KnownErrors]: InstanceType<typeof KnownErrors[K]>;
};
@ -1477,6 +1488,7 @@ export const KnownErrors = {
ApiKeyExpired,
ApiKeyRevoked,
WrongApiKeyType,
EmailRenderingError,
} satisfies Record<string, KnownErrorConstructor<any, any>>;

View File

@ -364,6 +364,8 @@ export const emailSenderEmailSchema = emailSchema.meta({ openapiField: { descrip
export const emailPasswordSchema = passwordSchema.meta({ openapiField: { description: 'Email password. Needs to be specified when using type="standard"', exampleValue: 'your-email-password' } });
// Project domain config
export const handlerPathSchema = yupString().test('is-handler-path', 'Handler path must start with /', (value) => value?.startsWith('/')).meta({ openapiField: { description: 'Handler path. If you did not setup a custom handler path, it should be "/handler" by default. It needs to start with /', exampleValue: '/handler' } });
// Project email theme config
export const emailThemeSchema = yupString().oneOf(['default-light', 'default-dark']).meta({ openapiField: { description: 'Email theme for the project. Determines the visual style of emails sent by the project.', exampleValue: 'default-light' } });
// Users
export class ReplaceFieldWithOwnUserId extends Error {

View File

@ -111,7 +111,7 @@ export function ActionDialog(props: ActionDialogProps) {
}}
{...cancelButton.props}
>
Cancel
{cancelButton.label ?? "Cancel"}
</Button>
)}
{okButton && (

View File

@ -46,6 +46,9 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
private readonly _metricsCache = createCache(async () => {
return await this._interface.getMetrics();
});
private readonly _emailThemePreviewCache = createCache(async ([theme, content]: [string, string]) => {
return await this._interface.renderEmailThemePreview(theme, content);
});
constructor(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>) {
super({
@ -128,6 +131,7 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
senderName: data.config.email_config.sender_name ?? throwErr("Email sender name is missing"),
senderEmail: data.config.email_config.sender_email ?? throwErr("Email sender email is missing"),
},
emailTheme: data.config.email_theme,
domains: data.config.domains.map((d) => ({
domain: d.domain,
handlerPath: d.handler_path,
@ -386,13 +390,13 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
}
async sendEmail(options: {
userId: string,
userIds: string[],
subject: string,
content: string,
notificationCategoryName: string,
}): Promise<void> {
await this._interface.sendEmail({
user_id: options.userId,
user_ids: options.userIds,
subject: options.subject,
html: options.content,
notification_category_name: options.notificationCategoryName,
@ -402,4 +406,11 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
async sendSignInInvitationEmail(email: string, callbackUrl: string): Promise<void> {
await this._interface.sendSignInInvitationEmail(email, callbackUrl);
}
// IF_PLATFORM react-like
useEmailThemePreview(theme: string, content: string): string {
const crud = useAsyncCache(this._emailThemePreviewCache, [theme, content] as const, "useEmailThemePreview()");
return crud.html;
}
// END_PLATFORM
}

View File

@ -148,6 +148,11 @@ export function useAsyncCache<D extends any[], T>(cache: AsyncCache<D, Result<T>
const id = React.useId();
// whenever the dependencies change, we need to refresh the promise cache
React.useEffect(() => {
cachePromiseByHookId.delete(id);
}, [...dependencies, id]);
const subscribe = useCallback((cb: () => void) => {
const { unsubscribe } = cache.onStateChange(dependencies, () => {
cachePromiseByHookId.delete(id);

View File

@ -60,11 +60,13 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
listSentEmails(): Promise<AdminSentEmail[]>,
sendEmail(options: {
userId: string,
userIds: string[],
subject: string,
content: string,
notificationCategoryName: string,
}): Promise<void>,
useEmailThemePreview(theme: string, content: string): string, // THIS_LINE_PLATFORM react-like
}
& StackServerApp<HasTokenStore, ProjectId>
);

View File

@ -27,6 +27,7 @@ export type AdminProjectConfig = {
readonly allowLocalhost: boolean,
readonly oauthProviders: AdminOAuthProviderConfig[],
readonly emailConfig?: AdminEmailConfig,
readonly emailTheme: 'default-light' | 'default-dark',
readonly domains: AdminDomainConfig[],
readonly createTeamOnSignUp: boolean,
readonly teamCreatorDefaultPermissions: AdminTeamPermission[],
@ -85,6 +86,7 @@ export type AdminProjectConfigUpdateOptions = {
allowLocalhost?: boolean,
createTeamOnSignUp?: boolean,
emailConfig?: AdminEmailConfig,
emailTheme?: 'default-light' | 'default-dark',
teamCreatorDefaultPermissions?: { id: string }[],
teamMemberDefaultPermissions?: { id: string }[],
userDefaultPermissions?: { id: string }[],

View File

@ -70,6 +70,7 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio
sender_email: options.config.emailConfig.senderEmail,
}
),
email_theme: options.config?.emailTheme,
sign_up_enabled: options.config?.signUpEnabled,
credential_enabled: options.config?.credentialEnabled,
magic_link_enabled: options.config?.magicLinkEnabled,

File diff suppressed because it is too large Load Diff