-
Notifications
You must be signed in to change notification settings - Fork 498
batch sending #875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
batch sending #875
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughAdds 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
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Pre-merge checks (2 passed, 1 warning)❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
Poem
✨ Finishing touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
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
getChunksutility function inpackages/stack-shared/src/utils/arrays.tsxto 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
renderEmailsWithTemplateBatchedfunction inemail-rendering.tsxthat renders multiple emails in parallel using a single Freestyle execution, reducing API overhead - Adds
sendEmailResendBatchedfunction inemails.tsxthat 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-emailroute 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.comconfigurations 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.tsxandapps/backend/src/app/api/latest/emails/send-email/route.tsx
6 files reviewed, 7 comments
There was a problem hiding this 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.toinstead of the currentto. 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-clauseWhen 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 emailsWithout 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 failuresIf one render throws,
Promise.allfails the whole batch. ConsiderPromise.allSettledwith 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 usersSilently 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 usersSame 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 pathsUse 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–catchUncaught 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
sizecan 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 depsSandbox 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 checksgetEnvVariable 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 checkshasNotificationEnabled is called per user; consider batching preferences for large sends to cut DB roundtrips.
216-223: Subject fallback can be emptyWhen 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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.tsapps/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.tspackages/stack-shared/src/utils/arrays.tsxapps/backend/src/lib/emails.tsxapps/backend/src/lib/email-rendering.tsxapps/e2e/tests/backend/endpoints/api/v1/send-email.test.tsapps/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
serializedInputsinlined 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 goodReturning users without primary emails while excluding them from sending is a sensible API design.
126-126: Good use of chunking to control loadBATCH_SIZE=100 aligns with Resend batch limits and keeps rendering memory bounded.
Also applies to: 200-202
169-190: Unsubscribe link generation LGTMPer-user links with can_disable gating and verification code are handled correctly.
80-84: Good use of ES6 Map per guidelinesMaps for templates and user lookups are appropriate.
Also applies to: 112-118
225-226: Verify Resend API key is stored in emailConfig.passwordapps/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.
There was a problem hiding this 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 batchAs 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 consistentlyFreestyle 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 scriptsEmbedding 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
📒 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 importImporting getEnvVariable here is appropriate for backend-only usage.
202-207: Good fix: unwrap Freestyle’s response envelope before returningYou 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.
Important
Implement batch email rendering and sending with enhanced processing and error handling.
route.tsxusingrenderEmailsWithTemplateBatched()andsendEmailResendBatched().renderEmailsWithTemplateBatched()inemail-rendering.tsxfor batch email rendering.sendEmailResendBatched()inemails.tsxfor batch email sending.getChunks()inarrays.tsxto split arrays into chunks.send-email.test.tsandunsubscribe-link.test.tsto stabilize email delivery checks.@react-email/renderandresendtopackage.jsonfor email rendering and sending.This description was created by
for ff1dea6. You can customize this summary. It will automatically update as commits are pushed.
Review by RecurseML
🔍 Review performed on 3c34140..1267879
✅ 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.yamlSummary by CodeRabbit