Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@BilalG1
Copy link
Contributor

@BilalG1 BilalG1 commented Sep 2, 2025


Important

Implement batch email rendering and sending with enhanced processing and error handling.

  • Behavior:
    • Implement batch email rendering and sending in route.tsx using renderEmailsWithTemplateBatched() and sendEmailResendBatched().
    • Add per-recipient notification category resolution and unsubscribe link generation.
    • Support templates from IDs, raw HTML, or drafts with dynamic theme handling.
    • Enhanced result reporting, including users without primary emails.
  • Functions:
    • Add renderEmailsWithTemplateBatched() in email-rendering.tsx for batch email rendering.
    • Add sendEmailResendBatched() in emails.tsx for batch email sending.
    • Add getChunks() in arrays.tsx to split arrays into chunks.
  • Tests:
    • Add timed waits in send-email.test.ts and unsubscribe-link.test.ts to stabilize email delivery checks.
  • Dependencies:
    • Add @react-email/render and resend to package.json for email rendering and sending.

This description was created by Ellipsis for ff1dea6. You can customize this summary. It will automatically update as commits are pushed.


Review by RecurseML

🔍 Review performed on 3c34140..1267879

Severity Location Issue
Medium apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts:593 Asynchronous wait function not wrapped with runAsynchronously
Medium apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts:743 Asynchronous wait function not wrapped with runAsynchronously
✅ Files analyzed, no issues (4)

apps/backend/src/app/api/latest/emails/send-email/route.tsx
apps/backend/src/lib/email-rendering.tsx
apps/backend/src/lib/emails.tsx
packages/stack-shared/src/utils/arrays.tsx

⏭️ Files skipped (low suspicion) (2)

apps/backend/package.json
pnpm-lock.yaml

Need help? Join our Discord

Summary by CodeRabbit

  • New Features
    • Batched email rendering and sending with background processing, per-recipient category resolution and unsubscribe link handling, and support for templates from IDs, raw HTML, or drafts with dynamic themes. Enhanced result reporting includes users lacking primary emails.
  • Utilities
    • New array chunking and array-type guard helpers.
  • Chores
    • Added dependencies for email rendering and bulk sending.
  • Tests
    • Stabilized e2e email checks with timed waits.

@vercel
Copy link

vercel bot commented Sep 2, 2025

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

Project Deployment Preview Comments Updated (UTC)
stack-backend Ready Ready Preview Comment Sep 11, 2025 10:29pm
stack-dashboard Ready Ready Preview Comment Sep 11, 2025 10:29pm
stack-demo Ready Ready Preview Comment Sep 11, 2025 10:29pm
stack-docs Ready Ready Preview Comment Sep 11, 2025 10:29pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 2, 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

Adds batched email rendering and batched sending (Resend) to the backend email route, moves sending to asynchronous background work, introduces getChunks and array type guards, updates e2e tests with waits for async delivery, and adds dependencies for React Email rendering and Resend.

Changes

