Environment variables for disabling email queue

This commit is contained in:
Konstantin Wohlwend 2025-12-15 10:48:00 -08:00
parent 5e20e0fcb3
commit de9cfb33a7
7 changed files with 45 additions and 23 deletions

View File

@ -135,8 +135,6 @@ jobs:
uses: JarvusInnovations/background-action@v1.0.7
with:
run: pnpm -C apps/backend run run-email-queue --log-order=stream &
wait-on: |
http://localhost:8102
tail: true
wait-for: 30s
log-output-if: true

View File

@ -129,8 +129,6 @@ jobs:
uses: JarvusInnovations/background-action@v1.0.7
with:
run: pnpm -C apps/backend run run-email-queue --log-order=stream &
wait-on: |
http://localhost:8102
tail: true
wait-for: 30s
log-output-if: true

View File

@ -135,8 +135,6 @@ jobs:
uses: JarvusInnovations/background-action@v1.0.7
with:
run: pnpm -C apps/backend run run-email-queue --log-order=stream &
wait-on: |
http://localhost:8102
tail: true
wait-for: 30s
log-output-if: true

View File

@ -44,6 +44,13 @@ STACK_EMAILABLE_API_KEY=# for Emailable email validation, see https://emailable.
STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR=# the number of emails a new project can send. Defaults to 200
# Email branching configuration
# If you have multiple deployments of compute accessing the same DB or multiple copies of a DBs connected to compute (as
# you would in preview/branching environments), you may want to either disable the auto-triggered email queue steps
# (those that trigger whenever an email is sent, besides the cron job), or disable email sending as a whole.
STACK_EMAIL_BRANCHING_DISABLE_QUEUE_AUTO_TRIGGER=# set to 'true' to disable the automatic triggering of the email queue step. the cron job must call /email-queue-step to run the queue step. Most useful on production domains where you know the cron job will run on the correct deployment and you don't need the auto-trigger (which may be on the wrong deployment)
STACK_EMAIL_BRANCHING_DISABLE_QUEUE_SENDING=# set to 'true' to throw an error instead of sending emails in the email queue step. Most useful on development branches that have a copy of the production DB, but should not send any emails (as otherwise some emails could be sent twice)
# Database
# For local development: `docker run -it --rm -e POSTGRES_PASSWORD=password -p "8128:5432" postgres`
@ -75,8 +82,7 @@ STACK_QSTASH_TOKEN=
STACK_QSTASH_CURRENT_SIGNING_KEY=
STACK_QSTASH_NEXT_SIGNING_KEY=
# Misc, optional
# Misc
STACK_ACCESS_TOKEN_EXPIRATION_TIME=# enter the expiration time for the access token here. Optional, don't specify it for default value
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:8131`

View File

@ -8,6 +8,7 @@ import { withTraceSpan } from "@/utils/telemetry";
import { allPromisesAndWaitUntilEach } from "@/utils/vercel";
import { EmailOutbox, EmailOutboxSkippedReason, Prisma } from "@prisma/client";
import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays";
import { getEnvBoolean } from "@stackframe/stack-shared/dist/utils/env";
import { captureError, errorToNiceString, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { Json } from "@stackframe/stack-shared/dist/utils/json";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
@ -139,7 +140,7 @@ async function updateLastExecutionTime(): Promise<number> {
-- Concurrent insert race: another worker just inserted, skip this run
WHEN NOT EXISTS (SELECT 1 FROM result) THEN 0.0
-- First run (inserted new row), use reasonable default delta
WHEN (SELECT previous_timestamp FROM result) IS NULL THEN 60.0
WHEN (SELECT previous_timestamp FROM result) IS NULL THEN 20.0
-- Normal update case: compute actual delta
ELSE EXTRACT(EPOCH FROM
(SELECT new_timestamp FROM result) -
@ -150,9 +151,14 @@ async function updateLastExecutionTime(): Promise<number> {
if (delta < 0) {
// TODO: why does this happen, actually? investigate.
console.warn("Email queue step delta is negative. Not sure why it happened. Ignoring the delta. TODO investigate", { delta });
return 0;
}
if (delta > 30) {
captureError("email-queue-step-delta-too-large", new StackAssertionError(`Email queue step delta is too large: ${delta}. Either the previous step took too long, or something is wrong.`));
}
return delta;
}
@ -491,15 +497,17 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO
return;
}
const result = await lowLevelSendEmailDirectViaProvider({
tenancyId: context.tenancy.id,
emailConfig: context.emailConfig,
to: resolution.emails,
subject: row.renderedSubject ?? "",
html: row.renderedHtml ?? undefined,
text: row.renderedText ?? undefined,
shouldSkipDeliverabilityCheck: row.shouldSkipDeliverabilityCheck,
});
const result = getEnvBoolean("STACK_EMAIL_BRANCHING_DISABLE_QUEUE_SENDING")
? Result.error({ errorType: "email-sending-disabled", canRetry: false, message: "Email sending is disabled", rawError: new Error("Email sending is disabled") })
: await lowLevelSendEmailDirectViaProvider({
tenancyId: context.tenancy.id,
emailConfig: context.emailConfig,
to: resolution.emails,
subject: row.renderedSubject ?? "",
html: row.renderedHtml ?? undefined,
text: row.renderedText ?? undefined,
shouldSkipDeliverabilityCheck: row.shouldSkipDeliverabilityCheck,
});
if (result.status === "error") {
await globalPrismaClient.emailOutbox.update({

View File

@ -3,7 +3,7 @@ import { runAsynchronouslyAndWaitUntil } from '@/utils/vercel';
import { EmailOutboxCreatedWith } from '@prisma/client';
import { DEFAULT_TEMPLATE_IDS } from '@stackframe/stack-shared/dist/helpers/emails';
import { UsersCrud } from '@stackframe/stack-shared/dist/interface/crud/users';
import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env';
import { getEnvBoolean, getEnvVariable } from '@stackframe/stack-shared/dist/utils/env';
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
import { Json } from '@stackframe/stack-shared/dist/utils/json';
import { runEmailQueueStep, serializeRecipient } from './email-queue-step';
@ -67,9 +67,12 @@ export async function sendEmailToMany(options: {
overrideNotificationCategoryId: options.overrideNotificationCategoryId,
})),
});
// The cron job should run runEmailQueueStep() to process the emails, but we call it here again for those self-hosters
// who didn't set up the cron job correctly, and also just in case something happens to the cron job.
runAsynchronouslyAndWaitUntil(runEmailQueueStep());
if (!getEnvBoolean("STACK_EMAIL_BRANCHING_DISABLE_QUEUE_AUTO_TRIGGER")) {
// The cron job should run runEmailQueueStep() to process the emails, but we call it here again for those self-hosters
// who didn't set up the cron job correctly, and also just in case something happens to the cron job.
runAsynchronouslyAndWaitUntil(runEmailQueueStep());
}
}
export async function sendEmailFromDefaultTemplate(options: {

View File

@ -1,4 +1,4 @@
import { throwErr } from "./errors";
import { StackAssertionError, throwErr } from "./errors";
import { deindent } from "./strings";
export function isBrowserLike() {
@ -57,6 +57,17 @@ export function getEnvVariable(name: string, defaultValue?: string | undefined):
return value;
}
export function getEnvBoolean(name: string): boolean {
const value = getEnvVariable(name, "false");
if (value === "true") {
return true;
} else if (value === "false") {
return false;
} else {
throw new StackAssertionError(`Environment variable ${name} must be either "true" or "false": found ${JSON.stringify(value)}`);
}
}
export function getNextRuntime() {
// This variable is compiled into the client bundle, so we can't use getEnvVariable here.
return process.env.NEXT_RUNTIME || throwErr("Missing environment variable: NEXT_RUNTIME");