diff --git a/.github/workflows/docker-server-test.yaml b/.github/workflows/docker-server-test.yaml index 4e792a863..b5ab61d24 100644 --- a/.github/workflows/docker-server-test.yaml +++ b/.github/workflows/docker-server-test.yaml @@ -13,7 +13,7 @@ concurrency: jobs: docker: - runs-on: ubuntu-latest + runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v3 diff --git a/apps/backend/.env b/apps/backend/.env index c77663f70..2f73e9025 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -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 diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 8559c6105..a13d37d70 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -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 diff --git a/apps/backend/package.json b/apps/backend/package.json index db22089b9..858d6bd94 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/src/app/api/latest/emails/render-email/route.tsx b/apps/backend/src/app/api/latest/emails/render-email/route.tsx new file mode 100644 index 000000000..825dfa9ac --- /dev/null +++ b/apps/backend/src/app/api/latest/emails/render-email/route.tsx @@ -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, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/emails/send-email/route.tsx b/apps/backend/src/app/api/latest/emails/send-email/route.tsx index 40d1dfdd2..4b5b8f866 100644 --- a/apps/backend/src/app/api/latest/emails/send-email/route.tsx +++ b/apps/backend/src/app/api/latest/emails/send-email/route.tsx @@ -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 += `
Click here to unsubscribe`; + }, + include: { + contactChannels: true, + }, + }); + const userMap = new Map(users.map(user => [user.projectUserId, user])); + const userSendErrors: Map = new Map(); + const userPrimaryEmails: Map = 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 }, }; }, }); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index b7a7bf107..93329f1f8 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -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) diff --git a/apps/backend/src/lib/email-themes.tsx b/apps/backend/src/lib/email-themes.tsx new file mode 100644 index 000000000..8d4efc529 --- /dev/null +++ b/apps/backend/src/lib/email-themes.tsx @@ -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 ? `

Click here to unsubscribe` : ""; + if (["development", "test"].includes(getNodeEnvironment()) && apiKey === "mock_stack_freestyle_key") { + return { + html: `
Mock api key detected, returning mock data ${unsubscribeLinkHtml}
`, + 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 = ${htmlContent + unsubscribeLinkHtml} + 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 ( + + + +
+ {children} +
+ +
+ + ); +}`; + + +const DarkEmailTheme = `function EmailTheme({ children }: { children: React.ReactNode }) { + return ( + + + +
+ {children} +
+ +
+ + ); +}`; + + +export const EMAIL_THEMES = { + 'default-light': LightEmailTheme, + 'default-dark': DarkEmailTheme, +} as const; diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index d765325cb..07016a234 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -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), diff --git a/apps/backend/src/oauth/providers/github.tsx b/apps/backend/src/oauth/providers/github.tsx index ffabd1b64..ab8362d1f 100644 --- a/apps/backend/src/oauth/providers/github.tsx +++ b/apps/backend/src/oauth/providers/github.tsx @@ -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, })); } diff --git a/apps/dashboard/.env b/apps/dashboard/.env index ab09af0e2..9484be7f6 100644 --- a/apps/dashboard/.env +++ b/apps/dashboard/.env @@ -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"]' diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx new file mode 100644 index 000000000..b0525f564 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx @@ -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(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 ( + + + + Set Theme} + open={dialogOpen} + onOpenChange={setDialogOpen} + title="Select Email Theme" + cancelButton + okButton={{ + label: "Save Theme", + onClick: handleSaveTheme + }} + > +
+ {themes.map((theme) => ( + + ))} +
+
+
+
+ ); +} + +function ThemeOption({ + theme, + isSelected, + onSelect +}: { + theme: Theme, + isSelected: boolean, + onSelect: (themeId: ThemeType) => void, +}) { + return ( + onSelect(theme.id)} + > +
+
+ {theme.name} + {isSelected && ( +
+ +
+ )} +
+ + +
+
+ ); +} + +function ThemePreview({ themeId }: { themeId: ThemeType }) { + const previewEmailHtml = deindent` +
+

+ Header text +

+

+ Body text content with some additional information. +

+
+ `; + const stackAdminApp = useAdminApp(); + const previewHtml = stackAdminApp.useEmailThemePreview(themeId, previewEmailHtml); + return ( +