Cohort / File(s) Summary of changes
Dependencies
apps/backend/package.json
Adds @react-email/render (^1.2.1) and resend (^6.0.1) to dependencies.
Send-email API route
apps/backend/src/app/api/latest/emails/send-email/route.tsx
Replaces single-send flow with batched rendering/sending, per-user category resolution, unsubscribe link generation, async background processing, batching (BATCH_SIZE/getChunks), conditional Resend vs SMTP sending, and draft.sentAt updates; returns user results (email optional).
Email rendering (batched)
apps/backend/src/lib/email-rendering.tsx
Adds renderEmailsWithTemplateBatched that bundles and executes a single render pass for multiple inputs (uses STACK_FREESTYLE_API_KEY), returns html/text/subject/notificationCategory[]; retains single-email renderer.
Email sending (Resend batch)
apps/backend/src/lib/emails.tsx
Adds sendEmailResendBatched using Resend client with input validation, tenancy checks, up-to-3 retries with exponential backoff, batch send via resend.batch.send, and logging via prisma.sentEmail.createMany.
E2E tests: email & unsubscribe
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts, apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
Adds fixed waits (2–3s) before mailbox assertions to allow asynchronous delivery to complete.
Shared array utilities
packages/stack-shared/src/utils/arrays.tsx
Adds getChunks<T>, isNumberArray, isBooleanArray, isObjectArray and corresponding unit tests.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client
  participant API_Route as API Route
  participant Worker as Background Worker
  participant Renderer as Batched Renderer
  participant Sender as Email Sender
  participant ResendAPI as Resend API
  participant SMTP as SMTP Host
  participant DB as Prisma/DB

  Client->>API_Route: POST /emails/send
  API_Route->>API_Route: collect recipients, drafts, template/draft/theme, unsub info
  API_Route->>Worker: runAsynchronouslyAndWaitUntil(schedule batch work)
  API_Route-->>Client: respond with user results (emails may be undefined)

  Worker->>Renderer: renderEmailsWithTemplateBatched(inputs[])
  Renderer-->>Worker: [{html,text,subject,notificationCategory}...]

  Worker->>Worker: build per-user unsub links & emailOptions (chunked)
  alt host == smtp.resend.com
    Worker->>Sender: sendEmailResendBatched(apiKey, optionsBatch)
    Sender->>ResendAPI: batch.send(payload)
    ResendAPI-->>Sender: result / error
  else other SMTP
    Worker->>Sender: sendEmail (per-email Promise.allSettled)
    Sender->>SMTP: send per-email
    SMTP-->>Sender: results
  end

  Sender->>DB: prisma.sentEmail.createMany(logs)
  Worker->>DB: update draft.sentAt (if applicable)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • N2D4

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 "batch sending" is short and directly related to the PR's main change (adding batched email rendering and sending), so it satisfies the requirement to be concise and focused; however it is somewhat generic and could be clearer by explicitly mentioning "email" or the primary mechanism to aid future readers scanning the change history.
Description Check ✅ Passed The pull request description includes the repository template header and a detailed Ellipsis-generated summary that documents behavior changes, new functions, tests, and dependency updates, and it also includes the automated RecurseML review; this maps closely to the provided raw_summary and PR objectives and is sufficiently complete for reviewers to understand the changes.

Poem

A rabbit bundles bytes in moonlit stacks,
Hops through templates, stitches subjects, tracks.
Rendered carrots, resend on cue,
Async hops deliver mail to you.
Burrow hums — batch sent, paws through! 🥕✉️

✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch email-batch-sending

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

Greptile Summary

This PR implements a comprehensive batch email sending system to improve performance for bulk email operations. The changes introduce several key components working together:

Core Infrastructure Changes:

  • Adds a new getChunks utility function in packages/stack-shared/src/utils/arrays.tsx to split arrays into smaller chunks for batch processing
  • Introduces the Resend package (^6.0.1) as a new dependency to leverage Resend's native batch sending API

Email Processing Enhancements:

  • Implements renderEmailsWithTemplateBatched function in email-rendering.tsx that renders multiple emails in parallel using a single Freestyle execution, reducing API overhead
  • Adds sendEmailResendBatched function in emails.tsx that can send up to 100 emails in a single batch operation when using Resend's SMTP service
  • Maintains database logging for all sent emails with proper error tracking

API Route Refactoring:

  • Completely refactors the /api/latest/emails/send-email route to use batch processing instead of sequential email handling
  • Introduces asynchronous processing with runAsynchronouslyAndWaitUntil, allowing API responses to return immediately while emails are processed in the background
  • Implements intelligent routing that uses Resend's batch API for smtp.resend.com configurations and falls back to individual sends for other SMTP providers
  • Processes emails in 100-email chunks to balance performance with memory constraints and API limits

Test Infrastructure Updates:

  • Updates E2E tests to account for the asynchronous nature of email delivery by adding appropriate wait periods before verification

The system maintains backward compatibility while significantly improving throughput for bulk operations like newsletters, notifications, or user campaigns. The batched approach reduces the number of API calls to email services and leverages parallel processing for better performance.

