Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/backend/src/lib/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,9 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete

email_config: renderedConfig.emails.server.isShared ? {
type: 'shared',
host: getEnvVariable("STACK_EMAIL_HOST"),
port: parseInt(getEnvVariable("STACK_EMAIL_PORT")),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 parseInt can silently produce NaN

If STACK_EMAIL_PORT is set but contains a non-integer value (e.g. "465abc" or whitespace), parseInt(...) returns NaN. This NaN value will be serialised and sent to the dashboard, where it may render as "NaN" in the port fields. Adding a validation guard or using Number() with a Number.isInteger check would make the failure explicit rather than silent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/backend/src/lib/config.tsx
Line: 1169

Comment:
**`parseInt` can silently produce `NaN`**

If `STACK_EMAIL_PORT` is set but contains a non-integer value (e.g. `"465abc"` or whitespace), `parseInt(...)` returns `NaN`. This `NaN` value will be serialised and sent to the dashboard, where it may render as `"NaN"` in the port fields. Adding a validation guard or using `Number()` with a `Number.isInteger` check would make the failure explicit rather than silent.

How can I resolve this? If you propose a fix, please make it concise.

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

port: parseInt(getEnvVariable("STACK_EMAIL_PORT")) can yield NaN for invalid input, which would serialize to null in JSON and violate the expected number | undefined shape. It’d be safer to parse with Number.parseInt(..., 10) and explicitly validate Number.isFinite(port) (throwing a clear error if invalid) or omit the field when the env var is unset.

Suggested change
port: parseInt(getEnvVariable("STACK_EMAIL_PORT")),
port: (() => {
const port = Number.parseInt(getEnvVariable("STACK_EMAIL_PORT"), 10);
if (!Number.isFinite(port)) {
throw new StackAssertionError("Invalid STACK_EMAIL_PORT: expected a finite number");
}
return port;
})(),

Copilot uses AI. Check for mistakes.
sender_email: getEnvVariable("STACK_EMAIL_SENDER"),
Comment on lines 1166 to +1170
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether STACK_EMAIL_HOST/PORT/SENDER are set unconditionally in deployment/env configs.
rg -nP 'STACK_EMAIL_(HOST|PORT|SENDER)\b' -C2
# Verify getEnvVariable semantics
rg -nP --type=ts -C3 'export function getEnvVariable\b'

Repository: stack-auth/stack-auth

Length of output: 7314


🏁 Script executed:

cat -n packages/stack-shared/src/utils/env.tsx | head -50

Repository: stack-auth/stack-auth

Length of output: 2214


🏁 Script executed:

sed -n '49,65p' packages/stack-shared/src/utils/env.tsx

Repository: stack-auth/stack-auth

Length of output: 443


🏁 Script executed:

# Check when isShared is set to true in email config
rg -n 'isShared' apps/backend/src/lib/config.tsx -B5 -A5 | head -60

Repository: stack-auth/stack-auth

Length of output: 2856


🏁 Script executed:

# Look for where emails.server.isShared is actually set/determined
rg -n 'emails.server' apps/backend/src/lib/config.tsx -B3 -A3 | head -80

Repository: stack-auth/stack-auth

Length of output: 1745


🏁 Script executed:

# Find where emails.server configuration is built/rendered
rg -n 'emails.*server' packages/stack-shared/src -A2 -B2 | grep -A5 -B5 'isShared'

Repository: stack-auth/stack-auth

Length of output: 765


🏁 Script executed:

# Find where isShared is assigned/set for emails
rg -n 'isShared.*=' packages/stack-shared/src/config/schema.ts -B5 -A2

Repository: stack-auth/stack-auth

Length of output: 1685


🏁 Script executed:

# Search for where isShared is computed in the config schema
rg -n 'isShared:' packages/stack-shared/src/config/schema.ts -B5 -A5

Repository: stack-auth/stack-auth

Length of output: 3169


🏁 Script executed:

# Check if STACK_EMAIL_HOST/PORT/SENDER are set in any standard deployment configs
find . -name ".env*" -o -name "*.env" | head -20 | xargs grep -l "STACK_EMAIL" 2>/dev/null || echo "No .env files found with STACK_EMAIL"

Repository: stack-auth/stack-auth

Length of output: 165


🏁 Script executed:

# Check contents of these env files for STACK_EMAIL variables
echo "=== apps/backend/.env ===" && cat apps/backend/.env | grep -i "STACK_EMAIL" || echo "Not found"
echo -e "\n=== apps/backend/.env.development ===" && cat apps/backend/.env.development | grep -i "STACK_EMAIL" || echo "Not found"
echo -e "\n=== docker/server/.env ===" && cat docker/server/.env | grep -i "STACK_EMAIL" || echo "Not found"

Repository: stack-auth/stack-auth

Length of output: 3318


Critical: getEnvVariable throws when STACK_EMAIL_* are unset, breaking all config rendering in shared mode.

getEnvVariable(name) throws Missing environment variable: ... when the variable is absent or an empty string without a provided default (see packages/stack-shared/src/utils/env.tsx). The default email configuration sets isShared: true, so any self-hosted deployment without explicit STACK_EMAIL_HOST / STACK_EMAIL_PORT / STACK_EMAIL_SENDER values — including the provided docker/server/.env template which defines these variables as empty strings — will crash when rendering organization configs on the dashboard hot path.

The downstream UI already handles undefined values for these fields (shown in apps/dashboard/.../email-settings/domain-settings.tsx with fallback text "Configured via STACK_EMAIL_SENDER"). Pass explicit defaults or undefined instead of throwing. Additionally, parseInt lacks an explicit radix; a non-numeric STACK_EMAIL_PORT produces NaN, which then fails the admin read schema validation.

🛠️ Proposed fix
    email_config: renderedConfig.emails.server.isShared ? {
      type: 'shared',
-     host: getEnvVariable("STACK_EMAIL_HOST"),
-     port: parseInt(getEnvVariable("STACK_EMAIL_PORT")),
-     sender_email: getEnvVariable("STACK_EMAIL_SENDER"),
+     host: getEnvVariable("STACK_EMAIL_HOST", "") || undefined,
+     port: (() => {
+       const raw = getEnvVariable("STACK_EMAIL_PORT", "");
+       if (!raw) return undefined;
+       const parsed = parseInt(raw, 10);
+       return Number.isFinite(parsed) ? parsed : undefined;
+     })(),
+     sender_email: getEnvVariable("STACK_EMAIL_SENDER", "") || undefined,
    } : renderedConfig.emails.server.provider === "managed" ? {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
email_config: renderedConfig.emails.server.isShared ? {
type: 'shared',
host: getEnvVariable("STACK_EMAIL_HOST"),
port: parseInt(getEnvVariable("STACK_EMAIL_PORT")),
sender_email: getEnvVariable("STACK_EMAIL_SENDER"),
email_config: renderedConfig.emails.server.isShared ? {
type: 'shared',
host: getEnvVariable("STACK_EMAIL_HOST", "") || undefined,
port: (() => {
const raw = getEnvVariable("STACK_EMAIL_PORT", "");
if (!raw) return undefined;
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : undefined;
})(),
sender_email: getEnvVariable("STACK_EMAIL_SENDER", "") || undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/config.tsx` around lines 1166 - 1170, The config code
currently calls
getEnvVariable("STACK_EMAIL_HOST"/"STACK_EMAIL_PORT"/"STACK_EMAIL_SENDER")
without defaults which throws when those envs are empty; update the email_config
shared branch so it passes explicit defaults (use undefined) to getEnvVariable
for host and sender and for port parse the returned string with parseInt(...,
10) and guard NaN to produce undefined (i.e., call
getEnvVariable("STACK_EMAIL_PORT", undefined), then const port = parsed === NaN
? undefined : parsed), ensuring email_config construction (the email_config
object and its shared branch) yields undefined for missing values instead of
throwing.

Comment on lines 1166 to +1170
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderedOrganizationConfigToProjectCrud now calls getEnvVariable("STACK_EMAIL_*") whenever renderedConfig.emails.server.isShared is true. Since the default config sets emails.server.isShared: true (see packages/stack-shared/src/config/schema.ts:643-655), this can make every project config serialization throw (and potentially break the dashboard) in environments where STACK_EMAIL_* isn’t configured yet. Consider reading these env vars optionally (e.g. via process.env and omitting undefined/empty values) so the config endpoint remains usable, and let the UI fall back as intended.

Copilot uses AI. Check for mistakes.
Comment on lines 1166 to +1170
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new shared-email serialization behavior isn’t covered by the in-source vitest tests in this file. Adding a small test for renderedOrganizationConfigToProjectCrud that (1) includes STACK_EMAIL_HOST/PORT/SENDER when set, and (2) does not throw / omits fields when unset, would help prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +1168 to +1170
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
host: getEnvVariable("STACK_EMAIL_HOST"),
port: parseInt(getEnvVariable("STACK_EMAIL_PORT")),
sender_email: getEnvVariable("STACK_EMAIL_SENDER"),
host: getEnvVariable("STACK_EMAIL_HOST", ""),
port: parseInt(getEnvVariable("STACK_EMAIL_PORT", "0")),
sender_email: getEnvVariable("STACK_EMAIL_SENDER", ""),

Missing default values for getEnvVariable calls cause all project read endpoints to crash when shared email env vars are not configured

Fix on Vercel

} : renderedConfig.emails.server.provider === "managed" ? {
Comment on lines 1166 to 1171
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unconditional throw breaks config reads when env vars are absent

getEnvVariable throws Missing environment variable: <name> if the named variable is not set and no default is provided. Before this PR, the isShared branch returned only { type: 'shared' } and never touched the env vars. After this PR every call to renderedOrganizationConfigToProjectCrud (which backs the /api/v1/internal/projects read endpoint) will throw for any project in shared-email mode that doesn't have all three of STACK_EMAIL_HOST, STACK_EMAIL_PORT, and STACK_EMAIL_SENDER defined. Self-hosted operators who haven't set these vars (or deliberately run without email) would find their entire project config endpoint broken after this change, even though no email-sending code was touched.

A safe approach is to fall back to undefined/null when the var is absent rather than throwing:

email_config: renderedConfig.emails.server.isShared ? {
  type: 'shared',
  ...(process.env.STACK_EMAIL_HOST ? { host: process.env.STACK_EMAIL_HOST } : {}),
  ...(process.env.STACK_EMAIL_PORT ? { port: parseInt(process.env.STACK_EMAIL_PORT) } : {}),
  ...(process.env.STACK_EMAIL_SENDER ? { sender_email: process.env.STACK_EMAIL_SENDER } : {}),
} : ...

Note: getEnvVariable cannot be used here with a fallback because an empty string is treated as "not set" by that utility; using process.env directly and checking truthiness is safer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/backend/src/lib/config.tsx
Line: 1166-1171

Comment:
**Unconditional throw breaks config reads when env vars are absent**

`getEnvVariable` throws `Missing environment variable: <name>` if the named variable is not set and no default is provided. Before this PR, the `isShared` branch returned only `{ type: 'shared' }` and never touched the env vars. After this PR every call to `renderedOrganizationConfigToProjectCrud` (which backs the `/api/v1/internal/projects` read endpoint) will throw for any project in shared-email mode that doesn't have all three of `STACK_EMAIL_HOST`, `STACK_EMAIL_PORT`, and `STACK_EMAIL_SENDER` defined. Self-hosted operators who haven't set these vars (or deliberately run without email) would find their entire project config endpoint broken after this change, even though no email-sending code was touched.

A safe approach is to fall back to `undefined`/`null` when the var is absent rather than throwing:

```typescript
email_config: renderedConfig.emails.server.isShared ? {
  type: 'shared',
  ...(process.env.STACK_EMAIL_HOST ? { host: process.env.STACK_EMAIL_HOST } : {}),
  ...(process.env.STACK_EMAIL_PORT ? { port: parseInt(process.env.STACK_EMAIL_PORT) } : {}),
  ...(process.env.STACK_EMAIL_SENDER ? { sender_email: process.env.STACK_EMAIL_SENDER } : {}),
} : ...
```

Note: `getEnvVariable` cannot be used here with a fallback because an empty string is treated as "not set" by that utility; using `process.env` directly and checking truthiness is safer.

How can I resolve this? If you propose a fix, please make it concise.

type: 'standard',
host: "smtp.resend.com",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type ServerFieldConfig = {
};

const SERVER_TYPE_LABELS: Record<ServerType, string> = {
shared: "Shared (noreply@stackframe.co)",
shared: "Shared (environment-configured)",
managed: "Managed (via managed domain setup)",
resend: "Resend",
standard: "Custom SMTP",
Expand Down Expand Up @@ -87,9 +87,16 @@ function getServerTypeFromConfig(config: CompleteConfig["emails"]["server"]): Se
return "standard";
}

function getFormValuesFromConfig(config: CompleteConfig["emails"]["server"], projectName: string): Record<string, string> {
function getFormValuesFromConfig(
config: CompleteConfig["emails"]["server"],
projectName: string,
sharedSenderEmail: string | null,
): Record<string, string> {
if (config.isShared) {
return { senderEmail: "noreply@stackframe.co", senderName: projectName };
return {
senderEmail: sharedSenderEmail ?? "Configured via STACK_EMAIL_SENDER",
senderName: projectName,
};
}
if (config.provider === "managed") {
const senderEmail = config.managedSubdomain && config.managedSenderLocalPart
Expand Down Expand Up @@ -357,12 +364,21 @@ export function DomainSettings() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const emailConfig = project.useConfig().emails.server;
const sharedSenderEmail = project.config.emailConfig?.type === "shared"
&& "senderEmail" in project.config.emailConfig
&& typeof project.config.emailConfig.senderEmail === "string"
? project.config.emailConfig.senderEmail
: null;
const updateConfig = useUpdateConfig();
const { toast } = useToast();
const isEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true";

const savedServerType = getServerTypeFromConfig(emailConfig);
const savedValues = getFormValuesFromConfig(emailConfig, project.displayName);
const savedValues = getFormValuesFromConfig(
emailConfig,
project.displayName,
sharedSenderEmail,
);

const [serverType, setServerType] = useState<ServerType>(savedServerType);
const [formValues, setFormValues] = useState<Record<string, string>>(savedValues);
Expand Down Expand Up @@ -541,8 +557,10 @@ export function DomainSettings() {
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sender Email</Label>
{isShared ? (
<SimpleTooltip tooltip="Sender email is fixed on the shared server">
<Typography className="text-sm font-medium text-foreground/60 cursor-default py-1">noreply@stackframe.co</Typography>
<SimpleTooltip tooltip="Sender email is read from STACK_EMAIL_SENDER on the server.">
<Typography className="text-sm font-medium text-foreground/60 cursor-default py-1">
{sharedSenderEmail ?? "Configured via STACK_EMAIL_SENDER"}
</Typography>
</SimpleTooltip>
) : serverType === "managed" ? (
<SimpleTooltip tooltip="Sender email is configured through the managed domain setup">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table";
import { DesignAnalyticsCard } from "@/components/design-components";
import { FormDialog } from "@/components/form-dialog";
import { InputField, SelectField, TextAreaField } from "@/components/form-fields";
import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, DataTable, DataTableColumnHeader, DataTableViewOptions, SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Typography, useToast } from "@/components/ui";
Expand All @@ -19,7 +20,6 @@ import * as yup from "yup";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { DesignAnalyticsCard } from "@/components/design-components";

// Section header with icon following design guide
function SectionHeader({ icon: Icon, title }: { icon: ElementType, title: string }) {
Expand Down Expand Up @@ -59,6 +59,11 @@ export default function PageClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const emailConfig = project.useConfig().emails.server;
const sharedSenderEmail = project.config.emailConfig?.type === "shared"
&& "senderEmail" in project.config.emailConfig
&& typeof project.config.emailConfig.senderEmail === "string"
? project.config.emailConfig.senderEmail
: null;
const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true";

return (
Expand All @@ -82,7 +87,7 @@ export default function PageClient() {
{isLocalEmulator && <EmulatorModeCard />}

{/* Email Server Card */}
<EmailServerCard emailConfig={emailConfig} />
<EmailServerCard emailConfig={emailConfig} sharedSenderEmail={sharedSenderEmail} />

{/* Email Log Card */}
<EmailLogCard />
Expand Down Expand Up @@ -130,16 +135,22 @@ function EmulatorModeCard() {
);
}

function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails']['server'] }) {
function EmailServerCard({
emailConfig,
sharedSenderEmail,
}: {
emailConfig: CompleteConfig['emails']['server'],
sharedSenderEmail: string | null,
}) {
const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true";
const serverType = emailConfig.isShared
? 'Shared'
? 'Shared (environment-configured)'
: emailConfig.provider === 'managed'
? 'Managed By Stack Auth'
: (emailConfig.provider === 'resend' ? 'Resend' : 'Custom SMTP');

const senderEmail = emailConfig.isShared
? 'noreply@stackframe.co'
? (sharedSenderEmail ?? 'Configured via STACK_EMAIL_SENDER')
: emailConfig.provider === 'managed' && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart
? `${emailConfig.managedSenderLocalPart}@${emailConfig.managedSubdomain}`
: emailConfig.senderEmail;
Expand Down Expand Up @@ -207,7 +218,7 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails'
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{serverType}</span>
{emailConfig.isShared && (
<SimpleTooltip tooltip="When you use the shared email server, all the emails are sent from Stack's email address" type='info' />
<SimpleTooltip tooltip="Shared email settings are read from STACK_EMAIL_* environment variables on the server." type='info' />
)}
</div>
</div>
Expand Down Expand Up @@ -820,7 +831,7 @@ function EditEmailServerDialog(props: {
name="type"
control={form.control}
options={[
{ label: "Shared (noreply@stackframe.co)", value: 'shared' },
{ label: "Shared (environment-configured)", value: 'shared' },
{ label: "Managed (via managed domain setup)", value: 'managed' },
{ label: "Resend (your own email address)", value: 'resend' },
{ label: "Custom SMTP server (your own email address)", value: 'standard' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, Interna
import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions";
import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects";
import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays";
import { ManagedEmailProviderListItem, ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, EmailOutboxUpdateOptions, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app";
import { EmailOutboxUpdateOptions, ManagedEmailProviderListItem, ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app";
import { clientVersion, createCache, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, resolveApiUrls, resolveConstructorOptions } from "./common";
import { _StackServerAppImplIncomplete } from "./server-app-impl";

Expand Down Expand Up @@ -208,7 +208,10 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
appleBundleIds: p.apple_bundle_ids,
} as const))),
emailConfig: data.config.email_config.type === 'shared' ? {
type: 'shared'
type: 'shared',
senderEmail: data.config.email_config.sender_email,
host: data.config.email_config.host,
port: data.config.email_config.port,
} : {
type: 'standard',
host: data.config.email_config.host ?? throwErr("Email host is missing"),
Expand Down
3 changes: 3 additions & 0 deletions packages/template/src/lib/stack-app/project-configs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export type AdminEmailConfig = (
}
| {
type: "shared",
senderEmail?: string,
host?: string,
port?: number,
}
);

Expand Down
Loading