Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughRefactors email infrastructure from SentEmail model to a new asynchronous EmailOutbox pipeline with enqueuing, rendering, queuing, and sending stages. Adds email delivery statistics tracking, low-level sending capabilities, and related API endpoints. Updates authentication flows and E2E tests accordingly. Changes
Sequence Diagram(s)sequenceDiagram
actor Client
participant API as send-email<br/>route
participant Queue as EmailOutbox<br/>queue
participant Step as email-queue-step<br/>orchestrator
participant Render as renderEmails<br/>batch
participant Send as processSendPlan<br/>batches
participant Provider as SMTP/<br/>Provider
Client->>API: POST /emails/send-email<br/>(recipients, template, variables)
API->>Queue: sendEmailToMany()<br/>(create EmailOutbox entries)
Queue-->>Client: 200 { user_ids }
rect rgba(100, 150, 200, 0.1)
note over Step: Periodic (cron or local loop)
Step->>Queue: claimEmailsForRendering()
Queue-->>Step: unrendered batch (locked)
Step->>Render: renderEmailsForTenancyBatched()<br/>(group by tenancy)
Render-->>Step: rendered HTML/Text/Subject
Step->>Queue: update renderedHtml,<br/>renderedText, status
Step->>Queue: queueReadyEmails()
Queue-->>Step: mark as queued
end
rect rgba(100, 200, 150, 0.1)
note over Step: Send phase (capacity-gated)
Step->>Queue: prepareSendPlan()<br/>(calc quota per tenancy)
Queue-->>Step: per-tenancy send batches
Step->>Send: processSendPlan(batches)
Send->>Send: resolveRecipientEmails()<br/>shouldSendEmail(category)
Send->>Provider: lowLevelSendEmailDirectViaProvider()
Provider-->>Send: success or error
Send->>Queue: update status, timestamps,<br/>delivery metadata
end
Step-->>Step: updateLastExecutionTime()
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Areas requiring extra attention:
Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
This PR is being reviewed by Cursor Bugbot
Details
You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.
To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.
Greptile Summary
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant API as "Send Email API"
participant Outbox as "EmailOutbox Table"
participant Cron as "Vercel Cron"
participant Queue as "Email Queue Step"
participant Renderer as "Email Renderer"
participant SMTP as "SMTP Server"
User->>API: "POST /send-email"
API->>Outbox: "Create EmailOutbox row(s)"
API->>Queue: "Trigger queue processing"
API-->>User: "Return success"
Cron->>Queue: "Run every ~1 second"
Queue->>Outbox: "Claim unrendered emails"
Queue->>Renderer: "Batch render by tenancy"
Renderer-->>Queue: "Rendered HTML/text/subject"
Queue->>Outbox: "Update rendered fields"
Queue->>Outbox: "Queue scheduled emails"
Queue->>Outbox: "Claim emails for sending"
Queue->>SMTP: "Send email via provider"
SMTP-->>Queue: "Delivery status"
Queue->>Outbox: "Update finishedSendingAt"
|
There was a problem hiding this comment.
56 files reviewed, 4 comments
Edit Code Review Agent Settings | Greptile
React with 👍 or 👎 to share your feedback on this new summary format
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx
Outdated
Show resolved
Hide resolved
apps/backend/prisma/migrations/20251020183000_migrate_sent_email/migration.sql
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Pull Request Overview
This PR introduces a comprehensive email outbox system that migrates from synchronous email sending to an asynchronous queue-based architecture. Instead of sending emails directly during API requests, emails are persisted to an EmailOutbox table and processed by background workers.
Key Changes:
- New
EmailOutboxdatabase table with comprehensive status tracking and constraints - Background email queue processor (
runEmailQueueStep) that renders, queues, and sends emails - Email delivery statistics and capacity rate limiting based on bounce/spam rates
Reviewed Changes
Copilot reviewed 57 out of 57 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/backend/prisma/schema.prisma | Adds EmailOutbox table with status tracking, constraints, and removes SentEmail table |
| apps/backend/src/lib/email-queue-step.tsx | Core queue processing logic for rendering and sending emails |
| apps/backend/src/lib/emails.tsx | Refactored to write to EmailOutbox instead of sending directly |
| apps/backend/src/lib/emails-low-level.tsx | Extracted low-level SMTP sending logic |
| apps/backend/src/lib/email-delivery-stats.tsx | Calculates delivery stats and capacity rates |
| apps/backend/src/app/api/latest/emails/send-email/route.tsx | Updated to use new email outbox system |
| apps/backend/src/app/api/latest/emails/delivery-info/route.tsx | New endpoint for delivery statistics |
| apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx | New cron endpoint for queue processing |
| vercel.json | Adds cron job for email queue processing |
| apps/backend/scripts/run-email-queue.ts | Local development email queue runner |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 18
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts (1)
61-154: Wire throughqueryparameter and reduce flakiness in helper
testFailedEmails(query: any)currently ignores itsqueryargument and always calls/failed-emails-digestwithdry_run: "true". That makes the signature misleading and will break the planned non–dry-run test once it’s enabled. Also, the fixedwait(5_000)adds a hard 5s delay and can make e2e runs slower/more brittle than necessary.Consider:
- Passing the
queryargument through toniceBackendFetchand tightening its type.- Optionally normalizing
tenant_owner_emailsas well asemailsto make snapshots more order-stable.For example:
- async function testFailedEmails(query: any) { + async function testFailedEmails(query: { dry_run: "true" | "false" }) { @@ - const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", { - method: "POST", - headers: { "Authorization": "Bearer mock_cron_secret" }, - query: { - dry_run: "true", - }, - }); + const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", { + method: "POST", + headers: { "Authorization": "Bearer mock_cron_secret" }, + query, + }); @@ - const mockProjectFailedEmails = failedEmailsByTenancy.filter( - (batch: any) => batch.tenant_owner_emails.includes(backendContext.value.mailbox.emailAddress) - ).map((batch: any) => ({ - ...batch, - emails: [...batch.emails].sort((a, b) => stringCompare(a.subject, b.subject)), - })); + const mockProjectFailedEmails = failedEmailsByTenancy + .filter((batch: any) => batch.tenant_owner_emails.includes(backendContext.value.mailbox.emailAddress)) + .map((batch: any) => ({ + ...batch, + emails: [...batch.emails].sort((a, b) => stringCompare(a.subject, b.subject)), + tenant_owner_emails: [...batch.tenant_owner_emails].sort(stringCompare), + }));If you find flakiness around the 5s sleep, you might also later replace
wait(5_000)with a small polling loop against delivery info rather than a fixed delay.apps/backend/src/prisma-client.tsx (2)
38-47: Fix Neon client caching: the new store is never populated
getNeonPrismaClientreads fromneonPrismaClientsStorebut only writes toprismaClientsStore.neon. As a result,neonPrismaClientsStorestays empty and every call with a given connection string creates a freshPrismaClient, defeating caching and potentially exhausting connections.You probably want to both (a) reuse any legacy cached clients in
prismaClientsStore.neonand (b) keep both caches in sync:-const neonPrismaClientsStore: Map<string, PrismaClient> = globalVar.__stack_neon_prisma_clients ??= new Map(); -function getNeonPrismaClient(connectionString: string) { - let neonPrismaClient = neonPrismaClientsStore.get(connectionString); +const neonPrismaClientsStore: Map<string, PrismaClient> = globalVar.__stack_neon_prisma_clients ??= new Map(); +function getNeonPrismaClient(connectionString: string) { + let neonPrismaClient = + neonPrismaClientsStore.get(connectionString) ?? + prismaClientsStore.neon.get(connectionString); if (!neonPrismaClient) { const schema = getSchemaFromConnectionString(connectionString); const adapter = new PrismaNeon({ connectionString }, { schema }); neonPrismaClient = new PrismaClient({ adapter }); - prismaClientsStore.neon.set(connectionString, neonPrismaClient); + neonPrismaClientsStore.set(connectionString, neonPrismaClient); + prismaClientsStore.neon.set(connectionString, neonPrismaClient); } return neonPrismaClient; }
196-204:isRetryablePrismaErrortreats all Prisma known request errors as retryable
isRetryablePrismaErrorcurrently returns an array of codes for anyPrisma.PrismaClientKnownRequestErrorandfalseotherwise:const isRetryablePrismaError = (e: unknown) => { if (e instanceof Prisma.PrismaClientKnownRequestError) { return [ "P2028", // Serializable/repeatable read conflict "P2034", // Transaction already closed (eg. timeout) ]; } return false; };Since arrays are truthy, every
PrismaClientKnownRequestErroris treated as retryable at the call sites (lines 221 and 246), not just P2028/P2034. That contradicts the comments and can cause inappropriate retries (e.g. on unique constraint violations) and re-running non-idempotent work inside a transaction.Consider tightening this to a real boolean check:
- const isRetryablePrismaError = (e: unknown) => { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - return [ - "P2028", // Serializable/repeatable read conflict - "P2034", // Transaction already closed (eg. timeout) - ]; - } - return false; - }; + const isRetryablePrismaError = (e: unknown) => { + if (!(e instanceof Prisma.PrismaClientKnownRequestError)) { + return false; + } + return ( + e.code === "P2028" || // Serializable/repeatable read conflict + e.code === "P2034" // Transaction already closed (eg. timeout) + ); + };apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx (1)
156-187: Remove dead code or mark temporary email preparation logic.Lines 156-184 prepare email content but Line 187 throws an error before any email is sent, making this code unreachable. Since the PR description notes this is temporary:
Consider one of these approaches:
Option 1 (Recommended): Comment out the dead code with a TODO:
- const tenancy = await globalPrismaClient.tenancy.findUnique({ - where: { - id: updatedApiKey.tenancyId - }, - include: { - project: true, - }, - }); - - if (!tenancy) { - throw new StackAssertionError("Tenancy not found"); - } - - // Create email content - const subject = `API Key Revoked: ${updatedApiKey.description}`; - const htmlContent = `...`; - - // Send email notifications + // TODO: Re-enable credential scanning emails with new outbox system throw new StackAssertionError("Credential scanning email is currently disabled!");Option 2: Remove the dead code entirely until the email outbox migration is complete.
🧹 Nitpick comments (25)
.vscode/settings.json (1)
19-19: Consider alphabetical ordering in the cSpell.words list.The word "dbgenerated" was inserted between "clsx" and "cmdk", but alphabetically it should appear after "cmdk" (since 'c' < 'd'). While this doesn't affect functionality, keeping the list sorted helps with maintainability.
If you'd like to reorder, move "dbgenerated" to after "cmdk" at line 20.
apps/e2e/tests/backend/endpoints/api/v1/auth/email-normalization.test.ts (1)
176-180: LGTM! Improved test reliability with subject-based message filtering.The change to use
waitForMessagesWithSubjectmakes the test more specific and reliable by directly filtering for the password reset email. The validation of message count and direct access tomessages[0]are both appropriate.Optional: Consider extracting the subject string to a constant.
The subject "Reset your password at New Project" is duplicated on lines 176 and 251. Extracting it to a constant would improve maintainability if the subject format changes.
Apply this refactor if desired:
+const PASSWORD_RESET_EMAIL_SUBJECT = "Reset your password at New Project"; + it("password reset should work with normalized email when account was created with unnormalized email", async ({ expect }) => { // ... setup code ... // Get the reset code from the mailbox - const messages = await backendContext.value.mailbox.waitForMessagesWithSubject("Reset your password at New Project"); + const messages = await backendContext.value.mailbox.waitForMessagesWithSubject(PASSWORD_RESET_EMAIL_SUBJECT);And apply the same change on line 251.
packages/stack-shared/src/utils/results.tsx (1)
367-385: Typedretry<T, E>correctly propagatesEintoRetryError<E>The new signature (
fnreturningResult<T, E>/Promise<Result<T, E>>, collectingconst errors: E[], and returningResult<T, RetryError<E>> & { attempts: number }) matches the implementation: success path returns a typedokresult withattempts, and the failure path wraps the collectedE[]innew RetryError(errors). Existing semantics (delay schedule, attempts counting) are preserved and tests cover first-try success, eventual success, and all-fail scenarios.If you ever expect
totalAttemptsto be0or negative, you might optionally guard that case (e.g., throw or early-return) to avoid constructing aRetryErrorwith an emptyerrorsarray andcause: undefined, but that’s preexisting behavior and not introduced by this change.apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts (1)
203-428: TODO block and/api/v1/send-emailusages to revisit when re-enabling digestThe remaining scenarios are correctly marked as
it.todowith clear TODO comments. They still hit/api/v1/send-email(rather than/api/v1/emails/send-email) and assume particular response shapes (e.g.{ "results": [] }), which may diverge from the outbox-backed flow over time.When you re-enable failed-emails digest, it would be good to:
- Double-check that
/api/v1/send-emailis still the right endpoint for these failure setups, or align them with/api/v1/emails/send-emailif that’s now canonical.- Reuse the
testFailedEmailshelper (once it wiresquerythrough) where possible to avoid duplicating “send failing email” setup across tests.No immediate breakage since these are todos, but worth keeping on the radar before switching them back to active tests.
apps/backend/src/prisma-client.tsx (3)
74-90: Keep Postgres client caches in sync with the legacy global storeThe new
postgresPrismaClientsStoreworks as a cache, butprismaClientsStore.postgres(the legacy map stored onglobalVar.__stack_prisma_clients) is no longer updated. If anything else in the codebase still relies on that legacy map, it will now see an empty cache.To preserve existing behavior and align with the Neon path, consider:
-const postgresPrismaClientsStore: Map<string, { - client: PrismaClient, - schema: string | null, -}> = globalVar.__stack_postgres_prisma_clients ??= new Map(); -function getPostgresPrismaClient(connectionString: string) { - let postgresPrismaClient = postgresPrismaClientsStore.get(connectionString); +const postgresPrismaClientsStore: Map<string, { + client: PrismaClient, + schema: string | null, +}> = globalVar.__stack_postgres_prisma_clients ??= new Map(); +function getPostgresPrismaClient(connectionString: string) { + let postgresPrismaClient = + postgresPrismaClientsStore.get(connectionString) ?? + prismaClientsStore.postgres.get(connectionString); if (!postgresPrismaClient) { const schema = getSchemaFromConnectionString(connectionString); const adapter = new PrismaPg({ connectionString }, schema ? { schema } : undefined); postgresPrismaClient = { client: new PrismaClient({ adapter }), schema, }; - postgresPrismaClientsStore.set(connectionString, postgresPrismaClient); + postgresPrismaClientsStore.set(connectionString, postgresPrismaClient); + prismaClientsStore.postgres.set(connectionString, postgresPrismaClient); } return postgresPrismaClient; }
92-127: Harden global connection resolution and verify top‑levelawaitsupportThe
tcpPing+ OrbStack probe is a nice dev-only optimization, but there are a couple of edge cases to tighten up:
originalGlobalConnectionStringis obtained viagetEnvVariable("STACK_DATABASE_CONNECTION_STRING", ""). If that env var is unset, you end up with"", which flows intogetPostgresPrismaClient(actualGlobalConnectionString)→getSchemaFromConnectionString("")→new URL(""), causing an opaqueInvalid URLat import time. It would be clearer to fail fast when the env var is missing instead of relying onnew URLto blow up. For example:-const originalGlobalConnectionString = getEnvVariable("STACK_DATABASE_CONNECTION_STRING", "");
+const originalGlobalConnectionString = getEnvVariable("STACK_DATABASE_CONNECTION_STRING");or explicitly throw if `originalGlobalConnectionString.trim().length === 0` before using it. - `actualGlobalConnectionString` is computed with a top‑level `await`. That requires this module to be compiled as an ES module / NodeNext and run in a runtime that supports top‑level `await`. Please double‑check the backend tsconfig and deployment target so this doesn’t introduce a subtle startup error in environments that still expect CJS-only modules. The rest of the OrbStack detection logic (short `tcpPing` timeout, darwin+development guard) looks reasonable. --- `26-36`: **Avoid keeping an extra unused global PrismaClient alive** With `globalPrismaClient` now sourced from `getPostgresPrismaClient(actualGlobalConnectionString)`, the `prismaClientsStore.global = new PrismaClient()` created at module init appears to be unused (at least within this file), yet still opens a DB connection and is stored on `globalVar.__stack_prisma_clients` in development. If nothing else in the repo relies on `globalVar.__stack_prisma_clients.global`, consider simplifying: - Remove the `global` field from `prismaClientsStore` entirely, or - Make `prismaClientsStore.global` a reference/alias to `globalPrismaClient` instead of a separate client. This avoids an unnecessary connection and clarifies which client is the canonical “global” one. Also applies to: 130-130 </blockquote></details> <details> <summary>apps/backend/src/route-handlers/smart-route-handler.tsx (1)</summary><blockquote> `105-113`: **Correct exclusion for long-running email queue endpoint.** The conditional correctly excludes the email queue step endpoint from the timeout watcher, which is appropriate for long-running background processing. Consider extracting the path to a constant to avoid typos and improve maintainability: ```diff +const EMAIL_QUEUE_STEP_PATH = "/api/latest/internal/email-queue-step"; + // request duration warning -if (req.nextUrl.pathname !== "/api/latest/internal/email-queue-step") { +if (req.nextUrl.pathname !== EMAIL_QUEUE_STEP_PATH) { const warnAfterSeconds = 12;apps/e2e/tests/js/email.test.ts (1)
174-225: Consider polling pattern when enabling this test.The test uses a fixed 5-second wait, which could be flaky. Consider using a polling pattern with a timeout instead:
// Wait for email delivery with polling let info; const maxAttempts = 20; for (let i = 0; i < maxAttempts; i++) { await wait(500); info = await serverApp.getEmailDeliveryStats(); if (info.stats.hour.sent > 0) { break; } } expect(info.stats.hour.sent).toBeGreaterThan(0);Also, the hardcoded snapshot values (like
ratePerSecond: 1.561904761904762) may be non-deterministic depending on timing. Consider asserting on structure and ranges rather than exact values when you enable this test.apps/backend/src/lib/notification-categories.ts (1)
5-5: Unsubscribe verification wiring looks correct; consider centralizing category lookup & validationThe switch to
unsubscribeLinkVerificationCodeHandler.createCodewithmethod: {}and adatapayload of{ user_id, notification_category_id }matches the handler’s schema and looks good.You now have both
getNotificationCategoryByNameandgetNotificationCategoryById; to keep behavior consistent withhasNotificationEnabled(which throws on invalid ids), consider:
- Reusing
getNotificationCategoryByIdinside bothhasNotificationEnabledandgenerateUnsubscribeLink.- Throwing a
StackAssertionErroringenerateUnsubscribeLinkwhen the id is unknown, so bad ids fail fast instead of producing unusable links.This removes the ad‑hoc
findcall and aligns all call sites on one source of truth for valid category ids.Also applies to: 29-31, 54-61
apps/e2e/tests/setup.ts (1)
1-2: Ensure callbacks array is cleared even if one cleanup callback throwsThe
afterEachhook correctly runs all registeredafterTestFinishesCallbacks, but if any callback rejects, the function returns early andafterTestFinishesCallbacks.length = 0is never executed. That can leak callbacks into later tests.You can preserve current behavior (fail fast on first error) while guaranteeing cleanup via
finally:-afterEach(async () => { - for (const callback of afterTestFinishesCallbacks) { - await callback(); - } - afterTestFinishesCallbacks.length = 0; -}); +afterEach(async () => { + try { + for (const callback of afterTestFinishesCallbacks) { + await callback(); + } + } finally { + afterTestFinishesCallbacks.length = 0; + } +});This keeps the lifecycle hook simple and avoids cross‑test contamination when a teardown step fails.
Also applies to: 13-18
apps/backend/src/app/api/latest/internal/failed-emails-digest/route.ts (1)
1-1: Failed‑emails digest sending is now deliberately disabledIn non‑
dry_runmode you now throwStackAssertionError("Failed emails digest is currently disabled!"), markanyDigestsFailedToSend = true, and capture the error. This cleanly disables actual digest sending while still surfacing failures via status 500 /success: false, which seems intentional for the outbox migration.Given this, you might consider short‑circuiting earlier (before building
emailHtml/ fetching email config) or guarding this whole block behind a feature flag to avoid doing unused work until the digest path is re‑enabled.Also applies to: 46-76
apps/backend/scripts/run-email-queue.ts (1)
12-21: Consider the operational implications of setInterval for a long-running process.This script uses
setIntervalto create a continuously-running process that calls the endpoint every 60 seconds. This approach requires:
- The script to run indefinitely without crashes
- Proper process management/restart on failures
- Resource allocation for a persistent process
Verify that your deployment environment supports long-running scripts and has appropriate monitoring/restart mechanisms in place.
apps/e2e/tests/backend/endpoints/api/v1/emails/delivery-info.test.ts (1)
69-74: The fixed 5-second wait makes the test timing-dependent and potentially flaky.The test uses a fixed 5-second wait assuming the background email queue processor will complete within that time. This approach can lead to:
- Flaky test failures if processing takes longer (e.g., under load, CI slowness)
- Unnecessarily slow tests if processing completes faster
Consider implementing a polling mechanism with timeout:
// Poll for updated stats with timeout const maxWaitMs = 10000; const pollIntervalMs = 500; let elapsed = 0; while (elapsed < maxWaitMs) { const response = await niceBackendFetch("/api/v1/emails/delivery-info", { method: "GET", accessType: "server", }); if (response.body.stats.hour.sent === 1) { break; } await wait(pollIntervalMs); elapsed += pollIntervalMs; }packages/template/src/lib/stack-app/email/index.ts (1)
33-54: Clarify naming convention and alignment with backend delivery‑stats typesThese new public template types use snake_case fields (
marked_as_spam,rate_per_second,penalty_factor), while other template‑level types here (e.g.AdminSentEmail) are camelCase, and the backend’s internalEmailDeliveryWindowStatsusesmarkedAsSpam(camelCase) inapps/backend/src/lib/email-delivery-stats.tsx. It would be good to:
- Decide whether template‑level types should consistently be camelCase (with the interface layer mapping to/from snake_case JSON), and
- Ensure these definitions stay structurally aligned with what
StackServerInterface.getEmailDeliveryInfo()and the/emails/delivery-inforoute actually return.If camelCase is preferred for consumer ergonomics, renaming these fields now is lower risk than changing them later once external usage grows.
apps/backend/prisma/migrations/20251020183000_migrate_sent_email/migration.sql (1)
1-112: Migration mapping looks consistent but relies on a few important behavioral assumptionsThe data migration from
"SentEmail"into"EmailOutbox"is generally well thought out (preserving HTML/text/subject, mapping error JSON into the new error fields, and marking legacy rows as already rendered/sent), but a few details are worth explicitly validating against the new outbox/queue semantics and schema:
Queue step behavior for migrated rows
- All migrated rows are marked
isQueued = TRUEand havestartedSendingAt/finishedSendingAtpopulated, withskippedReason = NULL. This should guarantee thatrunEmailQueueStepnever tries to re‑queue or re‑send these historical emails (especially ones withse."error" != NULL). Please confirm the queue selection query indeed excludes rows withisQueued = TRUEor non‑nullfinishedSendingAtso theLegacyEmailtsxSourceis never evaluated.
createdWithrepresentation
createdWithis hard‑coded to'PROGRAMMATIC_CALL'. In the new pipeline, fresh rows are written with{ type: "programmatic-call", templateId: ... }objects at the TypeScript level. Make sure the underlying Prisma/DB type for"createdWith"accepts this legacy string variant and that any consumers handling it treat unknown/legacy shapes defensively so historical entries don’t break UIs or stats.
tofield JSON shape
tois populated asjsonb_build_object('type', 'custom-emails', 'emails', COALESCE(to_jsonb(se."to"), '[]'::jsonb)). This is correct ifse."to"is already an array‑like value; if it’s a scalar address, this will yield a JSON string instead of a one‑element array. It’s worth confirming the oldSentEmail."to"column type and, if necessary, normalizing to always store an array for"emails".Legacy delivery stats interpretation
renderedIsTransactionalis set toTRUEandcanHaveDeliveryInfotoFALSEfor all migrated rows, with all delivery event timestamps leftNULL. Make sure this matches how the new delivery‑stats code aggregates data so that legacy sends are either intentionally excluded or included in a controlled way.None of these are blockers if the assumptions match the rest of the PR, but they’re subtle enough that I’d verify them against the queue‑step SQL and the Prisma schema before running this migration in production.
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (1)
30-30: Email delivery stats integration matches existing cache/hook patterns; consider refresh semanticsThe new
EmailDeliveryInfosurface is wired consistently with the rest of this class:
_emailDeliveryInfoCacheusescreateCache+getEmailDeliveryInfo()and storesResult<EmailDeliveryInfo>.getEmailDeliveryStats()correctly unwraps withResult.orThrow(...).useEmailDeliveryStats()usesuseAsyncCacheon the same cache with an empty dependency tuple, matching other “global” hooks.The only behavioral choice to be aware of is that this cache has no explicit invalidation or TTL; for a given
_StackServerAppImplIncompleteinstance, delivery stats will remain whatever they were on first fetch until you explicitly refresh or construct a new instance. If these stats are meant to update while a dashboard is open, you may eventually want:
- either a
refreshpath (e.g. method that calls_emailDeliveryInfoCache.refreshWhere(() => true)), or- to avoid caching entirely here and rely on the underlying interface for any desired memoization.
For now the implementation is correct and consistent; this is mainly about expectations for how “live” the stats should be.
Also applies to: 138-141, 1302-1310
apps/backend/src/app/api/latest/emails/delivery-info/route.tsx (1)
1-65: Delivery-info route looks correct; consider DRY-ing the stats schemaThe route wiring, auth requirements, and mapping from
EmailDeliveryStatsto the publicstats/capacityshape all look consistent and type-safe. Thewindowsarray also keeps the handler logic maintainable.If you end up adding/removing windows in the future, consider generating the
statsschema from the samewindowslist (or a shared type) to avoid the small duplication betweenwindowsand the hard-codedhour/day/week/monthshape in the yup response schema.apps/backend/src/app/api/latest/internal/emails/crud.tsx (2)
8-27: Recipient mapping and error derivation are reasonable; consider future UX foruser-primary-emailThe new
prismaModelToCrudlooks consistent with the EmailOutbox shape: you correctly handle the threetovariants and deriveerrorfrom render vs send error messages, while keepingsent_at_millisaligned withfinishedSendingAt ?? createdAt.One thing to keep in mind: for
type === "user-primary-email"you now surface"User ID: ${recipient.userId}"rather than an actual email address. If the existing admin UI or any tooling expects a literal email into, this could be a slight UX regression.If that becomes an issue, a possible follow-up would be to:
- Join to
ContactChannelfor the primary email in this case, or- Extend the InternalEmailsCrud read type with an explicit
recipientobject, keepingtoas a best-effort string list.For now, this mapping is acceptable as long as consumers only treat
toas display text rather than a strict email list.
38-46: Ordering bycreatedAtis simple; verify if you wantsent-centric ordering instead
onListcurrently orders bycreatedAtdescending, which is straightforward and likely mirrors the old SentEmail-based behavior.Given the detailed ordering rules documented on
EmailOutbox(finishedSendingAt,scheduledAtIfNotYetQueued,priority,id), consider whether this internal list should eventually use that same ordering (or at leastfinishedSendingAtwhen present) so that “recently sent” emails always float to the top, regardless of when they were created.Not urgent, but worth confirming with how the admin UI consumes this list.
apps/backend/src/lib/email-rendering.tsx (1)
236-346: Consider de-duplicating batched render logic across helpers
renderEmailsForTenancyBatchedis well-structured and correctly wires per-request template/theme modules and inputs, but it largely duplicates the bundling and Freestyle-execution logic fromrenderEmailsWithTemplateBatched(including nodeModules and error handling). Extracting a shared internal helper (e.g.executeFreestyleBundle(bundleText: string)) would reduce duplication and help keep behavior in sync if you need to tweak versions or error handling later.apps/backend/src/app/api/latest/emails/send-email/route.tsx (2)
63-65: Redundant env var check –getEnvVariablealready throws
getEnvVariable("STACK_FREESTYLE_API_KEY")will throw aStackAssertionErrorif the variable is missing, so theif (!getEnvVariable(...))condition is effectively unreachable for the “not set” case. If you want a cleanStatusError(500, ...)instead of a generic assertion error, consider explicitly catching that and re‑throwing as aStatusError; otherwise you can drop theifand just rely ongetEnvVariable’s failure mode.- if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) { - throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set"); - } + // Will throw if STACK_FREESTYLE_API_KEY is missing or empty + getEnvVariable("STACK_FREESTYLE_API_KEY");
19-26: Subject and notification category fields are currently ignoredThe new
subjectandnotification_category_namefields are accepted in the body schema but not yet forwarded into the outbox or rendering. The TODO comment notes this, but just to highlight: clients may assume they have an effect already.If you don’t plan to wire them in this PR, consider explicitly documenting them as “reserved/ignored for now” in the OpenAPI metadata to avoid confusion.
apps/backend/src/lib/email-delivery-stats.tsx (1)
38-96: Double‑check what counts as “sent” for penalty calculations
deliveryStatsQuerytreats an outbox row as “sent” when:
finishedSendingAtis within the window, andsendServerErrorInternalMessage IS NULLandskippedReason IS NULL.However, rows with only external send errors (
sendServerErrorExternalMessage/...Details) will still be counted as “sent_last_*, even though they are also classified asSERVER_ERRORin the generatedstatus`.If the capacity penalty is meant to react to actual delivery failures, you might want to exclude rows with any send server error from the sent counts (internal or external), or at least consider external errors too:
-- Example tweak inside each sent_last_* CASE AND "sendServerErrorInternalMessage" IS NULL AND "sendServerErrorExternalMessage" IS NULL AND "skippedReason" IS NULLNot necessarily a bug, but worth aligning with whatever semantics the UI and deliverability model are expecting from “sent”.
apps/backend/src/lib/email-queue-step.tsx (1)
475-479: Semantically incorrect skipped reason.
EmailOutboxSkippedReason.USER_DELETED_ACCOUNTis used in multiple scenarios:
- Line 477: when
custom-emailsrecipient has an empty email list- Line 484: when user is not found in the database
- Line 496: when resolved emails array is empty (user exists but has no email)
Only line 484 correctly represents an actual deleted account. The other cases could be:
- Empty custom email list: misconfiguration or API misuse
- User has no email: user data incomplete
Consider introducing more specific reasons like
NO_EMAIL_ADDRESSorINVALID_RECIPIENT_CONFIGfor better observability and debugging.Also applies to: 483-485, 495-497
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (57)
.vscode/settings.json(1 hunks)apps/backend/package.json(2 hunks)apps/backend/prisma/migrations/20251020180000_email_outbox/migration.sql(1 hunks)apps/backend/prisma/migrations/20251020183000_migrate_sent_email/migration.sql(1 hunks)apps/backend/prisma/schema.prisma(2 hunks)apps/backend/scripts/run-email-queue.ts(1 hunks)apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx(3 hunks)apps/backend/src/app/api/latest/auth/password/reset/verification-code-handler.tsx(2 hunks)apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx(1 hunks)apps/backend/src/app/api/latest/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx(1 hunks)apps/backend/src/app/api/latest/contact-channels/send-verification-code/route.tsx(1 hunks)apps/backend/src/app/api/latest/contact-channels/verify/verification-code-handler.tsx(2 hunks)apps/backend/src/app/api/latest/emails/README.md(1 hunks)apps/backend/src/app/api/latest/emails/delivery-info/route.tsx(1 hunks)apps/backend/src/app/api/latest/emails/render-email/route.tsx(2 hunks)apps/backend/src/app/api/latest/emails/send-email/route.tsx(5 hunks)apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx(1 hunks)apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx(1 hunks)apps/backend/src/app/api/latest/internal/emails/crud.tsx(2 hunks)apps/backend/src/app/api/latest/internal/failed-emails-digest/crud.tsx(4 hunks)apps/backend/src/app/api/latest/internal/failed-emails-digest/route.ts(2 hunks)apps/backend/src/app/api/latest/internal/send-sign-in-invitation/route.tsx(3 hunks)apps/backend/src/app/api/latest/internal/send-test-email/route.tsx(2 hunks)apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx(3 hunks)apps/backend/src/lib/email-delivery-stats.tsx(1 hunks)apps/backend/src/lib/email-queue-step.tsx(1 hunks)apps/backend/src/lib/email-queue.tsx(1 hunks)apps/backend/src/lib/email-rendering.test.tsx(1 hunks)apps/backend/src/lib/email-rendering.tsx(3 hunks)apps/backend/src/lib/emails-low-level.tsx(1 hunks)apps/backend/src/lib/emails.tsx(3 hunks)apps/backend/src/lib/notification-categories.ts(3 hunks)apps/backend/src/lib/upstash.tsx(1 hunks)apps/backend/src/prisma-client.tsx(3 hunks)apps/backend/src/route-handlers/smart-route-handler.tsx(1 hunks)apps/backend/src/utils/telemetry.tsx(1 hunks)apps/dashboard/tsconfig.json(1 hunks)apps/dev-launchpad/public/index.html(1 hunks)apps/e2e/tests/backend/backend-helpers.ts(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/auth/email-normalization.test.ts(2 hunks)apps/e2e/tests/backend/endpoints/api/v1/emails/delivery-info.test.ts(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/internal/email.test.ts(0 hunks)apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts(9 hunks)apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts(11 hunks)apps/e2e/tests/helpers.ts(3 hunks)apps/e2e/tests/js/email.test.ts(3 hunks)apps/e2e/tests/js/js-helpers.ts(2 hunks)apps/e2e/tests/setup.ts(2 hunks)docker/dependencies/docker.compose.yaml(3 hunks)package.json(1 hunks)packages/stack-shared/src/interface/crud/emails.ts(0 hunks)packages/stack-shared/src/interface/server-interface.ts(1 hunks)packages/stack-shared/src/utils/results.tsx(2 hunks)packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts(3 hunks)packages/template/src/lib/stack-app/apps/interfaces/server-app.ts(2 hunks)packages/template/src/lib/stack-app/email/index.ts(1 hunks)vercel.json(1 hunks)
💤 Files with no reviewable changes (2)
- packages/stack-shared/src/interface/crud/emails.ts
- apps/e2e/tests/backend/endpoints/api/v1/internal/email.test.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-11T04:13:19.308Z
Learnt from: N2D4
Repo: stack-auth/stack-auth PR: 943
File: examples/convex/app/action/page.tsx:23-28
Timestamp: 2025-10-11T04:13:19.308Z
Learning: In the stack-auth codebase, use `runAsynchronouslyWithAlert` from `stackframe/stack-shared/dist/utils/promises` for async button click handlers and form submissions instead of manual try/catch blocks. This utility automatically handles errors and shows alerts to users.
Applied to files:
apps/e2e/tests/js/email.test.tsapps/e2e/tests/helpers.tsapps/backend/src/app/api/latest/internal/failed-emails-digest/route.ts
🧬 Code graph analysis (39)
apps/backend/src/utils/telemetry.tsx (1)
packages/stack-shared/src/utils/telemetry.tsx (1)
traceSpan(13-27)
apps/e2e/tests/setup.ts (1)
apps/e2e/tests/helpers.ts (1)
afterTestFinishesCallbacks(14-14)
apps/backend/src/app/api/latest/emails/delivery-info/route.tsx (3)
apps/backend/src/route-handlers/smart-route-handler.tsx (1)
createSmartRouteHandler(211-296)packages/stack-shared/src/schema-fields.ts (5)
yupObject(247-251)serverOrHigherAuthTypeSchema(482-482)adaptSchema(330-330)yupString(187-190)yupNumber(191-194)apps/backend/src/lib/email-delivery-stats.tsx (2)
getEmailDeliveryStatsForTenancy(98-101)calculateCapacityRate(26-36)
apps/backend/src/lib/email-queue.tsx (3)
packages/stack-shared/src/utils/env.tsx (1)
getEnvVariable(16-58)apps/backend/src/lib/upstash.tsx (1)
upstash(6-9)packages/stack-shared/src/utils/errors.tsx (1)
captureError(126-134)
apps/backend/src/lib/notification-categories.ts (1)
apps/backend/src/app/api/latest/emails/unsubscribe-link/verification-handler.tsx (1)
unsubscribeLinkVerificationCodeHandler(5-15)
apps/e2e/tests/backend/backend-helpers.ts (2)
packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)packages/stack-shared/src/utils/errors.tsx (2)
StackAssertionError(69-85)throwErr(10-19)
apps/backend/src/route-handlers/smart-route-handler.tsx (2)
packages/stack-shared/src/utils/promises.tsx (2)
runAsynchronously(343-366)wait(260-268)packages/stack-shared/src/utils/errors.tsx (1)
captureError(126-134)
apps/e2e/tests/backend/endpoints/api/v1/auth/email-normalization.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
backendContext(35-57)
apps/backend/src/app/api/latest/internal/failed-emails-digest/crud.tsx (1)
apps/backend/src/lib/emails.tsx (1)
EmailOutboxRecipient(21-24)
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx (4)
apps/backend/src/route-handlers/smart-route-handler.tsx (1)
createSmartRouteHandler(211-296)packages/stack-shared/src/schema-fields.ts (4)
yupObject(247-251)yupString(187-190)yupNumber(191-194)yupBoolean(195-198)apps/backend/src/lib/email-queue-step.tsx (1)
runEmailQueueStep(28-47)packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
apps/e2e/tests/helpers.ts (1)
it(12-12)
apps/backend/src/app/api/latest/contact-channels/verify/verification-code-handler.tsx (3)
packages/stack-shared/src/interface/crud/users.ts (1)
UsersCrud(103-103)apps/backend/src/lib/tenancies.tsx (1)
getSoleTenancyFromProjectBranch(59-66)apps/backend/src/lib/emails.tsx (1)
sendEmailFromDefaultTemplate(71-94)
apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts (4)
apps/e2e/tests/backend/backend-helpers.ts (3)
backendContext(35-57)InternalProjectKeys(76-81)niceBackendFetch(108-172)packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)packages/stack-shared/src/utils/strings.tsx (1)
stringCompare(61-65)apps/e2e/tests/helpers.ts (1)
it(12-12)
apps/backend/src/app/api/latest/emails/render-email/route.tsx (1)
apps/backend/src/lib/email-rendering.tsx (1)
getEmailThemeForThemeId(28-37)
apps/backend/scripts/run-email-queue.ts (3)
packages/stack-shared/src/utils/env.tsx (1)
getEnvVariable(16-58)packages/stack-shared/src/utils/promises.tsx (1)
runAsynchronously(343-366)packages/stack-shared/src/utils/errors.tsx (1)
StackAssertionError(69-85)
apps/e2e/tests/backend/endpoints/api/v1/emails/delivery-info.test.ts (3)
apps/e2e/tests/helpers.ts (1)
it(12-12)apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch(108-172)packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)
apps/backend/src/lib/email-delivery-stats.tsx (2)
packages/template/src/lib/stack-app/email/index.ts (2)
EmailDeliveryWindowStats(33-37)EmailDeliveryStats(39-44)apps/backend/src/prisma-client.tsx (5)
RawQuery(271-275)RawQuery(277-343)PrismaClientTransaction(23-23)client(130-130)rawQuery(345-348)
apps/e2e/tests/js/email.test.ts (3)
apps/e2e/tests/helpers.ts (1)
it(12-12)apps/e2e/tests/js/js-helpers.ts (1)
createApp(41-93)packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)
apps/backend/src/app/api/latest/internal/send-test-email/route.tsx (2)
packages/stack-shared/src/utils/promises.tsx (1)
timeout(390-396)apps/backend/src/lib/emails-low-level.tsx (1)
lowLevelSendEmailDirectWithoutRetries(246-253)
apps/backend/src/lib/email-rendering.test.tsx (1)
apps/backend/src/lib/email-rendering.tsx (2)
RenderEmailRequestForTenancy(236-245)renderEmailsForTenancyBatched(247-346)
apps/e2e/tests/helpers.ts (2)
packages/stack-shared/src/utils/promises.tsx (2)
ignoreUnhandledRejection(228-230)wait(260-268)packages/stack-shared/src/utils/errors.tsx (1)
StackAssertionError(69-85)
apps/backend/src/lib/email-queue-step.tsx (15)
apps/backend/src/utils/telemetry.tsx (1)
withTraceSpan(7-11)apps/backend/src/lib/email-queue.tsx (1)
enqueueEmailQueueStep(10-27)packages/stack-shared/src/utils/errors.tsx (4)
captureError(126-134)StackAssertionError(69-85)throwErr(10-19)errorToNiceString(89-92)packages/stack-shared/src/utils/arrays.tsx (1)
groupBy(63-74)packages/stack-shared/src/utils/results.tsx (3)
error(36-41)Result(4-12)Result(26-56)apps/backend/src/lib/tenancies.tsx (2)
getTenancy(68-77)Tenancy(47-47)apps/backend/src/prisma-client.tsx (2)
getPrismaClientForTenancy(65-67)PrismaClientTransaction(23-23)packages/stack-shared/src/utils/json.tsx (1)
Json(3-9)apps/backend/src/lib/email-rendering.tsx (2)
getEmailThemeForThemeId(28-37)renderEmailsForTenancyBatched(247-346)apps/backend/src/lib/notification-categories.ts (2)
getNotificationCategoryById(29-31)generateUnsubscribeLink(54-65)packages/stack-shared/src/utils/objects.tsx (1)
filterUndefined(373-375)apps/backend/src/lib/email-delivery-stats.tsx (2)
getEmailDeliveryStatsForTenancy(98-101)calculateCapacityRate(26-36)apps/backend/src/lib/emails.tsx (2)
getEmailConfig(96-116)EmailOutboxRecipient(21-24)apps/backend/src/utils/vercel.tsx (1)
allPromisesAndWaitUntilEach(11-16)apps/backend/src/lib/emails-low-level.tsx (1)
lowLevelSendEmailDirectViaProvider(291-350)
apps/backend/src/app/api/latest/auth/password/reset/verification-code-handler.tsx (1)
apps/backend/src/lib/emails.tsx (1)
sendEmailFromDefaultTemplate(71-94)
apps/backend/src/lib/email-rendering.tsx (8)
apps/backend/src/lib/tenancies.tsx (1)
Tenancy(47-47)packages/stack-shared/src/utils/objects.tsx (2)
has(550-553)get(538-543)packages/stack-shared/src/utils/results.tsx (2)
Result(4-12)Result(26-56)packages/stack-shared/src/utils/env.tsx (1)
getEnvVariable(16-58)packages/stack-shared/src/utils/strings.tsx (1)
deindent(235-238)packages/stack-shared/src/utils/esbuild.tsx (1)
bundleJavaScript(43-203)apps/backend/src/lib/freestyle.tsx (1)
Freestyle(8-49)packages/stack-shared/src/utils/errors.tsx (2)
StackAssertionError(69-85)captureError(126-134)
apps/backend/src/lib/upstash.tsx (1)
packages/stack-shared/src/utils/env.tsx (1)
getNodeEnvironment(65-67)
apps/backend/src/app/api/latest/internal/send-sign-in-invitation/route.tsx (1)
apps/backend/src/lib/emails.tsx (1)
sendEmailFromDefaultTemplate(71-94)
apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx (1)
apps/backend/src/lib/emails.tsx (1)
sendEmailFromDefaultTemplate(71-94)
apps/backend/src/app/api/latest/internal/emails/crud.tsx (1)
packages/stack-shared/src/interface/crud/emails.ts (1)
InternalEmailsCrud(17-17)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (6)
packages/stack-shared/src/schema-fields.ts (2)
templateThemeIdSchema(532-532)yupRecord(283-322)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(65-67)packages/stack-shared/src/utils/errors.tsx (1)
throwErr(10-19)apps/backend/src/lib/email-rendering.tsx (1)
createTemplateComponentFromHtml(39-48)apps/backend/src/lib/email-drafts.tsx (2)
getEmailDraft(3-13)themeModeToTemplateThemeId(25-33)apps/backend/src/lib/emails.tsx (1)
sendEmailToMany(40-69)
packages/template/src/lib/stack-app/apps/interfaces/server-app.ts (1)
packages/template/src/lib/stack-app/email/index.ts (1)
EmailDeliveryInfo(51-54)
apps/backend/src/lib/emails-low-level.tsx (7)
packages/stack-shared/src/utils/results.tsx (3)
Result(4-12)Result(26-56)error(36-41)packages/stack-shared/src/utils/promises.tsx (2)
runAsynchronously(343-366)wait(260-268)packages/stack-shared/src/utils/errors.tsx (2)
captureError(126-134)StackAssertionError(69-85)packages/stack-shared/src/utils/objects.tsx (1)
pick(409-411)packages/stack-shared/src/utils/env.tsx (1)
getEnvVariable(16-58)apps/backend/src/utils/telemetry.tsx (1)
traceSpan(13-27)apps/backend/src/lib/tenancies.tsx (1)
getTenancy(68-77)
apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx (1)
packages/stack-shared/src/utils/errors.tsx (1)
StackAssertionError(69-85)
apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx (1)
apps/backend/src/lib/emails.tsx (1)
sendEmailFromDefaultTemplate(71-94)
apps/backend/src/lib/emails.tsx (6)
apps/backend/src/lib/tenancies.tsx (1)
Tenancy(47-47)apps/backend/src/utils/vercel.tsx (1)
runAsynchronouslyAndWaitUntil(5-9)apps/backend/src/lib/email-queue-step.tsx (1)
runEmailQueueStep(28-47)packages/stack-shared/src/interface/crud/users.ts (1)
UsersCrud(103-103)apps/backend/src/lib/email-rendering.tsx (1)
getEmailThemeForThemeId(28-37)apps/backend/src/lib/emails-low-level.tsx (1)
LowLevelEmailConfig(22-31)
apps/backend/src/prisma-client.tsx (2)
packages/stack-shared/src/utils/globals.tsx (1)
globalVar(8-8)packages/stack-shared/src/utils/env.tsx (2)
getEnvVariable(16-58)getNodeEnvironment(65-67)
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts (3)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
createCache(29-34)useAsyncCache(162-213)packages/template/src/lib/stack-app/email/index.ts (1)
EmailDeliveryInfo(51-54)packages/stack-shared/src/utils/results.tsx (2)
Result(4-12)Result(26-56)
packages/template/src/lib/stack-app/email/index.ts (1)
apps/backend/src/lib/email-delivery-stats.tsx (2)
EmailDeliveryWindowStats(4-8)EmailDeliveryStats(10-15)
apps/e2e/tests/js/js-helpers.ts (2)
packages/template/src/lib/stack-app/apps/interfaces/server-app.ts (2)
StackServerApp(18-104)StackServerApp(113-113)apps/e2e/tests/helpers.ts (1)
STACK_BACKEND_BASE_URL(297-297)
apps/backend/src/app/api/latest/internal/failed-emails-digest/route.ts (1)
packages/stack-shared/src/utils/errors.tsx (1)
StackAssertionError(69-85)
🪛 Biome (2.1.2)
apps/e2e/tests/backend/backend-helpers.ts
[error] 368-368: Unexpected constant condition.
(lint/correctness/noConstantCondition)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
- GitHub Check: Upload results
- GitHub Check: build (22.x)
- GitHub Check: setup-tests
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
- GitHub Check: docker
- GitHub Check: restart-dev-and-test-with-custom-base-port
- GitHub Check: lint_and_build (latest)
- GitHub Check: check_prisma_migrations (22.x)
- GitHub Check: all-good
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx
Outdated
Show resolved
Hide resolved
| export async function lowLevelSendEmailResendBatchedDirect(resendApiKey: string, emailOptions: LowLevelSendEmailOptions[]) { | ||
| if (emailOptions.length === 0) { | ||
| return Result.ok([]); | ||
| } | ||
| if (emailOptions.length > 100) { | ||
| throw new StackAssertionError("sendEmailResendBatchedDirect expects at most 100 emails to be sent at once", { emailOptions }); | ||
| } | ||
| if (emailOptions.some(option => option.tenancyId !== emailOptions[0].tenancyId)) { | ||
| throw new StackAssertionError("sendEmailResendBatchedDirect expects all emails to be sent from the same tenancy", { emailOptions }); | ||
| } | ||
| const tenancy = await getTenancy(emailOptions[0].tenancyId); | ||
| if (!tenancy) { | ||
| throw new StackAssertionError("Tenancy not found"); | ||
| } | ||
| const resend = new Resend(resendApiKey); | ||
| const result = await Result.retry(async (_) => { | ||
| const { data, error } = await resend.batch.send(emailOptions.map((option) => ({ | ||
| from: option.emailConfig.senderEmail, | ||
| to: option.to, | ||
| subject: option.subject, | ||
| html: option.html ?? "", | ||
| text: option.text, | ||
| }))); | ||
|
|
||
| if (data) { | ||
| return Result.ok(data.data); | ||
| } | ||
| if (error.name === "rate_limit_exceeded" || error.name === "internal_server_error") { | ||
| return Result.error(error); | ||
| } | ||
| return Result.ok(null); | ||
| }, 3, { exponentialDelayBase: 2000 }); | ||
|
|
||
| return result; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
Resend batch integration looks reasonable; confirm response shape
The Resend batching helper enforces a max of 100 emails, validates that all entries share a tenancy, and retries only on rate_limit_exceeded / internal_server_error, which matches typical provider semantics. Returning Result.ok(null) for other error types is conservative (treat as non‑retryable success from the caller’s perspective).
Since this depends on the Resend SDK’s { data, error } shape and error.name values, it’s worth double‑checking against the current Resend docs to ensure those names and properties are still accurate.
🌐 Web query:
Resend Node SDK: what is the response shape of `resend.batch.send(...)`, and which error `name` values are documented for rate limiting and internal server errors?
💡 Result:
Response shape
- resend.batch.send(...) returns an object with:
- data: array of { id: string } for each message sent
- errors: (only in permissive batch validation mode) array of per-item errors { index: number, message: string }. [1]
Documented error name values
- Rate limiting: "rate_limit_exceeded". [2]
- Internal server errors: "internal_server_error" (Resend also documents "application_error" for unexpected server errors). [2]
Sources
[1] Resend — Send Batch Emails (response example).
[2] Resend — Errors (error name list, includes rate_limit_exceeded and internal_server_error).
Fix response shape access in Resend batch handler
The code accesses data.data, but Resend's batch.send() returns data as an array of { id: string } for each message sent, not an object with a nested data property. This causes successful responses to fall through to Result.ok(null) instead of returning the actual sent message IDs.
Change line ~277 from:
return Result.ok(data.data);to:
return Result.ok(data);Additionally, Resend documents "application_error" as a possible error name for unexpected server errors, which should also be retried alongside the currently handled rate_limit_exceeded and internal_server_error.
🤖 Prompt for AI Agents
In apps/backend/src/lib/emails-low-level.tsx around lines 255 to 289, the Resend
batch response is handled incorrectly and an additional server error case should
be retried: change the successful-path to return the array returned by Resend
(use the returned data array instead of accessing data.data) so Result.ok
receives the sent-message IDs, and update the retry/error check to also consider
error.name === "application_error" alongside "rate_limit_exceeded" and
"internal_server_error" so those responses are retried.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
397-407: Bound the polling loop to satisfy linter.The
for (let i = 0; true; i++)loop has an infinite condition that triggers the Biome linter error, even though it breaks/throws inside. Additionally, the HTML body check needs a type guard to prevent.includes()being called on undefined.Apply this diff to bound the loop and add defensive checks:
- for (let i = 0; true; i++) { - const messages = await mailbox.fetchMessages(); - const containsSubstring = messages.some(message => message.subject.includes("Sign in to") && message.body?.html.includes(response.body.nonce)); - if (containsSubstring) { - break; - } - await wait(200); - if (i >= 30) { - throw new StackAssertionError(`Sign-in code message not found after ${i} attempts`, { messages }); - } - } + let lastMessages: Awaited<ReturnType<Mailbox["fetchMessages"]>> = []; + for (let i = 0; i < 30; i++) { + lastMessages = await mailbox.fetchMessages(); + const containsSubstring = lastMessages.some( + (message) => + message.subject.includes("Sign in to") && + typeof message.body?.html === "string" && + message.body.html.includes(response.body.nonce), + ); + if (containsSubstring) { + break; + } + await wait(200); + } + if (!lastMessages.some( + (message) => + message.subject.includes("Sign in to") && + typeof message.body?.html === "string" && + message.body.html.includes(response.body.nonce), + )) { + throw new StackAssertionError("Sign-in code message not found after 30 attempts", { messages: lastMessages }); + }
🧹 Nitpick comments (2)
apps/e2e/tests/backend/backend-helpers.ts (2)
422-428: Add defensive type guard for HTML body filtering.The
getSignInCodeFromMailboxfunction filters messages by nonce but doesn't guard againstmessage.body?.htmlbeing undefined or non-string before calling.includes().Apply this diff to add a type guard:
export async function getSignInCodeFromMailbox(nonce?: string) { const mailbox = backendContext.value.mailbox; const messages = await mailbox.fetchMessages(); - const message = messages.filter(message => nonce === undefined || message.body?.html.includes(nonce)).findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found"); + const message = messages + .filter(message => + nonce === undefined || + (typeof message.body?.html === "string" && message.body.html.includes(nonce)) + ) + .findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found"); const signInCode = message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Sign-in URL not found"); return signInCode; }
996-1008: Inconsistent assertion pattern.The
ProjectApiKey.User.createfunction changed from usingtoMatchInlineSnapshottotoMatchObject. While both work, this creates inconsistency with other helper functions in this file that use inline snapshots (e.g.,Team.createat line 1233).For consistency, consider either:
- Keeping the inline snapshot pattern used elsewhere in this file, or
- Documenting why
toMatchObjectis preferred here (e.g., if the response varies in ways that break snapshots)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
apps/e2e/tests/backend/backend-helpers.ts(5 hunks)apps/e2e/tests/backend/endpoints/api/v1/api-keys.test.ts(22 hunks)apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts(16 hunks)apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts(6 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (3)
niceBackendFetch(108-172)backendContext(35-57)bumpEmailAddress(178-186)
apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts (2)
packages/template/src/lib/stack-app/users/index.ts (1)
Auth(75-148)apps/e2e/tests/backend/backend-helpers.ts (2)
niceBackendFetch(108-172)backendContext(35-57)
apps/e2e/tests/backend/backend-helpers.ts (2)
packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)packages/stack-shared/src/utils/errors.tsx (2)
StackAssertionError(69-85)throwErr(10-19)
🪛 Biome (2.1.2)
apps/e2e/tests/backend/backend-helpers.ts
[error] 397-397: Unexpected constant condition.
(lint/correctness/noConstantCondition)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
- GitHub Check: check_prisma_migrations (22.x)
- GitHub Check: lint_and_build (latest)
- GitHub Check: all-good
- GitHub Check: build (22.x)
- GitHub Check: Vercel Agent Review
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
🔇 Additional comments (9)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (5)
5-5: LGTM!The new imports support the updated test patterns using centralized auth helpers and mailbox access through
backendContext.
120-154: Verify this test should remain skipped.The invalid notification category validation test is currently skipped. Since you're migrating to the email outbox backend, confirm whether this validation:
- Has been temporarily disabled during migration
- Needs to be re-implemented in the new outbox flow
- Is intentionally deferred
245-292: Complete mailbox verification flow for email outbox.This test is skipped and the PR notes indicate tests aren't passing yet. The test now uses
Auth.Password.signUpWithEmail()andbackendContext.value.mailbox.waitForMessagesWithSubject(), which is the correct pattern for the new email outbox backend.When re-enabling this test, ensure the email outbox worker is processing emails synchronously in tests or add appropriate delays/polling for async email delivery.
530-576: Complete broadcast email test for email outbox.This test correctly uses
bumpEmailAddress()to create separate mailboxes for each user and verifies that all users receive the broadcast email. The pattern looks sound, but the test is currently skipped.When re-enabling, verify that:
- The email outbox processes
all_users: truecorrectly by creating outbox entries for all users- The batch rendering and queuing logic handles multiple recipients properly
- Each mailbox receives its email independently
682-759: Verify notification category handling in email outbox.Both the Transactional and default category tests are skipped. Since notification categories affect email behavior (e.g., unsubscribe links, user preferences), ensure the email outbox backend:
- Preserves the notification category through the enqueue → render → send pipeline
- Applies category-specific logic (e.g., no unsubscribe link for Transactional)
- Respects user notification preferences per category
apps/e2e/tests/backend/backend-helpers.ts (2)
189-216: LGTM!The
fastSignUp()helper streamlines test setup by creating a user and server-side session in one call. This is cleaner than the multi-step OTP flow for tests that don't need to verify the full authentication flow.
1357-1395: No issues found -User.create()signature change is correctly implemented.Verification confirms that all 100+ call sites in the codebase already invoke
User.create()parameter-less. No code attempts to pass anemailAddressargument. Tests properly manage email addresses via the mailbox context (usingbumpEmailAddress()andbackendContext.value.mailbox.emailAddress) rather than relying onUser.create()parameters. The API response correctly shows anonymous users being created without email dependencies.apps/e2e/tests/backend/endpoints/api/v1/api-keys.test.ts (1)
12-12: LGTM!All tests correctly migrated from
Auth.Otp.signIn()toAuth.fastSignUp(). This streamlines test setup since API key tests don't need to verify the full OTP authentication flow. The mechanical replacement is consistent throughout the file.Also applies to: 79-79, 107-107, 236-236, 270-270, 298-298, 338-338, 399-399, 434-434, 438-438, 461-461, 529-529, 558-558, 661-661, 670-670, 718-718, 823-823, 830-830, 907-907, 963-963, 972-972, 999-999, 1008-1008, 1039-1039
apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts (1)
3-3: LGTM!The unsubscribe link tests are cleanly migrated to use:
Auth.Password.signUpWithEmail()for user creation with email addresses (needed for email delivery)backendContext.value.mailboxfor centralized mailbox accessuserIdfrom the signup response for API callsThis pattern is appropriate for these tests since they need to verify the full email delivery and unsubscribe flow.
Also applies to: 20-20, 27-27, 51-51, 67-67, 113-113, 120-120, 143-143
apps/backend/src/lib/email-queue.tsx
Outdated
| parallelism: 1, | ||
| rate: 1, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Bug: HTTP method mismatch in email queue enqueue
The enqueueEmailQueueStep function publishes to QStash with method: "POST", but the corresponding route handler in apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx only exports a GET handler. This mismatch means QStash will fail to invoke the endpoint, preventing the email queue from processing emails in production when using QStash.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (4)
apps/backend/prisma/schema.prisma (1)
758-759: FixisPausedcomment to match actual field semantics.The comment claims this column is auto-generated and cannot be set manually, but the field definition (
Boolean @default(false)) shows it's a plain mutable boolean flag. This contradicts other computed fields in the model (e.g.,status,simpleStatus,priority) which explicitly use@default(dbgenerated("<manually set>")).Since
isPausedshould be application-managed (e.g., to pause outbound email sending), the comment is incorrect and misleading. Update it to reflect thatisPausedis a mutable, application-managed flag.- // This column is auto-generated as defined in the SQL migration. It can not be set manually. + // Mutable flag managed by application code to pause/resume email sending. Can be set at any time. isPaused Boolean @default(false)docker/dependencies/docker.compose.yaml (1)
241-245: [Duplicate] Document tmpfs volume implications.The postgres-data volume uses tmpfs, which means all database state is lost when the container restarts. This is appropriate for development but should be clearly documented to prevent developer confusion when restarting Docker and losing test data.
apps/e2e/tests/backend/backend-helpers.ts (2)
423-429: Add type guard to prevent runtime error.Line 426 calls
.includes()onmessage.body?.htmlwithout verifying it's a string. This can throw a runtime error ifhtmlisundefinedor not a string.Apply this diff:
- const message = messages.filter(message => nonce === undefined || message.body?.html.includes(nonce)).findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found"); + const message = messages + .filter(message => + nonce === undefined || + (typeof message.body?.html === "string" && message.body.html.includes(nonce)) + ) + .findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found");Based on past review comments.
398-408: Address the linter error and add defensive type guards.This code still has the issues flagged in the previous review:
Infinite loop condition: Line 398 uses
for (let i = 0; true; i++)which Biome flags as a constant condition. Bound the loop explicitly withfor (let i = 0; i < 31; i++)to satisfy the linter.Missing type guard: Line 400 calls
.includes()onmessage.body?.htmlwithout verifying it's a string. Ifhtmlisundefinedor not a string, this will throw a runtime error.Apply this diff to fix both issues:
- for (let i = 0; true; i++) { + let lastMessages: Awaited<ReturnType<typeof mailbox.fetchMessages>> = []; + for (let i = 0; i < 31; i++) { - const messages = await mailbox.fetchMessages(); + lastMessages = await mailbox.fetchMessages(); - const containsSubstring = messages.some(message => message.subject.includes("Sign in to") && message.body?.html.includes(response.body.nonce)); + const containsSubstring = lastMessages.some( + (message) => + message.subject.includes("Sign in to") && + typeof message.body?.html === "string" && + message.body.html.includes(response.body.nonce) + ); if (containsSubstring) { break; } await wait(200); - if (i >= 30) { - throw new StackAssertionError(`Sign-in code message not found after ${i} attempts`, { messages }); - } } + if (!lastMessages.some( + (message) => + message.subject.includes("Sign in to") && + typeof message.body?.html === "string" && + message.body.html.includes(response.body.nonce) + )) { + throw new StackAssertionError("Sign-in code message not found after 30 attempts", { messages: lastMessages }); + }Based on past review comments.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
apps/backend/package.json(2 hunks)apps/backend/prisma/schema.prisma(2 hunks)apps/dev-launchpad/public/index.html(1 hunks)apps/e2e/tests/backend/backend-helpers.ts(5 hunks)docker/dependencies/docker.compose.yaml(3 hunks)packages/stack-shared/src/interface/server-interface.ts(1 hunks)packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/stack-shared/src/interface/server-interface.ts
- packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
- apps/backend/package.json
🧰 Additional context used
🧬 Code graph analysis (1)
apps/e2e/tests/backend/backend-helpers.ts (2)
packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)packages/stack-shared/src/utils/errors.tsx (2)
StackAssertionError(69-85)throwErr(10-19)
🪛 Biome (2.1.2)
apps/e2e/tests/backend/backend-helpers.ts
[error] 398-398: Unexpected constant condition.
(lint/correctness/noConstantCondition)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
- GitHub Check: check_prisma_migrations (22.x)
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
- GitHub Check: restart-dev-and-test-with-custom-base-port
- GitHub Check: setup-tests
- GitHub Check: lint_and_build (latest)
- GitHub Check: build (22.x)
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
🔇 Additional comments (6)
apps/backend/prisma/schema.prisma (4)
664-695: Well-designed status and metadata enums.The EmailOutboxStatus, EmailOutboxSimpleStatus, EmailOutboxSkippedReason, and EmailOutboxCreatedWith enums are clearly scoped and well-documented. They align well with the comprehensive status tracking and workflow design shown in the model comments below.
702-822: EmailOutbox model structure is comprehensive and well-documented.The model correctly uses a composite primary key
(tenancyId, id), maintains clear state transitions through detailed status comments (lines 725–742), and separates rendering, sending, and delivery tracking concerns. The generated columns (status, simpleStatus, priority, scheduledAtIfNotYetQueued, sentAt) are consistently marked with@default(dbgenerated("<manually set>")), and the field-level comments explain invariants clearly (e.g., lines 761, 764, 767, 787–789, 795, 804, 807, 812–814).The relation to Tenancy at line 819 is properly defined with cascading deletes.
824-831: EmailOutboxProcessingMetadata model is appropriately minimal.The model serves as a simple, global key–value store for tracking queue processor metadata (e.g.,
lastExecutedAt). The design is clean and aligns with the scheduled/cron-driven email processing pattern described in the PR objectives.
60-60: Tenancy.emailOutboxes relation properly connects multi-tenant emails.The relation cleanly establishes the link between tenancies and their outbox entries and supports querying all emails for a given tenant.
apps/e2e/tests/backend/backend-helpers.ts (2)
190-217: LGTM! Clean fast signup implementation.The new
fastSignUp()helper efficiently creates a user and server-side session in one flow, returning the necessary tokens. This is a good addition for streamlining test setup.
1358-1396: LGTM! Clean migration to server-side user creation.The refactored
User.create()now performs server-side user creation via the/api/v1/usersendpoint instead of the previous client-side password sign-up flow. This simplifies test setup and aligns with the newfastSignUp()helper.
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx
Outdated
Show resolved
Hide resolved
| html: htmlContent, | ||
| }); | ||
| } | ||
| throw new StackAssertionError("Credential scanning email is currently disabled!"); |
There was a problem hiding this comment.
Why can't we send email here?
There was a problem hiding this comment.
We totally could, I just didn't implement it since this endpoint is currently unused (and has been for a while)
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (1)
apps/backend/scripts/generate-openapi-fumadocs.ts (1)
82-86: Consider simplifying the error handler parameter.The error handler correctly ensures the script exits with a failure code. However,
.catch()receives a single error argument, so the...argsspread pattern is unconventional. Consider simplifying for clarity:-main().catch((...args) => { - console.error(`ERROR! Could not update Fumadocs OpenAPI schema`, ...args); +main().catch((error) => { + console.error(`ERROR! Could not update Fumadocs OpenAPI schema`, error); process.exit(1); });
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/backend/scripts/generate-openapi-fumadocs.ts(2 hunks)apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-registration/route.tsx(1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
- GitHub Check: build (22.x)
- GitHub Check: setup-tests
- GitHub Check: restart-dev-and-test-with-custom-base-port
- GitHub Check: all-good
- GitHub Check: lint_and_build (latest)
- GitHub Check: build (22.x)
- GitHub Check: check_prisma_migrations (22.x)
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
🔇 Additional comments (2)
apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-registration/route.tsx (1)
6-6: Good refactor to ES module import.The migration from CommonJS require to ES module import improves code consistency and aligns with modern JavaScript practices. The usage of
isoUint8Array.fromUTF8String()remains unchanged at lines 46 and 48, so this should be functionally equivalent.Please verify that the
@simplewebauthn/server/helpersexport path is supported by the version installed in your dependencies.Also applies to: 46-46, 48-48
apps/backend/scripts/generate-openapi-fumadocs.ts (1)
37-37: LGTM! Modernization to dynamic imports.The change from synchronous
require()to asynchronousawait import()properly modernizes the module loading to ES modules, aligning with the broader codebase updates in this PR.
| renderedText: output.text, | ||
| renderedSubject: output.subject ?? "", | ||
| renderedNotificationCategoryId: output.notificationCategory, | ||
| renderedIsTransactional: output.notificationCategory === "transactional", // TODO this should use smarter logic for notification category handling |
There was a problem hiding this comment.
Bug: Case sensitivity bug in transactional email detection
The code compares output.notificationCategory === "transactional" (lowercase) but the actual notification category name is "Transactional" (with capital T) as defined in notification-categories.ts. This causes renderedIsTransactional to always be set to false, even for transactional emails, which breaks the priority calculation and potentially affects email delivery behavior.
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx
Outdated
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx (1)
35-40: Long‑running 2‑minute loop is risky for serverless/cron executionThis loop can keep the route handler alive for up to 2 minutes, calling
runEmailQueueStepevery second. On typical serverless platforms (including Vercel route handlers/cron), this is close to or beyond common max execution times and can lead to the function being killed mid‑processing and overlapping invocations from the cron schedule.Consider instead:
- Having this endpoint invoke
runEmailQueueStep()once per request and relying on your scheduler/queue (cron/QStash) for repeated triggering, or- At least reducing the loop window substantially and/or enforcing a hard cap on invocations per request.
Example local change if you opt for single‑shot execution:
- const startTime = performance.now(); - - while (performance.now() - startTime < 2 * 60 * 1000) { - await runEmailQueueStep(); - await wait(1000); - } + await runEmailQueueStep();
🧹 Nitpick comments (1)
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx (1)
15-21: Request schema includes unusedauthfieldYou’re not using
authin the handler anymore, and the endpoint is authenticated purely via theAuthorizationheader. Keepingauth: yupObject({}).nullable().optional()here is slightly confusing; consider dropping it from the schema to better reflect how this endpoint is actually secured.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/emails/delivery-info.test.ts(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/e2e/tests/backend/endpoints/api/v1/emails/delivery-info.test.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx (6)
apps/backend/src/route-handlers/smart-route-handler.tsx (1)
createSmartRouteHandler(211-296)packages/stack-shared/src/schema-fields.ts (5)
yupObject(247-251)yupString(187-190)yupTuple(217-221)yupNumber(191-194)yupBoolean(195-198)packages/stack-shared/src/utils/env.tsx (1)
getEnvVariable(16-58)packages/stack-shared/src/utils/errors.tsx (1)
StatusError(152-261)apps/backend/src/lib/email-queue-step.tsx (1)
runEmailQueueStep(28-47)packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
- GitHub Check: docker
- GitHub Check: restart-dev-and-test-with-custom-base-port
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: lint_and_build (latest)
- GitHub Check: all-good
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: check_prisma_migrations (22.x)
🔇 Additional comments (2)
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx (2)
29-33: Cron secret validation pattern looks correctValidating
Authorization: Bearer ${CRON_SECRET}viagetEnvVariable("CRON_SECRET")and returning aStatusError(401, "Unauthorized")is consistent with how other internal/cron endpoints are typically secured. Just make sure the Vercel Cron job is configured to send this exact header (including theBearerprefix).
42-48: Response shape matches the declared schemaThe returned
{ statusCode: 200, bodyType: "json", body: { ok: true } }aligns with theresponseschema and is simple/robust for callers and monitoring.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/backend/src/prisma-client.tsx (1)
38-48: Fix cache inconsistency causing duplicate Prisma clients.Line 40 reads from
neonPrismaClientsStore, but line 45 writes to the oldprismaClientsStore.neon. This mismatch causes cache misses on every call, creating duplicatePrismaClientinstances for the same connection string, leading to memory leaks and potential connection pool exhaustion.Apply this diff to fix the cache inconsistency:
if (!neonPrismaClient) { const schema = getSchemaFromConnectionString(connectionString); const adapter = new PrismaNeon({ connectionString }, { schema }); neonPrismaClient = new PrismaClient({ adapter }); - prismaClientsStore.neon.set(connectionString, neonPrismaClient); + neonPrismaClientsStore.set(connectionString, neonPrismaClient); }
♻️ Duplicate comments (5)
apps/backend/src/prisma-client.tsx (1)
119-119: Remove unused variable.The
nowvariable is assigned but never used.Apply this diff:
const [, password, portPrefix, schema] = match; const orbStackDomain = `db.stack-dependencies-${portPrefix}.orb.local`; - const now = performance.now(); const ok = await tcpPing(orbStackDomain, 5432, 50); // extremely short timeout; OrbStack should be fast to respond, otherwise why are we doing this?apps/backend/src/lib/email-queue-step.tsx (3)
427-445: SetcanHaveDeliveryInfo: truefor successful sends.Line 438 sets
canHaveDeliveryInfo: falseeven when the email sends successfully. If your email provider supports delivery tracking (bounces, opens, etc.), this field should betruefor successful sends to enable downstream delivery status updates. The error case at line 420 correctly usesfalse.Apply this diff:
} else { await globalPrismaClient.emailOutbox.update({ where: { tenancyId_id: { tenancyId: row.tenancyId, id: row.id, }, finishedSendingAt: null, }, data: { finishedSendingAt: new Date(), - canHaveDeliveryInfo: false, + canHaveDeliveryInfo: true, sendServerErrorExternalMessage: null, sendServerErrorExternalDetails: Prisma.DbNull, sendServerErrorInternalMessage: null, sendServerErrorInternalDetails: Prisma.DbNull, }, }); }
374-379: Fix the isPrimary type check.Line 377 compares
isPrimary === "TRUE"(string comparison), but based on the Prisma schema and usage elsewhere in the codebase,isPrimaryis a boolean field. This bug causesgetPrimaryEmail()to always return undefined because the condition never matches. The eslint-disable comment on line 376 hints at this type mismatch.Apply this diff:
function getPrimaryEmail(user: ProjectUserWithContacts | undefined): string | undefined { if (!user) return undefined; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const primaryChannel = user.contactChannels.find((channel) => channel.type === "EMAIL" && channel.isPrimary === "TRUE"); + const primaryChannel = user.contactChannels.find((channel) => channel.type === "EMAIL" && channel.isPrimary === true); return primaryChannel?.value ?? undefined; }
237-241: Fix the case sensitivity bug and implement proper logic for determining if notification is transactional.Line 241 has a critical bug: it compares
output.notificationCategory(which is a UUID ID) against the lowercase string"transactional", which will always be false. The actual category name is"Transactional"(capitalized) and must be retrieved viagetNotificationCategoryById().Replace:
- renderedIsTransactional: output.notificationCategory === "transactional", // TODO this should use smarter logic for notification category handling + renderedIsTransactional: getNotificationCategoryById(output.notificationCategory)?.name === "Transactional" ?? false,apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx (1)
40-46: Revisit the 2-minute execution loop.Multiple reviewers flagged this loop as problematic for serverless environments (lines 40-46 in past comments). If Vercel Cron is invoking this endpoint regularly, the internal loop may be unnecessary—simply execute
runEmailQueueStep()once and exit. Theonly_one_stepparameter provides a testing escape hatch but doesn't address production concerns about exceeding typical serverless timeout limits.Consider removing the loop and relying on more frequent cron triggers instead:
- const startTime = performance.now(); - - while (performance.now() - startTime < 2 * 60 * 1000) { - await runEmailQueueStep(); - await wait(1000); - if (query.only_one_step === "true") { - break; - } - } + await runEmailQueueStep();Alternatively, reduce the loop duration to stay well within timeout limits (e.g., 30-50 seconds) if extended processing per invocation is essential.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
apps/backend/package.json(2 hunks)apps/backend/scripts/db-migrations.tsup.config.ts(1 hunks)apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx(1 hunks)apps/backend/src/lib/email-queue-step.tsx(1 hunks)apps/backend/src/prisma-client.tsx(3 hunks)apps/e2e/tests/backend/endpoints/api/v1/internal/email-queue-step.test.ts(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/backend/package.json
🧰 Additional context used
🧬 Code graph analysis (4)
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx (6)
apps/backend/src/route-handlers/smart-route-handler.tsx (1)
createSmartRouteHandler(211-296)packages/stack-shared/src/schema-fields.ts (5)
yupObject(247-251)yupString(187-190)yupTuple(217-221)yupNumber(191-194)yupBoolean(195-198)packages/stack-shared/src/utils/env.tsx (1)
getEnvVariable(16-58)packages/stack-shared/src/utils/errors.tsx (1)
StatusError(152-261)apps/backend/src/lib/email-queue-step.tsx (1)
runEmailQueueStep(27-44)packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)
apps/e2e/tests/backend/endpoints/api/v1/internal/email-queue-step.test.ts (2)
apps/e2e/tests/helpers.ts (1)
it(12-12)apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch(109-173)
apps/backend/src/lib/email-queue-step.tsx (14)
apps/backend/src/utils/telemetry.tsx (1)
withTraceSpan(7-11)packages/stack-shared/src/utils/errors.tsx (4)
captureError(126-134)StackAssertionError(69-85)throwErr(10-19)errorToNiceString(89-92)packages/stack-shared/src/utils/arrays.tsx (1)
groupBy(63-74)packages/stack-shared/src/utils/results.tsx (3)
error(36-41)Result(4-12)Result(26-56)apps/backend/src/lib/tenancies.tsx (2)
getTenancy(82-91)Tenancy(53-53)apps/backend/src/prisma-client.tsx (2)
getPrismaClientForTenancy(65-67)PrismaClientTransaction(23-23)packages/stack-shared/src/utils/json.tsx (1)
Json(3-9)apps/backend/src/lib/email-rendering.tsx (2)
getEmailThemeForThemeId(28-37)renderEmailsForTenancyBatched(247-346)apps/backend/src/lib/notification-categories.ts (2)
getNotificationCategoryById(29-31)generateUnsubscribeLink(54-65)packages/stack-shared/src/utils/objects.tsx (1)
filterUndefined(373-375)apps/backend/src/lib/email-delivery-stats.tsx (2)
getEmailDeliveryStatsForTenancy(98-101)calculateCapacityRate(26-36)apps/backend/src/lib/emails.tsx (2)
getEmailConfig(96-116)EmailOutboxRecipient(21-24)apps/backend/src/utils/vercel.tsx (1)
allPromisesAndWaitUntilEach(11-16)apps/backend/src/lib/emails-low-level.tsx (1)
lowLevelSendEmailDirectViaProvider(291-350)
apps/backend/src/prisma-client.tsx (2)
packages/stack-shared/src/utils/globals.tsx (1)
globalVar(8-8)packages/stack-shared/src/utils/env.tsx (2)
getEnvVariable(16-58)getNodeEnvironment(65-67)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
- GitHub Check: setup-tests
- GitHub Check: restart-dev-and-test-with-custom-base-port
- GitHub Check: check_prisma_migrations (22.x)
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: all-good
- GitHub Check: lint_and_build (latest)
🔇 Additional comments (13)
apps/backend/scripts/db-migrations.tsup.config.ts (1)
13-13: ESM format change is correct and fully verified.Verification confirms the migration script already uses pure ESM syntax and the project is configured for ESM. The
db-migrations.tsfile contains 10 import statements with no CommonJS patterns. This change is safe and aligns with the project's module configuration.apps/backend/src/prisma-client.tsx (1)
107-127: Verify top-level await doesn't block module initialization.The top-level
awaiton line 108 blocks module loading until the connection string is resolved. While thetcpPinghas a 50ms timeout and the result is cached inglobalVar, ensure that this pattern won't cause startup hangs or module load failures if the network check behaves unexpectedly (e.g., DNS resolution delays, network stack issues beyond TCP).Consider adding explicit timeout and error handling around the entire resolution IIFE:
let actualGlobalConnectionString: string = globalVar.__stack_actual_global_connection_string ??= await (async () => { try { if (getNodeEnvironment() === 'development' && process.platform === 'darwin') { const match = originalGlobalConnectionString.match(/^postgres:\/\/postgres:(.*)@localhost:(\d\d)28\/(.*)$/); if (match) { const [, password, portPrefix, schema] = match; const orbStackDomain = `db.stack-dependencies-${portPrefix}.orb.local`; const ok = await tcpPing(orbStackDomain, 5432, 50); if (ok) { return `postgres://postgres:${password}@${orbStackDomain}:5432/${schema}`; } } } } catch (error) { // Log but don't fail - fall back to original connection string console.warn('OrbStack optimization check failed:', error); } return originalGlobalConnectionString; })();apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx (1)
1-7: LGTM!Import statements are well-organized and all required dependencies are properly imported.
apps/e2e/tests/backend/endpoints/api/v1/internal/email-queue-step.test.ts (1)
1-70: LGTM!Test coverage is comprehensive, covering missing auth, invalid auth, and valid auth scenarios. The use of
only_one_step: "true"in the success test appropriately prevents test timeouts.apps/backend/src/lib/email-queue-step.tsx (9)
1-18: LGTM!Imports are comprehensive and the
MAX_RENDER_BATCHconstant provides reasonable batch size control.
20-44: LGTM!The orchestration flow is well-structured with proper tracing spans, and the comment about potential concurrent execution is helpful context.
46-64: LGTM!The 20-minute timeout for stuck renders is a good defensive mechanism with appropriate error capture.
104-135: LGTM!The claim-and-render strategy with
FOR UPDATE SKIP LOCKEDproperly handles concurrent workers, and the tenancy-based grouping is appropriate.
252-262: LGTM!The queueing logic correctly identifies emails that have finished rendering and are ready to send.
264-314: LGTM!The send plan preparation implements proper rate limiting with delivery statistics, and the stochastic quota handling is a clever approach to fractional rates. The priority-based claiming with
FOR UPDATE SKIP LOCKEDis correct.
316-372: LGTM!The batch processing setup is well-structured with proper context initialization and error handling.
467-504: LGTM!The recipient resolution logic properly handles different recipient types and edge cases like missing users or deleted accounts.
506-557: LGTM!The notification preference caching is well-implemented, and the skip handling correctly records reasons.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (8)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
19-20: Unused parameters flagged in previous review.The
subjectandnotification_category_nameparameters remain unused, as noted in the previous review comments. This continues to be confusing for API consumers who may expect these to work.apps/backend/src/lib/email-queue-step.tsx (7)
236-248: Verify case sensitivity in notification category matching.Line 236 matches
category.name === output.notificationCategory, where categories are defined with name "Transactional" (capital T). If templates returnnotificationCategory: "transactional"(lowercase), the lookup will fail andrenderedIsTransactionalwill always be false, breaking priority calculations.Run this script to check how templates define notification categories:
#!/bin/bash # Check notification category casing in templates rg -n "notificationCategory" apps/backend/src --type tsx -C2
365-370: VerifyisPrimarytype handling.The code compares
channel.isPrimary === "TRUE"(line 368), treating it as an enum string. However, the Prisma schema definesisPrimary BooleanTrue?whereBooleanTrueis an enum with a single valueTRUE. At runtime, Prisma enums are represented as strings, so the comparison is technically correct. However, verify this is consistent with howisPrimaryis handled elsewhere in the codebase.Run this script to check other uses of
isPrimary:#!/bin/bash # Check how isPrimary is used throughout the codebase rg -n "isPrimary" apps/backend/src --type ts --type tsx -C2
538-563: Document or remove unusedserializeRecipientexport.The
serializeRecipientfunction is exported but has no visible callers in this file. If it's part of a public API for external use, add a JSDoc comment explaining its purpose. Otherwise, consider removing the export.
72-72: Critical: metadata key mismatch breaks rate limiting.The key
"EMAIL_QUEUE_METADATA_KEY"doesn't match the migration, which inserts a row with key'email-queue-step'. The query will never find the existing metadata, causingdeltato always be 0 and breaking send-rate calculations.Apply this fix:
- const key = "EMAIL_QUEUE_METADATA_KEY"; + const key = "email-queue-step";
181-191: Major: unsubscribe links not generated during initial rendering.During the rendering phase,
row.renderedNotificationCategoryIdis NULL (it's only set at line 247 after rendering completes). This means unsubscribe links will never be generated during the initial render. The notification category is determined from the template output, creating a circular dependency.Consider generating unsubscribe links in a second pass after rendering completes, or require templates to declare their notification category upfront.
402-417: Add race condition check to error update.The error case update (lines 402-417) lacks a
finishedSendingAt: nullcheck in the WHERE clause, unlike the success case (line 425) and exception handler (line 445). This inconsistency could allow overwriting a row that was already marked as finished by another process.Apply this fix:
if (result.status === "error") { await globalPrismaClient.emailOutbox.update({ where: { tenancyId_id: { tenancyId: row.tenancyId, id: row.id, }, + finishedSendingAt: null, },
402-456:canHaveDeliveryInfois hardcoded tofalse.Lines 411, 429, and 449 all set
canHaveDeliveryInfo: falseregardless of the email provider. According to the schema comments, this should betruefor providers like Resend that support delivery tracking. Consider determining this value based oncontext.emailConfig.typeor a provider capability flag.
🧹 Nitpick comments (1)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
11-14: Remove unuseduser_emailfield from type definition.The
user_emailfield is defined in theUserResulttype but is never populated or returned in the response (lines 149-151 only returnuser_id). The response schema (lines 57-59) also only declaresuser_id.Apply this diff to simplify the type:
type UserResult = { user_id: string, - user_email?: string, };
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
apps/backend/prisma/migrations/20251020180000_email_outbox/migration.sql(1 hunks)apps/backend/prisma/schema.prisma(4 hunks)apps/backend/src/app/api/latest/emails/send-email/route.tsx(5 hunks)apps/backend/src/lib/email-queue-step.tsx(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/emails/delivery-info.test.ts(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/e2e/tests/backend/endpoints/api/v1/emails/delivery-info.test.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/backend/src/lib/email-queue-step.tsx (13)
apps/backend/src/utils/telemetry.tsx (1)
withTraceSpan(7-11)packages/stack-shared/src/utils/errors.tsx (4)
captureError(126-134)StackAssertionError(69-85)throwErr(10-19)errorToNiceString(89-92)packages/stack-shared/src/utils/arrays.tsx (1)
groupBy(63-74)packages/stack-shared/src/utils/results.tsx (3)
error(36-41)Result(4-12)Result(26-56)apps/backend/src/lib/tenancies.tsx (2)
getTenancy(82-91)Tenancy(53-53)apps/backend/src/prisma-client.tsx (2)
getPrismaClientForTenancy(65-67)PrismaClientTransaction(23-23)packages/stack-shared/src/utils/json.tsx (1)
Json(3-9)apps/backend/src/lib/email-rendering.tsx (2)
getEmailThemeForThemeId(28-37)renderEmailsForTenancyBatched(247-346)apps/backend/src/lib/notification-categories.ts (4)
getNotificationCategoryById(29-31)generateUnsubscribeLink(54-65)listNotificationCategories(8-23)hasNotificationEnabled(33-52)packages/stack-shared/src/utils/objects.tsx (1)
filterUndefined(373-375)apps/backend/src/lib/emails.tsx (2)
getEmailConfig(96-116)EmailOutboxRecipient(21-24)apps/backend/src/utils/vercel.tsx (1)
allPromisesAndWaitUntilEach(11-16)apps/backend/src/lib/emails-low-level.tsx (1)
lowLevelSendEmailDirectViaProvider(291-350)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (6)
packages/stack-shared/src/schema-fields.ts (4)
yupString(187-190)templateThemeIdSchema(548-548)yupBoolean(195-198)yupRecord(283-322)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(65-67)packages/stack-shared/src/utils/errors.tsx (1)
throwErr(10-19)apps/backend/src/lib/email-rendering.tsx (1)
createTemplateComponentFromHtml(39-48)apps/backend/src/lib/email-drafts.tsx (2)
getEmailDraft(3-13)themeModeToTemplateThemeId(25-33)apps/backend/src/lib/emails.tsx (1)
sendEmailToMany(40-69)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
🔇 Additional comments (7)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (7)
73-74: LGTM: High-priority flag properly extracted.The
isHighPriorityfield is correctly extracted with a sensible default and properly threaded through tosendEmailToMany.
77-78: LGTM: Variables properly extracted with default.The
variablesextraction logic correctly handles the optional field and defaults to an empty object when not provided.
80-109: LGTM: Content source resolution is correct.All three content sources (template, HTML, draft) are properly resolved with correct theme handling. The three-state theme logic (null for no theme, undefined for default, string for explicit theme) is correctly maintained across all branches.
111-118: LGTM: User ID collection simplified correctly.The user ID collection logic correctly handles both
all_usersanduser_idscases, selecting only the necessaryprojectUserIdfield for efficiency.
121-131: LGTM: Email sending correctly delegated to outbox.The
sendEmailToManycall properly consolidates all recipients into a single operation with correct parameter mapping. The theme resolution logic correctly handles all three states (null/undefined/explicit).
134-144: LGTM: Draft sentAt timestamp correctly updated.The draft update logic correctly uses the discriminated union to access
draftIdand properly updates thesentAttimestamp, preserving the existing behavior.
149-151: LGTM: Response simplified to user IDs only.The response correctly returns only
user_idfor each recipient, aligning with the simplified outbox-based flow and the updated response schema.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
apps/e2e/tests/backend/backend-helpers.ts (2)
423-427: Scope filter ingetSignInCodeFromMailboxis good; just ensure it matches the guarded HTML patternFiltering by
noncebefore picking the last “Sign in to” subject is exactly what we want for disambiguation. Once you add thetypeof message.body?.html === "string"guard as suggested above, this helper should be both precise and safe across different mailbox backends.
398-408: Bound the OTP polling loop and harden HTML checks to avoid lints and possible runtime errorsTwo issues to address here:
Infinite-style loop trips Biome and is harder to reason about
for (let i = 0; true; i++)with an in-loopif (i >= 30)is flagged by Biome and is effectively an unbounded loop. Use a bounded loop and throw after the loop instead, keeping the same semantics.Potential
.includeson undefined/non-string HTML
Both in the pollingsome(...)and ingetSignInCodeFromMailbox,message.body?.html.includes(...)can throw ifbodyexists buthtmlis missing or not a string. Add atypeofguard so.includesis only called on strings.A tighter version that addresses both problems would look like:
let lastMessages: Awaited<ReturnType<Mailbox["fetchMessages"]>> = []; for (let i = 0; i < 30; i++) { lastMessages = await mailbox.fetchMessages(); const containsSubstring = lastMessages.some( (message) => message.subject.includes("Sign in to") && typeof message.body?.html === "string" && message.body.html.includes(response.body.nonce), ); if (containsSubstring) { break; } await wait(200); } if ( !lastMessages.some( (message) => message.subject.includes("Sign in to") && typeof message.body?.html === "string" && message.body.html.includes(response.body.nonce), ) ) { throw new StackAssertionError("Sign-in code message not found after 30 attempts", { messages: lastMessages }); }And for
getSignInCodeFromMailbox:const message = messages .filter( (message) => nonce === undefined || (typeof message.body?.html === "string" && message.body.html.includes(nonce)), ) .findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found");This should clear the Biome error and make the helpers robust to slight variations in
Mailboxmessage shapes.Also applies to: 423-427
🧹 Nitpick comments (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
1348-1385: New server-sideUser.createhelper looks good; update the stale commentThe new
User.createcalling/api/v1/userswithaccessType: "server"and asserting the response body shape is clear and aligns with how the endpoint behaves, andcreateMultiplecorrectly composes it.The comment
// Create new mailboxat Line 1349 is now misleading, since no mailbox is created anymore. Suggest dropping or updating it to avoid confusion:- export async function create() { - // Create new mailbox + export async function create() {or replace with a brief description of what the helper actually does now.
Also applies to: 1389-1393
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/e2e/tests/backend/backend-helpers.ts(5 hunks)apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts(8 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts (2)
apps/e2e/tests/helpers.ts (2)
it(12-12)MailboxMessage(248-279)apps/e2e/tests/backend/backend-helpers.ts (2)
bumpEmailAddress(179-187)backendContext(35-57)
apps/e2e/tests/backend/backend-helpers.ts (2)
packages/stack-shared/src/utils/promises.tsx (1)
wait(260-268)packages/stack-shared/src/utils/errors.tsx (2)
StackAssertionError(69-85)throwErr(10-19)
🪛 Biome (2.1.2)
apps/e2e/tests/backend/backend-helpers.ts
[error] 398-398: Unexpected constant condition.
(lint/correctness/noConstantCondition)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Vercel Agent Review
- GitHub Check: Cursor Bugbot
🔇 Additional comments (5)
apps/e2e/tests/backend/backend-helpers.ts (4)
190-217: NewAuth.fastSignUphelper looks consistent and keeps auth state in syncThe flow (create user → create long-lived session → assert response shape → store tokens in
backendContext) is coherent and matches how other helpers manageuserAuth. This should simplify tests that just need a signed-in user without going through email flows.
416-421: Nonce-plumbedOtp.signInflow is a good tightening of the mailbox matchUsing
getSignInCodeFromMailboxscoped by thenoncefromsendSignInCodereduces flakiness when multiple sign-in emails exist in the mailbox, while keeping the rest of the flow unchanged.
997-1009: StricterUserAPI key creation expectations are reasonableAsserting the main shape of the
/api/v1/user-api-keyscreate response (ids, timestamps, flags, value) is helpful for catching regressions in the API contract and still usesexpect.objectContainingfor some flexibility.
1173-1189: SwitchingcreateAndGetAdminTokento useAuth.fastSignUpsimplifies setupUsing
Auth.fastSignUphere makes admin-token project creation independent of the OTP/mailbox flows and centralizes “create user + establish session” logic in one place. The subsequent expectation thatadminAccessTokenis defined still holds.apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts (1)
6-8: Tests intentionally disabled pending email notification re-enablement.The TODO comment clearly explains why these tests are skipped. Note that this temporarily reduces test coverage for credential scanning revocation flows.
apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts
Outdated
Show resolved
Hide resolved
| await globalPrismaClient.emailOutbox.update({ | ||
| where: { | ||
| tenancyId_id: { | ||
| tenancyId: row.tenancyId, | ||
| id: row.id, | ||
| }, | ||
| finishedSendingAt: null, | ||
| }, | ||
| data: { | ||
| finishedSendingAt: new Date(), | ||
| canHaveDeliveryInfo: false, | ||
| sendServerErrorExternalMessage: null, | ||
| sendServerErrorExternalDetails: Prisma.DbNull, | ||
| sendServerErrorInternalMessage: null, | ||
| sendServerErrorInternalDetails: Prisma.DbNull, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
The WHERE clause in the email update operations at lines 425 and 445 incorrectly includes finishedSendingAt: null as a filter condition. This causes the update to fail with a NotFoundError if finishedSendingAt is already set, preventing proper completion of email sending.
View Details
📝 Patch Details
diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx
index 13150677..f5b55576 100644
--- a/apps/backend/src/lib/email-queue-step.tsx
+++ b/apps/backend/src/lib/email-queue-step.tsx
@@ -422,7 +422,6 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO
tenancyId: row.tenancyId,
id: row.id,
},
- finishedSendingAt: null,
},
data: {
finishedSendingAt: new Date(),
@@ -442,7 +441,6 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO
tenancyId: row.tenancyId,
id: row.id,
},
- finishedSendingAt: null,
},
data: {
finishedSendingAt: new Date(),
Analysis
Incorrect WHERE clause in email update operations prevents completion of email sending
What fails: The processSingleEmail() function in apps/backend/src/lib/email-queue-step.tsx includes finishedSendingAt: null in the WHERE clause for update operations at lines 425 and 445. When finishedSendingAt is already set (due to concurrent processing, race conditions, or data state recovery), Prisma throws a NotFoundError ("Record to update not found"), preventing the email status from being properly recorded.
How to reproduce:
- An email is claimed by
claimEmailsForSending()(which setsstartedSendingAt) - Due to a race condition or data recovery scenario,
finishedSendingAtbecomes set on that email - The same email is processed by
processSingleEmail() - The update at line 425 or 445 attempts to match:
where: { tenancyId_id: {...}, finishedSendingAt: null } - No record matches this condition because
finishedSendingAtis already set - Prisma throws
PrismaClientKnownRequestError: "An operation failed because it depends on one or more records that were required but not found. Record to update not found."
Result: Email delivery status is never recorded, and the email remains stuck in processing state.
Expected: The update should succeed using only the composite key (tenancyId_id), which is the primary key and sufficient to uniquely identify the record. The finishedSendingAt: null check is:
- Logically redundant when the invariant is maintained (if
startedSendingAtis NULL, thenfinishedSendingAtshould be NULL) - Brittle against concurrent modifications or data state inconsistencies
- Inconsistent with other update paths (line 410 and 531 do not include this check)
Fix: Remove finishedSendingAt: null from the WHERE clauses at lines 425 and 445, relying only on the composite key tenancyId_id for record identification. This follows Prisma best practices for composite key updates.
e58e842 to
a6f1f04
Compare
|
Deployment failed with the following error: Learn More: https://vercel.com/docs/limits#rate-limits |
I'm sorry for whoever reviews this. I tried to split it up as much as possible so this is only the existing functionality migrated onto the new email outbox table; the new endpoints or UI changes are not here yet.
Tests not passing yet but already ready for reviews.
How to review:
The prompt that generated the first version (but note that the final version is somewhat different from what was generated, but it might help get some overview):
Note
Moves email sending to a new EmailOutbox with async render/queue/send, adds delivery metrics, internal queue step endpoint/runner, and updates APIs/SDK/tests accordingly.
EmailOutboxschema with statuses, constraints, indices; introduceEmailOutboxProcessingMetadata.SentEmailrows intoEmailOutboxand dropSentEmail.sendEmailToMany; add recipient modeling and scheduling/priority.runEmailQueueStep(render → queue → send) with capacity control and retries.emails-low-level) and batched Freestyle rendering (renderEmailsForTenancyBatched).GET /api/latest/emails/delivery-inforeturning per-window stats and capacity.GET /api/latest/internal/email-queue-stepfor cron-driven processing; add local runner script.EmailOutbox.getEmailDeliveryStats()and types; wire through server app caches.Written by Cursor Bugbot for commit e58e842. This will update automatically on new commits. Configure here.
Summary by CodeRabbit
New Features
Improvements
✏️ Tip: You can customize this high-level summary in your review settings.