Confidence score: 3/5

  • This PR introduces significant performance improvements but has several potential reliability issues that need attention
  • Score reflects complex architectural changes with insufficient error handling and validation in critical batch operations
  • Pay close attention to apps/backend/src/lib/emails.tsx and apps/backend/src/app/api/latest/emails/send-email/route.tsx

6 files reviewed, 7 comments

Edit Code Review Bot Settings | Greptile

@BilalG1 BilalG1 assigned N2D4 and unassigned BilalG1 Sep 2, 2025
Base automatically changed from email-drafts to dev September 10, 2025 22:57
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/backend/src/lib/emails.tsx (1)

83-89: Bug: verifying wrong address with Emailable.

Inside the loop you call Emailable with options.to instead of the current to. This breaks verification for multi-recipient sends.

-              const res = await fetch(`https://api.emailable.com/v1/verify?email=${encodeURIComponent(options.to as string)}&api_key=${emailableApiKey}`);
+              const res = await fetch(`https://api.emailable.com/v1/verify?email=${encodeURIComponent(to)}&api_key=${emailableApiKey}`);
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)

71-73: all_users path is not implemented; Prisma where uses an undefined in-clause

When all_users=true, the current query still sets projectUserId.in to undefined, which likely errors or returns nothing. Also, missingUserIds checks should only run for the user_ids path.

-    const users = await prisma.projectUser.findMany({
-      where: {
-        tenancyId: auth.tenancy.id,
-        projectUserId: {
-          in: body.user_ids
-        },
-      },
-      include: {
-        contactChannels: true,
-      },
-    });
-    const missingUserIds = body.user_ids?.filter(userId => !users.some(user => user.projectUserId === userId));
-    if (missingUserIds && missingUserIds.length > 0) {
-      throw new KnownErrors.UserIdDoesNotExist(missingUserIds[0]);
-    }
+    const users = await prisma.projectUser.findMany({
+      where: {
+        tenancyId: auth.tenancy.id,
+        ...(body.user_ids ? { projectUserId: { in: body.user_ids } } : {}),
+      },
+      include: { contactChannels: true },
+    });
+    if (body.user_ids) {
+      const missingUserIds = body.user_ids.filter(
+        (userId) => !users.some((user) => user.projectUserId === userId),
+      );
+      if (missingUserIds.length > 0) {
+        throw new KnownErrors.UserIdDoesNotExist(missingUserIds[0]);
+      }
+    }

Also applies to: 97-107, 108-111


84-86: Require subject for raw HTML emails

