Skip to content

Email outbox backend#1021

Closed
N2D4 wants to merge 86 commits intodevfrom
email-outbox-backend
Closed

Email outbox backend#1021
N2D4 wants to merge 86 commits intodevfrom
email-outbox-backend

Conversation

@N2D4
Copy link
Contributor

@N2D4 N2D4 commented Nov 19, 2025

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 most important file is schema.prisma. It contains the explanation of the new EmailOutbox table, and all its constraints.
  • After that, check the updated emails.tsx. It writes to the EmailOutbox table.
  • Then, check email-queue-step.tsx. It contains the logic that renders, queues, and sends an email (alongside emails-low-level.tsx). This is called once every ~second (in a Vercel cron job in prod or a script in dev) and whenever a new email is sent (see the callers of runEmailQueueStep for more info).
  • Then, look at the updated endpoints. Would love to have some eyes on the ones that @BilalG1 wrote in particular.
  • Finally, everything else. Everything else should be relatively straight-forward, I think, mostly migrations.

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):

i want you to implement a new email sending backend infrastructure. apps/backend is the right folder

let's start with the database. For this purpose, I've already created an EmailOutbox model in the prisma schema. Note that, for now, canHaveDeliveryInfo is always false (we never fetch delivery info rn). Note that the EmailOutbox is on the global database, not the source-of-truth database (which means it's not in the same database as the project users, for example, and can't have foreign key relations to those). You must create a new migration for this new schema; you will have to manually write the migration SQL for it (can't use the Prisma command). After that, create another subsequent migration that migrates from the old SentEmail table to the new one.

Create a file in `lib` that has functions for the email outbox for listing all emails (returning the database objects), editing an email, resetting an email, deleting an email (which should only work under the same conditions as the retry one). This file should be SUPER clean, make sure to have many explained/documented functions with concise docs.

You also want to update emails.tsx to use the new email outbox. The usual current email-sending functions should instead of sending the email directly, create an EmailOutbox row in the database instead. It should also queue the email-queue-step below onto QStash so that it can start queuing itself repeatedly (this should probably be exported as a function so. Keep the existing function implementations somewhere for your own reference until you've implemented the email queue step endpoint down below, as you'll need to implement the email queue step endpoint down below with them. Note that if you send from a templtae to many users, each user is its own row in the email outbox system. Feel free to update the signature of the send email functions to match the information that is required in EmailOutbox, just update all the callers.

Also, update the `email-rendering.tsx` file to export a new function that allows you to render many email templates/drafts/themes at once, as long as they are for the same tenancy ID. They should all be in a single call to Freestyle.

Note that difference between InternalError and ExternalError in the EmailOutbox schema. Internal errors contain all the information, but maybe have sensitive implementaiton-speciifc data that should not exposed to the end user who sent the email. External errors are guaranteed to be safe to expose to the end user who sent the email. There is already a similar handling that's implicit in the emails.tsx file, so take some of its logic and make it more explicit (for both rendering & sending server errors, make functions that take internal full error objects and returns an external error object, with just a string like "Internal server error" if it's an unknown error). 

Also, create the following endpoints (please look at other endpoints in the backend to see how they're implemented, and make sure the security is good):

- GET /api/latest/emails/delivery-info: This endpoint returns some stats on the emails. It should return the number of emails sent in the last hour, last day, last week, and last month, and how many of those emails (per hour, day, week, and month) were marked as spam or bounced. It should also return the "capacity rate": the number of emails that can be sent per second. It should be calculated like this: `max(max(<the number of emails sent in the last week> * 4, 200) * penaltyFactor, 5) / 7 / 60 / 60`, where penaltyFactor is a number between 0 and 1 depending on what % of emails were bounced or marked as spam (I will build a precise metric for this later, for now you can think about it and make your own).
- GET /api/latest/emails/outbox: List all emails in the outbox, paginated like the users endpoint. Should return a nice type for the individual rows which should at least contain the status and simple status, and probably some other useful fields that you think should be displayed to the user in the UI, but not all those from the database (if you're not sure whether it's needed, don't include it, we can include it later). The function which converts DB EmailOutbox objects to the API type should be separate from the other stuff as we will use it elsewhere too.
- PATCH /api/latest/emails/outbox/{id}: Edit an email in the outbox, if the conditions for it are met. Only some fields can be edited, see the Prisma schema for an explanation
- POST /api/latest/emails/outbox/{id}/cancel: Cancel an email in the outbox, if the conditions for it are met
- POST /api/latest/emails/outbox/{id}/retry: Retry an email in the outbox, if the conditions for it are met. See the Prisma schema for an explanation
- POST /api/latest/internal/email-queue-step: This is a QStash receiver that is constantly called by QStash in an infinite loop. Make sure that it is super clean and easy to understand. Feel free to split it up into multiple functions and files as needed.
- - First thing it should do is add a call to itself back into the QStash queue, with Flow Control with a flow control key equal to the URL, a rate limit of 1 per second, and a parallelism limit of 1.
- - Then, it should acquire a Postgres exclusive lock, to make sure that only one email-queue-step can be running at a time (make sure this is easy to understand and free of data races). It should be released automatically at the end of this endpoint (think about how we can make it that there's no deadlock if the endpoint fails for whatever reason, eg. the server it's running on dies).
- - Now, it should get the timestamp of the last execution of this endpoint (which is stored in some metadata table in the global database), and calculate the precise time delta in seconds. The new time should be updated in the metadata table. (Make sure that it's not negative, which can happen due to clock skew, so make sure to handle that by just not doing anything if the new time is before the last execution time).
- - Then, it should fetch all the email outbox rows that do not have a renderedByWorkerId, and set renderedByWorkerId to a random UUID and startedRenderingAt to now (it should do this in an atomic statement so that an email is never rendered by multiple workers at the same time). It should then use email-rendering.tsx to render the email, using the previously created batch-by-tenancy function (you need to group the email outbox rows by tenancy ID for this). Once done, it should (again, in a single atomic statement) check whether the renderedByWorkerId is still set to the same UUID (this is to make sure there are no race conditions), and if so, set the appropriate fields in the EmailOutbox row.
- - Next, it should run a SQL query that updates all rows with scheduledAt < now() and isQueued = false to set isQueued = true.
- - Now, it should fetch a deduplicated list of all tenancy IDs of all email outbox rows that have isQueued = true and startedSendingAt being null, and set startedSendingAt to now.
- - For each tenancy ID in the list:
- - - Calculate the capacity rate. Multiply it by the time delta since the last endpoint execution. Use stochastic rounding to round to the nearest integer.
- - - For that tenancy, fetch at most that many email outbox rows that have isQueued = true and startedSendingAt being null, and set startedSendingAt to now. Fetch those rows that have the highest priority (see the Prisma schema for an explanation).
- - - Then, asynchronously in parallel, process each email that needs to be sent. Use the email-sending functions to send the emails, and update finishedSendingAt and canHaveDeliveryInfo (recall it's always set to false for now) for each row when the email is sent. Before you send the email, check whether the user still exists in the DB, and use the same notification category logic from the send-email endpoint (extract it into a helper function) to find out which users have already unsubscribed and should not receive the email, and set skippedReason accordingly if necessary. If the email encountered an error while sending, set the sendServerError-ish fields in the same atomic statement. `waitUntil` each promise (to make sure that they continue running in the background). Ensure that a bug when handling one of the emails does not mess with the other emails. Don't use `.catch`, ever, use try-catch blocks, and use `captureError("<some-machine-readable-location-id>", ...)` to capture all unknown errors aggressively.


While you're at it, we now have some new features like scheduling, add those to the send-email endpoint.

Make sure to make VERY comprehensive E2E tests for ALL the edge cases.

The code should be ULTRA clean and NEVER repetitive, feel free to create helper functions and files as needed. Definitely make sure everything is clean and easy to understand. When there's some logic that is used in multiple places, create a helper function for it. In the end, write a human-readable README.md file in the email endpoint folder that explains how the email infrastructure works, meant to be read by a technical human (it should be VERY concise and understandable!)


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.

  • Database:
    • Add EmailOutbox schema with statuses, constraints, indices; introduce EmailOutboxProcessingMetadata.
    • Migrate legacy SentEmail rows into EmailOutbox and drop SentEmail.
  • Email pipeline:
    • Replace direct sends with outbox enqueue via sendEmailToMany; add recipient modeling and scheduling/priority.
    • Implement queue worker runEmailQueueStep (render → queue → send) with capacity control and retries.
    • Add low-level sending module (emails-low-level) and batched Freestyle rendering (renderEmailsForTenancyBatched).
  • APIs:
    • New GET /api/latest/emails/delivery-info returning per-window stats and capacity.
    • New GET /api/latest/internal/email-queue-step for cron-driven processing; add local runner script.
    • Update send flows (OTP, password reset, invitations, verification) to use default template enqueue and allow deliverability bypass.
    • Internal emails list now reads from EmailOutbox.
  • SDK:
    • Expose getEmailDeliveryStats() and types; wire through server app caches.
  • Tooling & ops:
    • Add Vercel cron for queue step; dev script to run queue; switch builds to ESM where needed; minor OpenAPI import fix.
    • Docker: add Drizzle Gateway; tweak Postgres init.
  • Tests:
    • Extensive E2E for delivery-info, queue step, API keys, email flows; adjust helpers for mailbox/waits.

Written by Cursor Bugbot for commit e58e842. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • New Features

    • Email delivery statistics endpoint showing sent, bounced, and marked-as-spam metrics across multiple time windows.
    • Background email queue processing system for asynchronous email delivery with rendering and sending orchestration.
    • High-priority email support for critical communications.
    • Email capacity tracking and rate limiting information.
  • Improvements

    • Simplified email sending API response structure.
    • Enhanced deliverability check configuration options.

✏️ Tip: You can customize this high-level summary in your review settings.

@N2D4 N2D4 requested review from BilalG1 and Copilot November 19, 2025 08:27
@vercel
Copy link

vercel bot commented Nov 19, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
stack-backend Error Error Nov 25, 2025 11:37am
stack-dashboard Ready Ready Preview Comment Nov 25, 2025 11:37am
stack-demo Ready Ready Preview Comment Nov 25, 2025 11:37am
stack-docs Error Error Nov 25, 2025 11:37am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 19, 2025

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

Refactors 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

Cohort / File(s) Summary
Prisma Schema & Migrations
apps/backend/prisma/schema.prisma, apps/backend/prisma/migrations/20251020180000_email_outbox/migration.sql, apps/backend/prisma/migrations/20251020183000_migrate_sent_email/migration.sql
Adds EmailOutbox model with status tracking, rendering/sending lifecycle fields, delivery metadata, and computed columns; defines associated enums and processing metadata table. Creates indexes for queue optimization. Migrates SentEmail data to EmailOutbox with field mapping and conditional error/rendering state preservation.
Email Core Infrastructure
apps/backend/src/lib/email-rendering.tsx, apps/backend/src/lib/email-rendering.test.tsx, apps/backend/src/lib/emails.tsx, apps/backend/src/lib/emails-low-level.tsx, apps/backend/src/lib/email-queue-step.tsx, apps/backend/src/lib/email-delivery-stats.tsx
Introduces batched email rendering with template/theme resolution; new EmailOutbox-based sendEmailToMany flow; low-level SMTP/provider sending with error typing and retry mechanics; comprehensive queue orchestration (enqueue, render, queue, send); and delivery stats aggregation with capacity calculation.
Email API Routes
apps/backend/src/app/api/latest/emails/*, apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx, apps/backend/src/app/api/latest/internal/send-test-email/route.tsx, apps/backend/src/app/api/latest/internal/failed-emails-digest/*, apps/backend/src/app/api/latest/internal/send-sign-in-invitation/route.tsx, apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx
Adds delivery-info endpoint for stats/capacity; adds email-queue-step cron handler; updates send-email to use outbox with simplified response; refactors failed-emails-digest and credential-scanning to use outbox; updates send-test-email to low-level path; modifies internal CRUD to map EmailOutbox.
Contact/Verification Handlers
apps/backend/src/app/api/latest/contact-channels/*/route.tsx, apps/backend/src/app/api/latest/auth/{otp,password}/*, apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx, apps/backend/src/app/api/latest/contact-channels/verify/verification-code-handler.tsx
Replaces sendEmailFromTemplate with sendEmailFromDefaultTemplate; adds shouldSkipDeliverabilityCheck parameter; updates verification code handlers to route through outbox-based email sending.
Configuration & Scripts
.vscode/settings.json, apps/backend/package.json, apps/backend/scripts/{run-email-queue.ts,generate-openapi-fumadocs.ts,db-migrations.tsup.config.ts}, vercel.json, docker/dependencies/docker.compose.yaml, package.json
Adds cron task for email-queue-step with "type": "module" in backend package.json; adds Drizzle Gateway service to docker-compose; updates Vercel crons; switches scripts to ESM imports; adds spellcheck entry for "dbgenerated".
Prisma Client & Core Utilities
apps/backend/src/prisma-client.tsx, apps/backend/src/route-handlers/smart-route-handler.tsx, apps/backend/src/utils/telemetry.tsx, apps/backend/src/lib/{notification-categories.ts,upstash.tsx}, apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-registration/route.tsx
Adds dynamic global connection string resolution with OrbStack optimization; introduces per-connection Prisma client caching; skips request-timeout watcher for email-queue-step; adds getNotificationCategoryById and updates unsubscribe link handler; switches CommonJS requires to ESM imports.
SDK & Public API Types
packages/stack-shared/src/interface/{crud/emails.ts,server-interface.ts}, packages/stack-shared/src/utils/results.tsx, packages/template/src/lib/stack-app/email/index.ts, packages/template/src/lib/stack-app/apps/{interfaces,implementations}/*
Removes sender_config from email CRUD schema; adds getEmailDeliveryInfo endpoint; introduces generic typed RetryError; adds EmailDeliveryInfo, EmailDeliveryStats, and related types to SDK; adds getEmailDeliveryStats and useEmailDeliveryStats to StackServerApp.
E2E Tests & Test Infrastructure
apps/e2e/tests/{backend/backend-helpers.ts,helpers.ts,setup.ts}, apps/e2e/tests/backend/endpoints/api/v1/{api-keys.test.ts,email-normalization.test.ts}, apps/e2e/tests/backend/endpoints/api/v1/{emails,internal,integrations,unsubscribe-link.test.ts,send-email.test.ts}, apps/e2e/tests/js/{email.test.ts,js-helpers.ts}, apps/dev-launchpad/public/index.html
Adds fastSignUp and getSignInCodeFromMailbox helpers; introduces afterTestFinishesCallbacks hook and mailbox.waitForMessagesWithSubject; updates tests to use new authentication flows and simplified email response shapes; adds delivery-info and email-queue-step tests; marks several tests as skipped; adds Drizzle Gateway to dev launchpad.

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()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Areas requiring extra attention:

  • Database schema and migrations (apps/backend/prisma/*): Complex enum definitions, generated stored columns with CASE logic, extensive CHECK constraints, and data migration with payload/recipient structure changes require careful validation of consistency rules and data transformation correctness.
  • Email queue orchestration (apps/backend/src/lib/email-queue-step.tsx): Intricate multi-stage pipeline with locking, tenancy grouping, rendering, queuing, and sending; error handling and recovery paths (e.g., retryEmailsStuckInRendering) need thorough review for race conditions and state consistency.
  • Email rendering and batching (apps/backend/src/lib/email-rendering.tsx): New batched rendering with per-request template/theme pairs and freestyle workflow aggregation; test suite coverage validates behavior but the in-memory project construction and serialization logic warrants close inspection.
  • Send-email route refactor (apps/backend/src/app/api/latest/emails/send-email/route.tsx): Significant simplification from per-user rendering to sendEmailToMany, change in response shape (removed user_email), and addition of is_high_priority and createdWith discriminators; verify all call sites and downstream dependencies are compatible.
  • Low-level email sending (apps/backend/src/lib/emails-low-level.tsx): New module with error typing, retry mechanics, Emailable verification, and Resend batching; intricate error mapping to errorType and canRetry flags requires validation of failure scenarios.
  • Prisma client dynamic resolution (apps/backend/src/prisma-client.tsx): New per-connection caching, OrbStack optimization, dynamic global connection string resolution at runtime; verify no connection leaks or inconsistent schema resolution across environments.
  • E2E test infrastructure changes (apps/e2e/tests/backend/backend-helpers.ts and others): Large refactoring of authentication helpers (fastSignUp, getSignInCodeFromMailbox with nonce filtering) and mailbox access patterns; many tests marked as skipped or significantly modified; verify test coverage and reliability of new helpers.
  • Type system changes (packages/stack-shared/src/utils/results.tsx): Generic typed RetryError changes the error typing contract; verify all call sites propagate and handle typed errors correctly.
  • API surface simplifications: send-email response shape, sender_config removal from CRUD, and new EmailDeliveryInfo types affect clients; ensure backward compatibility or versioning strategy is documented.

Possibly related PRs

  • test fixes #893: Modifies E2E test mailbox infrastructure (waitForMessagesWithSubject) and email test helpers, directly overlapping the changes to test helpers and mailbox message filtering in this PR.
  • batch sending #875: Refactors email rendering and sending code (email-rendering.tsx, emails.tsx, send-email route) to add batched rendering/sending capabilities, aligning with this PR's core rendering and queue infrastructure changes.
  • Remove legacy tenancy config #802: Refactors backend configuration access to centralize settings under tenancy.config and removes legacy tenancy config paths, complementing this PR's updates to email rendering, theming, and tenancy-scoped operations.

Poem

🐰 Hops through the outbox, rendering dreams so true,
Batches and queues and stats anew,
With capacity curves and retry delight,
The email pipeline takes flight!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Email outbox backend' is specific and directly summarizes the main change: migrating email sending to a durable outbox system.
Description check ✅ Passed The PR description provides comprehensive detail about changes, includes review guidance, and explains implementation intent and structure.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch email-outbox-backend

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3b758 and e58e842.

📒 Files selected for processing (2)
  • apps/backend/package.json (2 hunks)
  • apps/backend/src/route-handlers/smart-route-handler.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/backend/src/route-handlers/smart-route-handler.tsx
  • apps/backend/package.json
⏰ 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: build (22.x)
  • GitHub Check: Cursor Bugbot
  • GitHub Check: build (22.x)
  • GitHub Check: docker
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: setup-tests
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: all-good

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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-apps
Copy link
Contributor

greptile-apps bot commented Nov 19, 2025

Greptile Summary

  • Migrates email infrastructure from direct sending to queued EmailOutbox system with rendering, scheduling, and rate-limiting
  • Implements email queue processing with worker-based rendering, capacity calculation, and delivery tracking
  • Critical logic bug in email-queue-step.tsx:178 attempts to access renderedNotificationCategoryId before it's set, and capacity calculation formula deviates from specification

Confidence Score: 2/5

  • This PR has critical logical errors that will cause runtime failures and incorrect behavior in production
  • Score reflects three critical logic bugs: (1) accessing renderedNotificationCategoryId before it's set during rendering will break unsubscribe link generation, (2) capacity rate calculation formula completely deviates from specification, and (3) queue step endpoint architecture doesn't match the described QStash flow control pattern
  • Pay close attention to apps/backend/src/lib/email-queue-step.tsx, apps/backend/src/lib/email-delivery-stats.tsx, and apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx

Important Files Changed

Filename Overview
apps/backend/prisma/schema.prisma Added comprehensive EmailOutbox table with generated columns, constraints, and indexes for the new email infrastructure
apps/backend/src/lib/email-queue-step.tsx Core email queue processing logic with critical bug: attempts to access renderedNotificationCategoryId before it's set during rendering
apps/backend/src/lib/email-delivery-stats.tsx Email capacity calculation logic deviates significantly from the specified formula in the prompt
apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx Queue step endpoint loops internally for 2 minutes instead of using QStash flow control as specified
apps/backend/prisma/migrations/20251020183000_migrate_sent_email/migration.sql Migrates legacy SentEmail data to EmailOutbox; hardcodes all legacy emails as transactional which may be inaccurate

Sequence Diagram

sequenceDiagram
    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"
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

56 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile
React with 👍 or 👎 to share your feedback on this new summary format

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 EmailOutbox database 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 through query parameter and reduce flakiness in helper

testFailedEmails(query: any) currently ignores its query argument and always calls /failed-emails-digest with dry_run: "true". That makes the signature misleading and will break the planned non–dry-run test once it’s enabled. Also, the fixed wait(5_000) adds a hard 5s delay and can make e2e runs slower/more brittle than necessary.

Consider:

  • Passing the query argument through to niceBackendFetch and tightening its type.
  • Optionally normalizing tenant_owner_emails as well as emails to 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

getNeonPrismaClient reads from neonPrismaClientsStore but only writes to prismaClientsStore.neon. As a result, neonPrismaClientsStore stays empty and every call with a given connection string creates a fresh PrismaClient, defeating caching and potentially exhausting connections.

You probably want to both (a) reuse any legacy cached clients in prismaClientsStore.neon and (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: isRetryablePrismaError treats all Prisma known request errors as retryable

isRetryablePrismaError currently returns an array of codes for any Prisma.PrismaClientKnownRequestError and false otherwise:

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 PrismaClientKnownRequestError is 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 waitForMessagesWithSubject makes the test more specific and reliable by directly filtering for the password reset email. The validation of message count and direct access to messages[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: Typed retry<T, E> correctly propagates E into RetryError<E>

The new signature (fn returning Result<T, E>/Promise<Result<T, E>>, collecting const errors: E[], and returning Result<T, RetryError<E>> & { attempts: number }) matches the implementation: success path returns a typed ok result with attempts, and the failure path wraps the collected E[] in new 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 totalAttempts to be 0 or negative, you might optionally guard that case (e.g., throw or early-return) to avoid constructing a RetryError with an empty errors array and cause: 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-email usages to revisit when re-enabling digest

The remaining scenarios are correctly marked as it.todo with 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-email is still the right endpoint for these failure setups, or align them with /api/v1/emails/send-email if that’s now canonical.
  • Reuse the testFailedEmails helper (once it wires query through) 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 store

The new postgresPrismaClientsStore works as a cache, but prismaClientsStore.postgres (the legacy map stored on globalVar.__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‑level await support

The tcpPing + OrbStack probe is a nice dev-only optimization, but there are a couple of edge cases to tighten up:

  • originalGlobalConnectionString is obtained via getEnvVariable("STACK_DATABASE_CONNECTION_STRING", ""). If that env var is unset, you end up with "", which flows into getPostgresPrismaClient(actualGlobalConnectionString)getSchemaFromConnectionString("")new URL(""), causing an opaque Invalid URL at import time. It would be clearer to fail fast when the env var is missing instead of relying on new URL to 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 & validation

The switch to unsubscribeLinkVerificationCodeHandler.createCode with method: {} and a data payload of { user_id, notification_category_id } matches the handler’s schema and looks good.

You now have both getNotificationCategoryByName and getNotificationCategoryById; to keep behavior consistent with hasNotificationEnabled (which throws on invalid ids), consider:

  • Reusing getNotificationCategoryById inside both hasNotificationEnabled and generateUnsubscribeLink.
  • Throwing a StackAssertionError in generateUnsubscribeLink when the id is unknown, so bad ids fail fast instead of producing unusable links.

This removes the ad‑hoc find call 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 throws

The afterEach hook correctly runs all registered afterTestFinishesCallbacks, but if any callback rejects, the function returns early and afterTestFinishesCallbacks.length = 0 is 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 disabled

In non‑dry_run mode you now throw StackAssertionError("Failed emails digest is currently disabled!"), mark anyDigestsFailedToSend = 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 setInterval to 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 types

These 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 internal EmailDeliveryWindowStats uses markedAsSpam (camelCase) in apps/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-info route 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 assumptions

The 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 = TRUE and have startedSendingAt / finishedSendingAt populated, with skippedReason = NULL. This should guarantee that runEmailQueueStep never tries to re‑queue or re‑send these historical emails (especially ones with se."error" != NULL). Please confirm the queue selection query indeed excludes rows with isQueued = TRUE or non‑null finishedSendingAt so the LegacyEmail tsxSource is never evaluated.
  • createdWith representation

    • createdWith is 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.
  • to field JSON shape

    • to is populated as jsonb_build_object('type', 'custom-emails', 'emails', COALESCE(to_jsonb(se."to"), '[]'::jsonb)). This is correct if se."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 old SentEmail."to" column type and, if necessary, normalizing to always store an array for "emails".
  • Legacy delivery stats interpretation

    • renderedIsTransactional is set to TRUE and canHaveDeliveryInfo to FALSE for all migrated rows, with all delivery event timestamps left NULL. 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 semantics

The new EmailDeliveryInfo surface is wired consistently with the rest of this class:

  • _emailDeliveryInfoCache uses createCache + getEmailDeliveryInfo() and stores Result<EmailDeliveryInfo>.
  • getEmailDeliveryStats() correctly unwraps with Result.orThrow(...).
  • useEmailDeliveryStats() uses useAsyncCache on 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 _StackServerAppImplIncomplete instance, 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 refresh path (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 schema

The route wiring, auth requirements, and mapping from EmailDeliveryStats to the public stats/capacity shape all look consistent and type-safe. The windows array also keeps the handler logic maintainable.

If you end up adding/removing windows in the future, consider generating the stats schema from the same windows list (or a shared type) to avoid the small duplication between windows and the hard-coded hour/day/week/month shape 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 for user-primary-email

The new prismaModelToCrud looks consistent with the EmailOutbox shape: you correctly handle the three to variants and derive error from render vs send error messages, while keeping sent_at_millis aligned with finishedSendingAt ?? 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 in to, this could be a slight UX regression.

If that becomes an issue, a possible follow-up would be to:

  • Join to ContactChannel for the primary email in this case, or
  • Extend the InternalEmailsCrud read type with an explicit recipient object, keeping to as a best-effort string list.

For now, this mapping is acceptable as long as consumers only treat to as display text rather than a strict email list.


38-46: Ordering by createdAt is simple; verify if you want sent-centric ordering instead

onList currently orders by createdAt descending, 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 least finishedSendingAt when 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

renderEmailsForTenancyBatched is well-structured and correctly wires per-request template/theme modules and inputs, but it largely duplicates the bundling and Freestyle-execution logic from renderEmailsWithTemplateBatched (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 – getEnvVariable already throws

getEnvVariable("STACK_FREESTYLE_API_KEY") will throw a StackAssertionError if the variable is missing, so the if (!getEnvVariable(...)) condition is effectively unreachable for the “not set” case. If you want a clean StatusError(500, ...) instead of a generic assertion error, consider explicitly catching that and re‑throwing as a StatusError; otherwise you can drop the if and just rely on getEnvVariable’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 ignored

The new subject and notification_category_name fields 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

deliveryStatsQuery treats an outbox row as “sent” when:

  • finishedSendingAt is within the window, and
  • sendServerErrorInternalMessage IS NULL and skippedReason IS NULL.

However, rows with only external send errors (sendServerErrorExternalMessage / ...Details) will still be counted as “sent_last_*, even though they are also classified as SERVER_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 NULL

Not 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_ACCOUNT is used in multiple scenarios:

  • Line 477: when custom-emails recipient 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_ADDRESS or INVALID_RECIPIENT_CONFIG for better observability and debugging.

Also applies to: 483-485, 495-497

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3e7ba1d and 0faaaf0.

📒 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.ts
  • apps/e2e/tests/helpers.ts
  • apps/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

Comment on lines 255 to 289
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 getSignInCodeFromMailbox function filters messages by nonce but doesn't guard against message.body?.html being 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.create function changed from using toMatchInlineSnapshot to toMatchObject. While both work, this creates inconsistency with other helper functions in this file that use inline snapshots (e.g., Team.create at line 1233).

For consistency, consider either:

  1. Keeping the inline snapshot pattern used elsewhere in this file, or
  2. Documenting why toMatchObject is preferred here (e.g., if the response varies in ways that break snapshots)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0faaaf0 and 280942c.

📒 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() and backendContext.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: true correctly 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 an emailAddress argument. Tests properly manage email addresses via the mailbox context (using bumpEmailAddress() and backendContext.value.mailbox.emailAddress) rather than relying on User.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() to Auth.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.mailbox for centralized mailbox access
  • userId from the signup response for API calls

This 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

parallelism: 1,
rate: 1,
},
});
Copy link

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (4)
apps/backend/prisma/schema.prisma (1)

758-759: Fix isPaused comment 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 isPaused should be application-managed (e.g., to pause outbound email sending), the comment is incorrect and misleading. Update it to reflect that isPaused is 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() on message.body?.html without verifying it's a string. This can throw a runtime error if html is undefined or 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:

  1. Infinite loop condition: Line 398 uses for (let i = 0; true; i++) which Biome flags as a constant condition. Bound the loop explicitly with for (let i = 0; i < 31; i++) to satisfy the linter.

  2. Missing type guard: Line 400 calls .includes() on message.body?.html without verifying it's a string. If html is undefined or 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

📥 Commits

Reviewing files that changed from the base of the PR and between 280942c and fe0f404.

📒 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/users endpoint instead of the previous client-side password sign-up flow. This simplifies test setup and aligns with the new fastSignUp() helper.

html: htmlContent,
});
}
throw new StackAssertionError("Credential scanning email is currently disabled!");
Copy link
Contributor

Choose a reason for hiding this comment

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

Why can't we send email here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We totally could, I just didn't implement it since this endpoint is currently unused (and has been for a while)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 ...args spread 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

📥 Commits

Reviewing files that changed from the base of the PR and between fe0f404 and 137f40f.

📒 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/helpers export 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 asynchronous await 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
Copy link

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 execution

This loop can keep the route handler alive for up to 2 minutes, calling runEmailQueueStep every 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 unused auth field

You’re not using auth in the handler anymore, and the endpoint is authenticated purely via the Authorization header. Keeping auth: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 137f40f and 7612034.

📒 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 correct

Validating Authorization: Bearer ${CRON_SECRET} via getEnvVariable("CRON_SECRET") and returning a StatusError(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 the Bearer prefix).


42-48: Response shape matches the declared schema

The returned { statusCode: 200, bodyType: "json", body: { ok: true } } aligns with the response schema and is simple/robust for callers and monitoring.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 old prismaClientsStore.neon. This mismatch causes cache misses on every call, creating duplicate PrismaClient instances 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 now variable 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: Set canHaveDeliveryInfo: true for successful sends.

Line 438 sets canHaveDeliveryInfo: false even when the email sends successfully. If your email provider supports delivery tracking (bounces, opens, etc.), this field should be true for successful sends to enable downstream delivery status updates. The error case at line 420 correctly uses false.

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, isPrimary is a boolean field. This bug causes getPrimaryEmail() 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 via getNotificationCategoryById().

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. The only_one_step parameter 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7612034 and 22c922a.

📒 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.ts file 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 await on line 108 blocks module loading until the connection string is resolved. While the tcpPing has a 50ms timeout and the result is cached in globalVar, 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_BATCH constant 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 LOCKED properly 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 LOCKED is 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 subject and notification_category_name parameters 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 return notificationCategory: "transactional" (lowercase), the lookup will fail and renderedIsTransactional will 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: Verify isPrimary type handling.

The code compares channel.isPrimary === "TRUE" (line 368), treating it as an enum string. However, the Prisma schema defines isPrimary BooleanTrue? where BooleanTrue is an enum with a single value TRUE. At runtime, Prisma enums are represented as strings, so the comparison is technically correct. However, verify this is consistent with how isPrimary is 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 unused serializeRecipient export.

The serializeRecipient function 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, causing delta to 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.renderedNotificationCategoryId is 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: null check 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: canHaveDeliveryInfo is hardcoded to false.

Lines 411, 429, and 449 all set canHaveDeliveryInfo: false regardless of the email provider. According to the schema comments, this should be true for providers like Resend that support delivery tracking. Consider determining this value based on context.emailConfig.type or a provider capability flag.

🧹 Nitpick comments (1)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)

11-14: Remove unused user_email field from type definition.

The user_email field is defined in the UserResult type but is never populated or returned in the response (lines 149-151 only return user_id). The response schema (lines 57-59) also only declares user_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

📥 Commits

Reviewing files that changed from the base of the PR and between 22c922a and 1aabe14.

📒 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 isHighPriority field is correctly extracted with a sensible default and properly threaded through to sendEmailToMany.


77-78: LGTM: Variables properly extracted with default.

The variables extraction 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_users and user_ids cases, selecting only the necessary projectUserId field for efficiency.


121-131: LGTM: Email sending correctly delegated to outbox.

The sendEmailToMany call 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 draftId and properly updates the sentAt timestamp, preserving the existing behavior.


149-151: LGTM: Response simplified to user IDs only.

The response correctly returns only user_id for each recipient, aligning with the simplified outbox-based flow and the updated response schema.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
apps/e2e/tests/backend/backend-helpers.ts (2)

423-427: Scope filter in getSignInCodeFromMailbox is good; just ensure it matches the guarded HTML pattern

Filtering by nonce before picking the last “Sign in to” subject is exactly what we want for disambiguation. Once you add the typeof 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 errors

Two issues to address here:

  1. Infinite-style loop trips Biome and is harder to reason about
    for (let i = 0; true; i++) with an in-loop if (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.

  2. Potential .includes on undefined/non-string HTML
    Both in the polling some(...) and in getSignInCodeFromMailbox, message.body?.html.includes(...) can throw if body exists but html is missing or not a string. Add a typeof guard so .includes is 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 Mailbox message shapes.

Also applies to: 423-427

🧹 Nitpick comments (1)
apps/e2e/tests/backend/backend-helpers.ts (1)

1348-1385: New server-side User.create helper looks good; update the stale comment

The new User.create calling /api/v1/users with accessType: "server" and asserting the response body shape is clear and aligns with how the endpoint behaves, and createMultiple correctly composes it.

The comment // Create new mailbox at 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

📥 Commits

Reviewing files that changed from the base of the PR and between 1aabe14 and d44ef8e.

📒 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: New Auth.fastSignUp helper looks consistent and keeps auth state in sync

The flow (create user → create long-lived session → assert response shape → store tokens in backendContext) is coherent and matches how other helpers manage userAuth. This should simplify tests that just need a signed-in user without going through email flows.


416-421: Nonce-plumbed Otp.signIn flow is a good tightening of the mailbox match

Using getSignInCodeFromMailbox scoped by the nonce from sendSignInCode reduces flakiness when multiple sign-in emails exist in the mailbox, while keeping the rest of the flow unchanged.


997-1009: Stricter User API key creation expectations are reasonable

Asserting the main shape of the /api/v1/user-api-keys create response (ids, timestamps, flags, value) is helpful for catching regressions in the API contract and still uses expect.objectContaining for some flexibility.


1173-1189: Switching createAndGetAdminToken to use Auth.fastSignUp simplifies setup

Using Auth.fastSignUp here makes admin-token project creation independent of the OTP/mailbox flows and centralizes “create user + establish session” logic in one place. The subsequent expectation that adminAccessToken is 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.

Comment on lines 419 to 435
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,
},
});
Copy link

Choose a reason for hiding this comment

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

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:

  1. An email is claimed by claimEmailsForSending() (which sets startedSendingAt)
  2. Due to a race condition or data recovery scenario, finishedSendingAt becomes set on that email
  3. The same email is processed by processSingleEmail()
  4. The update at line 425 or 445 attempts to match: where: { tenancyId_id: {...}, finishedSendingAt: null }
  5. No record matches this condition because finishedSendingAt is already set
  6. 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 startedSendingAt is NULL, then finishedSendingAt should 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.

@BilalG1 BilalG1 closed this Nov 25, 2025
@BilalG1 BilalG1 force-pushed the email-outbox-backend branch from e58e842 to a6f1f04 Compare November 25, 2025 15:48
@vercel
Copy link

vercel bot commented Nov 25, 2025

Deployment failed with the following error:

Too many requests - try again in 3 minutes (more than 120, code: "api-deployments-flood-pro").

Learn More: https://vercel.com/docs/limits#rate-limits

@N2D4 N2D4 deleted the email-outbox-backend branch November 25, 2025 18:20
@N2D4 N2D4 restored the email-outbox-backend branch November 26, 2025 01:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants