-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Fix payout reminder cron #2812
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
Fix payout reminder cron #2812
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughThis PR removes optional chaining on the Resend client across many email-sending call sites, making sends unconditional. It updates the email package to always export a Resend client and to gate usage via the RESEND_API_KEY environment variable, with SMTP fallback. One route adds a guard to skip zero-sum pending payouts. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Caller
participant EmailIndex as packages/email/src/index.ts
participant Resend as Resend Client
participant SMTP as NodeMailer
Caller->>EmailIndex: sendEmail(opts)
alt RESEND_API_KEY set
EmailIndex->>Resend: sendEmailViaResend(opts)
Resend-->>EmailIndex: result
else SMTP_HOST and SMTP_PORT set
EmailIndex->>SMTP: sendViaNodeMailer(opts)
SMTP-->>EmailIndex: result
else
Note over EmailIndex: Log: no email provider configured
end
EmailIndex-->>Caller: return/throw
sequenceDiagram
autonumber
actor Cron as Program Owners Cron
participant DB as DB
participant Resend as Resend.batch
Cron->>DB: fetch programs with custom min payouts
loop programs
Cron->>DB: compute pendingPayout._sum.amount
alt amount is falsy (0/undefined)
Note over Cron: continue (skip program)
else
Cron->>Resend: batch.send(emails)
Resend-->>Cron: result
end
end
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
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
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (6)
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)
111-129
: Make batch send resilient and avoid returning PII.
- Catch and log per-batch failures so one bad batch doesn’t fail the whole cron.
- Don’t return the full recipient payload (emails) in the HTTP response; return counts only. Also avoid logging full provider responses.
- for (const reminderDomainsChunk of reminderDomainsChunks) { - const res = await resend.batch.send( + for (const reminderDomainsChunk of reminderDomainsChunks) { + try { + const res = await resend.batch.send( reminderDomainsChunk.map(({ workspace, user, domain }) => ({ from: VARIANT_TO_FROM_MAP.notifications, to: user.email!, subject: "Your domain is expiring soon", variant: "notifications", react: DomainRenewalReminder({ email: user.email!, workspace, domain, }), })), - ); - console.log(`Sent ${reminderDomainsChunk.length} emails`, res); + ); + console.log(`Sent ${reminderDomainsChunk.length} emails`); + } catch (err) { + await log({ + message: `Domain renewal reminder batch failed: ${String(err)}`, + type: "errors", + }); + } } - - return NextResponse.json(reminderDomains); + return NextResponse.json({ + batches: reminderDomainsChunks.length, + recipients: reminderDomains.length, + });apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
109-121
: Correct per-domain expiry
Applying a singlenewExpiresAt
from the earliest domain overwrites later expiry dates. In apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (lines 109–121), replace:- const newExpiresAt = addDays(domains[0].expiresAt, 365); - - await prisma.registeredDomain.updateMany({ - where: { - id: { - in: domains.map(({ id }) => id), - }, - }, - data: { - expiresAt: newExpiresAt, - autoRenewalDisabledAt: null, - }, - });with:
+ await Promise.all( + domains.map(d => + prisma.registeredDomain.update({ + where: { id: d.id }, + data: { + expiresAt: addDays(d.expiresAt, 365), + autoRenewalDisabledAt: null, + }, + }), + ), + );Verify whether the email template or user-facing UX should display per-domain renewed dates rather than a single date.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
51-65
: Add idempotency key and guard empty recipient list before batch sendPrevents duplicate emails on retries and avoids calling Resend with an empty array.
- const batchEmails = await resend.batch.send( - payouts - .filter((payout) => payout.partner.email) - .map((payout) => ({ - from: VARIANT_TO_FROM_MAP.notifications, - to: payout.partner.email!, - subject: "You've been paid!", - react: PartnerPayoutProcessed({ - email: payout.partner.email!, - program: payout.program, - payout, - variant: "paypal", - }), - })), - ); + const emails = payouts + .filter((payout) => payout.partner.email) + .map((payout) => ({ + from: VARIANT_TO_FROM_MAP.notifications, + to: payout.partner.email!, + subject: "You've been paid!", + react: PartnerPayoutProcessed({ + email: payout.partner.email!, + program: payout.program, + payout, + variant: "paypal", + }), + })); + + if (emails.length === 0) { + console.log("No recipients for PayPal payout emails, skipping send."); + return; + } + + const { data: sent } = await resend.batch.send(emails, { + idempotencyKey: `payouts/paypal/${invoiceId}`, + });Resend’s batch API supports up to 100 messages and idempotency keys to prevent duplicate sends. (resend.com)
apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (2)
20-45
: Filter out zero-sum groups at the DB levelPrevents emailing partners who have $0 pending across grouped payouts.
const pendingPayouts = await prisma.payout.groupBy({ by: ["partnerId", "programId"], where: { status: "pending", partner: { payoutsEnabledAt: null, OR: [ { connectPayoutsLastRemindedAt: null }, { connectPayoutsLastRemindedAt: { lte: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Last notified was at least 3 days ago }, }, ], }, }, _sum: { amount: true, }, + having: { + amount: { + _sum: { + gt: 0, + }, + }, + }, orderBy: { _sum: { amount: "desc", }, }, });This matches Prisma’s groupBy “having” pattern for aggregate filters. (prisma.io)
133-133
: Remove PII-heavy loggingThis logs full partner+program structures including emails. Log counts/ids instead.
- console.info(partnerProgramsChunk); + console.info("Sent payout reminders", { chunkSize: partnerProgramsChunk.length });apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
46-51
: Redact PII in logsAvoid logging user emails in plaintext.
- console.log({ - userId, - sourceEmail, - targetEmail, - }); + console.log("Merging partner accounts", { userId, sourceDomain: sourceEmail.split("@")[1], targetDomain: targetEmail.split("@")[1] });
🧹 Nitpick comments (27)
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)
37-45
: Use addDays for clarity (equivalent, less surprising).
subDays(now, -days)
works but is harder to read. PreferaddDays(now, days)
.- const targetDates = REMINDER_WINDOWS.map((days) => { - const date = subDays(now, -days); + const targetDates = REMINDER_WINDOWS.map((days) => { + const date = addDays(now, days);Add import:
import { differenceInCalendarDays, endOfDay, formatDistanceStrict, startOfDay, - subDays, + addDays, + subDays, } from "date-fns";apps/web/lib/api/domains/claim-dot-link-domain.ts (1)
191-193
: Unconditional resend: guard failures so background work isn’t marked failed.Wrap the send to avoid bubbling an email error out of
waitUntil
’s Promise.all.- if (emails.length > 0) { - return await resend.batch.send(emails); - } + if (emails.length > 0) { + try { + return await resend.batch.send(emails); + } catch (err) { + console.error("sendDomainClaimedEmails failed:", err); + return null; + } + }apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (2)
67-86
: Confirm custom min-payout semantics (filter vs. threshold on total).Current query sums only payouts whose individual amount >= minPayoutAmount. If the intent is “notify when total pending >= min,” sum all pending amounts and compare the total to the threshold instead.
const pendingPayout = await prisma.payout.aggregate({ where: { programId: program.id, status: "pending", - amount: { - gte: program.minPayoutAmount, - }, partner: { payoutsEnabledAt: { not: null, }, }, }, _sum: { amount: true }, _count: { _all: true }, }); + + // Only notify if total pending meets the custom minimum + if ((pendingPayout._sum?.amount ?? 0) < program.minPayoutAmount) { + continue; + }
116-121
: Comment says “last week” but code uses 14 days.Align the comment with the logic.
- // only send notifications for programs that have not paid out any invoices in the last week + // only send notifications for programs that have not paid out any invoices in the last 2 weeksapps/web/lib/partners/approve-partner-enrollment.ts (1)
179-202
: Unconditional batch send—consider batch size and partial-failure handling.
- If partnerEmailsToNotify > 100, chunk to stay within provider limits.
- Optional: wrap per-batch in try/catch so one failure doesn’t mask others (you’re already using Promise.allSettled, which helps).
Example (outside this hunk):
import { chunk } from "@dub/utils"; for (const emails of chunk(partnerEmailsToNotify, 100)) { await resend.batch.send( emails.map((email) => ({ subject: `Your application to join ${program.name} partner program has been approved!`, from: VARIANT_TO_FROM_MAP.notifications, to: email, react: PartnerApplicationApproved(/* ... */), })), ); }packages/email/src/resend/client.ts (1)
3-3
: Non-null assertion for TS strictness.Avoid “string | undefined” arg errors in strict mode.
-export const resend = new Resend(process.env.RESEND_API_KEY); +export const resend = new Resend(process.env.RESEND_API_KEY!);apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
155-169
: Guard batch email send or centralize fallback to avoid runtime failures without RESEND_API_KEYDirect
resend.batch.send
will throw in dev/preview where the key isn’t set. Either short-circuit here or route through a wrapper that no-ops/falls back when missing.Minimal inline guard:
- await resend.batch.send( + if (!process.env.RESEND_API_KEY) { + console.warn("RESEND_API_KEY not set. Skipping domain renewal emails."); + return; + } + await resend.batch.send(Preferable: add a
safeBatchSend(emails)
in@dub/email/resend
and call that here.apps/web/lib/actions/partners/merge-partner-accounts.ts (1)
174-195
: Add resilience: gate or wrap batch send to support dev/preview without RESEND_API_KEYUnconditional
resend.batch.send
can throw when the key is absent. Keep behavior consistent withunsubscribe()
which guards on env.Quick guard:
- await resend.batch.send([ + if (!process.env.RESEND_API_KEY) { + console.warn("RESEND_API_KEY not set. Skipping verification emails."); + return; + } + await resend.batch.send([Longer-term: export a
safeBatchSend()
helper from@dub/email/resend
and use it here.packages/email/src/resend/unsubscribe.ts (2)
20-23
: Nit: avoid unnecessaryreturn await
return await resend.contacts.remove(...)
adds an extra microtask without benefit here.- return await resend.contacts.remove({ + return resend.contacts.remove({ email, audienceId, });
1-24
: Unify send behavior behind a safe wrapper to remove per-callsite guardsTo keep all call sites consistent (batch sends, subscribe/unsubscribe, etc.), expose a
safeBatchSend
/safeSend
that gates onRESEND_API_KEY
and delegates to SMTP fallback when available. Then replace directresend.batch.send
usages across the app.apps/web/lib/api/partners/notify-partner-commission.ts (1)
143-144
: Harden batch dispatch: use a safe sender and tolerate per-chunk failures
- Add env gating or use a wrapper to avoid crashes without
RESEND_API_KEY
.- Prefer
Promise.allSettled
so one bad chunk doesn’t fail the entire flow.- await Promise.all( - emailChunks.map((emailChunk) => resend.batch.send(emailChunk)), - ); + if (!process.env.RESEND_API_KEY) { + console.warn("RESEND_API_KEY not set. Skipping commission emails."); + return; + } + const results = await Promise.allSettled( + emailChunks.map((emailChunk) => resend.batch.send(emailChunk)), + ); + const rejected = results.filter((r) => r.status === "rejected"); + if (rejected.length) { + console.error(`Failed to send ${rejected.length} email chunk(s).`); + }Also confirm 100 is within the current Resend batch limit.
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
143-169
: Guard batch send or use a helper; optionally chunk recipients
- Add env check or route through a safe helper to avoid throwing without
RESEND_API_KEY
.- If owners could exceed 100, chunk to match batch limits (optional).
Minimal guard:
- if (users.length > 0) { - await resend.batch.send( + if (users.length > 0) { + if (!process.env.RESEND_API_KEY) { + console.warn("RESEND_API_KEY not set. Skipping owner notifications."); + } else { + await resend.batch.send( users.map((user) => ({ from: VARIANT_TO_FROM_MAP.notifications, to: user.email, subject: "Pending bounty review", react: BountyPendingReview({ email: user.email, workspace: { slug: workspace.slug, }, bounty: { id: bounty.id, name: bounty.name, }, partner: { name: partner.name, image: partner.image, email: partner.email!, }, submission: { id: submission.id, }, }), })), - ); + ); + } }apps/web/ui/analytics/feedback/action.ts (1)
10-16
: Harden the send path: guard against transient failures (and missing RESEND_API_KEY) instead of letting the server action throw.Direct
resend.emails.send
will now throw on bad/missing credentials or network blips. Catch and log so the action doesn’t surface a 500 to users.- return await resend.emails.send({ - from: "feedback@dub.co", - to: "steven@dub.co", - ...(email && { replyTo: email }), - subject: "🎉 New Feedback Received!", - react: FeedbackEmail({ email, feedback }), - }); + try { + return await resend.emails.send({ + from: "feedback@dub.co", + to: "steven@dub.co", + ...(email && { replyTo: email }), + subject: "🎉 New Feedback Received!", + react: FeedbackEmail({ email, feedback }), + }); + } catch (err) { + console.error("submitFeedback: email send failed", err); + }apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (2)
247-263
: Don’t block the webhook; send in the background with idempotency and error handling.Wrap this batch in
waitUntil
, add per-recipient Idempotency-Key, and catch errors so renewal handling isn’t aborted by email failures.- if (workspaceOwners.length > 0) { - await resend.batch.send( - workspaceOwners.map(({ user }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, - to: user.email!, - subject: "Domain expired", - react: DomainExpired({ - email: user.email!, - workspace: { - name: workspace.name, - slug: workspace.slug, - }, - domains, - }), - })), - ); - } + if (workspaceOwners.length > 0) { + waitUntil( + (async () => { + try { + await resend.batch.send( + workspaceOwners.map(({ user }) => ({ + from: VARIANT_TO_FROM_MAP.notifications, + to: user.email!, + subject: "Domain expired", + react: DomainExpired({ + email: user.email!, + workspace: { name: workspace.name, slug: workspace.slug }, + domains, + }), + headers: { + "Idempotency-Key": `domain-expired-${invoice.id}-${user.id}`, + }, + })), + ); + } catch (err) { + console.error("Failed to send 'Domain expired' emails:", err); + } + })(), + ); + }
277-292
: Same here: background send + idempotency for the “renewal failed” emails.- if (workspaceOwners.length > 0) { - await resend.batch.send( - workspaceOwners.map(({ user }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, - to: user.email!, - subject: "Domain renewal failed", - react: DomainRenewalFailed({ - email: user.email!, - workspace: { - slug: workspace.slug, - }, - domains, - }), - })), - ); - } + if (workspaceOwners.length > 0) { + waitUntil( + (async () => { + try { + await resend.batch.send( + workspaceOwners.map(({ user }) => ({ + from: VARIANT_TO_FROM_MAP.notifications, + to: user.email!, + subject: "Domain renewal failed", + react: DomainRenewalFailed({ + email: user.email!, + workspace: { slug: workspace.slug }, + domains, + }), + headers: { + "Idempotency-Key": `domain-renewal-failed-${invoice.id}-${user.id}`, + }, + })), + ); + } catch (err) { + console.error("Failed to send 'Domain renewal failed' emails:", err); + } + })(), + ); + }apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
101-129
: Make the cron robust to single-batch failures; continue on error and log.A transient Resend error currently aborts the whole page. Catch per-batch and proceed.
- await resend.batch.send( - programEnrollmentChunk.map(({ partner }) => ({ + try { + await resend.batch.send( + programEnrollmentChunk.map(({ partner }) => ({ from: VARIANT_TO_FROM_MAP.notifications, to: partner.email!, // coerce the type here because we've already filtered out partners with no email in the prisma query subject: `New bounty available for ${bounty.program.name}`, react: NewBountyAvailable({ email: partner.email!, bounty: { name: bounty.name, type: bounty.type, endsAt: bounty.endsAt, description: bounty.description, }, program: { name: bounty.program.name, slug: bounty.program.slug, }, }), headers: { "Idempotency-Key": `${bountyId}-${partner.id}`, }, - })), - ); + })), + ); + } catch (err: any) { + await log({ + message: `Resend batch failed for bounty ${bountyId}, page ${page}: ${err?.message ?? err}`, + type: "errors", + }); + }apps/web/lib/api/partners/notify-partner-application.ts (1)
74-76
: Avoid failing the whole send on a single chunk; use allSettled.This keeps the rest of the notifications flowing and you can still inspect failures.
- await Promise.all( - emailChunks.map((emailChunk) => resend.batch.send(emailChunk)), - ); + await Promise.allSettled( + emailChunks.map((emailChunk) => resend.batch.send(emailChunk)), + );apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1)
95-111
: Harden payout emails: try/catch and per-email Idempotency-Key to dedupe retries.Prevents the payout flow from erroring on email issues and avoids duplicates on retries.
- const resendBatch = await resend.batch.send( + let resendBatch; + try { + resendBatch = await resend.batch.send( currentInvoicePayouts .filter((p) => p.partner.email) .map((p) => { return { from: VARIANT_TO_FROM_MAP.notifications, to: p.partner.email!, subject: "You've been paid!", react: PartnerPayoutProcessed({ email: p.partner.email!, program: p.program, payout: p, variant: "stripe", }), + headers: { + "Idempotency-Key": `payout-processed-${p.id}`, + }, }; }), - ); + ); + } catch (err) { + console.error("Failed to send PartnerPayoutProcessed emails:", err); + }apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
49-50
: Redact logs to avoid leaking payout/email payloadsPrefer logging counts/ids instead of full objects that may contain PII or payout metadata.
- console.log("PayPal batch payout created", batchPayout); + console.log("PayPal batch payout created", { invoiceId, count: payouts.length }); … - console.log("Resend batch emails sent", batchEmails); + console.log("Resend batch emails sent", { count: sent?.length ?? 0 });Also applies to: 67-67
apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)
119-131
: Add idempotency keys per batch to avoid duplicate reminders on retriesSafer for cron replays and transient failures.
- for (const partnerProgramsChunk of partnerProgramsChunks) { - await resend.batch.send( + for (const [i, partnerProgramsChunk] of partnerProgramsChunks.entries()) { + await resend.batch.send( partnerProgramsChunk.map(({ partner, programs }) => ({ from: VARIANT_TO_FROM_MAP.notifications, to: partner.email, subject: "Connect your payout details on Dub Partners", variant: "notifications", react: ConnectPayoutReminder({ email: partner.email, programs, }), })), - ); + { idempotencyKey: `payout-reminders/${connectPayoutsLastRemindedAt.toISOString()}/${i}` }, + );Resend batch supports idempotency keys; use a deterministic key per chunk. (resend.com)
apps/web/lib/actions/partners/bulk-approve-partners.ts (1)
136-137
: Optional: add idempotency keys to batch sendsHelps dedupe on retries; consider hashing recipient emails to form a stable key per chunk.
- ...emailChunks.map((emailChunk) => resend.batch.send(emailChunk)), + ...emailChunks.map((emailChunk, i) => + resend.batch.send(emailChunk, { + // e.g., approvals/<program>/<chunk-index> + idempotencyKey: `approvals/${program.id}/${i}`, + }), + ),Resend batch idempotency reference. (resend.com)
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
247-268
: Add idempotency key to batch email after mergePrevents duplicate notifications if the job is retried.
- await resend.batch.send([ + await resend.batch.send( + [ { from: VARIANT_TO_FROM_MAP.notifications, to: sourceEmail, subject: "Your Dub partner accounts are now merged", react: PartnerAccountMerged({ email: sourceEmail, sourceEmail, targetEmail, }), }, { from: VARIANT_TO_FROM_MAP.notifications, to: targetEmail, subject: "Your Dub partner accounts are now merged", react: PartnerAccountMerged({ email: targetEmail, sourceEmail, targetEmail, }), }, - ]); + ], + { idempotencyKey: `partners/merge/${sourceEmail}->${targetEmail}` }, + );Resend batch idempotency reference. (resend.com)
packages/email/src/resend/subscribe.ts (1)
27-35
: Handle unauthorized keys and normalize namesAdds a clearer log when the API key is present but invalid, and trims name parts.
- return await resend.contacts.create({ - email, - ...(name && { - firstName: name.split(" ")[0], - lastName: name.split(" ").slice(1).join(" "), - }), - audienceId, - }); + try { + return await resend.contacts.create({ + email, + ...(name && { + firstName: name.trim().split(/\s+/)[0], + lastName: name.trim().split(/\s+/).slice(1).join(" "), + }), + audienceId, + }); + } catch (err: any) { + // Common failure: 401 Unauthorized due to bad key or domain issues + console.warn("Resend subscribe failed", { + status: err?.status, + code: err?.code, + audience, + }); + return; + }Resend contact creation flow reference. (resend.com)
packages/email/src/index.ts (4)
6-8
: Harden the Resend config check (trim whitespace).Guard against cases where
RESEND_API_KEY
is set to whitespace.- if (process.env.RESEND_API_KEY) { + const resendConfigured = Boolean(process.env.RESEND_API_KEY?.trim()); + if (resendConfigured) { return await sendEmailViaResend(opts); }
7-7
: Drop redundantawait
on returned promises.Minor micro-optimization; preserves behavior while avoiding an extra microtask hop.
- return await sendEmailViaResend(opts); + return sendEmailViaResend(opts);- return await sendViaNodeMailer({ + return sendViaNodeMailer({ email, subject, text, react, });Also applies to: 17-17
25-28
: Elevate log level for missing email configuration.Use
warn
to make misconfiguration more visible in logs. Consider throwing if callers should handle this explicitly.- console.info( + console.warn( "Email sending failed: Neither SMTP nor Resend is configured. Please set up at least one email service to send emails.", );
1-5
: Annotate a concrete return type to improve DX and safety.Unify the return type across transports so call sites know what to expect.
import { ResendEmailOptions } from "./resend/types"; import { sendViaNodeMailer } from "./send-via-nodemailer"; import { sendEmailViaResend } from "./send-via-resend"; -export const sendEmail = async (opts: ResendEmailOptions) => { +type ResendResult = Awaited<ReturnType<typeof sendEmailViaResend>>; +type SmtpResult = Awaited<ReturnType<typeof sendViaNodeMailer>>; +export type EmailSendResult = ResendResult | SmtpResult | undefined; + +export const sendEmail = async (opts: ResendEmailOptions): Promise<EmailSendResult> => {
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (21)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
(1 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
(1 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts
(1 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts
(2 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts
(1 hunks)apps/web/lib/actions/partners/bulk-approve-partners.ts
(1 hunks)apps/web/lib/actions/partners/create-bounty-submission.ts
(1 hunks)apps/web/lib/actions/partners/merge-partner-accounts.ts
(1 hunks)apps/web/lib/api/domains/claim-dot-link-domain.ts
(1 hunks)apps/web/lib/api/partners/notify-partner-application.ts
(1 hunks)apps/web/lib/api/partners/notify-partner-commission.ts
(1 hunks)apps/web/lib/partners/approve-partner-enrollment.ts
(1 hunks)apps/web/ui/analytics/feedback/action.ts
(1 hunks)packages/email/src/index.ts
(1 hunks)packages/email/src/resend/client.ts
(1 hunks)packages/email/src/resend/subscribe.ts
(1 hunks)packages/email/src/resend/unsubscribe.ts
(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (15)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/ui/analytics/feedback/action.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/actions/partners/bulk-approve-partners.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/actions/partners/merge-partner-accounts.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/api/partners/notify-partner-commission.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/partners/approve-partner-enrollment.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/api/partners/notify-partner-application.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/api/domains/claim-dot-link-domain.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
⏰ 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: Vade Review
- GitHub Check: build
🔇 Additional comments (7)
apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (1)
88-91
: Good guard—prevents zero-amount reminders.This avoids noise for programs with no payable amount. LGTM.
packages/email/src/resend/client.ts (1)
3-3
: Ensure RESEND_API_KEY coverage or use sendEmail for SMTP fallback
Direct imports of @dub/email/resend detected across numerous modules bypass the SMTP fallback—confirm that RESEND_API_KEY is set in every target runtime, or replace those imports with sendEmail from @dub/email to guarantee fallback.packages/email/src/resend/unsubscribe.ts (1)
11-16
: LGTM: environment-gated unsubscribe pathGood central guard on
RESEND_API_KEY
with a clear log and early return.apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
107-107
: Confirm Resend batch API support and env variable
- Verified the project uses Resend SDK v^4.0.0 in
packages/email/package.json
(so batch.send is available in v4.x) and all usages import the same client instance; SDK docs confirmresend.batch.send
exists in v4.x.- Ensured
RESEND_API_KEY
is referenced in the email client (packages/email/src/resend/client.ts
) and guarded in scripts—verify your deployment envs set this variable where these cron routes run.apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)
7-7
: Import change looks goodRemoving the unused
log
import is fine.apps/web/lib/actions/partners/bulk-approve-partners.ts (1)
164-165
: Unconditional send looks fine; background failure won’t break the actionGiven
waitUntil
andPromise.allSettled
, email failures won’t block the user flow.packages/email/src/resend/subscribe.ts (1)
13-18
: Env-var gate is correctSwitching to an env check is consistent with always exporting a Resend client.
/bug0 run |
export const resend = process.env.RESEND_API_KEY | ||
? new Resend(process.env.RESEND_API_KEY) | ||
: null; | ||
export const resend = new Resend(process.env.RESEND_API_KEY); |
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.
export const resend = new Resend(process.env.RESEND_API_KEY); | |
export const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; |
The Resend client will be instantiated with undefined
when RESEND_API_KEY
is not set, which will likely cause runtime errors when the client is used.
View Details
Analysis
Resend Client Instantiation Bug: Runtime Error When API Key Not Set
Issue Summary
The Resend email client in packages/email/src/resend/client.ts
is instantiated unconditionally with process.env.RESEND_API_KEY
, which causes immediate runtime errors when the environment variable is undefined. This creates application crashes at startup in environments where the API key is not configured.
Technical Details
Current Problematic Code
// packages/email/src/resend/client.ts
export const resend = new Resend(process.env.RESEND_API_KEY);
Error Behavior
When process.env.RESEND_API_KEY
is undefined, the Resend constructor immediately throws:
Error: Missing API key. Pass it to the constructor `new Resend("re_123")`
This error occurs at module import time, not when the client is used, meaning any application importing this module will crash during startup if the environment variable is not set.
Codebase Inconsistency
The codebase shows an inconsistent approach to handling the missing API key scenario:
-
Environment variable checks: Some files properly check
process.env.RESEND_API_KEY
:// packages/email/src/index.ts if (process.env.RESEND_API_KEY) { return await sendEmailViaResend(opts); }
-
Client null checks: Other files attempt to check the
resend
client itself:// packages/email/src/send-via-resend.ts if (!resend) { console.info("RESEND_API_KEY is not set in the .env. Skipping sending email."); return; }
However, the client null checks are unreachable code because the constructor throws an error before the client variable can be assigned a value.
Impact
This bug manifests as:
- Application crashes during startup/import in development environments without API keys
- Deployment failures in environments where Resend is not configured
- Inconsistent error handling where some parts of the application expect graceful degradation but get hard crashes instead
Verification Evidence
Through concrete testing with Resend v4.0.0, I confirmed:
new Resend(undefined)
immediately throws an error- Module import fails when the constructor is called with undefined
- The error occurs before any null checks can be executed
- The conditional pattern
process.env.RESEND_API_KEY ? new Resend(...) : null
works correctly
Recommended Fix
Use conditional instantiation to allow graceful handling of missing API keys:
export const resend = process.env.RESEND_API_KEY
? new Resend(process.env.RESEND_API_KEY)
: null;
This approach:
- Prevents startup crashes when API key is not configured
- Makes the existing null checks functional
- Maintains consistency with the codebase's defensive programming patterns
- Allows applications to gracefully degrade or use alternative email providers
Summary by CodeRabbit
Bug Fixes
Chores