Without a subject, html path falls back to empty string, which harms deliverability. Make subject mandatory when html is used.

     } else if ("html" in body) {
       templateSource = createTemplateComponentFromHtml(body.html);
+      if (!body.subject) {
+        throw new KnownErrors.SchemaError("Subject is required when sending raw HTML emails");
+      }

255-265: Duplicate draft.sentAt update (inner + outer)

This causes two writes and can mark drafts as sent before background work completes. Keep only the inner update (after sending) for accurate status.

-    if ("draft_id" in body) {
-      await prisma.emailDraft.update({
-        where: {
-          tenancyId_id: {
-            tenancyId: auth.tenancy.id,
-            id: body.draft_id,
-          },
-        },
-        data: { sentAt: new Date() },
-      });
-    }
♻️ Duplicate comments (8)
apps/backend/src/lib/email-rendering.tsx (1)

179-180: Batch resilience: avoid all-or-nothing on render failures

If one render throws, Promise.all fails the whole batch. Consider Promise.allSettled with aggregated errors or partial successes.

Example keeping all-or-nothing but with better diagnostics:

-        return await Promise.all(inputs.map(renderOne));
+        const settled = await Promise.allSettled(inputs.map(renderOne));
+        const failures = settled
+          .map((r, i) => ({ r, i }))
+          .filter(x => x.r.status === "rejected");
+        if (failures.length) {
+          throw new Error(`renderAll failed for ${failures.length}/${inputs.length} inputs at indexes: ${failures.map(f => f.i).join(", ")}`);
+        }
+        return settled.map(r => (r as PromiseFulfilledResult<any>).value);

If you want partial success semantics, we can update the return type to include per-item status.

apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (2)

593-600: Broadcast test: poll for each user instead of sleeping.

This reduces timing-related flakes across three mailboxes.

The automated suggestion to wrap with runAsynchronously isn’t appropriate here—we need to await delivery before assertions. Polling is the right fit.

-    await wait(2000);
-    const messagesA = await userA.mailbox.fetchMessages();
-    const messagesB = await userB.mailbox.fetchMessages();
-    const messagesC = await userC.mailbox.fetchMessages();
-    expect(messagesA.find(m => m.subject === subject)).toBeDefined();
-    expect(messagesB.find(m => m.subject === subject)).toBeDefined();
-    expect(messagesC.find(m => m.subject === subject)).toBeDefined();
+    const waitForSubject = async (user: any) => {
+      for (let i = 0; i < 40; i++) {
+        const msgs = await user.mailbox.fetchMessages();
+        if (msgs.find(m => m.subject === subject)) return;
+        await wait(250);
+      }
+      throw new Error("Timed out waiting for email");
+    };
+    await Promise.all([waitForSubject(userA), waitForSubject(userB), waitForSubject(userC)]);
+    const messagesA = await userA.mailbox.fetchMessages();
+    const messagesB = await userB.mailbox.fetchMessages();
+    const messagesC = await userC.mailbox.fetchMessages();
+    expect(messagesA.find(m => m.subject === subject)).toBeDefined();
+    expect(messagesB.find(m => m.subject === subject)).toBeDefined();
+    expect(messagesC.find(m => m.subject === subject)).toBeDefined();

743-747: Same: poll mailbox instead of a fixed 2s sleep.

-    await wait(2000);
-    const messages = await user.mailbox.fetchMessages();
-    const sentEmail = messages.find(msg => msg.subject === "Transactional Test Subject");
-    expect(sentEmail).toBeDefined();
+    let sentEmail: any | undefined;
+    for (let i = 0; i < 40; i++) {
+      const msgs = await user.mailbox.fetchMessages();
+      sentEmail = msgs.find(m => m.subject === "Transactional Test Subject");
+      if (sentEmail) break;
+      await wait(250);
+    }
+    expect(sentEmail).toBeDefined();
apps/backend/src/lib/emails.tsx (1)

287-334: Don’t swallow batch send errors; include sender name in “from”; store full error object.

Returning ok(null) on non-429/500 errors hides failures from callers and logs only the message string, unlike sendEmailWithoutRetries. Also, set a proper “From” with sender name for deliverability.

 export async function sendEmailResendBatched(resendApiKey: string, emailOptions: SendEmailOptions[]) {
@@
   const result = await Result.retry(async (_) => {
     const { data, error } = await resend.batch.send(emailOptions.map((option) => ({
-      from: option.emailConfig.senderEmail,
-      to: option.to,
+      from: `"${option.emailConfig.senderName}" <${option.emailConfig.senderEmail}>`,
+      to: Array.isArray(option.to) ? option.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);
+    if (data) return Result.ok(data.data);
+    // Propagate all errors so callers can act on them; retry policy is governed by Result.retry.
+    return Result.error(error);
   }, 3, { exponentialDelayBase: 2000 });
 
   await prisma.sentEmail.createMany({
     data: emailOptions.map((options) => ({
       tenancyId: options.tenancyId,
       to: typeof options.to === 'string' ? [options.to] : options.to,
       subject: options.subject,
       html: options.html,
       text: options.text,
       senderConfig: omit(options.emailConfig, ['password']),
-      error: result.status === 'error' ? result.error.message : undefined,
+      error: result.status === 'error' ? result.error : undefined,
     })),
   });
   return result;
 }
apps/backend/src/app/api/latest/emails/send-email/route.tsx (4)

131-153: Don’t silently skip category pre-render failures; log the chunk and users

Silently continuing hides errors and defaults categories unexpectedly. Add logging.

-          if (rendered.status === "error") {
-            continue;
-          }
+          if (rendered.status === "error") {
+            console.warn("Category pre-render failed for chunk", {
+              error: rendered.error,
+              userIds: correspondingUsers.map(u => u.projectUserId),
+            });
+            continue;
+          }

203-211: Don’t silently skip final renders; log failures with affected users

Same concern as pre-render path—capture the error so failures are observable.

-        if (rendered.status === "error") {
-          continue;
-        }
+        if (rendered.status === "error") {
+          console.warn("Final render failed for chunk", {
+            error: rendered.error,
+            userIds: correspondingUsers.map(u => u.projectUserId),
+          });
+          continue;
+        }

225-229: Host check for Resend is brittle; also capture outcomes for both paths

Use a provider flag or a resilient host match, and log failures from both send paths.

-        if (emailConfig.host === "smtp.resend.com") {
-          await sendEmailResendBatched(emailConfig.password, emailOptions);
-        } else {
-          await Promise.allSettled(emailOptions.map(option => sendEmail(option)));
-        }
+        const isResend = /(^|\.)(resend\.com)$/i.test(emailConfig.host || "");
+        if (isResend) {
+          const batchResult = await sendEmailResendBatched(emailConfig.password, emailOptions);
+          if (batchResult.status === "error") {
+            console.warn("Resend batch send failed", { error: batchResult.error, size: emailOptions.length });
+          }
+        } else {
+          const outcomes = await Promise.allSettled(emailOptions.map((option) => sendEmail(option)));
+          const failures = outcomes.filter((o) => o.status === "rejected");
+          if (failures.length) {
+            console.warn("Some emails failed to send", { failures: failures.map((f) => (f as PromiseRejectedResult).reason) });
+          }
+        }

233-253: Wrap runAsynchronouslyAndWaitUntil body in try–catch

Uncaught errors will reject the background promise; capture them for visibility.

-    runAsynchronouslyAndWaitUntil((async () => {
+    runAsynchronouslyAndWaitUntil((async () => {
+      try {
         const usersArray = Array.from(userMap.values());
         // ...
         if ("draft_id" in body) {
           await prisma.emailDraft.update({
             // ...
           });
         }
-    })());
+      } catch (err) {
+        console.error("Batched email workflow failed", err);
+      }
+    })());
🧹 Nitpick comments (9)
packages/stack-shared/src/utils/arrays.tsx (1)

205-213: Guard against non-integer, non-finite, or tiny chunk sizes

size can be non-integer/NaN/Infinity. Today fractional sizes "work" via slice coercion but are surprising. Clamp to a positive integer and early-return for invalid values.

Apply:

 export function getChunks<T>(arr: readonly T[], size: number): T[][] {
-  const result: T[][] = [];
-  if (size <= 0) return result;
-  for (let i = 0; i < arr.length; i += size) {
-    result.push(arr.slice(i, i + size));
-  }
-  return result;
+  const result: T[][] = [];
+  if (!Number.isFinite(size)) return result;
+  const chunkSize = Math.trunc(size);
+  if (chunkSize <= 0) return result;
+  for (let i = 0; i < arr.length; i += chunkSize) {
+    result.push(arr.slice(i, i + chunkSize));
+  }
+  return result;
 }
apps/backend/src/lib/email-rendering.tsx (1)

198-201: Version drift between sandboxed nodeModules and repo deps

Sandbox uses React 19.1.1 while the app depends on 19.0.0. Minor React deltas can affect JSX/runtime semantics. Recommend aligning to repo versions or centralizing these in one place.

apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts (2)

51-55: Replace fixed sleep with polling to deflake mailbox checks.

A hard 2s sleep can still be flaky under load. Poll the mailbox with a short interval and a reasonable timeout.

-  await wait(2000);
-  const messages = await user.mailbox.fetchMessages();
-  const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
+  let sentEmail: any | undefined;
+  for (let i = 0; i < 40; i++) {
+    const msgs = await user.mailbox.fetchMessages();
+    sentEmail = msgs.find(m => m.subject === "Custom Test Email Subject");
+    if (sentEmail) break;
+    await wait(250);
+  }

145-150: Same here: poll instead of sleeping.

-  await wait(2000);
-  const messages = await user.mailbox.fetchMessages();
-  const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
+  let sentEmail: any | undefined;
+  for (let i = 0; i < 40; i++) {
+    const msgs = await user.mailbox.fetchMessages();
+    sentEmail = msgs.find(m => m.subject === "Custom Test Email Subject");
+    if (sentEmail) break;
+    await wait(250);
+  }
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (2)

297-301: Avoid fixed sleep; poll mailbox until the email arrives.

Prevents flakes and keeps tests fast locally.

-  await wait(2000);
-  const messages = await user.mailbox.fetchMessages();
-  const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
+  let sentEmail: any | undefined;
+  for (let i = 0; i < 40; i++) {
+    const msgs = await user.mailbox.fetchMessages();
+    sentEmail = msgs.find(m => m.subject === "Custom Test Email Subject");
+    if (sentEmail) break;
+    await wait(250);
+  }

406-410: Same: replace 3s sleep with polling.

-  await wait(3000);
-  const messages = await user.mailbox.fetchMessages();
-  const sentEmail = messages.find(m => m.subject === "Overridden Subject");
+  let sentEmail: any | undefined;
+  for (let i = 0; i < 40; i++) {
+    const msgs = await user.mailbox.fetchMessages();
+    sentEmail = msgs.find(m => m.subject === "Overridden Subject");
+    if (sentEmail) break;
+    await wait(250);
+  }
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)

65-67: Avoid calling getEnvVariable for presence checks

getEnvVariable throws if the var is missing; this branch won’t run. Use process.env to ensure a consistent 500 with your message.

-    if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
-      throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
-    }
+    if (!process.env.STACK_FREESTYLE_API_KEY) {
+      throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
+    }

162-168: N+1 preference checks

hasNotificationEnabled is called per user; consider batching preferences for large sends to cut DB roundtrips.


216-223: Subject fallback can be empty

When neither body.subject nor template Subject is present, subject becomes "". Consider enforcing a non-empty subject before sending.

Would you like me to update the schema so subject is required for the html variant and ensure a non-empty fallback for template/draft variants?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5da45d8 and d85d66c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • apps/backend/package.json (2 hunks)
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx (2 hunks)
  • apps/backend/src/lib/email-rendering.tsx (2 hunks)
  • apps/backend/src/lib/emails.tsx (2 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (6 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts (3 hunks)
  • packages/stack-shared/src/utils/arrays.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.test.{ts,tsx,js}

📄 CodeRabbit inference engine (AGENTS.md)

In tests, prefer .toMatchInlineSnapshot where possible; refer to snapshot-serializer.ts for snapshot formatting and handling of non-deterministic values

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Prefer ES6 Map over Record when representing key–value collections

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
  • packages/stack-shared/src/utils/arrays.tsx
  • apps/backend/src/lib/emails.tsx
  • apps/backend/src/lib/email-rendering.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx
apps/backend/src/app/api/latest/**

📄 CodeRabbit inference engine (AGENTS.md)

apps/backend/src/app/api/latest/**: Organize backend API routes by resource under /api/latest (e.g., auth at /api/latest/auth/, users at /api/latest/users/, teams at /api/latest/teams/, oauth providers at /api/latest/oauth-providers/)
Use the custom route handler system in the backend to ensure consistent API responses

Files:

  • apps/backend/src/app/api/latest/emails/send-email/route.tsx
🧬 Code graph analysis (5)
apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts (1)
packages/stack-shared/src/utils/promises.tsx (1)
  • wait (260-268)
apps/backend/src/lib/emails.tsx (6)
packages/template/src/lib/stack-app/email/index.ts (1)
  • SendEmailOptions (19-31)
packages/stack-shared/src/utils/results.tsx (3)
  • Result (4-12)
  • Result (26-56)
  • error (36-41)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
apps/backend/src/lib/tenancies.tsx (1)
  • getTenancy (68-77)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (64-66)
packages/stack-shared/src/utils/objects.tsx (1)
  • omit (421-424)
apps/backend/src/lib/email-rendering.tsx (3)
packages/stack-shared/src/utils/env.tsx (1)
  • getEnvVariable (16-58)
packages/stack-shared/src/utils/esbuild.tsx (1)
  • bundleJavaScript (43-203)
apps/backend/src/lib/freestyle.tsx (1)
  • Freestyle (8-48)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
packages/stack-shared/src/utils/promises.tsx (1)
  • wait (260-268)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (7)
apps/backend/src/lib/notification-categories.ts (2)
  • getNotificationCategoryByName (25-27)
  • hasNotificationEnabled (29-48)
packages/stack-shared/src/utils/arrays.tsx (1)
  • getChunks (206-213)
apps/backend/src/lib/email-rendering.tsx (1)
  • renderEmailsWithTemplateBatched (129-203)
apps/backend/src/app/api/latest/emails/unsubscribe-link/verification-handler.tsx (1)
  • unsubscribeLinkVerificationCodeHandler (5-15)
packages/stack-shared/src/utils/env.tsx (1)
  • getEnvVariable (16-58)
apps/backend/src/lib/emails.tsx (2)
  • sendEmailResendBatched (287-333)
  • sendEmail (335-376)
apps/backend/src/utils/vercel.tsx (1)
  • runAsynchronouslyAndWaitUntil (5-9)
⏰ 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). (9)
  • GitHub Check: Security Check
  • GitHub Check: restart-dev-and-test
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: all-good
  • GitHub Check: setup-tests
  • GitHub Check: build (22.x)
  • GitHub Check: docker
🔇 Additional comments (10)
apps/backend/package.json (1)

64-64: New deps: verify @react-email/render usage and server-only Resend

  • apps/backend/package.json adds "@react-email/render" (line 64) but there are no imports of "@react-email/render" — code imports render/components from "@react-email/components" (e.g. apps/backend/src/lib/email-rendering.tsx, packages/stack-shared/src/helpers/emails.ts, e2e tests). Either remove the explicit @react-email/render dep or align imports to @react-email/render and ensure it's only required server-side (avoid bundling into client).
  • "resend" is used server-side (apps/backend/src/lib/emails.tsx imports Resend; sendEmailResendBatched is called from apps/backend/src/app/api/latest/emails/send-email/route.tsx). The batch sender enforces the 100-email limit — confirm it also has explicit retry/backoff, timeouts, and idempotency handling (or add them) to avoid duplicate sends or unbounded retries.
apps/backend/src/lib/email-rendering.tsx (1)

141-142: PII risk: embedding inputs directly into the script

serializedInputs inlined into the bundled script may get logged by the sandbox on errors. If Freestyle logs code payloads, this could leak user data/unsubscribe links.

  • Confirm Freestyle’s logging/redaction policy.
  • If needed, consider passing inputs through a supported “data”/stdin channel or encrypting/redacting sensitive fields before embedding.
apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts (1)

1-1: Import looks good.

Using the shared wait util is consistent with the rest of the suite.

apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)

1-1: Import looks good.

Shared wait util is fine for test polling.

apps/backend/src/lib/emails.tsx (1)

13-13: Resend import: LGTM.

apps/backend/src/app/api/latest/emails/send-email/route.tsx (5)

121-125: Results mapping looks good

Returning users without primary emails while excluding them from sending is a sensible API design.


126-126: Good use of chunking to control load

BATCH_SIZE=100 aligns with Resend batch limits and keeps rendering memory bounded.

Also applies to: 200-202


169-190: Unsubscribe link generation LGTM

Per-user links with can_disable gating and verification code are handled correctly.


80-84: Good use of ES6 Map per guidelines

Maps for templates and user lookups are appropriate.

Also applies to: 112-118


225-226: Verify Resend API key is stored in emailConfig.password

apps/backend/src/lib/emails.tsx uses auth.pass = emailConfig.password (apps/backend/src/lib/emails.tsx:131–136) and the send-email route passes emailConfig.password into sendEmailResendBatched (apps/backend/src/app/api/latest/emails/send-email/route.tsx:225–226). I did not find where EmailConfig.password is populated in the repo — confirm your config/model stores the Resend API key when host === "smtp.resend.com"; otherwise sendEmailResendBatched(emailConfig.password, ...) will fail auth.

@github-actions github-actions bot assigned BilalG1 and unassigned N2D4 Sep 11, 2025
@BilalG1 BilalG1 enabled auto-merge (squash) September 11, 2025 22:18
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/lib/email-rendering.tsx (1)

158-181: Don’t let a single render failure nuke the whole batch

As noted earlier, Promise.all will reject the entire batch on the first failure. Consider using Promise.allSettled and aggregating failures so you can decide upstream whether to continue sending partial successes.

Non-breaking internal change that preserves the current return type (throws on any failure but provides a clearer aggregated error), while paving the way for partial-success handling later:

-        return await Promise.all(inputs.map(renderOne));
+        const settled = await Promise.allSettled(inputs.map(renderOne));
+        const rejected = settled.filter((s: any) => s.status === "rejected");
+        if (rejected.length) {
+          const first = rejected[0] as any;
+          const reason = first?.reason?.message || String(first?.reason ?? "unknown error");
+          throw new Error(`Batched render failed for ${rejected.length}/${inputs.length} items. First error: ${reason}`);
+        }
+        return (settled as any[]).map((s: any) => s.value);

If you prefer partial success, introduce an optional flag (e.g., continueOnError) and return { successes, failures } instead—happy to draft that change if you want it in this PR.

To inspect call sites and decide which semantics you want (fail-fast vs partial success), run:

#!/bin/bash
# Locate all usages of the batched renderer to assess error-handling expectations.
rg -nP --type=ts --type=tsx -C2 '\brenderEmailsWithTemplateBatched\s*\('

Also applies to: 179-180

🧹 Nitpick comments (2)
apps/backend/src/lib/email-rendering.tsx (2)

139-140: Avoid duplicating Freestyle API key wiring; pick one style consistently

Freestyle already reads STACK_FREESTYLE_API_KEY internally. Either keep the explicit retrieval here and also do it in the single-email path for consistency, or drop it here and rely on Freestyle’s default. Consistency reduces cognitive load.

Minimal change (drop explicit retrieval here):

-  const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");
...
-  const freestyle = new Freestyle({ apiKey });
+  const freestyle = new Freestyle();

Additionally remove the now-unused import of getEnvVariable at Line 9.

Also applies to: 196-196


141-159: Guard against oversized embedded payloads to prevent huge sandbox scripts

Embedding the entire inputs array into the bundled script can balloon script size and exceed sandbox/request limits. Add a simple size backstop.

   const serializedInputs = JSON.stringify(inputs);
+  // Backstop to avoid shipping excessively large scripts to Freestyle.
+  // Rough char count ~ bytes for ASCII JSON.
+  const MAX_EMBEDDED_PAYLOAD_CHARS = 1_500_000; // ~1.5MB
+  if (serializedInputs.length > MAX_EMBEDDED_PAYLOAD_CHARS) {
+    return Result.error(
+      `Batched render payload too large (${serializedInputs.length} chars). Reduce batch size or chunk further.`
+    );
+  }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d85d66c and d850208.

📒 Files selected for processing (1)
  • apps/backend/src/lib/email-rendering.tsx (2 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Prefer ES6 Map over Record when representing key–value collections

Files:

  • apps/backend/src/lib/email-rendering.tsx
🧬 Code graph analysis (1)
apps/backend/src/lib/email-rendering.tsx (3)
packages/stack-shared/src/utils/env.tsx (1)
  • getEnvVariable (16-58)
packages/stack-shared/src/utils/esbuild.tsx (1)
  • bundleJavaScript (43-203)
apps/backend/src/lib/freestyle.tsx (1)
  • Freestyle (8-48)
⏰ 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). (9)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: docker
  • GitHub Check: all-good
  • GitHub Check: setup-tests
  • GitHub Check: Security Check
🔇 Additional comments (2)
apps/backend/src/lib/email-rendering.tsx (2)

9-9: LGTM: safe server-side env access import

Importing getEnvVariable here is appropriate for backend-only usage.


202-207: Good fix: unwrap Freestyle’s response envelope before returning

You now extract executeResult.data.result and return the rendered array, matching the single-email path’s behavior. This resolves the earlier mismatch flagged in prior reviews.

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