Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- Fail early, fail loud. Fail fast with an error instead of silently continuing.
- Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible.
- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples.
- Each migration file runs in its own transaction with a relatively short timeout. Split long-running operations into separate migration files to avoid timeouts. For example, when adding CHECK constraints, use `NOT VALID` in one migration, then `VALIDATE CONSTRAINT` in a separate migration file.
- **When building frontend code, always carefully deal with loading and error states.** Be very explicit with these; some components make this easy, eg. the button onClick already takes an async callback for loading state, but make sure this is done everywhere, and make sure errors are NEVER just silently swallowed.
- Unless very clearly equivalent from types, prefer explicit null/undefinedness checks over boolean checks, eg. `foo == null` instead of `!foo`.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Add deferred retry fields for email sending
-- These fields allow the email queue to schedule retries for later iterations
-- instead of blocking the current iteration with inline retries.

ALTER TABLE "EmailOutbox"
ADD COLUMN "sendRetries" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "nextSendRetryAt" TIMESTAMP(3),
ADD COLUMN "sendAttemptErrors" JSONB;

-- Constraint: nextSendRetryAt can only be set after at least one failed attempt
-- (if sendRetries is 0, no attempt has failed, so there's nothing to retry)
-- Use NOT VALID to avoid holding ACCESS EXCLUSIVE lock during full-table validation.
-- Validation happens in a separate migration to avoid transaction timeout.
ALTER TABLE "EmailOutbox"
ADD CONSTRAINT "EmailOutbox_nextSendRetryAt_requires_failure"
CHECK ("nextSendRetryAt" IS NULL OR "sendRetries" > 0) NOT VALID;

-- Constraint: sendAttemptErrors can only be set after at least one failed attempt
ALTER TABLE "EmailOutbox"
ADD CONSTRAINT "EmailOutbox_sendAttemptErrors_requires_failure"
CHECK ("sendAttemptErrors" IS NULL OR "sendRetries" > 0) NOT VALID;

-- Constraint: nextSendRetryAt must be null when email has finished sending
-- (if finishedSendingAt is set, there's nothing more to retry)
ALTER TABLE "EmailOutbox"
ADD CONSTRAINT "EmailOutbox_no_retry_after_finished"
CHECK ("finishedSendingAt" IS NULL OR "nextSendRetryAt" IS NULL) NOT VALID;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Validate the deferred retry constraints added in the previous migration.
-- This runs in a separate transaction to avoid timeout, and only takes
-- SHARE UPDATE EXCLUSIVE lock (allows concurrent reads/writes).

ALTER TABLE "EmailOutbox" VALIDATE CONSTRAINT "EmailOutbox_nextSendRetryAt_requires_failure";
ALTER TABLE "EmailOutbox" VALIDATE CONSTRAINT "EmailOutbox_sendAttemptErrors_requires_failure";
ALTER TABLE "EmailOutbox" VALIDATE CONSTRAINT "EmailOutbox_no_retry_after_finished";
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
-- Index on isQueued for efficient queueReadyEmails() queries.
-- Most emails have isQueued=TRUE (already processed), so filtering for FALSE is highly selective.
CREATE INDEX CONCURRENTLY IF NOT EXISTS "EmailOutbox_isQueued_idx" ON /* SCHEMA_NAME_SENTINEL */."EmailOutbox" ("isQueued");
17 changes: 13 additions & 4 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ model ExternalDbSyncMetadata {

singleton BooleanTrue @unique @default(TRUE)

sequencerEnabled Boolean @default(true)
pollerEnabled Boolean @default(true)
sequencerEnabled Boolean @default(true)
pollerEnabled Boolean @default(true)

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -833,7 +833,7 @@ model EmailOutbox {
// The scheduled time of when the email should be added to the queue. Can be edited, but only if the email has not yet started sending. Doing so should set isQueued to false.
scheduledAt DateTime

// The scheduled time of the email if it is in the future.
// Whether the email has been queued for sending. Once queued, this stays true unless the email is retried or rescheduled.
isQueued Boolean @default(false)

// A generated column that is equal to scheduledAt if isQueued is false, otherwise null. See the note above on EmailOutboxStatus.status for more details on dbgenerated values.
Expand All @@ -844,6 +844,14 @@ model EmailOutbox {
// if startedSendingAt is not set, then finishedSendingAt is also not set
finishedSendingAt DateTime?

// Deferred retry fields for email sending
// Number of send retries attempted (starts at 0, incremented on each failure). Reset when email content is edited.
sendRetries Int @default(0)
// When to retry sending (null = not waiting for retry). Set when a retryable error occurs. Reset when email content is edited. Must be null if sendRetries is 0 (enforced by EmailOutbox_nextSendRetryAt_requires_failure). Must be null when finishedSendingAt is set (enforced by EmailOutbox_no_retry_after_finished).
nextSendRetryAt DateTime?
// JSON array of errors from each failed send attempt. Each entry has: { attemptNumber, timestamp, externalMessage, externalDetails, internalMessage, internalDetails }. Reset when email content is edited. Must be null if sendRetries is 0 (enforced by EmailOutbox_sendAttemptErrors_requires_failure).
sendAttemptErrors Json?

// A generated column that is equal to finishedSendingAt if canHaveDeliveryInfo is false, otherwise deliveredAt.
sentAt DateTime? @default(dbgenerated("\nCASE\n WHEN (\"canHaveDeliveryInfo\" IS TRUE) THEN \"deliveredAt\"\n WHEN (\"canHaveDeliveryInfo\" IS FALSE) THEN \"finishedSendingAt\"\n ELSE NULL::timestamp without time zone\nEND"))

Expand Down Expand Up @@ -878,6 +886,7 @@ model EmailOutbox {
@@index([tenancyId, finishedSendingAt(sort: Desc), scheduledAtIfNotYetQueued(sort: Desc), priority, id], map: "EmailOutbox_ordering_idx")
@@index([tenancyId, simpleStatus], map: "EmailOutbox_simple_status_tenancy_idx")
@@index([tenancyId, status], map: "EmailOutbox_status_tenancy_idx")
@@index([isQueued], map: "EmailOutbox_isQueued_idx")
}

model EmailOutboxProcessingMetadata {
Expand Down Expand Up @@ -1088,7 +1097,7 @@ model OutgoingRequest {

qstashOptions Json
startedFulfillingAt DateTime?
deduplicationKey String?
deduplicationKey String?

// Partial unique index on deduplicationKey WHERE startedFulfillingAt IS NULL
// is created in a custom migration (not expressible in Prisma schema)
Expand Down
30 changes: 30 additions & 0 deletions apps/backend/src/app/api/latest/emails/outbox/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
import { emailOutboxCrud, EmailOutboxCrud } from "@stackframe/stack-shared/dist/interface/crud/email-outbox";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { Json } from "@stackframe/stack-shared/dist/utils/json";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";

/**
Expand Down Expand Up @@ -57,6 +58,25 @@ function prismaModelToCrud(prismaModel: EmailOutbox): EmailOutboxCrud["Server"][
to = { type: "custom-emails", emails: recipient?.emails ?? [] };
}

// Convert sendAttemptErrors from DB format (camelCase) to API format (snake_case)
const sendAttemptErrors = prismaModel.sendAttemptErrors
? (prismaModel.sendAttemptErrors as Array<{
attemptNumber: number,
timestamp: string,
externalMessage: string,
externalDetails: Record<string, Json>,
internalMessage: string,
internalDetails: Record<string, Json>,
}>).map(e => ({
attempt_number: e.attemptNumber,
timestamp: e.timestamp,
external_message: e.externalMessage,
external_details: e.externalDetails,
internal_message: e.internalMessage,
internal_details: e.internalDetails,
}))
: null;
Comment thread
nams1570 marked this conversation as resolved.

// Base fields present on all emails
const base = {
id: prismaModel.id,
Expand All @@ -68,6 +88,9 @@ function prismaModelToCrud(prismaModel: EmailOutbox): EmailOutboxCrud["Server"][
variables: (prismaModel.extraRenderVariables ?? {}) as Record<string, any>,
skip_deliverability_check: prismaModel.shouldSkipDeliverabilityCheck,
scheduled_at_millis: prismaModel.scheduledAt.getTime(),
send_retries: prismaModel.sendRetries,
next_send_retry_at_millis: prismaModel.nextSendRetryAt?.getTime() ?? null,
send_attempt_errors: sendAttemptErrors,
// Default flags (overridden in specific statuses)
is_paused: false,
has_rendered: false,
Expand Down Expand Up @@ -358,6 +381,7 @@ export const emailOutboxCrudHandlers = createLazyProxy(() => createCrudHandlers(
// Cancel action - mark as skipped
set("isPaused", Prisma.sql`false`);
set("isQueued", Prisma.sql`false`);
setNull("nextSendRetryAt"); // Clear any pending retry so it won't be picked up
set("skippedReason", Prisma.sql`'MANUALLY_CANCELLED'::"EmailOutboxSkippedReason"`);
set("skippedDetails", Prisma.sql`'{}'::jsonb`);
} else {
Expand Down Expand Up @@ -395,13 +419,16 @@ export const emailOutboxCrudHandlers = createLazyProxy(() => createCrudHandlers(
// If content changed, reset rendering and sending state
if (needsRerenderReset) {
set("isQueued", Prisma.sql`false`);
// Reset retry fields (sendRetries to 0, others to null)
set("sendRetries", Prisma.sql`0`);
setNull(
"renderedByWorkerId", "startedRenderingAt", "finishedRenderingAt",
"renderErrorExternalMessage", "renderErrorExternalDetails",
"renderErrorInternalMessage", "renderErrorInternalDetails",
"renderedHtml", "renderedText", "renderedSubject",
"renderedIsTransactional", "renderedNotificationCategoryId",
"startedSendingAt", "finishedSendingAt",
"nextSendRetryAt", "sendAttemptErrors",
"sendServerErrorExternalMessage", "sendServerErrorExternalDetails",
"sendServerErrorInternalMessage", "sendServerErrorInternalDetails",
"skippedReason", "skippedDetails", "canHaveDeliveryInfo",
Expand Down Expand Up @@ -494,6 +521,9 @@ function parseEmailOutboxFromJson(j: Record<string, unknown>): EmailOutbox {
scheduledAtIfNotYetQueued: dateOrNull("scheduledAtIfNotYetQueued"),
startedSendingAt: dateOrNull("startedSendingAt"),
finishedSendingAt: dateOrNull("finishedSendingAt"),
sendRetries: j.sendRetries as number,
nextSendRetryAt: dateOrNull("nextSendRetryAt"),
sendAttemptErrors: j.sendAttemptErrors as Prisma.JsonValue,
sentAt: dateOrNull("sentAt"),
sendServerErrorExternalMessage: j.sendServerErrorExternalMessage as string | null,
sendServerErrorExternalDetails: j.sendServerErrorExternalDetails as Prisma.JsonValue,
Expand Down
Loading