-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Fixes emails in local development and extracts batch email calls into a single function #2835
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
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Warning Rate limit exceeded@steven-tey has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 10 minutes and 34 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (1)
WalkthroughReplaces direct Resend usage with a new sendBatchEmail/sendEmail surface from @dub/email, standardizes recipient prop from Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Caller as Caller (routes/actions)
participant Email as @dub/email
participant Resend as Resend (client)
participant SMTP as SMTP (NodeMailer)
Caller->>Email: sendEmail({ to, subject, react, variant, ... })
alt Resend available
Email->>Resend: emails.send(mapped payload)
Resend-->>Email: result
else SMTP configured
Email->>SMTP: sendMail({ to, subject, text/html })
SMTP-->>Email: ok
else
Email-->>Caller: noop (logged)
end
Email-->>Caller: response
sequenceDiagram
autonumber
participant Caller as Caller (batch flows)
participant Email as @dub/email
participant Resend as Resend
participant SMTP as SMTP
Caller->>Email: sendBatchEmail(ResendBulkEmailOptions)
alt Resend available
Email->>Resend: batch.send(mapped payload[])
Resend-->>Email: CreateBatchResponse
else SMTP configured
loop For each entry
Email->>SMTP: sendMail({ to, subject, text/html })
SMTP-->>Email: ok
end
Email-->>Caller: { data: null, error: null }
else
Email-->>Caller: { data: null, error: null } (logged)
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~30 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
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.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (13)
apps/web/ui/analytics/feedback/action.ts (1)
10-16
: packages/email/src/index.ts — forward from/replyTo in SMTP fallback and batchVerified: SMTP fallback and batch branches only forward { email, subject, text, react } and do not pass from or replyTo — From/Reply-To will be dropped. Apply the changes below.
- const { email, subject, text, react } = opts; - return await sendViaNodeMailer({ - email, - subject, - text, - react, - }); + const { email, subject, text, react, from, replyTo } = opts as any; + return await sendViaNodeMailer({ + email, + subject, + text, + react, + from, + replyTo, + });- await Promise.all(payload.map((p) => sendViaNodeMailer({ - email: p.email, - subject: p.subject, - text: p.text, - react: p.react, - }))); + await Promise.all(payload.map((p) => sendViaNodeMailer({ + email: p.email, + subject: p.subject, + text: p.text, + react: p.react, + from: (p as any).from, + replyTo: (p as any).replyTo, + })));Verify in MailHog that From and Reply-To are set correctly.
apps/web/lib/api/partners/notify-partner-application.ts (1)
44-69
: Add idempotency headers to prevent duplicate sends on retriesStripe/QStash/cron retries can cause duplicate emails. Include a stable Idempotency-Key per recipient to let transports dedupe.
Apply this diff:
const allEmails: ResendBulkEmailOptions = workspaceUsers.map(({ user, project }) => ({ subject: `New partner application for ${program.name}`, variant: "notifications", email: user.email!, + headers: { + "Idempotency-Key": `partner-app-${program.id}-${user.email}`, + }, react: PartnerApplicationReceived({apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
154-168
: Add Idempotency-Key to renewal emailsWebhook replays can resend emails. Key on invoice.id to dedupe at the provider.
await sendBatchEmail( workspaceOwners.map(({ user }) => ({ variant: "notifications", email: user.email!, subject: `Your ${pluralize("domain", domains.length)} have been renewed`, + headers: { + "Idempotency-Key": `domain-renewed-${invoice.id}-${user.email}`, + }, react: DomainRenewed({apps/web/lib/actions/partners/bulk-approve-partners.ts (1)
105-134
: Add per-recipient Idempotency-KeyPrevents duplicates on retries and aligns with other routes.
return partnerEmailsToNotify.map((email) => ({ subject: `Your application to join ${program.name} partner program has been approved!`, variant: "notifications", email, + headers: { + "Idempotency-Key": `partner-approved-${program.id}-${email}`, + }, react: PartnerApplicationApproved({apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)
119-129
: Add Idempotency-Key to reminder emailsCron retries can send duplicates. Use a stable key per partner-day.
await sendBatchEmail( partnerProgramsChunk.map(({ partner, programs }) => ({ email: partner.email, subject: "Connect your payout details on Dub Partners", variant: "notifications", + headers: { + "Idempotency-Key": `payouts-reminder-${partner.id}-${connectPayoutsLastRemindedAt.toISOString().slice(0,10)}`, + }, react: ConnectPayoutReminder({apps/web/lib/actions/partners/bulk-ban-partners.ts (1)
158-177
: Chunk batch to avoid provider limits and add idempotencyLarge partner sets can exceed provider batch limits. Also add per-recipient idempotency.
- await sendBatchEmail( - programEnrollments - .filter(({ partner }) => partner.email) - .map(({ partner }) => ({ - email: partner.email!, - subject: `You've been banned from the ${program.name} Partner Program`, - variant: "notifications", - react: PartnerBanned({ - partner: { - name: partner.name, - email: partner.email!, - }, - program: { - name: program.name, - supportEmail: program.supportEmail || "support@dub.co", - }, - bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], - }), - })), - ); + { + const recipients = programEnrollments.filter(({ partner }) => partner.email); + for (let i = 0; i < recipients.length; i += 100) { + const slice = recipients.slice(i, i + 100); + // eslint-disable-next-line no-await-in-loop + await sendBatchEmail( + slice.map(({ partner }) => ({ + email: partner.email!, + subject: `You've been banned from the ${program.name} Partner Program`, + variant: "notifications", + headers: { + "Idempotency-Key": `partner-banned-${programId}-${partner.id}`, + }, + react: PartnerBanned({ + partner: { + name: partner.name, + email: partner.email!, + }, + program: { + name: program.name, + supportEmail: program.supportEmail || "support@dub.co", + }, + bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], + }), + })), + ); + } + }apps/web/lib/api/partners/notify-partner-commission.ts (1)
105-121
: Primary partner email is not notified; dedupe recipientsCurrently only partner.users are emailed. Include partner.email (if present) and dedupe to avoid double sends.
- const partnerEmailsToNotify = partner.users - .map(({ user }) => user.email) - .filter(Boolean) as string[]; + const partnerEmailsToNotify = Array.from( + new Set( + [ + partner.email, + ...partner.users.map(({ user }) => user.email), + ].filter(Boolean) as string[], + ), + );apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (2)
111-121
: Guard against null emails before sendingworkspace owners aren’t filtered for non-null emails here; remove the non-null assertion and filter instead.
- const res = await sendBatchEmail( - reminderDomainsChunk.map(({ workspace, user, domain }) => ({ - email: user.email!, + const res = await sendBatchEmail( + reminderDomainsChunk + .filter(({ user }) => Boolean(user.email)) + .map(({ workspace, user, domain }) => ({ + email: user.email as string, subject: "Your domain is expiring soon", variant: "notifications", react: DomainRenewalReminder({ - email: user.email!, + email: user.email as string, workspace, domain, }), })), );
128-133
: Use safe error extraction in catch blockerror is unknown in TS; accessing error.message can throw a type error at compile-time.
- await log({ - message: "Domains renewal reminders cron failed. Error: " + error.message, + const message = error instanceof Error ? error.message : String(error); + await log({ + message: "Domains renewal reminders cron failed. Error: " + message, type: "errors", });apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (3)
172-181
: Promise.all does not await sendEmail calls (missing return)The arrow callback uses braces but doesn’t return the promise, so emails may never be awaited.
- await Promise.all( - emailData.map((data) => { - sendEmail({ + await Promise.all( + emailData.map((data) => + sendEmail({ subject: "Partner payout failed", email: data.email, react: PartnerPayoutFailed(data), variant: "notifications", - }); - }), + }), + ), );
246-260
: Filter null emails before batch send (domain expired)Avoid non-null assertion and filter workspaceOwners to those with emails.
- await sendBatchEmail( - workspaceOwners.map(({ user }) => ({ + await sendBatchEmail( + workspaceOwners + .filter(({ user }) => Boolean(user.email)) + .map(({ user }) => ({ variant: "notifications", - email: user.email!, + email: user.email as string, subject: "Domain expired", react: DomainExpired({ - email: user.email!, + email: user.email as string, workspace: { name: workspace.name, slug: workspace.slug, }, domains, }), })), );
276-289
: Filter null emails before batch send (renewal failed)Same guard as above for the renewal-failed path.
- await sendBatchEmail( - workspaceOwners.map(({ user }) => ({ + await sendBatchEmail( + workspaceOwners + .filter(({ user }) => Boolean(user.email)) + .map(({ user }) => ({ variant: "notifications", - email: user.email!, + email: user.email as string, subject: "Domain renewal failed", react: DomainRenewalFailed({ - email: user.email!, + email: user.email as string, workspace: { slug: workspace.slug, }, domains, }), })), );packages/email/src/send-via-resend.ts (1)
19-40
: Don't pass non-Resend fields ("variant") to Resend API payloadRemove
variant
from the object sent toresend.emails.send
/resend.batch.send
and use it only to derivefrom
/headers
. Also applies to: 51-52, 66-69return { to: email, from: from || VARIANT_TO_FROM_MAP[variant], bcc: bcc, replyTo: replyTo || "support@dub.co", subject, text, react, scheduledAt, - variant, - ...(variant === "marketing" - ? { - headers: { - ...headers, - "List-Unsubscribe": "https://app.dub.co/account/settings", - }, - } - : { - headers, - }), + headers: + variant === "marketing" + ? { ...(headers ?? {}), "List-Unsubscribe": "https://app.dub.co/account/settings" } + : headers, }
🧹 Nitpick comments (21)
apps/web/package.json (1)
6-6
: Avoid duplicate prisma:generate; add a predev and simplify the dev scriptRunning prisma:generate twice in parallel is redundant and can add noise. Suggest running it once via a predev script.
Apply this diff to the dev line:
-"dev": "concurrently --kill-others \"pnpm prisma:generate && next dev --turbo --port 8888\" \"pnpm prisma:generate && pnpm prisma:studio --browser none\"", +"dev": "concurrently --kill-others \"next dev --turbo --port 8888\" \"pnpm prisma:studio --browser none\"",And add this script entry (outside the changed line) so generation runs once before dev:
{ "scripts": { "predev": "pnpm prisma:generate" } }packages/email/src/resend/types.ts (1)
10-10
: Prefer readonly array for bulk payload typeThis prevents accidental in-place mutation of the batch payload.
-export type ResendBulkEmailOptions = ResendEmailOptions[]; +export type ResendBulkEmailOptions = ReadonlyArray<ResendEmailOptions>;apps/web/next.config.js (1)
20-22
: Conditionally applying esmExternals only in production makes senseGood call—keeps dev simpler (and Turbopack doesn’t use this anyway). Consider future-proofing by keying off Next’s phase if you ever need finer control, but this is fine as-is.
packages/email/src/resend/client.ts (1)
3-3
: Nullable Resend client aligns with local dev without API keyLGTM. For ergonomics, consider a tiny type guard to reduce repetitive null checks at call sites.
import type { Resend as ResendType } from "resend"; export const hasResend = (client: typeof resend): client is ResendType => !!client;packages/email/src/resend/subscribe.ts (1)
13-18
: Tweak log level/message when client is missingThe guard is correct. Switch to warn and clarify the message since we’re checking the client, not the env directly.
- if (!resend) { - console.error( - "No RESEND_API_KEY is set in the environment variables. Skipping.", - ); - return; - } + if (!resend) { + console.warn("Resend client not configured (RESEND_API_KEY missing). Skipping subscribe()."); + return; + }packages/email/src/resend/unsubscribe.ts (1)
11-16
: Match unsubscribe’s guard logging to subscribe’sSame suggestion: use warn and clarify the message for consistency.
- if (!resend) { - console.error( - "No RESEND_API_KEY is set in the environment variables. Skipping.", - ); - return; - } + if (!resend) { + console.warn("Resend client not configured (RESEND_API_KEY missing). Skipping unsubscribe()."); + return; + }apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (1)
186-201
: Batch API usage looks correct; consider filtering null emails and deduping.Map uses user.email!; add a truthy filter and dedupe to avoid bounces/duplicates.
- const res = await sendBatchEmail( - programOwnerChunk.map(({ workspace, user, program, payout }) => ({ + const res = await sendBatchEmail( + Array.from( + new Set(programOwnerChunk.map((x) => x.user.email).filter(Boolean)), + ).map((email) => ({ variant: "notifications", - email: user.email!, + email: email!, subject: `${payout.partnersCount} ${pluralize( "partner", payout.partnersCount, )} awaiting your payout for ${program.name}`, react: ProgramPayoutReminder({ - email: user.email!, + email: email!, workspace, program, payout, }), })), );apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1)
94-111
: Rename resendBatch variable; optional email-null guard.Now that Resend isn’t called directly, rename to batchResult for clarity and filter out null emails pre-map.
- const resendBatch = await sendBatchEmail( - currentInvoicePayouts - .filter((p) => p.partner.email) + const batchResult = await sendBatchEmail( + currentInvoicePayouts + .filter((p) => !!p.partner.email) .map((p) => { return { variant: "notifications", email: p.partner.email!, subject: "You've been paid!", react: PartnerPayoutProcessed({ email: p.partner.email!, program: p.program, payout: p, variant: "stripe", }), }; }), );apps/web/lib/actions/partners/create-bounty-submission.ts (1)
142-166
: Guard against missing recipient emails in batch.A stray null email will throw downstream; filter before mapping.
- await sendBatchEmail( - users.map((user) => ({ + await sendBatchEmail( + users + .filter((u) => !!u.email) + .map((user) => ({ variant: "notifications", email: 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/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
50-67
: LGTM; minor logging nit.Consider updating the log line to avoid “Resend” wording now that you’re using sendBatchEmail.
- console.log("Resend batch emails sent", batchEmails); + console.log("Batch emails sent", batchEmails);apps/web/lib/api/partners/notify-partner-application.ts (2)
1-2
: Avoid coupling app code to Resend internals for typesPrefer importing a payload type from "@dub/email" (re-export) to avoid leaking implementation details of the Resend transport into app code.
73-76
: Use allSettled to avoid aborting all batches on one failureOne failing batch will reject the whole Promise.all. Safely fan-out and log failures.
- await Promise.all( - emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), - ); + const results = await Promise.allSettled( + emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), + ); + results.forEach((r, i) => r.status === "rejected" && console.error("sendBatchEmail chunk failed", { i, error: r.reason }));apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (2)
62-63
: String interpolation nitMinor readability: wrap the ternary in parens or extract to a const; current form is fine though.
106-128
: Good inclusion of Idempotency-Key; consider per-recipient keysThe batch-level key prevents duplicate batch sends, but a per-recipient key is safer if batches are retried partially by the provider.
- headers: { - "Idempotency-Key": `${bountyId}-page-${page}`, - }, + headers: { + "Idempotency-Key": `${bountyId}-page-${page}-${partner.email}`, + },apps/web/lib/actions/partners/bulk-approve-partners.ts (1)
19-21
: Avoid importing transport-specific types in app layerIf possible, re-export ResendBulkEmailOptions (or a transport-agnostic alias) from "@dub/email" and import from there.
apps/web/lib/partners/approve-partner-enrollment.ts (1)
178-201
: Optional: add idempotency and tolerate multi-user listsPartner can have multiple users; consider a per-email Idempotency-Key. Chunking is probably unnecessary here but harmless if added.
sendBatchEmail( partnerEmailsToNotify.map((email) => ({ subject: `Your application to join ${program.name} partner program has been approved!`, variant: "notifications", email, + headers: { + "Idempotency-Key": `partner-approved-${program.id}-${partner.id}-${email}`, + }, react: PartnerApplicationApproved({apps/web/lib/api/partners/notify-partner-commission.ts (2)
69-77
: Unnecessary Promise.resolve wrapper around Prisma callPassing the Prisma promise directly is clearer; Promise.resolve is redundant here.
- commission.linkId - ? Promise.resolve( - prisma.link.findUnique({ - where: { - id: commission.linkId, - }, - select: { - shortLink: true, - }, - }), - ) - : Promise.resolve(null), + commission.linkId + ? prisma.link.findUnique({ + where: { id: commission.linkId }, + select: { shortLink: true }, + }) + : null,
141-144
: Batch send approach LGTMChunking by 100 and parallelizing batches is reasonable. Consider sequentializing batches later if you hit provider rate limits.
packages/email/src/index.ts (2)
16-24
: Preserve replyTo/from in SMTP fallback (single send)Parity with the Resend path helps support proper replies and branding.
if (smtpConfigured) { - const { email, subject, text, react } = opts; + const { email, subject, text, react, replyTo, from } = opts; return await sendViaNodeMailer({ email, subject, text, react, + replyTo, + from, }); }
41-48
: Preserve replyTo/from in SMTP fallback (batch send)Ensure batch SMTP sends carry reply-to/from.
- await Promise.all(payload.map((p) => sendViaNodeMailer({ - email: p.email, - subject: p.subject, - text: p.text, - react: p.react, - }))); + await Promise.all( + payload.map((p) => + sendViaNodeMailer({ + email: p.email, + subject: p.subject, + text: p.text, + react: p.react, + replyTo: p.replyTo, + from: p.from, + }), + ), + );apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
247-268
: Deduplicate recipients and log batch-send errors — 'notifications' variant verifiedAvoid double-sending when sourceEmail === targetEmail and surface failures from sendBatchEmail (variant "notifications" exists in packages/email/src/resend/constants.ts).
- await sendBatchEmail([ - { - variant: "notifications", - email: sourceEmail, - subject: "Your Dub partner accounts are now merged", - react: PartnerAccountMerged({ - email: sourceEmail, - sourceEmail, - targetEmail, - }), - }, - { - variant: "notifications", - email: targetEmail, - subject: "Your Dub partner accounts are now merged", - react: PartnerAccountMerged({ - email: targetEmail, - sourceEmail, - targetEmail, - }), - }, - ]); + const recipients = [...new Set([sourceEmail, targetEmail])]; + const result = await sendBatchEmail( + recipients.map((email) => ({ + variant: "notifications", + email, + subject: "Your Dub partner accounts are now merged", + react: PartnerAccountMerged({ email, sourceEmail, targetEmail }), + })), + ); + if (result?.error) { + await log({ + message: `merge-partner-accounts: batch email failed: ${String(result.error)}`, + type: "alerts", + }); + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (32)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
(3 hunks)apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
(3 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/year-in-review/route.ts
(2 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts
(3 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts
(2 hunks)apps/web/lib/actions/partners/bulk-approve-partners.ts
(4 hunks)apps/web/lib/actions/partners/bulk-ban-partners.ts
(2 hunks)apps/web/lib/actions/partners/create-bounty-submission.ts
(2 hunks)apps/web/lib/actions/partners/create-program.ts
(1 hunks)apps/web/lib/actions/partners/invite-partner.ts
(1 hunks)apps/web/lib/actions/partners/merge-partner-accounts.ts
(3 hunks)apps/web/lib/actions/partners/resend-program-invite.ts
(1 hunks)apps/web/lib/api/domains/add-domain-vercel.ts
(1 hunks)apps/web/lib/api/domains/claim-dot-link-domain.ts
(3 hunks)apps/web/lib/api/partners/notify-partner-application.ts
(3 hunks)apps/web/lib/api/partners/notify-partner-commission.ts
(3 hunks)apps/web/lib/partners/approve-partner-enrollment.ts
(4 hunks)apps/web/next.config.js
(1 hunks)apps/web/package.json
(2 hunks)apps/web/ui/analytics/feedback/action.ts
(1 hunks)packages/email/src/index.ts
(2 hunks)packages/email/src/resend/client.ts
(1 hunks)packages/email/src/resend/subscribe.ts
(1 hunks)packages/email/src/resend/types.ts
(1 hunks)packages/email/src/resend/unsubscribe.ts
(1 hunks)packages/email/src/send-via-resend.ts
(3 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.
Applied to files:
apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-08-26T15:38:48.173Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/lib/api/bounties/get-bounty-or-throw.ts:53-63
Timestamp: 2025-08-26T15:38:48.173Z
Learning: In bounty-related code, getBountyOrThrow returns group objects with { id } field (transformed from BountyGroup.groupId), while other routes working directly with BountyGroup Prisma records use the actual groupId field. This is intentional - getBountyOrThrow abstracts the join table details.
Applied to files:
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
PR: dubinc/dub#2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.
Applied to files:
apps/web/lib/api/partners/notify-partner-commission.ts
apps/web/lib/partners/approve-partner-enrollment.ts
🧬 Code graph analysis (24)
apps/web/app/(ee)/api/cron/year-in-review/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/api/partners/notify-partner-application.ts (2)
packages/email/src/resend/types.ts (1)
ResendBulkEmailOptions
(10-10)packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
packages/email/src/resend/unsubscribe.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/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/actions/partners/merge-partner-accounts.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
packages/email/src/resend/subscribe.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/api/partners/notify-partner-commission.ts (2)
packages/email/src/resend/types.ts (1)
ResendEmailOptions
(3-8)packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/actions/partners/bulk-ban-partners.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/api/domains/claim-dot-link-domain.ts (2)
packages/email/src/resend/types.ts (1)
ResendBulkEmailOptions
(10-10)packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
packages/email/src/send-via-resend.ts (2)
packages/email/src/resend/types.ts (2)
ResendEmailOptions
(3-8)ResendBulkEmailOptions
(10-10)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/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/actions/partners/bulk-approve-partners.ts (2)
packages/email/src/resend/types.ts (1)
ResendBulkEmailOptions
(10-10)packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/partners/approve-partner-enrollment.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
packages/email/src/index.ts (3)
packages/email/src/resend/types.ts (2)
ResendEmailOptions
(3-8)ResendBulkEmailOptions
(10-10)packages/email/src/resend/client.ts (1)
resend
(3-3)packages/email/src/send-via-resend.ts (1)
sendBatchEmailViaResend
(54-69)
apps/web/ui/analytics/feedback/action.ts (1)
packages/email/src/index.ts (1)
sendEmail
(6-29)
⏰ 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 (20)
apps/web/package.json (2)
151-151
: No actionable change at file terminatorNothing to review here.
6-6
: Sanity-check Turbopack locally — sandbox couldn't verifySandbox run failed (turbo/concurrently missing; node_modules absent) — cannot confirm whether --turbo breaks Prisma/TS imports. Reproduce locally after installing deps (pnpm install) and run: pnpm -w --filter web dev. If you see dev-only import/TS resolution errors, remove --turbo from the dev script or gate Turbopack behind an env flag. File: apps/web/package.json.
apps/web/lib/actions/partners/create-program.ts (1)
261-262
: Using variant: "notifications" for partner invites is correctThis aligns with the new sender-selection model and keeps the callsite transport-agnostic. No further changes needed here.
apps/web/lib/actions/partners/resend-program-invite.ts (1)
55-56
: Consistent switch to variant-based senderGood migration to
variant: "notifications"
; matches the pattern used elsewhere.apps/web/lib/actions/partners/invite-partner.ts (1)
100-107
: Good switch to variant/email API.Using variant: "notifications" with email aligns with the new transport-agnostic sender. No issues spotted here.
apps/web/app/(ee)/api/cron/year-in-review/route.ts (1)
126-135
: Don't mark YearInReview rows as sent unless delivery is confirmed.Unconditional updateMany will set sentAt for all selected rows even if emails failed or no provider is configured.
await prisma.yearInReview.updateMany({ where: { id: { in: yearInReviews.map(({ id }) => id), }, }, data: { sentAt: new Date(), }, });
- Short term: gate this update on an explicit success signal from the email sender (e.g., a boolean returned by sendBatchEmail).
- Better: track per-workspace delivery results and only set sentAt for successful workspaces (update per-id or updateMany with only successful ids).
- Also: make packages/email return a non-null error when no provider is configured to avoid false positives in dev.
- I can draft the follow-up changes in packages/email and this route.
apps/web/lib/actions/partners/merge-partner-accounts.ts (1)
173-194
: OTP emails via batch look good.Variant/email usage is correct, expiry math is right (5 minutes), and rate limits are in place.
apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
3-3
: LGTM on switching to unified email APIapps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
10-10
: LGTM on unified email importapps/web/lib/actions/partners/bulk-approve-partners.ts (2)
51-53
: LGTM on group fetch usageConsistent with getGroupOrThrow contract.
162-165
: Nice: chunked and allSettled fan-outGood resilience pattern.
apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)
3-3
: LGTM on API switchapps/web/lib/actions/partners/bulk-ban-partners.ts (1)
16-16
: LGTM on switching to sendBatchEmailapps/web/lib/partners/approve-partner-enrollment.ts (3)
12-12
: LGTM on unified email import
41-44
: Consistent findUniqueOrThrow usageLooks good; ensures explicit error on invalid linkId.
106-121
: Update payload includes tags; good for downstream consumersNo issues spotted.
apps/web/lib/api/domains/claim-dot-link-domain.ts (1)
178-193
: LGTM on batch email migrationType-safe ResendBulkEmailOptions and sendBatchEmail usage looks correct; filtering owners by email avoids null issues.
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)
228-244
: LGTM on Direct Debit batch notificationsChunking and payload shape are correct; variant and template payloads look consistent with the new email API.
packages/email/src/send-via-resend.ts (1)
31-38
: headers spread throws if undefinedSpreading undefined will throw at runtime. Use a default object when variant is marketing.
- headers: { - ...headers, + headers: { + ...(headers ?? {}), "List-Unsubscribe": "https://app.dub.co/account/settings", },Likely an incorrect or invalid review comment.
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
8-9
: unsubscribe is safe without RESEND_API_KEY — no action required.unsubscribe early-returns when
resend
is null;resend
is set tonull
if RESEND_API_KEY is unset (packages/email/src/resend/client.ts:3), and send-via-resend also logs-and-returns when missing (packages/email/src/send-via-resend.ts:43–60; packages/email/src/resend/unsubscribe.ts:10–16).
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: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
apps/web/lib/actions/partners/bulk-ban-partners.ts (1)
158-177
: Chunk batch emails and use settled to avoid partial short-circuitPrevents oversize payloads and ensures one failing chunk doesn’t abort others.
- await sendBatchEmail( - programEnrollments - .filter(({ partner }) => partner.email) - .map(({ partner }) => ({ - email: partner.email!, - subject: `You've been banned from the ${program.name} Partner Program`, - variant: "notifications", - react: PartnerBanned({ - partner: { - name: partner.name, - email: partner.email!, - }, - program: { - name: program.name, - supportEmail: program.supportEmail || "support@dub.co", - }, - bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], - }), - })), - ); + const emails = programEnrollments + .filter(({ partner }) => partner.email) + .map(({ partner }) => ({ + email: partner.email!, + subject: `You've been banned from the ${program.name} Partner Program`, + variant: "notifications", + react: PartnerBanned({ + partner: { name: partner.name, email: partner.email! }, + program: { + name: program.name, + supportEmail: program.supportEmail || "support@dub.co", + }, + bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], + }), + })); + const batches = chunk(emails, 100); + await Promise.allSettled(batches.map((b) => sendBatchEmail(b)));apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
50-67
: Harden batch email dispatch: handle errors and use transport-agnostic loggingCurrent log implies Resend even when SMTP/no-op paths are used. Also add a try/catch so a single transport error doesn’t crash the whole handler.
- const batchEmails = await sendBatchEmail( - payouts - .filter((payout) => payout.partner.email) - .map((payout) => ({ - variant: "notifications", - email: payout.partner.email!, - subject: "You've been paid!", - react: PartnerPayoutProcessed({ - email: payout.partner.email!, - program: payout.program, - payout, - variant: "paypal", - }), - })), - ); - - console.log("Resend batch emails sent", batchEmails); + try { + const result = await sendBatchEmail( + payouts + .filter((payout) => payout.partner.email) + .map((payout) => ({ + variant: "notifications", + email: payout.partner.email!, + subject: "You've been paid!", + react: PartnerPayoutProcessed({ + email: payout.partner.email!, + program: payout.program, + payout, + variant: "paypal", + }), + })), + ); + console.log("Batch emails dispatched", result); + } catch (err) { + console.error("Batch email dispatch failed", err); + }packages/email/src/send-via-resend.ts (1)
19-40
: Don’t pass non-Resend fields to resend.emails.send; add optional one-click headerResend payload likely doesn’t accept a "variant" field. Strip it from the payload you send. While here, optionally add List-Unsubscribe-Post for better inbox support on marketing.
- return { + return { to: email, from: from || VARIANT_TO_FROM_MAP[variant], bcc: bcc, replyTo: replyTo || "support@dub.co", subject, text, react, scheduledAt, - variant, - ...(variant === "marketing" - ? { - headers: { - ...headers, - "List-Unsubscribe": "https://app.dub.co/account/settings", - }, - } - : { - headers, - }), + ...(variant === "marketing" + ? { + headers: { + ...headers, + "List-Unsubscribe": "https://app.dub.co/account/settings", + // Optional: improves Gmail one-click unsubscribe + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + } + : { headers }), }apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
172-181
: Bug: Promise.all isn’t awaiting the sendEmail calls (missing return)The mapper uses a block without returning the promise, so Promise.all resolves immediately and emails aren’t awaited.
- await Promise.all( - emailData.map((data) => { - sendEmail({ - subject: "Partner payout failed", - email: data.email, - react: PartnerPayoutFailed(data), - variant: "notifications", - }); - }), - ); + await Promise.all( + emailData.map((data) => + sendEmail({ + subject: "Partner payout failed", + email: data.email, + react: PartnerPayoutFailed(data), + variant: "notifications", + }), + ), + );
♻️ Duplicate comments (3)
apps/web/lib/api/domains/add-domain-vercel.ts (1)
12-20
: Fix return type mismatch and avoid masking prod misconfigReturning
Response.json(...)
violates the declaredPromise<CustomResponse>
(you’re returning aResponse
), and unconditionally succeeding hides missing credentials in production. Gate byNODE_ENV
and return a plain object.- if ( - !process.env.PROJECT_ID_VERCEL || - !process.env.TEAM_ID_VERCEL || - !process.env.AUTH_BEARER_TOKEN - ) { - return Response.json({ - success: true, - }); - } + const missingCreds = + !process.env.PROJECT_ID_VERCEL || + !process.env.TEAM_ID_VERCEL || + !process.env.AUTH_BEARER_TOKEN; + if (missingCreds) { + if (process.env.NODE_ENV !== "production") { + return { success: true, skipped: true }; + } + return { success: false, error: "Missing Vercel credentials" }; + }apps/web/app/(ee)/api/cron/year-in-review/route.ts (1)
115-118
: Fix payload shape: mapping Resend-style objects to{ email, ... }
requiredYou’re still passing
{ from, to, reply_to, subject, text, react }
objects intosendBatchEmail
, which expects items with anvariant
,replyTo
, etc. This will fail at runtime. Prior review already flagged this.Apply this diff:
- const { data, error } = await sendBatchEmail( - // @ts-ignore - batch.map((b) => b.email), - ); + const { data, error } = await sendBatchEmail( + batch.map((b) => { + const e = b.email; + const to = Array.isArray(e.to) ? e.to[0] : e.to; + return { + variant: "notifications", + email: to, + subject: e.subject, + text: e.text, + react: e.react, + replyTo: (e as any).reply_to ?? (e as any).replyTo, + from: e.from, + }; + }), + );apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)
227-245
: Re-introduce contextual logging before dispatch (aligns with earlier feedback)Helps correlate chunked sends and investigate local no-op paths. Mirrors the prior “keep this check/log” suggestion.
for (const payoutChunk of payoutChunks) { + console.info(`Dispatching ${payoutChunk.length} payout confirmation emails`); await sendBatchEmail( payoutChunk.map((payout) => ({ variant: "notifications", email: payout.partner.email!, subject: "You've got money coming your way!", react: PartnerPayoutConfirmed({ email: payout.partner.email!, program, payout: { id: payout.id, amount: payout.amount, startDate: payout.periodStart, endDate: payout.periodEnd, }, }), })), ); }
🧹 Nitpick comments (16)
apps/web/package.json (1)
151-151
: Restore trailing newline at EOF.Prevents needless diffs and satisfies editors/linters that expect a newline-terminated file.
-} +} +apps/web/lib/api/domains/add-domain-vercel.ts (1)
22-38
: Handle non-2xx from Vercel and add a timeoutCurrently errors just fall through as JSON; add
res.ok
handling and a short timeout to avoid hanging calls. MaintainCustomResponse
shape on both paths.- return await fetch( + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const res = await fetch( `https://api.vercel.com/v10/projects/${process.env.PROJECT_ID_VERCEL}/domains?teamId=${process.env.TEAM_ID_VERCEL}`, - { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: domain.toLowerCase(), - ...(redirectToApex && { - redirect: getDomainWithoutWWW(domain.toLowerCase()), - }), - }), - }, - ).then((res) => res.json()); + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: domain.toLowerCase(), + ...(redirectToApex && { + redirect: getDomainWithoutWWW(domain.toLowerCase()), + }), + }), + signal: controller.signal, + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + // Treat "already exists" as success if Vercel reports a conflict. + if (res.status === 409 && payload?.error?.code === "domain_already_exists") { + return { success: true, data: payload, alreadyExists: true }; + } + return { + success: false, + error: payload?.error?.message ?? `Vercel API ${res.status}`, + status: res.status, + }; + } + return { success: true, data: payload }; + } finally { + clearTimeout(timeout); + }apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (2)
62-64
: Nit: log formatting is clearer nowReadable log of applicable groups; no action needed.
106-128
: Confirm header passthrough in SMTP fallback (Idempotency-Key may be ignored)You set
headers: { "Idempotency-Key": ... }
, which Resend supports. The SMTP fallback in@dub/email
currently sends only{ email, subject, text, react }
and appears to dropheaders
. If you rely on this header for dedupe, it won’t apply locally.Option A (preferred): update
sendViaNodeMailer
path to accept and forwardheaders
where feasible.Option B: if headers aren’t needed locally, add a brief comment noting headers are only effective with Resend.
I can draft the small change in
packages/email/src/index.ts
to forwardheaders
if helpful.apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1)
94-113
: Guard empty payloads and avoid logging PII; also debrand log messageBuild the batch once, skip when empty, and log a compact summary instead of the full response.
- const resendBatch = await sendBatchEmail( - currentInvoicePayouts - .filter((p) => p.partner.email) - .map((p) => { - return { - variant: "notifications", - email: p.partner.email!, - subject: "You've been paid!", - react: PartnerPayoutProcessed({ - email: p.partner.email!, - program: p.program, - payout: p, - variant: "stripe", - }), - }; - }), - ); - - console.log("Sent Resend batch emails", JSON.stringify(resendBatch, null, 2)); + const emails = currentInvoicePayouts + .filter((p) => p.partner.email) + .map((p) => ({ + variant: "notifications" as const, + email: p.partner.email!, + subject: "You've been paid!", + react: PartnerPayoutProcessed({ + email: p.partner.email!, + program: p.program, + payout: p, + variant: "stripe", + }), + })); + + if (emails.length === 0) { + console.log("No partner payout emails to send."); + } else { + const res = await sendBatchEmail(emails); + console.log("Sent payout emails", { count: emails.length, error: res?.error }); + }packages/email/src/resend/subscribe.ts (1)
13-18
: Align message with new gatingYou now gate on the client instance, not strictly the env var; make the log transport-agnostic.
- if (!resend) { - console.error( - "No RESEND_API_KEY is set in the environment variables. Skipping.", - ); + if (!resend) { + console.error("Resend client not configured. Skipping subscribe."); return; }packages/email/src/resend/client.ts (1)
3-3
: Add explicit union type for clarity across module boundariesImproves IDE/helpfulness when consumers import
resend
.-export const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null +export const resend: Resend | null = + process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : nullapps/web/lib/actions/partners/create-bounty-submission.ts (1)
142-166
: Filter null emails before batchingPrevents undefined recipients in SMTP fallback and removes need for implicit non-null assumptions.
- await sendBatchEmail( - users.map((user) => ({ + const recipients = users.filter((u) => !!u.email); + if (recipients.length) { + await sendBatchEmail( + recipients.map((user) => ({ variant: "notifications", email: 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/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)
111-123
: Filter recipients and log accurate countsAvoid sending to undefined addresses; keep logs concise.
- const res = await sendBatchEmail( - reminderDomainsChunk.map(({ workspace, user, domain }) => ({ - email: user.email!, + const payload = reminderDomainsChunk + .filter(({ user }) => !!user.email) + .map(({ workspace, user, domain }) => ({ + email: user.email!, subject: "Your domain is expiring soon", variant: "notifications", react: DomainRenewalReminder({ email: user.email!, workspace, domain, }), - })), - ); - console.log(`Sent ${reminderDomainsChunk.length} emails`, res); + })); + const res = await sendBatchEmail(payload); + console.log(`Sent ${payload.length} emails`, { error: res?.error });apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)
119-129
: Capture result and avoid verbose/PII logsLog a summary and potential error; consider trimming the subsequent
console.info(partnerProgramsChunk)
as it may include PII.- await sendBatchEmail( + const res = await sendBatchEmail( partnerProgramsChunk.map(({ partner, programs }) => ({ email: partner.email, subject: "Connect your payout details on Dub Partners", variant: "notifications", react: ConnectPayoutReminder({ email: partner.email, programs, }), })), ); + console.log("Sent partner payout reminders", { + count: partnerProgramsChunk.length, + error: res?.error, + });Additionally update the next log line to avoid dumping recipient data:
// replace line 131 console.info({ partnersNotified: partnerProgramsChunk.length });apps/web/lib/actions/partners/merge-partner-accounts.ts (1)
173-194
: Add plain-text fallback for OTP emailsImproves deliverability/readability in clients that block HTML.
await sendBatchEmail([ { variant: "notifications", email: sourceEmail, subject: "Verify your email to merge your Dub Partners accounts", + text: `Your verification code is ${sourceEmailCode}. It expires in ${EMAIL_OTP_EXPIRY_IN / 60} minutes.`, react: VerifyEmailForAccountMerge({ email: sourceEmail, code: sourceEmailCode, expiresInMinutes: EMAIL_OTP_EXPIRY_IN / 60, }), }, { variant: "notifications", email: targetEmail, subject: "Verify your email to merge your Dub Partners accounts", + text: `Your verification code is ${targetEmailCode}. It expires in ${EMAIL_OTP_EXPIRY_IN / 60} minutes.`, react: VerifyEmailForAccountMerge({ email: targetEmail, code: targetEmailCode, expiresInMinutes: EMAIL_OTP_EXPIRY_IN / 60, }), }, ]);apps/web/lib/api/partners/notify-partner-application.ts (1)
74-76
: Prefer settled when sending multiple chunksPrevents one failing chunk from rejecting the whole batch.
- await Promise.all( - emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), - ); + await Promise.allSettled( + emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), + );apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
247-268
: Optional: include plain-text fallbackAdd text for clients that block HTML.
await sendBatchEmail([ { variant: "notifications", email: sourceEmail, subject: "Your Dub partner accounts are now merged", + text: `Your partner accounts (${sourceEmail} → ${targetEmail}) are now merged.`, react: PartnerAccountMerged({ email: sourceEmail, sourceEmail, targetEmail, }), }, { variant: "notifications", email: targetEmail, subject: "Your Dub partner accounts are now merged", + text: `Your partner accounts (${sourceEmail} → ${targetEmail}) are now merged.`, react: PartnerAccountMerged({ email: targetEmail, sourceEmail, targetEmail, }), }, ]);apps/web/lib/api/partners/notify-partner-commission.ts (2)
69-77
: Remove redundant Promise.resolve wrapperThe inner prisma call already returns a Promise; the wrapper adds noise.
- commission.linkId - ? Promise.resolve( - prisma.link.findUnique({ - where: { - id: commission.linkId, - }, - select: { - shortLink: true, - }, - }), - ) - : Promise.resolve(null), + commission.linkId + ? prisma.link.findUnique({ + where: { id: commission.linkId }, + select: { shortLink: true }, + }) + : Promise.resolve(null),
143-144
: Optional: use settled to isolate failures between chunksAvoids a single chunk failure rejecting the whole send.
- await Promise.all( - emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), - ); + await Promise.allSettled( + emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), + );packages/email/src/index.ts (1)
26-29
: Return a consistent result from sendEmail when no transport is configuredRight now this falls through and returns undefined; consider returning a uniform result (e.g., { error }) to make call-sites observable.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (31)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
(3 hunks)apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
(3 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/year-in-review/route.ts
(2 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts
(3 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts
(2 hunks)apps/web/lib/actions/partners/bulk-approve-partners.ts
(4 hunks)apps/web/lib/actions/partners/bulk-ban-partners.ts
(2 hunks)apps/web/lib/actions/partners/create-bounty-submission.ts
(2 hunks)apps/web/lib/actions/partners/create-program.ts
(1 hunks)apps/web/lib/actions/partners/invite-partner.ts
(1 hunks)apps/web/lib/actions/partners/merge-partner-accounts.ts
(3 hunks)apps/web/lib/actions/partners/resend-program-invite.ts
(1 hunks)apps/web/lib/api/domains/add-domain-vercel.ts
(1 hunks)apps/web/lib/api/domains/claim-dot-link-domain.ts
(3 hunks)apps/web/lib/api/partners/notify-partner-application.ts
(3 hunks)apps/web/lib/api/partners/notify-partner-commission.ts
(3 hunks)apps/web/lib/partners/approve-partner-enrollment.ts
(4 hunks)apps/web/package.json
(1 hunks)apps/web/ui/analytics/feedback/action.ts
(1 hunks)packages/email/src/index.ts
(2 hunks)packages/email/src/resend/client.ts
(1 hunks)packages/email/src/resend/subscribe.ts
(1 hunks)packages/email/src/resend/types.ts
(1 hunks)packages/email/src/resend/unsubscribe.ts
(1 hunks)packages/email/src/send-via-resend.ts
(3 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.
Applied to files:
apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-08-26T15:38:48.173Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/lib/api/bounties/get-bounty-or-throw.ts:53-63
Timestamp: 2025-08-26T15:38:48.173Z
Learning: In bounty-related code, getBountyOrThrow returns group objects with { id } field (transformed from BountyGroup.groupId), while other routes working directly with BountyGroup Prisma records use the actual groupId field. This is intentional - getBountyOrThrow abstracts the join table details.
Applied to files:
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
PR: dubinc/dub#2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.
Applied to files:
apps/web/lib/partners/approve-partner-enrollment.ts
apps/web/lib/api/partners/notify-partner-commission.ts
🧬 Code graph analysis (24)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
packages/email/src/resend/unsubscribe.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
packages/email/src/resend/subscribe.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/actions/partners/merge-partner-accounts.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/ui/analytics/feedback/action.ts (1)
packages/email/src/index.ts (1)
sendEmail
(6-29)
apps/web/lib/api/partners/notify-partner-application.ts (2)
packages/email/src/resend/types.ts (1)
ResendBulkEmailOptions
(10-10)packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/partners/approve-partner-enrollment.ts (3)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)packages/email/src/templates/partner-application-approved.tsx (1)
PartnerApplicationApproved
(19-181)apps/web/ui/partners/program-reward-description.tsx (1)
ProgramRewardDescription
(6-97)
apps/web/app/(ee)/api/cron/year-in-review/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/api/domains/claim-dot-link-domain.ts (2)
packages/email/src/resend/types.ts (1)
ResendBulkEmailOptions
(10-10)packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
packages/email/src/send-via-resend.ts (2)
packages/email/src/resend/types.ts (2)
ResendEmailOptions
(3-8)ResendBulkEmailOptions
(10-10)packages/email/src/resend/client.ts (1)
resend
(3-3)
apps/web/lib/actions/partners/bulk-ban-partners.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
packages/email/src/index.ts (3)
packages/email/src/resend/types.ts (2)
ResendEmailOptions
(3-8)ResendBulkEmailOptions
(10-10)packages/email/src/resend/client.ts (1)
resend
(3-3)packages/email/src/send-via-resend.ts (1)
sendBatchEmailViaResend
(54-69)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/actions/partners/bulk-approve-partners.ts (2)
packages/email/src/resend/types.ts (1)
ResendBulkEmailOptions
(10-10)packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/lib/api/partners/notify-partner-commission.ts (2)
packages/email/src/resend/types.ts (1)
ResendEmailOptions
(3-8)packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(31-63)
⏰ 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). (1)
- GitHub Check: Vade Review
🔇 Additional comments (29)
packages/email/src/resend/types.ts (1)
10-10
: Good addition: clear bulk type alias
ResendBulkEmailOptions
makes batch APIs self-documenting and consistent. LGTM.packages/email/src/resend/unsubscribe.ts (1)
11-16
: Client-null guard looks rightSwitching to
if (!resend)
aligns with the nullable client approach and avoids env branching in callers.apps/web/lib/actions/partners/invite-partner.ts (1)
102-102
: Use ofvariant: "notifications"
is consistent with new APIMatches the transport-agnostic send API. No further changes needed here.
apps/web/lib/actions/partners/create-program.ts (1)
261-261
: Consistent variant usageSetting
variant: "notifications"
for invites matches the new conventions.apps/web/lib/actions/partners/resend-program-invite.ts (1)
55-55
: Consistent variant usageLooks good and aligns with other invite flows.
apps/web/app/(ee)/api/cron/year-in-review/route.ts (1)
3-3
: ImportingsendBatchEmail
is correct for the new batch surfaceGood replacement for direct Resend dependency.
apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (2)
3-3
: Correct migration to central email APIReplacing direct Resend usage with
sendBatchEmail
keeps the route transport-agnostic.
186-201
: Batch payload matches new contractUsing
{ variant: "notifications", email, subject, react }
per recipient is correct. Chunking keeps batches manageable.apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
10-10
: Good: centralized batch email importRemoves direct Resend coupling.
apps/web/lib/api/domains/claim-dot-link-domain.ts (1)
13-15
: LGTM on batch migration and typingClean swap to
sendBatchEmail
with typed batch payload and recipient filtering.Also applies to: 178-193
apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
154-168
: LGTM on batch email migrationOwners are filtered; payload matches new API; template props expanded appropriately.
apps/web/lib/actions/partners/merge-partner-accounts.ts (1)
12-12
: Centralized batch email import — good moveapps/web/lib/api/partners/notify-partner-application.ts (2)
1-2
: Correct imports for new email API
44-49
: Payload shape migration looks correctUsing variant + email with non-null user.email is consistent with new API.
apps/web/lib/actions/partners/bulk-approve-partners.ts (4)
19-21
: New email API adoption — good
51-53
: Group fetch reads cleanlyPassing program.id explicitly is clearer.
105-115
: Email payload shape and typing LGTMCorrectly builds ResendBulkEmailOptions with variant/email.
164-164
: Nice: email sends inside waitUntil with Promise.allSettledGood isolation from request lifecycle.
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
8-9
: Imports align with refactorapps/web/lib/partners/approve-partner-enrollment.ts (4)
12-12
: Centralized email sending — good
41-45
: Correct use of findUniqueOrThrowClearer failure mode.
106-121
: Link update + tag selection LGTM
178-201
: Email send migration looks correctSingle-partner fanout, variant/email payload, and content are correct.
apps/web/ui/analytics/feedback/action.ts (1)
3-3
: Correct import for single-email APIapps/web/lib/api/partners/notify-partner-commission.ts (2)
1-2
: Imports are correct
112-121
: Payload shape migration looks goodCorrect use of variant/email for both partner and workspace recipients.
Also applies to: 124-135
packages/email/src/send-via-resend.ts (1)
54-69
: LGTM on Resend batch wiringMapping options via the shared builder keeps parity with single-send and centralizes headers/from decisions.
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (2)
246-260
: Batch domain-expired emails: looks consistent with the new APIFilters owners with an email, uses variant/email/React template correctly.
276-289
: Batch domain-renewal-failed emails: looks consistent with the new APISame as above; payload is correct.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
Show resolved
Hide resolved
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
♻️ Duplicate comments (1)
packages/email/src/send-via-resend.ts (1)
39-39
: Add missing semicolons to satisfy project style.Prettier with semi: true will flag these; add semicolons after the function expression and the return statement.
-} +}; @@ - } + };You can check locally with:
npx prettier --check "packages/email/src/send-via-resend.ts"
Also applies to: 62-62
🧹 Nitpick comments (4)
apps/web/next.config.js (1)
20-22
: Use a ternary for conditional spread (clearer, avoids spreading a boolean)Spreading the result of
cond && {}
works but is unconventional. A ternary reads clearer and avoids coercingfalse
into an object.- ...(process.env.NODE_ENV === "production" && { - esmExternals: "loose", - }), + ...(process.env.NODE_ENV === "production" + ? { esmExternals: "loose" } + : {}),packages/email/src/send-via-resend.ts (3)
53-68
: Keep batch noop response shape consistent with resend.batch.send.The fallback returns
{ data: [], error: null }
. Verify this matches the exact type returned byresend.batch.send(payload)
to avoid narrowing/union issues at call sites. Optionally annotate the function’s return type to lock the contract.
5-17
: Don't drop supported Resend fields — spread remaining CreateEmailOptions and use nullish default for replyTo.Preserve unknown Resend/CreateEmailOptions with a rest spread and prefer replyTo ?? "support@dub.co".
-const resendEmailForOptions = (opts: ResendEmailOptions) => { +const resendEmailForOptions = (opts: ResendEmailOptions) => { const { email, from, variant = "primary", bcc, replyTo, subject, text, react, scheduledAt, - headers, + headers, + ...rest, } = opts; return { to: email, from: from || VARIANT_TO_FROM_MAP[variant], + ...rest, bcc: bcc, - replyTo: replyTo || "support@dub.co", + replyTo: replyTo ?? "support@dub.co", subject, text, react, scheduledAt,
41-51
: Clarify/normalize sendEmail return type.sendEmailViaResend returns undefined when RESEND is not configured and otherwise returns the Resend SDK response; sendEmail therefore can return a Resend response, Nodemailer sendMail info, or undefined. Call sites that capture/inspect the result: apps/web/scripts/send-emails.tsx:17, apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts:46, apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts:137. Either normalize sendEmail/sendEmailViaResend to a stable envelope (and update types) or update callers to handle undefined and multiple provider response shapes.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/next.config.js
(1 hunks)apps/web/package.json
(2 hunks)packages/email/src/send-via-resend.ts
(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/package.json
🧰 Additional context used
🧬 Code graph analysis (1)
packages/email/src/send-via-resend.ts (2)
packages/email/src/resend/types.ts (2)
ResendEmailOptions
(3-8)ResendBulkEmailOptions
(10-10)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 (3)
apps/web/next.config.js (2)
20-22
: Prod-only esmExternals looks goodScoping
experimental.esmExternals
to production reduces dev-time module resolution surprises. Nice tightening.
20-22
: Verify Next.js esmExternals support and environment gatingFound Next versions: apps/web -> next@14.3.0-canary.42; packages/ui & packages/utils -> next@14.2.0-canary.67. apps/web/next.config.js exports an object and contains:
...(process.env.NODE_ENV === "production" && { esmExternals: "loose", }),
- Confirm whether these Next canary versions expect
esmExternals
at top-level or underexperimental
and update the key if required (check the Next release notes/docs for the specific canary versions).- If you intended this flag only for real production (not Vercel Previews), don't rely on
NODE_ENV === "production"
— Vercel Preview and Production commonly setNODE_ENV=production
. Useprocess.env.VERCEL_ENV === "production"
or a dedicated env var to gate only true production.- If enabling in both Preview and Prod is intended, leave as-is.
packages/email/src/send-via-resend.ts (1)
19-23
: Nice: variant used only to derive from; not leaked to Resend payload.This aligns with Resend’s API surface and keeps variant internal. LGTM.
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.
Looking pretty good overall, just a few things remaining that could use a double-check.
I would prefer to stick with / switch to to
instead of email
for the to-address, since it reads better in my opinion and matches Resend, but probably not be worth the extra work for you to go through and update everything again.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
Show resolved
Hide resolved
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: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (15)
apps/web/app/api/webhooks/route.ts (1)
147-186
: waitUntil resolves too early; guard linkIds and remove stray []
- The inner async IIFE doesn’t await Promise.allSettled, so waitUntil resolves immediately and background work (email, caches, toggle) may be dropped.
- prisma.link.findMany unconditionally uses in: linkIds; if linkIds is undefined/empty this can throw.
- The extra [] inside Promise.allSettled adds a non-promise value unnecessarily.
Apply:
- const links = await prisma.link.findMany({ - where: { - id: { in: linkIds }, - projectId: workspace.id, - }, - include: { - webhooks: { - select: { - webhookId: true, - }, - }, - }, - }); + const links = + linkIds?.length + ? await prisma.link.findMany({ + where: { + id: { in: linkIds }, + projectId: workspace.id, + }, + include: { + webhooks: { select: { webhookId: true } }, + }, + }) + : []; - Promise.allSettled([ + await Promise.allSettled([ toggleWebhooksForWorkspace({ workspaceId: workspace.id, }), sendEmail({ to: session.user.email, subject: "New webhook added", react: WebhookAdded({ email: session.user.email, workspace: { name: workspace.name, slug: workspace.slug, }, webhook: { name, }, }), }), - ...(links && links.length > 0 ? [linkCache.mset(links), []] : []), + ...(links.length ? [linkCache.mset(links)] : []), ...(isLinkLevelWebhook(webhook) ? [webhookCache.set(webhook)] : []), ]);apps/web/app/(ee)/api/cron/program-application-reminder/route.ts (1)
87-94
: Use QStash time-string for delay and set a deduplication idLocation: apps/web/app/(ee)/api/cron/program-application-reminder/route.ts (around lines 87–94)
- Replace numeric delay (24 * 60 * 60) with a time-string, e.g. delay: "24h" (QStash expects values like "10s", "3m", "2h", "7d" or an absolute unix timestamp).
- Add a deterministic dedupe id to avoid duplicate schedules — supply Upstash-Deduplication-Id (e.g.
application-reminder:{application.id}
) or enable Upstash-Content-Based-Deduplication.apps/web/app/api/dub/webhook/lead-created.ts (1)
54-65
: Emails not awaited: nested array passed to Promise.allworkspace.users.map(...) returns an array that’s inserted as a single element; Promise.all won’t await its contents. Spread the array and avoid pushing booleans by filtering first.
- // send notification email to workspace owners - workspace.users.map( - ({ user: owner }) => - owner.email && - sendEmail({ - to: owner.email, - subject: "Someone signed up for Dub via your referral link!", - react: NewReferralSignup({ - email: owner.email, - workspace, - }), - }), - ), + // send notification email to workspace owners + ...workspace.users + .filter(({ user }) => Boolean(user.email)) + .map(({ user }) => + sendEmail({ + to: user.email!, + subject: "Someone signed up for Dub via your referral link!", + react: NewReferralSignup({ email: user.email!, workspace }), + }), + ),apps/web/app/(ee)/api/cron/domains/verify/utils.ts (2)
128-141
: Bug: nested array passed to Promise.allSettled; email sends not awaited.emails.map(...) returns an array. Without spreading, Promise.allSettled receives it as one element and won’t wait for individual sends.
Apply:
- emails.map((email) => + ...emails.map((email) => limiter.schedule(() => sendEmail({ subject: `Your domain ${domain} has been deleted`, to: email, react: DomainDeleted({ email, domain, workspaceSlug, }), variant: "notifications", }), ), ),
195-209
: Bug: nested array passed to Promise.allSettled; email sends not awaited.Same issue as above; spread the mapped promises.
- emails.map((email) => + ...emails.map((email) => limiter.schedule(() => sendEmail({ subject: `Your domain ${domain} needs to be configured`, to: email, react: InvalidDomain({ email, domain, workspaceSlug, invalidDays, }), variant: "notifications", }), ), ),apps/web/lib/partnerstack/update-stripe-customers.ts (1)
151-158
: Escape quotes in Stripe search query to prevent bad requests and false negatives.If
customer.email
contains a quote,email:'${customer.email}'
can break the query. Sanitize and use double quotes.- const stripeCustomers = await stripe.customers.search( - { - query: `email:'${customer.email}'`, - }, + const sanitizedEmail = String(customer.email ?? "").replace(/["']/g, ""); + const stripeCustomers = await stripe.customers.search( + { + query: `email:"${sanitizedEmail}"`, + }, { stripeAccount: workspace.stripeConnectId!, }, );apps/web/lib/actions/partners/create-bounty-submission.ts (1)
169-186
: sendEmail usesto
; guard partner.email before passing into owner template
- sendEmail expects a
to
field (packages/email/src/resend/types.ts) — no change to the caller field name.- The owners' notification passes
partner.email!
into BountyPendingReview (apps/web/lib/actions/partners/create-bounty-submission.ts) without checking — if partner.email can be absent this will sendundefined
into the template. Guard or omit the email (e.g. only include when present or use a fallback) and scan otherpartner.email!
usages.apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts (2)
159-173
: Don’t let a failed email block cleanup; guard empty recipient.If
ownerEmail
is empty orsendEmail
fails,Promise.all
rejects and the Redis cleanup/tag pruning may not complete, leaving the import in a bad state. Wrap the email send and make it non-fatal.- // send email to user - sendEmail({ - subject: `Your Rebrandly links have been imported!`, - to: ownerEmail, - react: LinksImported({ - email: ownerEmail, - provider: "Rebrandly", - count, - links, - domains: [domain], - workspaceName: workspace?.name ?? "", - workspaceSlug: workspace?.slug ?? "", - }), - }), + // send email to user (non-fatal + skip if no recipient) + (ownerEmail + ? sendEmail({ + subject: "Your Rebrandly links have been imported!", + to: ownerEmail, + react: LinksImported({ + email: ownerEmail, + provider: "Rebrandly", + count, + links, + domains: [domain], + workspaceName: workspace?.name ?? "", + workspaceSlug: workspace?.slug ?? "", + }), + }).catch((err) => { + // intentionally do not fail the import finalization on email errors + console.warn("sendEmail failed for Rebrandly import completion", { + workspaceId, + ownerEmail, + err, + }); + }) + : Promise.resolve()),Using your retrieved learning: the NodeMailer fallback is a simple backup and doesn’t preserve all metadata; making the email step non-fatal is appropriate here.
242-243
: Fix overcounting: only add newly created links tocount
.
count += importedLinks.length
inflates totals when some links are deduped. UselinksToCreate.length
.- count += importedLinks.length; + count += linksToCreate.length;apps/web/app/(ee)/api/cron/import/bitly/utils.ts (1)
221-223
: Potential crash when owners array is empty; also skip email if no owner address
workspace?.users[0].user.email
will throw whenusers
is empty. Guard the array access and avoid attempting to send when no email is available.Apply:
- const ownerEmail = workspace?.users[0].user.email ?? ""; + const ownerEmail = workspace?.users?.[0]?.user?.email ?? ""; @@ - // send email to user - sendEmail({ - subject: `Your Bitly links have been imported!`, - to: ownerEmail, - react: LinksImported({ - email: ownerEmail, - provider: "Bitly", - count, - links, - domains, - workspaceName: workspace?.name ?? "", - workspaceSlug: workspace?.slug ?? "", - }), - }), + // send email to user (only if an owner email exists) + ...(ownerEmail + ? [ + sendEmail({ + subject: `Your Bitly links have been imported!`, + to: ownerEmail, + react: LinksImported({ + email: ownerEmail, + provider: "Bitly", + count, + links, + domains, + workspaceName: workspace?.name ?? "", + workspaceSlug: workspace?.slug ?? "", + }), + }), + ] + : []),Also applies to: 239-253
apps/web/app/(ee)/api/cron/partner-program-summary/route.ts (1)
320-340
: Bug: promises from limiter are not returned; emails may not be awaitedInside
summary.map
, the body doesn’t return thelimiter.schedule
promise, soPromise.allSettled
receives an array ofundefined
and completes immediately. Also, we should skip partners with no email.- await Promise.allSettled( - summary.map(({ partner, ...rest }) => { - limiter.schedule(() => - sendEmail({ - subject: `Your ${reportingMonth} performance report for ${program.name} program`, - to: partner.email!, - react: PartnerProgramSummary({ - program, - partner, - ...rest, - reportingPeriod: { - month: reportingMonth, - start: currentMonth.toISOString(), - end: endOfMonth(currentMonth).toISOString(), - }, - }), - variant: "notifications", - }), - ); - }), - ); + await Promise.allSettled( + summary + .filter(({ partner }) => !!partner.email) + .map(({ partner, ...rest }) => + limiter.schedule(() => + sendEmail({ + subject: `Your ${reportingMonth} performance report for ${program.name} program`, + to: partner.email as string, + react: PartnerProgramSummary({ + program, + partner, + ...rest, + reportingPeriod: { + month: reportingMonth, + start: currentMonth.toISOString(), + end: endOfMonth(currentMonth).toISOString(), + }, + }), + variant: "notifications", + }), + ), + ), + );apps/web/lib/cron/send-limit-email.ts (1)
25-33
: Bug:Promise.allSettled
receives an array element that is itself an array; map body doesn’t return promisesCurrently, emails may not be awaited and the function can resolve early while DB record is written. Return the scheduled promises and spread them.
- return await Promise.allSettled([ - emails.map((email) => { - limiter.schedule(() => - sendEmail({ - subject: type.endsWith("UsageLimitEmail") - ? "Dub Alert: Clicks Limit Exceeded" - : `Dub Alert: ${workspace.name} has used ${percentage.toString()}% of its links limit for the month.`, - to: email, - react: type.endsWith("UsageLimitEmail") - ? ClicksExceeded({ - email, - workspace, - type: type as "firstUsageLimitEmail" | "secondUsageLimitEmail", - }) - : LinksLimitAlert({ - email, - workspace, - }), - variant: "notifications", - }), - ); - }), - prisma.sentEmail.create({ + return await Promise.allSettled([ + ...emails + .filter(Boolean) + .map((email) => + limiter.schedule(() => + sendEmail({ + subject: type.endsWith("UsageLimitEmail") + ? "Dub Alert: Clicks Limit Exceeded" + : `Dub Alert: ${workspace.name} has used ${percentage.toString()}% of its links limit for the month.`, + to: email, + react: type.endsWith("UsageLimitEmail") + ? ClicksExceeded({ + email, + workspace, + type: type as + | "firstUsageLimitEmail" + | "secondUsageLimitEmail", + }) + : LinksLimitAlert({ + email, + workspace, + }), + variant: "notifications", + }), + ), + ), + prisma.sentEmail.create({ data: { projectId: workspace.id, type, }, }), ]);apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
172-181
: Bug:Promise.all
doesn’t await sendEmail calls (missing return).The mapper uses braces without returning the promise, so emails may not be awaited by waitUntil. Prefer the new batch API for consistency.
- await Promise.all( - emailData.map((data) => { - sendEmail({ - subject: "Partner payout failed", - to: data.email, - react: PartnerPayoutFailed(data), - variant: "notifications", - }); - }), - ); + await sendBatchEmail( + emailData.map((data) => ({ + variant: "notifications", + to: data.email, + subject: "Partner payout failed", + react: PartnerPayoutFailed(data), + })), + );If you prefer keeping sendEmail:
- await Promise.all( - emailData.map((data) => { - sendEmail({ + await Promise.all( + emailData.map((data) => + sendEmail({ subject: "Partner payout failed", to: data.email, react: PartnerPayoutFailed(data), variant: "notifications", - }); - }), + }), + ), );apps/web/app/(ee)/api/cron/import/csv/utils.ts (2)
63-93
: Guard empty ownerEmail and await email sends to avoid drops/unhandled rejections.
- If
workspace?.users[0]
is missing,ownerEmail
becomes""
; current code would attempt to send to an empty address.sendCsvImportEmails
isasync
but bothsendEmail
calls aren’t awaited. In serverless/cron contexts this risks dropped emails and unhandled rejections.Consider this refactor:
@@ - const ownerEmail = workspace?.users[0].user.email ?? ""; + const ownerEmail = workspace?.users[0]?.user.email?.trim() ?? ""; + if (!ownerEmail) { + console.warn(`[sendCsvImportEmails] No owner email for workspace ${workspaceId}; skipping notifications.`); + return; + } @@ - if (count > 0) { - sendEmail({ - subject: `Your CSV links have been imported!`, - to: ownerEmail, - react: LinksImported({ - email: ownerEmail, - provider: "CSV", - count, - links: workspace?.links ?? [], - domains, - workspaceName: workspace?.name ?? "", - workspaceSlug: workspace?.slug ?? "", - }), - }); - } + const tasks: Promise<unknown>[] = []; + if (count > 0) { + tasks.push( + sendEmail({ + subject: `Your CSV links have been imported!`, + to: ownerEmail, + react: LinksImported({ + email: ownerEmail, + provider: "CSV", + count, + links: workspace?.links ?? [], + domains, + workspaceName: workspace?.name ?? "", + workspaceSlug: workspace?.slug ?? "", + }), + }) + ); + } @@ - if (errorLinks.length > 0) { - sendEmail({ - subject: `Some CSV links failed to import`, - to: ownerEmail, - react: LinksImportErrors({ - email: ownerEmail, - provider: "CSV", - errorLinks, - workspaceName: workspace?.name ?? "", - workspaceSlug: workspace?.slug ?? "", - }), - }); - } + if (errorLinks.length > 0) { + tasks.push( + sendEmail({ + subject: `Some CSV links failed to import`, + to: ownerEmail, + react: LinksImportErrors({ + email: ownerEmail, + provider: "CSV", + errorLinks, + workspaceName: workspace?.name ?? "", + workspaceSlug: workspace?.slug ?? "", + }), + }) + ); + } + + await Promise.allSettled(tasks);
1-1
: Replace remaining sendEmail "email:" props with "to:"Call-sites where the object passed to sendEmail contains "email:" — change those properties to "to:" (remove the duplicate "email" key if both exist):
- apps/web/app/(ee)/api/cron/domains/transfer/utils.ts:49
- apps/web/app/(ee)/api/cron/import/bitly/utils.ts:240
- apps/web/app/(ee)/api/cron/import/csv/utils.ts:66
- apps/web/app/(ee)/api/cron/import/csv/utils.ts:82
- apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts:160
- apps/web/app/(ee)/api/cron/import/short/utils.ts:216
- apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts:137
- apps/web/app/(ee)/api/cron/program-application-reminder/route.ts:74
- apps/web/app/(ee)/api/cron/welcome-user/route.ts:50
- apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts:73
- apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts:46
- apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts:110
- apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx:67
- apps/web/app/api/auth/reset-password/route.ts:75
- apps/web/app/api/dub/webhook/lead-created.ts:57
- apps/web/app/api/tokens/route.ts:169
- apps/web/app/api/user/password/route.ts:65
- apps/web/app/api/user/set-password/route.ts:40
- apps/web/app/api/webhooks/route.ts:167
- apps/web/lib/actions/folders/request-folder-edit-access.ts:72
- apps/web/lib/actions/partners/approve-bounty-submission.ts:98
- apps/web/lib/actions/partners/ban-partner.ts:114
- apps/web/lib/actions/partners/create-bounty-submission.ts:171
- apps/web/lib/actions/partners/create-program.ts:192
- apps/web/lib/actions/partners/create-program.ts:259
- apps/web/lib/actions/partners/reject-bounty-submission.ts:90
- apps/web/lib/actions/partners/resend-program-invite.ts:53
- apps/web/lib/api/workflows/execute-award-bounty-action.ts:117
- apps/web/lib/auth/options.ts:56
- apps/web/lib/integrations/install.ts:82
- apps/web/lib/partnerstack/update-stripe-customers.ts:121
- apps/web/lib/rewardful/import-commissions.ts:118
- apps/web/lib/tolt/import-commissions.ts:127
🧹 Nitpick comments (55)
apps/web/app/(ee)/api/cron/domains/transfer/utils.ts (1)
47-48
: Guard against missing owner email instead of non-null asserting.
currentWorkspace?.users[0]?.user?.email!
can still beundefined
at runtime and crash email sending. Prefer an explicit check and early return/log.Apply:
- const ownerEmail = currentWorkspace?.users[0]?.user?.email!; + const ownerEmail = currentWorkspace?.users?.[0]?.user?.email; + if (!ownerEmail) { + // TODO: route to your logger/telemetry if available + return; + }apps/web/app/api/tokens/route.ts (1)
168-185
: Consider setting an explicit variant and guarding for missing recipient.To keep “from” consistent across transports (Resend vs. NodeMailer fallback per retrieved learnings) and avoid accidental undefined recipients in edge cases, add an explicit variant and a simple runtime guard.
If
sendEmail
supportsvariant
, apply:- waitUntil( - sendEmail({ - to: session.user.email, + waitUntil( + sendEmail({ + to: session.user.email, + variant: "notifications", subject: `A new API key has been created for your workspace ${workspace.name} on Dub`, react: APIKeyCreated({ email: session.user.email, token: { name, type: scopesToName(scopes || []).name, permissions: scopesToName(scopes || []).description, }, workspace: { name: workspace.name, slug: workspace.slug, }, }), }), );Optional guard (if
session.user.email
can be nullable in some auth flows):- waitUntil( + if (session.user.email) { + waitUntil( sendEmail({ to: session.user.email, subject: `A new API key has been created for your workspace ${workspace.name} on Dub`, react: APIKeyCreated({ email: session.user.email, token: { name, type: scopesToName(scopes || []).name, permissions: scopesToName(scopes || []).description, }, workspace: { name: workspace.name, slug: workspace.slug, }, }), }), - ); + ); + }Note: Referencing the retrieved learning about the NodeMailer fallback’s simplified sender handling; an explicit
variant
helps ensure consistency in local SMTP.apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts (2)
64-66
: Reduce PII/noise in logs — log only message identifiersCurrent log dumps the full provider response and email address. Prefer minimal, structured logs.
- console.log( - `Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`, - ); + const msgId = + sentEmail && typeof sentEmail === "object" && "id" in (sentEmail as any) + ? (sentEmail as any).id + : undefined; + console.log( + JSON.stringify({ + event: "partner_payout_email_sent", + stripeAccount, + to: partner.email, + id: msgId ?? null, + }), + );
46-67
: Make webhook resilient: don’t let email send failure bubble upIf sendEmail throws, you risk failing the webhook after DB updates. Wrap with try/catch and log.
- const sentEmail = await sendEmail({ + try { + const sentEmail = await sendEmail({ variant: "notifications", subject: "Your funds have been transferred to your bank account", to: partner.email, react: PartnerPayoutWithdrawalCompleted({ email: partner.email, payout: { amount: stripePayout.amount, currency: stripePayout.currency, arrivalDate: stripePayout.arrival_date, traceId: typeof stripePayout.trace_id === "string" ? stripePayout.trace_id : null, }, }), - }); - - console.log( - `Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`, - ); + }); + const msgId = + sentEmail && typeof sentEmail === "object" && "id" in (sentEmail as any) + ? (sentEmail as any).id + : undefined; + console.log( + JSON.stringify({ + event: "partner_payout_email_sent", + stripeAccount, + to: partner.email, + id: msgId ?? null, + }), + ); + } catch (err) { + console.error( + JSON.stringify({ + event: "partner_payout_email_error", + stripeAccount, + to: partner.email, + error: err instanceof Error ? err.message : String(err), + }), + ); + }apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts (2)
86-87
: Avoid non-null assertion on paypalEmail.Template receives
partner.paypalEmail!
without a guard. If null in prod data, this will render invalid content or throw. Guard or provide a fallback.Apply:
- partner: { - paypalEmail: payout.partner.paypalEmail!, - }, + partner: { + paypalEmail: payout.partner.paypalEmail ?? paypalEmail, + },
41-45
: Use centralized logger instead of console.log for missing payout.For consistency and observability, prefer
log({ type: "errors", ... })
here too.- if (!payout) { - console.log( - `[PayPal] Payout not found for invoice ${invoiceId} and partner ${paypalEmail}`, - ); - return; - } + if (!payout) { + await log({ + message: `[PayPal] Payout not found for invoice ${invoiceId} and partner ${paypalEmail}`, + type: "errors", + }); + return; + }apps/web/lib/integrations/install.ts (1)
39-99
: Add error handling around waitUntil(sendEmail) and drop unnecessary non-null assertions.Prevents silent failures in background work; also removes redundant
!
inside a guarded block.Apply:
if (email && integration) { - await sendEmail({ - to: email!, - subject: `The "${integration.name}" integration has been added to your workspace`, - react: IntegrationInstalled({ - email: email!, - workspace: { - name: workspace.name, - slug: workspace.slug, - }, - integration: { - name: integration.name, - slug: integration.slug, - }, - }), - }); + try { + await sendEmail({ + to: email, + subject: `The "${integration.name}" integration has been added to your workspace`, + react: IntegrationInstalled({ + email, + workspace: { name: workspace.name, slug: workspace.slug }, + integration: { name: integration.name, slug: integration.slug }, + }), + }); + } catch (err) { + console.error("Failed to send integration-installed email", { + userId, + workspaceId, + integrationId, + email, + error: err instanceof Error ? err.message : String(err), + }); + } }apps/web/app/api/webhooks/route.ts (1)
119-126
: Prefer deterministic unique lookup for Zapier installationIf installedIntegration has a unique constraint on (projectId, integrationId), use findUnique with the compound key for determinism and intent. Otherwise, consider adding that constraint.
Would you confirm whether a unique index exists on (projectId, integrationId) in InstalledIntegration?
apps/web/app/(ee)/api/cron/program-application-reminder/route.ts (2)
38-42
: Clarify message: application may exist but be older than 3 daysThe current message can mislead when the record is filtered out by
createdAt
. Suggest tweaking copy.- return new Response( - `Application ${applicationId} not found. Skipping...`, - ); + return new Response( + `Application ${applicationId} not found or older than 3 days. Skipping...`, + );
21-25
: Minor: extract magic number to a named constant for readabilityCreates a single source of truth for the 3-day window.
- createdAt: { - gt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), - }, + createdAt: { + gt: new Date(Date.now() - THREE_DAYS_MS), + },Outside this hunk (near Line 16), add:
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;apps/web/app/api/dub/webhook/lead-created.ts (1)
54-65
: Option: consider sendBatchEmail for multiple ownersIf owners > 1 is common, batching will reduce transport calls and centralize error handling. Not required, just aligns with the PR’s direction to consolidate batch sends.
apps/web/lib/rewardful/import-commissions.ts (1)
117-129
: Guard email send so a notification failure doesn’t fail the import job.If
sendEmail
throws (e.g., missing transport locally), the whole import completion path will error after credentials are deleted. Consider soft-failing with logging.Apply:
- if (workspaceUser && workspaceUser.user.email) { - await sendEmail({ + if (workspaceUser && workspaceUser.user.email) { + try { + await sendEmail({ to: workspaceUser.user.email, subject: "Rewardful campaign imported", react: ProgramImported({ email: workspaceUser.user.email, workspace: workspaceUser.project, program, provider: "Rewardful", importId, }), - }); + }); + } catch (err) { + console.error("Failed to send import completion email", { + importId, + programId: program.id, + userId: workspaceUser.userId, + err, + }); + } - } + }apps/web/app/(ee)/api/cron/welcome-user/route.ts (1)
44-65
: Make Resend subscribe non-blocking so email still sends without RESEND_API_KEYIf
subscribe()
throws in local dev (no Resend key),Promise.all
will reject and preventsendEmail
from running. To preserve the “email always sends; subscribe best-effort” behavior, decouple and swallow subscribe errors.- await Promise.all([ - subscribe({ - email: user.email, - name: user.name || undefined, - audience: isPartner ? "partners.dub.co" : "app.dub.co", - }), - sendEmail({ - to: user.email, - replyTo: "steven.tey@dub.co", - subject: `Welcome to Dub${isPartner ? " Partners" : ""}!`, - react: isPartner - ? WelcomeEmailPartner({ - email: user.email, - name: user.name, - }) - : WelcomeEmail({ - email: user.email, - name: user.name, - }), - variant: "marketing", - }), - ]); + const subscribePromise = subscribe({ + email: user.email, + name: user.name || undefined, + audience: isPartner ? "partners.dub.co" : "app.dub.co", + }).catch((err) => { + console.warn("Resend subscribe failed; continuing.", err); + }); + + await sendEmail({ + to: user.email, + replyTo: "steven.tey@dub.co", + subject: `Welcome to Dub${isPartner ? " Partners" : ""}!`, + react: isPartner + ? WelcomeEmailPartner({ + email: user.email, + name: user.name, + }) + : WelcomeEmail({ + email: user.email, + name: user.name, + }), + variant: "marketing", + }); + + await subscribePromise;apps/web/app/(ee)/api/cron/import/short/utils.ts (3)
33-34
: Optional: construct the Short.io URL with URL/URLSearchParams and add timeout/error handlingNo behavior change here, but building the URL via URLSearchParams avoids subtle formatting issues and guarantees proper encoding; adding a timeout and status check prevents hanging/crashing on upstream issues.
Apply this diff:
- const data = await fetch( - `https://api.short.io/api/links?domain_id=${domainId}&limit=50${pageToken ? `&pageToken=${pageToken}` : "" - }`, - { - headers: { - "Content-Type": "application/json", - Authorization: shortApiKey, - }, - }, - ).then((res) => res.json()); + const url = new URL("https://api.short.io/api/links"); + const params = new URLSearchParams({ domain_id: String(domainId), limit: "50" }); + if (pageToken) params.set("pageToken", pageToken); + url.search = params.toString(); + const data = await fetch(url.toString(), { + headers: { + "Content-Type": "application/json", + Authorization: shortApiKey, + }, + // Avoid hanging if Short.io is slow/unavailable + signal: AbortSignal.timeout(10_000), + }).then(async (res) => { + if (!res.ok) throw new Error(`Short.io ${res.status} ${await res.text()}`); + return res.json(); + });
149-155
: Type-safe filter and faster tag lookupTwo small wins:
- Replace filter(Boolean) with a type-guard to narrow to string[].
- Use a Map for O(1) lookups instead of Array.find in a loop. Helpful when many tags exist.
Apply this diff within the mapping:
- tagIds: tags - .map( - (tag: string) => - allTags.find((t) => t.name === tag)?.id ?? null, - ) - .filter(Boolean), + tagIds: tags + .map((tag) => tagNameToId.get(tag)) + .filter((id): id is string => Boolean(id)),And add this once above the bulkCreateLinks call (right before Line 142):
// Build a lookup to avoid O(n^2) scans const tagNameToId = new Map(allTags.map(({ name, id }) => [name, id] as const));
216-229
: Guard empty recipient and make email non-blockingThe rename to to is consistent with the new email API. To avoid failing the import flow due to an email transient and to skip sending when ownerEmail is empty, make the send optional and use Promise.allSettled. This aligns with the “SMTP fallback is a simple backup” behavior noted in retrieved learnings.
Apply this diff:
- await Promise.all([ + await Promise.allSettled([ // delete key from redis redis.del(`import:short:${workspaceId}`), // send email to user - sendEmail({ - subject: `Your Short.io links have been imported!`, - to: ownerEmail, - react: LinksImported({ - email: ownerEmail, - provider: "Short.io", - count, - links, - domains: [domain], - workspaceName: workspace?.name ?? "", - workspaceSlug: workspace?.slug ?? "", - }), - }), + ...(ownerEmail + ? [ + sendEmail({ + subject: "Your Short.io links have been imported!", + to: ownerEmail, + react: LinksImported({ + email: ownerEmail, + provider: "Short.io", + count, + links, + domains: [domain], + workspaceName: workspace?.name ?? "", + workspaceSlug: workspace?.slug ?? "", + }), + }), + ] + : []), ]);apps/web/lib/actions/partners/ban-partner.ts (2)
34-36
: Prefer enum comparison for status check.Improves type-safety and consistency with the enum used on updates.
- if (programEnrollment.status === "banned") { + if (programEnrollment.status === ProgramEnrollmentStatus.banned) {
95-99
: Enrich the log message for missing partner email.Helps triage by including identifiers.
- if (!partner.email) { - console.error("Partner has no email address."); + if (!partner.email) { + console.error(`Partner ${partnerId} has no email address (programId=${programId}).`); return; }apps/web/lib/actions/send-invite-referral-email.ts (2)
37-37
: Guard against missing app name in subjectAvoid “undefined” in subjects when
NEXT_PUBLIC_APP_NAME
isn’t set (common in local).- subject: `You've been invited to start using ${process.env.NEXT_PUBLIC_APP_NAME}`, + subject: `You've been invited to start using ${process.env.NEXT_PUBLIC_APP_NAME ?? "Dub"}`,
28-30
: Avoid PII in rate-limit keys by hashing the emailStoring raw emails in keys can leak PII via metrics/logs. Hash before keying.
Add import:
+import { createHash } from "node:crypto";
Hash and use the digest:
- const { success: successEmail } = await ratelimit(2, "2 h").limit( - `invite-referral-email:${email}`, - ); + const hashedEmail = createHash("sha256") + .update(email.toLowerCase()) + .digest("hex"); + const { success: successEmail } = await ratelimit(2, "2 h").limit( + `invite-referral-email:${hashedEmail}`, + );apps/web/lib/tolt/import-commissions.ts (1)
314-390
: Avoid mixing booleans with promises in Promise.all
Promise.all([ ..., saleAmount > 0 && recordSale..., ..., saleAmount > 0 && prisma.customer.update(...) ])
relies on truthy coercion. It works but harms readability and types.Consider:
const tasks = [ prisma.commission.create({ /* ... */ }), prisma.link.update({ /* ... */ }), ] as Promise<unknown>[]; if (saleAmount > 0) { tasks.push( recordSaleWithTimestamp({ /* ... */ }), prisma.customer.update({ /* ... */ }) ); } await Promise.all(tasks);apps/web/app/(ee)/api/cron/domains/verify/utils.ts (4)
125-127
: Typo in log message ("andhas").Minor but noisy in ops logs.
- message: `Domain *${domain}* has been invalid for > 30 days andhas links but no link clicks, deleting.`, + message: `Domain *${domain}* has been invalid for > 30 days and has links but no link clicks, deleting.`,
108-121
: Avoid re-promoting a deleted domain to primary.findFirst() doesn’t exclude the just-deleted domain or deleted domains in general. Could flip primary back onto a deleted record.
Apply:
- const anotherDomain = await prisma.domain.findFirst({ - where: { - projectId: workspace.id, - }, - }); + const anotherDomain = await prisma.domain.findFirst({ + where: { + projectId: workspace.id, + slug: { not: domain }, + deletedAt: null, // adjust to your schema + }, + orderBy: { primary: "desc" }, // prefer an existing primary if any + });
197-207
: Persisting “sentEmail” even if delivery fails.With allSettled, prisma.sentEmail.create will still run if sends fail. If that’s intended (record attempt), ignore. Otherwise, gate creation on successful sends.
- return await Promise.allSettled([ - log({...}), - ...emails.map(...sendEmail...), - prisma.sentEmail.create({...}), - ]); + const results = await Promise.allSettled([ + ...emails.map(...sendEmail...), + ]); + const allOk = results.every(r => r.status === "fulfilled"); + await log({...}); + if (allOk) { + await prisma.sentEmail.create({...}); + } + return results;Also applies to: 210-220
3-5
: Use sendBatchEmail (single scheduled batch) with the repo's array payload shapesendBatchEmail is exported in packages/email and expects an array of per-recipient items ({ to, subject, variant, react, … }); change the import to
import { sendEmail, sendBatchEmail } from "@dub/email";
and replace the per-recipient...emails.map(email => limiter.schedule(() => sendEmail(...)))
with a single scheduled call:limiter.schedule(() =>
sendBatchEmail(
emails.map((email) => ({
to: email,
subject:Your domain ${domain} has been deleted
,
variant: "notifications",
react: DomainDeleted({ email, domain, workspaceSlug }),
})),
),
);Do the same for the InvalidDomain path. Locations: apps/web/app/(ee)/api/cron/domains/verify/utils.ts (imports at lines 3–5; callsites around 128–141 and 195–209).
apps/web/app/api/user/set-password/route.ts (1)
39-47
: Rename toto
is consistent; consider non-blocking send for latency.Optional: mirror reset-password and avoid awaiting email to reduce API latency.
Apply within this block:
- await sendEmail({ + waitUntil(sendEmail({ subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Password reset instructions`, to: session.user.email, react: ResetPasswordLink({ email: session.user.email, url: `${process.env.NEXTAUTH_URL}/auth/reset-password/${token}`, }), - }); + }));Add import (outside the range):
import { waitUntil } from "@vercel/functions";Verify this route runs in an environment where
@vercel/functions
waitUntil
is available.apps/web/lib/partnerstack/update-stripe-customers.ts (3)
181-186
: Use an accurate error code and fix the metadata key in the message.The case is “multiple customers,” but code is
STRIPE_CUSTOMER_NOT_FOUND
, and the message referencesmetadata.tolt_referral
while the logic checksmetadata.customer_key
.- await logImportError({ - ...commonImportLogInputs, - code: "STRIPE_CUSTOMER_NOT_FOUND", - message: `Stripe search returned multiple customers for ${customer.email} for workspace ${workspace.slug} and none had metadata.tolt_referral set`, - }); + await logImportError({ + ...commonImportLogInputs, + code: "STRIPE_MULTIPLE_CUSTOMERS_FOUND", + message: `Stripe search returned multiple customers for ${customer.email} in workspace ${workspace.slug} and none had metadata.customer_key set`, + });
104-119
: Don’t throw if the workspace user membership changed; fall back gracefully.If the user was removed during the long-running job,
findUniqueOrThrow
will crash the tail of the import. PreferfindUnique
and keep the existing null guard.- const workspaceUser = await prisma.projectUsers.findUniqueOrThrow({ + const workspaceUser = await prisma.projectUsers.findUnique({ where: { userId_projectId: { userId, projectId: workspace.id, }, },
57-61
: Trim unused field from select to save I/O.
name
isn’t used downstream; fetch only what you need.- select: { - id: true, - name: true, - email: true, - }, + select: { + id: true, + email: true, + },apps/web/lib/actions/partners/approve-bounty-submission.ts (3)
98-115
: Avoid inserting non-promises into Promise.allSettled.Using partner.email && sendEmail(...) can push a falsy scalar into the array. Prefer conditionally spreading an array so the list only contains Promises.
Apply this minimal diff:
- partner.email && - sendEmail({ + ...(partner.email + ? [ + sendEmail({ subject: "Bounty approved!", to: partner.email, variant: "notifications", react: BountyApproved({ email: partner.email, program: { name: program.name, slug: program.slug, supportEmail: program.supportEmail || "support@dub.co", }, bounty: { name: bounty.name, type: bounty.type, }, }), - }), + }), + ] + : []),
99-103
: Align subject with template copy or add context.Template preview says “Bounty confirmed”. Consider matching that or adding program/bounty context for clarity.
Example:
- subject: "Bounty approved!", + subject: `Bounty confirmed — ${program.name}`,
98-115
: Optional: set replyTo to program.supportEmail (Resend path)Resend supports replyTo (ResendEmailOptions extends CreateEmailOptions) and send-via-resend already forwards replyTo (defaults to "support@dub.co"). Add replyTo: program.supportEmail to the sendEmail call in apps/web/lib/actions/partners/approve-bounty-submission.ts to improve reply UX; NodeMailer fallback intentionally ignores extras.
apps/web/lib/actions/send-otp.ts (2)
72-85
: Rename misspelled local variable for clarity.
hasExistingApplicaitons
→hasExistingApplications
.- const [isPartnerAccount, hasExistingApplicaitons] = await Promise.all([ + const [isPartnerAccount, hasExistingApplications] = await Promise.all([ ... - if (!isPartnerAccount && !hasExistingApplicaitons) { + if (!isPartnerAccount && !hasExistingApplications) {
112-129
: Avoid partial success between DB write and email send.
Promise.all
can leave a token created even if email sending fails. Sequence the operations to improve consistency.- await Promise.all([ - prisma.emailVerificationToken.create({ - data: { - identifier: email, - token: code, - expires: new Date(Date.now() + EMAIL_OTP_EXPIRY_IN * 1000), - }, - }), - - sendEmail({ - subject: `${process.env.NEXT_PUBLIC_APP_NAME}: OTP to verify your account`, - to: email, - react: VerifyEmail({ - email, - code, - }), - }), - ]); + await prisma.emailVerificationToken.create({ + data: { + identifier: email, + token: code, + expires: new Date(Date.now() + EMAIL_OTP_EXPIRY_IN * 1000), + }, + }); + await sendEmail({ + subject: `${process.env.NEXT_PUBLIC_APP_NAME}: OTP to verify your account`, + to: email, + react: VerifyEmail({ email, code }), + });If you prefer atomicity, wrap in a transaction and only commit before sending, or add a retry on send failure.
apps/web/lib/actions/partners/reject-bounty-submission.ts (1)
89-110
: Prefer an explicit ternary to keep Promise.allSettled inputs homogeneous and aid TS narrowingUsing partner.email && … inserts a non-Promise value when falsy. Promise.allSettled will coerce it, but a ternary keeps intent clear and narrows partner.email to string in the true branch.
- partner.email && - sendEmail({ - subject: "Bounty rejected", - to: partner.email, - variant: "notifications", - react: BountyRejected({ - email: partner.email, - program: { - name: program.name, - slug: program.slug, - supportEmail: program.supportEmail || "support@dub.co", - }, - bounty: { - name: bounty.name, - }, - submission: { - rejectionReason: - REJECT_BOUNTY_SUBMISSION_REASONS[rejectionReason], - }, - }), - }), + partner.email + ? sendEmail({ + subject: "Bounty rejected", + to: partner.email, + variant: "notifications", + react: BountyRejected({ + email: partner.email, + program: { + name: program.name, + slug: program.slug, + supportEmail: program.supportEmail || "support@dub.co", + }, + bounty: { + name: bounty.name, + }, + submission: { + rejectionReason: + REJECT_BOUNTY_SUBMISSION_REASONS[rejectionReason], + }, + }), + }) + : Promise.resolve(),apps/web/lib/actions/partners/create-bounty-submission.ts (3)
135-152
: Avoid brittle rest-destructuring for workspace.
const { users, program, ...workspace } = ...
assumes flat fields; if the API returns{ workspace }
,workspace.slug
here will be undefined.Proposed explicit destructure (adjust to the actual return shape):
-const { users, program, ...workspace } = await getWorkspaceUsers({ +const { users, program, workspace } = await getWorkspaceUsers({ programId, role: Role.owner, notificationPreference: "newBountySubmitted", });If the function doesn’t return
workspace
, keep current code but add a runtime guard before usingworkspace.slug
.
141-167
: Harden batch send: filter/unique recipients and keep payload small.Guard against missing emails and duplicates before calling
sendBatchEmail
.- if (users.length > 0) { - await sendBatchEmail( - users.map((user) => ({ + if (users.length > 0) { + const recipients = Array.from( + new Set(users.map((u) => u.email).filter(Boolean)) + ) as string[]; + if (recipients.length) { + await sendBatchEmail( + recipients.map((email) => ({ variant: "notifications", - to: user.email, + to: email, subject: "Pending bounty review", react: BountyPendingReview({ - email: user.email, + email, workspace: { slug: workspace.slug, }, bounty: { id: bounty.id, name: bounty.name, }, partner: { name: partner.name, image: partner.image, - email: partner.email!, + email: partner.email ?? "(not provided)", }, submission: { id: submission.id, }, }), })), - ); + ); + } }Note: If bulk payload should use
to
, swap the key accordingly after verifying the API.
132-188
: WrapwaitUntil
task with try/catch for safe background execution.Prevents unhandled rejections from surfacing as function errors and adds context to logs.
- waitUntil( - (async () => { + waitUntil( + (async () => { + try { // Send email to the program owners const { users, program, ...workspace } = await getWorkspaceUsers({ programId, role: Role.owner, notificationPreference: "newBountySubmitted", }); ... - } - })(), + } + } catch (err) { + console.error("createBountySubmissionAction: email dispatch failed", { + programId, + bountyId, + submissionId: submission.id, + err, + }); + } + })(), );apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts (4)
21-22
: Use URLSearchParams for tags URL construction (consistency + safety).Match the pattern used below for links to avoid hand-built query strings and ensure proper encoding.
- `https://api.rebrandly.com/v1/tags?orderBy=name&orderDir=desc&limit=25${lastTagId ? `&last=${lastTagId}` : ""}`, + new URL( + "https://api.rebrandly.com/v1/tags?" + + new URLSearchParams({ + orderBy: "name", + orderDir: "desc", + limit: "25", + ...(lastTagId ? { last: lastTagId } : {}), + }).toString(), + ).toString(),
44-49
: Preserve Rebrandly tag color when available (fallback to random).If Rebrandly provides a color, prefer it to maintain user context; otherwise use
randomBadgeColor()
.- color: randomBadgeColor(), + color: tag.color || randomBadgeColor(),
141-143
: Avoid shadowinglinks
identifier inside the completion block.Minor readability nit: renaming prevents confusion with the outer
links
array used earlier.- const links = workspace?.links ?? []; + const recentLinks = workspace?.links ?? []; @@ - links, + links: recentLinks,
174-174
: Unify importLinksFromRebrandly return type (number vs QStash result).Currently the function returns the final count when the import completes (utils.ts:174) but returns the qstash.publishJSON result when scheduling another job (utils.ts:253–267). The only caller (apps/web/app/(ee)/api/cron/import/rebrandly/route.ts) awaits the call but does not use its return value, so no call-site depends on the mixed type. Make the function consistently return the count (preferred) or change it to Promise and handle qstash responses upstream.
apps/web/app/(ee)/api/stripe/webhook/utils.ts (1)
33-45
: Prefer filtering valid recipients and using allSettled to avoid short-circuiting on single failureMap currently returns
false
for owners without email andPromise.all
will reject on first failure. Filter first and useallSettled
for resilience.- return await Promise.all( - owners.map( - (owner) => - owner.email && - sendEmail({ - to: owner.email, - from: "Steven Tey <steven@dub.co>", - replyTo: "steven.tey@dub.co", - subject: "Feedback for Dub.co?", - text: `Hey ${owner.name ? owner.name.split(" ")[0] : "there"}!\n\nSaw you canceled your Dub subscription${reasonText ? ` and mentioned that ${reasonText}` : ""} – do you mind sharing if there's anything we could've done better on our side?\n\nWe're always looking to improve our product offering so any feedback would be greatly appreciated!\n\nThank you so much in advance!\n\nBest,\nSteven Tey\nFounder, Dub.co`, - }), - ), - ); + return await Promise.allSettled( + owners + .filter((o) => !!o.email) + .map((owner) => + sendEmail({ + to: owner.email!, + from: "Steven Tey <steven@dub.co>", + replyTo: "steven.tey@dub.co", + subject: "Feedback for Dub.co?", + text: `Hey ${owner.name ? owner.name.split(" ")[0] : "there"}!\n\nSaw you canceled your Dub subscription${reasonText ? ` and mentioned that ${reasonText}` : ""} – do you mind sharing if there's anything we could've done better on our side?\n\nWe're always looking to improve our product offering so any feedback would be greatly appreciated!\n\nThank you so much in advance!\n\nBest,\nSteven Tey\nFounder, Dub.co`, + }), + ), + );apps/web/lib/cron/send-limit-email.ts (1)
21-24
: Guard against division by zero for percentageIf
workspace.linksLimit
is 0, this willInfinity
/NaN. Add a safe guard.- const percentage = Math.round( - (workspace.linksUsage / workspace.linksLimit) * 100, - ); + const percentage = + workspace.linksLimit > 0 + ? Math.round((workspace.linksUsage / workspace.linksLimit) * 100) + : 0;apps/web/lib/webhook/failure.ts (2)
109-126
: Consider awaiting or deferring email to avoid process teardown dropping sendsThese sends are fire-and-forget. If this runs in a serverless request context, they can be dropped when the handler returns.
- sendEmail({ + await sendEmail({ subject: "Webhook is failing to deliver", to: email, react: WebhookFailed({ email, workspace: { name: workspace.name, slug: workspace.slug, }, webhook: { id: webhook.id, url: webhook.url, consecutiveFailures: webhook.consecutiveFailures, disableThreshold: WEBHOOK_FAILURE_DISABLE_THRESHOLD, }, }), });
156-172
: Same here: await or queue send to ensure deliveryMirror the approach for the disabled notification.
- sendEmail({ + await sendEmail({ subject: "Webhook has been disabled", to: email, react: WebhookDisabled({ email, workspace: { name: workspace.name, slug: workspace.slug, }, webhook: { id: webhook.id, url: webhook.url, disableThreshold: WEBHOOK_FAILURE_DISABLE_THRESHOLD, }, }), });apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx (1)
69-75
: Use strict equality in the subject prefix.Avoid
==
in TS; switch to===
forattemptCount
.- subject: `${attemptCount == 2 + subject: `${attemptCount === 2 ? "2nd notice: " - : attemptCount == 3 + : attemptCount === 3 ? "3rd notice: " : "" }Your payment for Dub.co failed`,packages/email/src/send-via-nodemailer.ts (2)
15-23
: Remove ts-ignore and coerce env types; add sane defaults for local dev.Prevents type leaks and eases MailHog defaults.
- const transporter = nodemailer.createTransport({ - // @ts-ignore (Fix this) - host: process.env.SMTP_HOST, - port: process.env.SMTP_PORT, + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST ?? "localhost", + port: Number(process.env.SMTP_PORT ?? 1025),
29-31
: Optional: clearer local sender.A recognizable local from helps debugging.
- from: "noreply@example.com", + from: "Dub Local <noreply@localhost>",apps/web/app/(ee)/api/cron/year-in-review/route.ts (1)
106-121
: Remove stale ts-ignore comments; types line up now.
emailData
is typed withResendEmailOptions
, so both the recipients log and the batch call can be strictly typed without@ts-ignore
.- console.log( - `📨 Recipients:`, - // @ts-ignore - batch.map((b) => b.email.to), - ); + console.log( + `📨 Recipients:`, + batch.map((b) => b.email.to), + ); @@ - const { data, error } = await sendBatchEmail( - // @ts-ignore - batch.map((b) => b.email), - ); + const { data, error } = await sendBatchEmail( + batch.map((b) => b.email), + );apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts (2)
109-121
: Rename toto
looks correct; watch SMTP fallback ignoring replyTo.The switch to
to
aligns with the new email API. Note: in local SMTP fallback, replyTo/from aren’t preserved (per retrieved learning), so don’t rely on replyTo being honored during MailHog testing.Using the retrieved learning: sendViaNodeMailer is a minimal backup and won’t preserve from/replyTo. If replyTo is important, consider reflecting it in the email body for local runs or adding a header note.
100-106
: Avoid unsafe casts by filtering null emails.You can drop the
as string
by filtering falsy emails up front.- const users = workspace.users.map(({ user }) => ({ - id: user.id, - name: user.name, - email: user.email, - })); + const users = workspace.users + .filter(({ user }) => Boolean(user.email)) + .map(({ user }) => ({ + id: user.id, + name: user.name, + email: user.email as string, + }));apps/web/scripts/send-emails.tsx (1)
20-25
: Ternary subject is hard to scan; simplify for readability.- subject: `${attemptCount == 2 - ? "2nd notice: " - : attemptCount == 3 - ? "3rd notice: " - : "" - }Your payment for Dub.co failed`, + subject: `${attemptCount === 2 ? "2nd notice: " : attemptCount === 3 ? "3rd notice: " : ""}Your payment for Dub.co failed`,Or:
+ const prefix = attemptCount === 2 ? "2nd notice: " : attemptCount === 3 ? "3rd notice: " : ""; + subject: `${prefix}Your payment for Dub.co failed`,apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (2)
246-260
: Batch API usage looks good; consider surfacing send errors.- await sendBatchEmail( + const res1 = await sendBatchEmail( workspaceOwners.map(({ user }) => ({ variant: "notifications", to: user.email!, subject: "Domain expired", react: DomainExpired({ email: user.email!, workspace: { name: workspace.name, slug: workspace.slug, }, domains, }), })), ); + if (res1.error) console.warn("sendBatchEmail(domain expired) error:", res1.error);
276-289
: Same as above: log/send telemetry on batch result.- await sendBatchEmail( + const res2 = await sendBatchEmail( workspaceOwners.map(({ user }) => ({ variant: "notifications", to: user.email!, subject: "Domain renewal failed", react: DomainRenewalFailed({ email: user.email!, workspace: { slug: workspace.slug, }, domains, }), })), ); + if (res2.error) console.warn("sendBatchEmail(domain renewal failed) error:", res2.error);
48eec1e
to
9a9bfe0
Compare
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: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (10)
apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx (1)
6-21
: Enforce route-level webhook idempotency (dedupe by Stripe event.id).Route constructs events at apps/web/app/(ee)/api/stripe/webhook/route.ts; some handlers already use dedupe (apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts uses deduplicationId: event.id; apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts uses an invoice-based dedupe), but apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx does not appear to dedupe. Persist/upsert incoming event.id at the route level or ensure handlers enqueue/process with deduplicationId: event.id to avoid duplicate emails/updates on Stripe retries.
apps/web/lib/actions/partners/bulk-ban-partners.ts (2)
164-183
: Chunk batch emails to <=100 per requestUnchunked sends can exceed provider limits and fail when banning >100 partners.
- await sendBatchEmail( - programEnrollments - .filter(({ partner }) => partner.email) - .map(({ partner }) => ({ - to: partner.email!, - subject: `You've been banned from the ${program.name} Partner Program`, - variant: "notifications", - react: PartnerBanned({ - partner: { - name: partner.name, - email: partner.email!, - }, - program: { - name: program.name, - slug: program.slug, - }, - bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], - }), - })), - ); + const BATCH_LIMIT = 100; // Resend cap + const payload = programEnrollments + .filter(({ partner }) => partner.email) + .map(({ partner }) => ({ + to: partner.email!, + subject: `You've been banned from the ${program.name} Partner Program`, + variant: "notifications", + react: PartnerBanned({ + partner: { + name: partner.name, + email: partner.email!, + }, + program: { + name: program.name, + slug: program.slug, + }, + bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], + }), + })); + + for (const batch of chunk(payload, BATCH_LIMIT)) { + await sendBatchEmail(batch); + }
159-162
: Remove Resend guard to enable SMTP fallbacksendBatchEmail (packages/email/src/index.ts) already handles Resend → SMTP fallback; the guard in apps/web/lib/actions/partners/bulk-ban-partners.ts (lines 159–162) short-circuits that fallback and must be removed.
- if (!resend) { - console.error("Resend is not configured, skipping email sending."); - return; - }apps/web/lib/tolt/import-commissions.ts (1)
20-26
: Status mapping:approved
→pending
is likely incorrect.FirstPromoter maps
approved
→processed
; Tolt should probably do the same to avoid under-reporting progress.Apply:
const toDubStatus: Record<ToltCommission["status"], CommissionStatus> = { pending: "pending", - approved: "pending", + approved: "processed", paid: "paid", rejected: "canceled", refunded: "refunded", };apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (2)
93-104
: Make payout creation idempotent to avoid double payouts on retries.This route is retried by QStash on non‑2xx; without an idempotency key, a transient failure after
payouts.create
(e.g., email error) can create duplicate payouts.Apply:
{ stripeAccount, + idempotencyKey: `payout:${stripeAccount}:${currency}:${availableBalance}:${new Date().toISOString().slice(0, 10)}`, },
136-154
: Don’t fail the job on email send; log and continue. Also avoid logging full provider response.Email errors shouldn’t trigger QStash retries post‑payout; and logging the full response may include PII/provider metadata.
Apply:
- if (partner.email) { - const sentEmail = await sendEmail({ + if (partner.email) { + try { + const sentEmail = await sendEmail({ variant: "notifications", subject: "Your funds are on their way to your bank", to: partner.email, react: PartnerPayoutWithdrawalInitiated({ email: partner.email, payout: { amount: stripePayout.amount, currency: stripePayout.currency, arrivalDate: stripePayout.arrival_date, }, }), - }); - - console.log( - `Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`, - ); - } + }); + console.log(`Sent payout email to ${partner.email} (${stripeAccount}).`); + } catch (e) { + console.warn( + `Email notification failed for ${partner.email} (${stripeAccount}): ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + }apps/web/app/(ee)/api/cron/messages/notify-program/route.ts (1)
113-144
: Don’t treat data === null as failure; SMTP fallback intentionally returns null.When Resend is absent, sendBatchEmail resolves with
{ data: null, error: null }
. Current checks will throw and block MailHog/local SMTP. Gate persistence ondata?.data
only.- const { data, error } = await sendBatchEmail( + const { data, error } = await sendBatchEmail( userEmailsToNotify.map((email) => ({ subject: `${unreadMessages.length === 1 ? "New message from" : `${unreadMessages.length} new messages from`} ${partner.name}`, variant: "notifications", to: email, react: NewMessageFromPartner({ workspaceSlug: program.workspace.slug, partner: { id: partner.id, name: partner.name, image: partner.image, }, messages: unreadMessages.map((message) => ({ text: message.text, createdAt: message.createdAt, })), email, }), tags: [{ name: "type", value: "message-notification" }], })), ); - if (error) + if (error) throw new Error( `Error sending message emails to program ${programId} users: ${error.message}`, ); - if (!data) - throw new Error( - `No data received from sending message emails to program ${programId} users`, - ); + // SMTP fallback returns no IDs; skip persistence in that case + if (!data?.data?.length) { + await log({ + message: + "Sent via SMTP (no Resend IDs); skipping notificationEmail persistence.", + type: "debug", + }); + } else { + await prisma.notificationEmail.createMany({ + data: unreadMessages.flatMap((message) => + data.data.map(({ id }) => ({ + type: NotificationEmailType.Message, + emailId: id, + messageId: message.id, + })), + ), + }); + } - - await prisma.notificationEmail.createMany({ - data: unreadMessages.flatMap((message) => - data.data.map(({ id }) => ({ - type: NotificationEmailType.Message, - emailId: id, - messageId: message.id, - })), - ), - });apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts (1)
104-132
: Same fallback handling: avoid throwing on null data and gate persistence.Mirror the SMTP fallback semantics to keep local dev (MailHog) working.
- const { data, error } = await sendBatchEmail( + const { data, error } = await sendBatchEmail( partnerEmailsToNotify.map((email) => ({ subject: `${program.name} sent ${unreadMessages.length === 1 ? "a message" : `${unreadMessages.length} messages`}`, variant: "notifications", to: email, react: NewMessageFromProgram({ program: { name: program.name, logo: program.logo, slug: program.slug, }, messages: unreadMessages.map((message) => ({ text: message.text, createdAt: message.createdAt, user: message.senderUser.name ? { name: message.senderUser.name, image: message.senderUser.image, } : { name: program.name, image: program.logo, }, })), email, }), tags: [{ name: "type", value: "message-notification" }], })), ); - if (error) + if (error) throw new Error( `Error sending message emails to partner ${partnerId}: ${error.message}`, ); - if (!data) - throw new Error( - `No data received from sending message emails to partner ${partnerId}`, - ); + if (!data?.data?.length) { + await log({ + message: + "Sent via SMTP (no Resend IDs); skipping notificationEmail persistence.", + type: "debug", + }); + } else { + await prisma.notificationEmail.createMany({ + data: unreadMessages.flatMap((message) => + data.data.map(({ id }) => ({ + type: NotificationEmailType.Message, + emailId: id, + messageId: message.id, + })), + ), + }); + }packages/email/src/send-via-resend.ts (1)
6-18
: Pass through cc; it’s currently dropped.Resend supports cc; omitting it will silently ignore callers’ cc. Add it to destructure and payload.
-const resendEmailForOptions = (opts: ResendEmailOptions) => { +const resendEmailForOptions = (opts: ResendEmailOptions) => { const { to, from, variant = "primary", - bcc, + cc, + bcc, replyTo, subject, text, react, scheduledAt, headers, } = opts; return { to, from: from || VARIANT_TO_FROM_MAP[variant], - bcc: bcc, + cc, + bcc, replyTo: replyTo || "support@dub.co", subject, text, react, scheduledAt, ...(variant === "marketing" ? { headers: { ...(headers || {}), "List-Unsubscribe": "https://app.dub.co/account/settings", }, } : { headers, }), }; };Also applies to: 20-39
apps/web/lib/api/partners/notify-partner-commission.ts (1)
88-106
: Guard nullable partner.email in email templates
- packages/email/src/templates/new-sale-alert-program-owner.tsx — the expression
{partner.name ?${partner.name} (${partner.email})
: partner.email}
will render "null" when partner.email is null. Only interpolate the email when present (e.g. use: partner.name ? (partner.email ?${partner.name} (${partner.email})
: partner.name) : (partner.email ?? "")).- packages/email/src/templates/new-commission-alert-partner.tsx — this component expects a non-null email (prop typed as string). Confirm callers only pass a real email (apps/web/lib/api/partners/notify-partner-commission.ts — the NewCommissionAlertPartner call around the sendEmail block) or add a guard/coerce before invoking.
♻️ Duplicate comments (2)
apps/web/lib/actions/partners/bulk-ban-partners.ts (1)
11-11
: Import chunk helper to respect provider batch limitsResend batch API caps payload size (100). Prepare to chunk the payload.
import { sendBatchEmail } from "@dub/email"; +import { chunk } from "@dub/utils";
apps/web/lib/tolt/import-commissions.ts (1)
345-347
: Don’t serialize fullcommission
into analytics metadata (PII + payload bloat).Repeat of prior feedback; please sanitize before sending to Tinybird.
Apply:
- metadata: JSON.stringify(commission), + metadata: JSON.stringify({ + commission: { + id: commission.id, + status: commission.status, + amount: Number(commission.amount ?? 0), + }, + sale: { + transaction_id: sale.transaction_id, + created_at: sale.created_at, + revenue: Number(sale.revenue ?? 0), + }, + provider: "tolt", + }),
🧹 Nitpick comments (22)
apps/web/lib/actions/partners/create-program.ts (1)
196-205
: Set an explicit variant for deterministic sender.To avoid transport‑dependent defaults, set
variant: "notifications"
here as well (the invite email below already does this).sendEmail({ subject: `Your program ${program.name} is created and ready to share with your partners.`, + variant: "notifications", to: user.email!, react: ProgramWelcome({ email: user.email!, workspace, program, }), }),
apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx (3)
40-43
: Guard against null user emails; avoid relying onas string
.
A null email will flow through at runtime despite TS casts. Filter at the query and/or at runtime.Option A — filter in Prisma:
where: { user: { isMachine: false, + email: { not: null }, }, },
Option B — runtime filter (can be combined with A):
- ...workspace.users.map(({ user }) => + ...workspace.users + .filter(({ user }) => !!user.email) + .map(({ user }) => sendEmail({ - to: user.email as string, + to: user.email as string, subject: `${ attemptCount == 2 ? "2nd notice: " : attemptCount == 3 ? "3rd notice: " : "" }Your payment for Dub.co failed`, react: ( <FailedPayment attemptCount={attemptCount} amountDue={amountDue} user={{ name: user.name, email: user.email as string, }} workspace={workspace} /> ), variant: "notifications", }), - ), + ),Also applies to: 66-83
70-74
: Use strict equality for attempt count checks.
Avoid implicit coercion in TS/JS conditionals.- attemptCount == 2 + attemptCount === 2 - : attemptCount == 3 + : attemptCount === 3
57-91
: Surface failures from Promise.allSettled for observability.
Currently failures are dropped; log or metric them.- await Promise.allSettled([ + const results = await Promise.allSettled([ prisma.project.update({ where: { id: workspace.id, }, data: { paymentFailedAt: new Date(), }, }), ...workspace.users.map(({ user }) => sendEmail({ to: user.email as string, subject: `${ attemptCount === 2 ? "2nd notice: " : attemptCount === 3 ? "3rd notice: " : "" }Your payment for Dub.co failed`, react: ( <FailedPayment attemptCount={attemptCount} amountDue={amountDue} user={{ name: user.name, email: user.email as string, }} workspace={workspace} /> ), variant: "notifications", }), ), - ]); + ]); + for (const r of results) { + if (r.status === "rejected") { + console.error("invoicePaymentFailed: task failed", r.reason); + } + }apps/web/lib/firstpromoter/import-commissions.ts (1)
332-342
: Auditmetadata
for PII; avoid passing arbitrarycommission.metadata
.Even nested metadata can include emails or IDs. Prefer a small, whitelisted summary or drop it.
Apply one of the safer options:
- metadata: JSON.stringify(commission.metadata), + // Option A: minimal, consistent shape + metadata: JSON.stringify({ + commission: { + id: `${commission.id}`, + status: commission.status, + amount: Number(commission.amount ?? 0), + }, + provider: "firstpromoter", + }), + // Option B: if not needed downstream, omit entirely + // metadata: undefined,apps/web/app/(ee)/api/cron/welcome-user/route.ts (3)
44-66
: Avoid tying email delivery success to audience subscribe; guard or all‑settle.If
subscribe
throws (e.g., RESEND not configured or transient Resend errors),Promise.all
will reject and QStash may retry, risking duplicate welcome emails. Prefer guarding on config or usingallSettled
so email success dictates the route outcome.Apply one of these:
- await Promise.all([ - subscribe({ - email: user.email, - name: user.name || undefined, - audience: isPartner ? "partners.dub.co" : "app.dub.co", - }), - sendEmail({ + const [subRes, emailRes] = await Promise.allSettled([ + (async () => { + if (process.env.RESEND_API_KEY) { + return subscribe({ + email: user.email, + name: user.name || undefined, + audience: isPartner ? "partners.dub.co" : "app.dub.co", + }); + } + })(), + (async () => sendEmail({ to: user.email, replyTo: "steven.tey@dub.co", subject: `Welcome to Dub${isPartner ? " Partners" : ""}!`, react: isPartner ? WelcomeEmailPartner({ email: user.email, name: user.name, }) : WelcomeEmail({ email: user.email, name: user.name, }), variant: "marketing", - }), - ]); + }))(), + ]); + if (emailRes.status !== "fulfilled") throw emailRes.reason; + if (subRes.status === "rejected") { + console.warn("Resend subscribe failed for user", user.email, subRes.reason); + }
22-31
: Don’t fetch entire partner rows when you only need existence.Pulling full
partners
is heavier than needed. Select a single id (or use_count
) to computeisPartner
.- const user = await prisma.user.findUnique({ + const user = await prisma.user.findUnique({ where: { id: userId, }, select: { name: true, email: true, - partners: true, + partners: { select: { id: true }, take: 1 }, }, });This keeps
isPartner = user.partners.length > 0
unchanged.
20-21
: Validate request body shape before use.A minimal schema guard avoids noisy 500s from malformed JSON and makes retries deterministic.
- const { userId } = JSON.parse(rawBody); + const { z } = await import("zod"); + const parsed = z.object({ userId: z.string().min(1) }).safeParse(JSON.parse(rawBody)); + if (!parsed.success) { + return new Response("Invalid payload. Skipping...", { status: 200 }); + } + const { userId } = parsed.data;apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (4)
56-63
: Include in_transit payouts in the subtraction.
payouts.list({ status: "pending" })
won’t returnin_transit
items; your subsequent filter never sees them.Apply:
- const { data: stripePayouts } = await stripe.payouts.list( - { - status: "pending", - }, + const { data: stripePayouts } = await stripe.payouts.list( + { + limit: 100, + }, { stripeAccount, }, );(Your existing filter for
pending
/in_transit
then works.)Also applies to: 69-76
87-92
: Guard against rounding to 0 for HUF/TWD.If available < 100, rounding can produce 0, causing an invalid payout amount.
Apply:
if (["huf", "twd"].includes(currency)) { // For HUF and TWD, Stripe requires payout amounts to be evenly divisible by 100 // We need to round down to the nearest 100 units availableBalance = Math.floor(availableBalance / 100) * 100; } + if (availableBalance === 0) { + return logAndRespond( + `Rounded available balance is below minimum payout increment for ${currency.toUpperCase()}. Skipping...`, + ); + }
97-100
: Clarify description date.Comment shows “Aug 1, 2025” but only month is formatted. Include day/year.
Apply:
- description: `Dub Partners auto-withdrawal (${formatDate(new Date(), { month: "short" })})`, + description: `Dub Partners auto-withdrawal (${formatDate(new Date(), { month: "short", day: "numeric", year: "numeric" })})`,
151-153
: Reduce PII in logs.If you keep this log, avoid dumping full objects and prefer minimal fields (e.g., messageId).
If you keep the earlier try/catch change, this block will already be simplified; otherwise:
- console.log( - `Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`, - ); + console.log(`Sent payout email to ${partner.email} (${stripeAccount}).`);package.json (1)
38-38
: Version pin mismatch with README; align packageManager with docs.README recommends pnpm 9.15.9 but packageManager pins 8.6.10. Pick one to avoid Corepack confusion.
Apply:
- "packageManager": "pnpm@8.6.10" + "packageManager": "pnpm@9.15.9"packages/email/src/resend/client.ts (1)
3-5
: Make the optional client type explicit.Helps downstream inference in strict mode.
-export const resend = process.env.RESEND_API_KEY - ? new Resend(process.env.RESEND_API_KEY) - : null; +export const resend: Resend | null = process.env.RESEND_API_KEY + ? new Resend(process.env.RESEND_API_KEY) + : null;apps/web/scripts/unsubscribe-inactive-users.ts (1)
9-11
: Good guard; avoid duplicating audience config and drop ts-nocheck.
- Import the audience ID from your shared constants instead of hard‑coding.
- Replace file‑wide ts‑nocheck with targeted disables if needed.
-// @ts-nocheck – install resend lib before running this script +// install resend lib before running this script +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ @@ -import { Resend } from "resend"; +import { Resend } from "resend"; +import { RESEND_AUDIENCES } from "@dub/email/resend/constants"; @@ - if (!resend) { + if (!resend) { console.error( "No RESEND_API_KEY is set in the environment variables. Skipping.", ); return; } @@ - resend.contacts.remove({ - email: user.email!, - audienceId: "f5ff0661-4234-43f6-b0ca-a3f3682934e3", - }), + resend.contacts.remove({ + email: user.email!, + audienceId: RESEND_AUDIENCES["app.dub.co"], + }),Also applies to: 14-19
README.md (1)
76-80
: Minor wording/punctuation polish.“Run pnpm prisma:push to push the state … without using migration files.”
-- `The table <table-name> does not exist in the current database.` - Run `pnpm prisma:push` push the state of the Prisma schema file to the database without using migrations files. +- `The table <table-name> does not exist in the current database.` — Run `pnpm prisma:push` to push the state of the Prisma schema to the database without using migration files.apps/web/lib/api/partners/notify-partner-application.ts (1)
43-71
: Minor tidy: simplify boolean and keep template payload lean.Replace the ternary with Boolean() for clarity.
- program: { - name: program.name, - autoApprovePartners: program.autoApprovePartnersEnabledAt - ? true - : false, - }, + program: { + name: program.name, + autoApprovePartners: Boolean(program.autoApprovePartnersEnabledAt), + },apps/web/lib/actions/partners/create-bounty-submission.ts (1)
132-188
: Harden the waitUntil task with error logging.Uncaught rejections inside waitUntil can be hard to trace. Wrap sends in try/catch and log.
- waitUntil( - (async () => { + waitUntil( + (async () => { + try { // Send email to the program owners const { users, program, ...workspace } = await getWorkspaceUsers({ programId, role: Role.owner, notificationPreference: "newBountySubmitted", }); @@ - ); + ).catch((e) => { + console.error("sendBatchEmail failed (bounty pending review)", e); + }); } @@ - await sendEmail({ + await sendEmail({ subject: "Bounty submitted!", to: partner.email, react: BountySubmitted({ email: partner.email, bounty: { name: bounty.name, }, program: { name: program.name, slug: program.slug, }, }), - }); + }).catch((e) => { + console.error("sendEmail failed (bounty submitted)", e); + }); - } - })(), + } + } catch (e) { + console.error("waitUntil(createBountySubmission) task error", e); + } + })(), );apps/web/lib/api/partners/notify-partner-commission.ts (3)
1-5
: LGTM on imports; minor type-cast cleanups possible.If you adopt the refactor below, you can drop the ResendEmailOptions import altogether.
112-146
: Remove per-item type assertions; let inference or satisfies handle it.Casting each element to ResendEmailOptions is noisy. Prefer a single assertion or satisfies for the array.
- const allEmails: ResendBulkEmailOptions = [ - // Partner emails (for all commission types) - ...partnerEmailsToNotify.map( - (email) => - ({ + const allEmails = [ + ...partnerEmailsToNotify.map((email) => + ({ subject: "You just made a commission via Dub Partners!", variant: "notifications", to: email, react: NewCommissionAlertPartner({ email, ...data, }), - }) as ResendEmailOptions, - ), + }) + ), // Workspace owner emails (only for sale commissions) ...(commission.type === "sale" - ? workspaceUsers.map( - ({ user }) => - ({ + ? workspaceUsers.map(({ user }) => ({ subject: `New commission for ${partner.name}`, variant: "notifications", to: user.email!, react: NewSaleAlertProgramOwner({ ...data, user: { name: user.name, email: user.email!, }, workspace, }), - }) as ResendEmailOptions, - ) + })) : []), - ]; + ] satisfies ResendBulkEmailOptions;
150-153
: LGTM; batch size and usage are correct. Consider idempotency (optional).Sending in chunks with the helper is right. Consider adding idempotency keys in the email library to avoid duplicate sends on retries. (resend.com)
packages/email/src/send-via-resend.ts (1)
54-71
: Optional: add idempotency keys to batch sends.Protects against duplicate sends on retries; Resend supports an Idempotency-Key for batch. (resend.com)
- return await resend.batch.send(payload); + // Optionally derive a stable idempotency key from the payload + // (Consider threading an explicit key from the caller instead.) + const idempotencyKey = JSON.stringify(payload).length.toString(); + return await resend.batch.send(payload, { idempotencyKey });If preferred, expose an optional idempotencyKey param on sendBatchEmail/sendBatchEmailViaResend and pass it through rather than deriving it here.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (78)
README.md
(1 hunks)apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/domains/transfer/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/domains/verify/utils.ts
(2 hunks)apps/web/app/(ee)/api/cron/import/bitly/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/import/csv/utils.ts
(2 hunks)apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/import/short/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
(3 hunks)apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/messages/notify-program/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/partner-program-summary/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/program-application-reminder/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/usage/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/welcome-user/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/year-in-review/route.ts
(2 hunks)apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts
(1 hunks)apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts
(1 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts
(4 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts
(2 hunks)apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts
(1 hunks)apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx
(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils.ts
(1 hunks)apps/web/app/api/auth/reset-password/route.ts
(1 hunks)apps/web/app/api/dub/webhook/lead-created.ts
(1 hunks)apps/web/app/api/tokens/route.ts
(1 hunks)apps/web/app/api/user/password/route.ts
(1 hunks)apps/web/app/api/user/set-password/route.ts
(1 hunks)apps/web/app/api/webhooks/route.ts
(1 hunks)apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx
(1 hunks)apps/web/lib/actions/folders/request-folder-edit-access.ts
(1 hunks)apps/web/lib/actions/partners/approve-bounty-submission.ts
(1 hunks)apps/web/lib/actions/partners/ban-partner.ts
(1 hunks)apps/web/lib/actions/partners/bulk-approve-partners.ts
(3 hunks)apps/web/lib/actions/partners/bulk-ban-partners.ts
(2 hunks)apps/web/lib/actions/partners/create-bounty-submission.ts
(3 hunks)apps/web/lib/actions/partners/create-program.ts
(2 hunks)apps/web/lib/actions/partners/invite-partner.ts
(1 hunks)apps/web/lib/actions/partners/merge-partner-accounts.ts
(3 hunks)apps/web/lib/actions/partners/reject-bounty-submission.ts
(1 hunks)apps/web/lib/actions/partners/resend-program-invite.ts
(1 hunks)apps/web/lib/actions/request-password-reset.ts
(1 hunks)apps/web/lib/actions/send-invite-referral-email.ts
(1 hunks)apps/web/lib/actions/send-otp.ts
(1 hunks)apps/web/lib/api/domains/claim-dot-link-domain.ts
(3 hunks)apps/web/lib/api/partners/notify-partner-application.ts
(2 hunks)apps/web/lib/api/partners/notify-partner-commission.ts
(2 hunks)apps/web/lib/api/users.ts
(1 hunks)apps/web/lib/api/workflows/execute-award-bounty-action.ts
(1 hunks)apps/web/lib/auth/confirm-email-change.ts
(1 hunks)apps/web/lib/auth/options.ts
(1 hunks)apps/web/lib/cron/send-limit-email.ts
(1 hunks)apps/web/lib/firstpromoter/import-commissions.ts
(1 hunks)apps/web/lib/integrations/install.ts
(1 hunks)apps/web/lib/partners/approve-partner-enrollment.ts
(2 hunks)apps/web/lib/partnerstack/update-stripe-customers.ts
(1 hunks)apps/web/lib/rewardful/import-commissions.ts
(1 hunks)apps/web/lib/tolt/import-commissions.ts
(1 hunks)apps/web/lib/webhook/failure.ts
(2 hunks)apps/web/scripts/send-emails.tsx
(1 hunks)apps/web/scripts/unsubscribe-inactive-users.ts
(1 hunks)apps/web/ui/analytics/feedback/action.ts
(1 hunks)apps/web/ui/messages/messages-panel.tsx
(1 hunks)package.json
(2 hunks)packages/email/src/index.ts
(3 hunks)packages/email/src/resend/client.ts
(1 hunks)packages/email/src/resend/subscribe.ts
(1 hunks)packages/email/src/resend/types.ts
(1 hunks)packages/email/src/resend/unsubscribe.ts
(1 hunks)packages/email/src/send-via-nodemailer.ts
(2 hunks)packages/email/src/send-via-resend.ts
(2 hunks)
✅ Files skipped from review due to trivial changes (1)
- apps/web/ui/messages/messages-panel.tsx
🚧 Files skipped from review as they are similar to previous changes (58)
- apps/web/app/(ee)/api/cron/program-application-reminder/route.ts
- apps/web/app/(ee)/api/cron/import/csv/utils.ts
- apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
- apps/web/lib/webhook/failure.ts
- apps/web/app/api/user/password/route.ts
- apps/web/lib/actions/partners/resend-program-invite.ts
- apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts
- apps/web/lib/partnerstack/update-stripe-customers.ts
- apps/web/app/(ee)/api/cron/domains/transfer/utils.ts
- apps/web/app/(ee)/api/cron/import/bitly/utils.ts
- apps/web/lib/actions/partners/invite-partner.ts
- apps/web/lib/rewardful/import-commissions.ts
- apps/web/lib/integrations/install.ts
- apps/web/app/api/auth/reset-password/route.ts
- apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts
- apps/web/lib/actions/partners/ban-partner.ts
- apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx
- apps/web/lib/actions/request-password-reset.ts
- apps/web/app/api/tokens/route.ts
- apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts
- apps/web/app/(ee)/api/stripe/webhook/utils.ts
- apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts
- apps/web/lib/api/workflows/execute-award-bounty-action.ts
- apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts
- apps/web/lib/cron/send-limit-email.ts
- packages/email/src/send-via-nodemailer.ts
- apps/web/lib/actions/partners/merge-partner-accounts.ts
- apps/web/app/(ee)/api/cron/year-in-review/route.ts
- packages/email/src/resend/subscribe.ts
- apps/web/lib/auth/options.ts
- apps/web/app/(ee)/api/cron/partner-program-summary/route.ts
- apps/web/lib/actions/send-invite-referral-email.ts
- apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
- apps/web/app/(ee)/api/cron/usage/utils.ts
- apps/web/lib/actions/folders/request-folder-edit-access.ts
- apps/web/lib/api/domains/claim-dot-link-domain.ts
- apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts
- apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts
- apps/web/lib/auth/confirm-email-change.ts
- apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
- apps/web/app/api/user/set-password/route.ts
- apps/web/scripts/send-emails.tsx
- apps/web/app/(ee)/api/cron/import/short/utils.ts
- apps/web/lib/actions/partners/approve-bounty-submission.ts
- apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
- apps/web/lib/actions/partners/bulk-approve-partners.ts
- apps/web/app/api/webhooks/route.ts
- apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
- apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts
- apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
- apps/web/lib/actions/partners/reject-bounty-submission.ts
- apps/web/app/(ee)/api/cron/domains/verify/utils.ts
- apps/web/lib/api/users.ts
- apps/web/app/api/dub/webhook/lead-created.ts
- apps/web/lib/actions/send-otp.ts
- apps/web/lib/partners/approve-partner-enrollment.ts
- apps/web/ui/analytics/feedback/action.ts
- packages/email/src/index.ts
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2835
File: apps/web/ui/analytics/feedback/action.ts:10-13
Timestamp: 2025-09-12T21:08:43.942Z
Learning: In the Dub email system, sendViaNodeMailer is intentionally designed as a simple backup mechanism with hard-coded sender fields, not as a full-featured email sender that preserves all metadata like from/replyTo fields. The primary email path uses Resend for full functionality.
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.
Applied to files:
apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-09-12T21:08:43.942Z
Learnt from: TWilson023
PR: dubinc/dub#2835
File: apps/web/ui/analytics/feedback/action.ts:10-13
Timestamp: 2025-09-12T21:08:43.942Z
Learning: In the Dub email system, sendViaNodeMailer is intentionally designed as a simple backup mechanism with hard-coded sender fields, not as a full-featured email sender that preserves all metadata like from/replyTo fields. The primary email path uses Resend for full functionality.
Applied to files:
apps/web/lib/api/partners/notify-partner-application.ts
apps/web/lib/api/partners/notify-partner-commission.ts
🧬 Code graph analysis (9)
apps/web/scripts/unsubscribe-inactive-users.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-5)
apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
packages/email/src/resend/unsubscribe.ts (1)
packages/email/src/resend/client.ts (1)
resend
(3-5)
apps/web/app/(ee)/api/cron/messages/notify-program/route.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
apps/web/lib/api/partners/notify-partner-application.ts (2)
packages/email/src/resend/types.ts (1)
ResendBulkEmailOptions
(10-10)packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
apps/web/lib/api/partners/notify-partner-commission.ts (2)
packages/email/src/resend/types.ts (2)
ResendBulkEmailOptions
(10-10)ResendEmailOptions
(3-8)packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
packages/email/src/send-via-resend.ts (2)
packages/email/src/resend/types.ts (2)
ResendEmailOptions
(3-8)ResendBulkEmailOptions
(10-10)packages/email/src/resend/client.ts (1)
resend
(3-5)
apps/web/lib/actions/partners/bulk-ban-partners.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
⏰ 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 (15)
apps/web/lib/actions/partners/create-program.ts (1)
260-261
: Variant + “to” migration looks correct.Switching to
variant: "notifications"
andto
aligns with the new email API and removes the need forfrom
/VARIANT_TO_FROM_MAP
. Given the local SMTP fallback hard‑codes sender fields, confirm this template doesn’t rely on custom from/replyTo semantics.apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx (2)
78-80
: Resolved — FailedPayment template converts smallest-unit amounts to major units.
Template renders amount as ${amountDue / 100} in apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx, so Stripe's amount_due (smallest currency unit) is converted to major units; no change required.
68-68
: Approve — recipient renamed toto
(matches sendEmail) — LGTM.
Aligns call sites with the unified email helper. Verification script returned "No files were searched" — re-run or confirm no remainingemail:
usages with:
rg -nP -C1 -uu $'\bsendEmail\s*(\s*{[^}]\bemail\s:' --glob '!/node_modules/'apps/web/lib/firstpromoter/import-commissions.ts (1)
119-131
: Good migration toto:
; ensure no lingeringemail:
usages.Matches the new helper signature; SMTP fallback should still deliver without custom from/replyTo (per our learnings).
If you didn’t run it already, use the same repo-wide scan from the other comment to catch any stragglers.
apps/web/lib/tolt/import-commissions.ts (1)
126-137
: ```shell
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=Cecho "rg version:"
rg --version || trueecho
echo "1) search for sendEmail/sendBatchEmail calls (ignore .gitignore):"
rg -n --hidden -uu -S -C5 '\b(sendEmail|sendBatchEmail)\s*(' || trueecho
echo "2) search for 'email:' object keys:"
rg -n --hidden -uu -S -C3 '\bemail\s*:' || trueecho
echo "3) search for 'from:' or 'replyTo:' object keys:"
rg -n --hidden -uu -S -C3 '\b(from|replyTo)\s*:' || trueecho
echo "4) search for implementations/exports of sendEmail/sendBatchEmail:"
rg -n --hidden -uu -S -e 'function\s+sendEmail\b' -e 'export\s+function\s+sendEmail\b' -e 'const\s+sendEmail\s*=' -e 'function\s+sendBatchEmail\b' -e 'export\s+function\s+sendBatchEmail\b' -e 'const\s+sendBatchEmail\s*=' || trueecho
echo "5) if implementation files found, print first 300 lines for inspection:"
impl_files=$(rg -n --hidden -uu -S -l -e 'function\s+sendEmail\b' -e 'export\s+function\s+sendEmail\b' -e 'const\s+sendEmail\s*=' -e 'function\s+sendBatchEmail\b' -e 'export\s+function\s+sendBatchEmail\b' -e 'const\s+sendBatchEmail\s*=' || true)
if [ -n "$impl_files" ]; then
while IFS= read -r f; do
echo "----- $f -----"
sed -n '1,300p' "$f" || true
echo
done <<< "$impl_files"
else
echo "No sendEmail/sendBatchEmail implementation files found by simple patterns."
fiecho
echo "6) search for ProgramImported usage/definition:"
rg -n --hidden -uu -S 'ProgramImported' || trueecho
echo "7) search for 'nodemailer' mentions and package.json deps (if present):"
rg -n --hidden -uu -S 'nodemailer' || true
if [ -f package.json ]; then
echo "package.json dependencies/devDependencies:"
jq '(.dependencies // {}) + (.devDependencies // {})' package.json || true
else
echo "package.json not found at repo root."
fiecho
echo "8) fallback: scan common code files directly to avoid ripgrep ignore issues:"
while IFS= read -r -d '' f; do
echo "----- $f -----"
rg -n -C5 '\b(sendEmail|sendBatchEmail)\s*(' "$f" || true
done < <(find . -type f ( -name '.ts' -o -name '.tsx' -o -name '.js' -o -name '.jsx' ) -not -path './node_modules/' -not -path './.git/' -not -path './dist/' -not -path './build/' -print0)</blockquote></details> <details> <summary>apps/web/app/(ee)/api/cron/welcome-user/route.ts (1)</summary><blockquote> `51-64`: **Approve — `to:` usage is correct; no action required.** - Repo search found no `sendEmail({ email: ... })` usages; call sites use `to:`. - packages/email/src/send-via-resend.ts handles `replyTo` (uses `replyTo: replyTo || "support@dub.co"`). - No nodemailer fallback found in the repo that would drop `replyTo`; if your local/dev transport behaves differently, document that deviation. </blockquote></details> <details> <summary>apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (1)</summary><blockquote> `136-144`: **Param rename to "to" looks good — approve; unable to confirm no remaining sendEmail({ email: ... }) usages.** Matches the new email API; nodemailer fallback intentionally ignores custom from/replyTo. Sandbox ripgrep couldn't search files ("No files were searched"); verify locally with: rg -nP -U 'sendEmail\s*\(\s*\{[^}]*\bemail\s*:' -S (or rg -n 'sendEmail' -S). </blockquote></details> <details> <summary>packages/email/src/resend/unsubscribe.ts (1)</summary><blockquote> `11-16`: **Graceful no‑op when Resend is unavailable.** LGTM. Ensure callers tolerate a void return from packages/email/src/resend/unsubscribe.ts (lines 11–16). Run to confirm no callers expect a value: ```shell rg -nP 'unsubscribe\(\s*{[^}]*}\s*\)\s*(?:\.then|\.data|\sawait\s+\w+\s*=)' --type=ts -C1
README.md (1)
69-75
: Align README versions with repo: update README or packageManager to match pnpm@8.6.10Root package.json.packageManager is pnpm@8.6.10 (verified). README still shows pnpm 9.15.9 and Node v23.11.0; no .nvmrc/.node-version found. Either:
- Update README.md (lines 69–75) to recommend pnpm@8.6.10 and add/pin the Node version (via .nvmrc or engines.node), or
- Update package.json.packageManager to pnpm@9.15.9 if you intend to standardize on pnpm 9.x.
package.json (1)
7-7
: Filter verified — './packages/' includes nested packages; no change needed**
- pnpm -r --filter "./packages/**" matched all workspace packages under packages/ (cli, email, embeds/core, embeds/react, prisma, stripe-app, tailwind-config, tsconfig, ui, utils).
- pnpm emitted a warning: "resolutions" is present in packages/stripe-app/package.json — move that field to the root workspace package.json if you want it to take effect.
packages/email/src/resend/types.ts (1)
5-5
: Keepto: string
— batching covers multi-recipient; no array usage found.Confirmed: packages/email/src/resend/types.ts defines
to: string
andResendBulkEmailOptions = ResendEmailOptions[]
; callsites pass single-stringto
(packages/email/src/send-via-nodemailer.ts, packages/email/src/index.ts). Noto: string[]
,Array<string>
, or object-literalto: [ ... ]
occurrences found across the repo.apps/web/lib/api/partners/notify-partner-application.ts (2)
75-78
: Batch size matches Resend’s limit.Chunking by 100 is correct for the batch endpoint’s max-per-call. (resend.com)
1-2
: LGTM — centralize batch email API; only wrapper uses resend.batch.sendFound a single direct call at packages/email/src/send-via-resend.ts:70 — this is the centralized wrapper and acceptable; no other direct calls detected.
apps/web/lib/actions/partners/create-bounty-submission.ts (2)
12-12
: LGTM on using the shared email helpers.Consolidates transport decisions and keeps the action clean.
171-174
: Partner email path looks good.Swapping to the unified helper and using “to” keeps it consistent with the batch path.
6ded1fd
to
a4d8f97
Compare
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 (1)
packages/email/src/index.ts (1)
27-30
: Return a structured error when no email service is configured.Currently returns undefined; callers can’t detect failure and may crash on logging.
console.info( "Email sending failed: Neither SMTP nor Resend is configured. Please set up at least one email service to send emails.", ); -}; + return { + data: null, + error: new Error( + "No email service configured (set RESEND_API_KEY or SMTP_HOST/SMTP_PORT)", + ), + }; +};
♻️ Duplicate comments (4)
apps/web/lib/tolt/import-commissions.ts (1)
335-347
: Don’t serialize the full commission object into metadata (PII + bloat).Repeat of prior feedback; please sanitize before logging/sending analytics.
- metadata: JSON.stringify(commission), + metadata: JSON.stringify({ + commission: { + id: commission.id, + status: commission.status, + amount: Number(commission.amount ?? 0), + }, + sale: { + transaction_id: sale.transaction_id, + created_at: sale.created_at, + revenue: Number(sale.revenue ?? 0), + }, + provider: "tolt", + }),packages/email/src/index.ts (2)
62-70
: Return an explicit error when neither SMTP nor Resend is configured.Avoid returning a success-shaped object on failure.
console.info( "Email sending failed: Neither SMTP nor Resend is configured. Please set up at least one email service to send emails.", ); - return { - data: null, - error: null, - }; + return { + data: { data: [] }, + error: new Error("No email service configured"), + };
44-60
: SMTP batch path: don’t swallow partial failures; return Resend‑compatible shape.Use Promise.allSettled and surface a summary error; also provide mock IDs to match CreateBatchResponse.
- if (smtpConfigured) { - await Promise.all( - payload.map((p) => - sendViaNodeMailer({ - to: p.to, - subject: p.subject, - text: p.text, - react: p.react, - }), - ), - ); - - return { - data: null, - error: null, - }; - } + if (smtpConfigured) { + const results = await Promise.allSettled( + payload.map((p) => + sendViaNodeMailer({ + to: p.to, + subject: p.subject, + text: p.text, + react: p.react, + }), + ), + ); + const failed = results.filter((r) => r.status === "rejected"); + return { + data: { + data: results.map((r, i) => ({ + id: `smtp-${Date.now()}-${i}-${Math.random() + .toString(36) + .slice(2, 10)}`, + })), + }, + error: + failed.length > 0 + ? new Error(`SMTP: ${failed.length}/${results.length} sends failed`) + : null, + }; + }packages/email/src/send-via-resend.ts (1)
54-71
: RemovescheduledAt
from batch payload (Resend batch API doesn’t accept it).This can cause validation errors for batch sends. Strip it per item before calling
resend.batch.send
.- const payload = opts.map(resendEmailForOptions); + const payload = opts.map((o) => { + const { scheduledAt, ...rest } = resendEmailForOptions(o) as any; + return rest; + });Does Resend's batch email API accept a scheduledAt/scheduled_at field?
🧹 Nitpick comments (9)
apps/web/lib/actions/send-invite-referral-email.ts (1)
35-45
: Guard against undefined NEXT_PUBLIC_APP_NAME in subject.Avoid “undefined” in subject lines locally; default the app name.
- return await sendEmail({ - subject: `You've been invited to start using ${process.env.NEXT_PUBLIC_APP_NAME}`, + const appName = process.env.NEXT_PUBLIC_APP_NAME ?? "Dub"; + return await sendEmail({ + subject: `You've been invited to start using ${appName}`, to: email, react: ReferralInvite({apps/web/app/(ee)/api/cron/domains/verify/utils.ts (2)
124-127
: Typo in log message (“andhas”).Minor readability nit.
- message: `Domain *${domain}* has been invalid for > 30 days andhas links but no link clicks, deleting.`, + message: `Domain *${domain}* has been invalid for > 30 days and has links but no link clicks, deleting.`,
79-81
: Filter null emails before sending.Current cast to string[] can hide nulls and cause send attempts to “null”.
- const emails = workspace.users.map((user) => user.user.email) as string[]; + const emails = workspace.users + .map((u) => u.user.email) + .filter((e): e is string => Boolean(e));apps/web/lib/actions/partners/create-program.ts (1)
196-205
: Consider setting variant for the program welcome email.Not mandatory, but using variant: "notifications" aligns with other system emails.
sendEmail({ subject: `Your program ${program.name} is created and ready to share with your partners.`, - to: user.email!, + variant: "notifications", + to: user.email!, react: ProgramWelcome({apps/web/app/api/user/password/route.ts (1)
65-71
: LGTM on to: migration; consider notifications variant for security emails.Password updates are transactional/notification class emails.
waitUntil( sendEmail({ subject: `Your ${process.env.NEXT_PUBLIC_APP_NAME} account password has been updated`, - to: session.user.email, + variant: "notifications", + to: session.user.email, react: PasswordUpdated({ email: session.user.email, }),packages/email/src/index.ts (1)
7-25
: Optional: normalize sendEmail return shape across transports.Consider wrapping nodemailer’s result to a Resend-like
{ data, error }
for consistency.packages/email/src/send-via-resend.ts (3)
1-1
: Use a type-only import for CreateBatchResponse.Avoids pulling runtime code from the
resend
package.-import { CreateBatchResponse } from "resend"; +import type { CreateBatchResponse } from "resend";
20-40
: Make payload type-safe and fold in small nits (shorthand + nullish coalescing).Guarantees API-shape at compile time and removes minor style nits.
+import type { CreateEmailOptions } from "resend"; @@ - return { + const payload = { to, from: from || VARIANT_TO_FROM_MAP[variant], - bcc: bcc, + bcc, replyTo: replyTo || "support@dub.co", subject, text, react, scheduledAt, ...(variant === "marketing" ? { headers: { - ...(headers || {}), + ...(headers ?? {}), "List-Unsubscribe": "https://app.dub.co/account/settings", }, } : { headers, }), - }; + } satisfies CreateEmailOptions; + return payload;
51-51
: Drop unnecessaryreturn await
.Saves a micro tick and preserves stack traces identically here.
- return await resend.emails.send(resendEmailForOptions(opts)); + return resend.emails.send(resendEmailForOptions(opts)); @@ - return await resend.batch.send(payload); + return resend.batch.send(payload);Also applies to: 70-70
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (78)
README.md
(1 hunks)apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/domains/transfer/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/domains/verify/utils.ts
(2 hunks)apps/web/app/(ee)/api/cron/import/bitly/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/import/csv/utils.ts
(2 hunks)apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/import/short/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
(3 hunks)apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/messages/notify-program/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/partner-program-summary/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/program-application-reminder/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/usage/utils.ts
(1 hunks)apps/web/app/(ee)/api/cron/welcome-user/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/year-in-review/route.ts
(2 hunks)apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts
(1 hunks)apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts
(1 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts
(4 hunks)apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts
(2 hunks)apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts
(1 hunks)apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx
(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils.ts
(1 hunks)apps/web/app/api/auth/reset-password/route.ts
(1 hunks)apps/web/app/api/dub/webhook/lead-created.ts
(1 hunks)apps/web/app/api/tokens/route.ts
(1 hunks)apps/web/app/api/user/password/route.ts
(1 hunks)apps/web/app/api/user/set-password/route.ts
(1 hunks)apps/web/app/api/webhooks/route.ts
(1 hunks)apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx
(1 hunks)apps/web/lib/actions/folders/request-folder-edit-access.ts
(1 hunks)apps/web/lib/actions/partners/approve-bounty-submission.ts
(1 hunks)apps/web/lib/actions/partners/ban-partner.ts
(1 hunks)apps/web/lib/actions/partners/bulk-approve-partners.ts
(3 hunks)apps/web/lib/actions/partners/bulk-ban-partners.ts
(2 hunks)apps/web/lib/actions/partners/create-bounty-submission.ts
(3 hunks)apps/web/lib/actions/partners/create-program.ts
(2 hunks)apps/web/lib/actions/partners/invite-partner.ts
(1 hunks)apps/web/lib/actions/partners/merge-partner-accounts.ts
(3 hunks)apps/web/lib/actions/partners/reject-bounty-submission.ts
(1 hunks)apps/web/lib/actions/partners/resend-program-invite.ts
(1 hunks)apps/web/lib/actions/request-password-reset.ts
(1 hunks)apps/web/lib/actions/send-invite-referral-email.ts
(1 hunks)apps/web/lib/actions/send-otp.ts
(1 hunks)apps/web/lib/api/domains/claim-dot-link-domain.ts
(3 hunks)apps/web/lib/api/partners/notify-partner-application.ts
(2 hunks)apps/web/lib/api/partners/notify-partner-commission.ts
(2 hunks)apps/web/lib/api/users.ts
(1 hunks)apps/web/lib/api/workflows/execute-award-bounty-action.ts
(1 hunks)apps/web/lib/auth/confirm-email-change.ts
(1 hunks)apps/web/lib/auth/options.ts
(1 hunks)apps/web/lib/cron/send-limit-email.ts
(1 hunks)apps/web/lib/firstpromoter/import-commissions.ts
(1 hunks)apps/web/lib/integrations/install.ts
(1 hunks)apps/web/lib/partners/approve-partner-enrollment.ts
(2 hunks)apps/web/lib/partnerstack/update-stripe-customers.ts
(1 hunks)apps/web/lib/rewardful/import-commissions.ts
(1 hunks)apps/web/lib/tolt/import-commissions.ts
(1 hunks)apps/web/lib/webhook/failure.ts
(2 hunks)apps/web/scripts/send-emails.tsx
(1 hunks)apps/web/scripts/unsubscribe-inactive-users.ts
(1 hunks)apps/web/ui/analytics/feedback/action.ts
(1 hunks)package.json
(2 hunks)packages/email/src/index.ts
(3 hunks)packages/email/src/resend/client.ts
(1 hunks)packages/email/src/resend/subscribe.ts
(1 hunks)packages/email/src/resend/types.ts
(1 hunks)packages/email/src/resend/unsubscribe.ts
(1 hunks)packages/email/src/send-via-nodemailer.ts
(2 hunks)packages/email/src/send-via-resend.ts
(2 hunks)packages/ui/src/icons/payout-platforms/stripe.tsx
(0 hunks)
💤 Files with no reviewable changes (1)
- packages/ui/src/icons/payout-platforms/stripe.tsx
🚧 Files skipped from review as they are similar to previous changes (70)
- apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts
- apps/web/lib/actions/send-otp.ts
- README.md
- apps/web/app/(ee)/api/cron/import/csv/utils.ts
- apps/web/app/(ee)/api/cron/program-application-reminder/route.ts
- apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts
- apps/web/app/(ee)/api/cron/partner-program-summary/route.ts
- apps/web/lib/integrations/install.ts
- apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts
- apps/web/lib/partners/approve-partner-enrollment.ts
- apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx
- apps/web/lib/cron/send-limit-email.ts
- apps/web/app/(ee)/api/stripe/webhook/utils.ts
- apps/web/lib/actions/partners/invite-partner.ts
- apps/web/lib/api/users.ts
- apps/web/lib/auth/options.ts
- apps/web/lib/actions/partners/ban-partner.ts
- apps/web/lib/api/partners/notify-partner-commission.ts
- apps/web/lib/actions/partners/resend-program-invite.ts
- apps/web/lib/api/workflows/execute-award-bounty-action.ts
- apps/web/lib/actions/partners/approve-bounty-submission.ts
- apps/web/app/(ee)/api/cron/usage/utils.ts
- apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts
- packages/email/src/resend/unsubscribe.ts
- apps/web/app/(ee)/api/cron/import/short/utils.ts
- apps/web/lib/actions/request-password-reset.ts
- apps/web/app/(ee)/api/cron/messages/notify-program/route.ts
- apps/web/app/api/auth/reset-password/route.ts
- apps/web/scripts/unsubscribe-inactive-users.ts
- apps/web/lib/actions/partners/reject-bounty-submission.ts
- apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
- apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts
- packages/email/src/resend/types.ts
- apps/web/app/(ee)/api/cron/year-in-review/route.ts
- apps/web/app/api/user/set-password/route.ts
- apps/web/scripts/send-emails.tsx
- apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
- apps/web/lib/rewardful/import-commissions.ts
- apps/web/app/api/tokens/route.ts
- apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts
- apps/web/lib/actions/partners/create-bounty-submission.ts
- apps/web/lib/partnerstack/update-stripe-customers.ts
- apps/web/lib/auth/confirm-email-change.ts
- packages/email/src/send-via-nodemailer.ts
- apps/web/ui/analytics/feedback/action.ts
- apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts
- apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
- apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts
- apps/web/lib/api/partners/notify-partner-application.ts
- apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts
- apps/web/lib/actions/partners/bulk-ban-partners.ts
- apps/web/lib/api/domains/claim-dot-link-domain.ts
- packages/email/src/resend/subscribe.ts
- apps/web/lib/webhook/failure.ts
- apps/web/lib/actions/partners/bulk-approve-partners.ts
- apps/web/lib/actions/partners/merge-partner-accounts.ts
- apps/web/lib/firstpromoter/import-commissions.ts
- apps/web/app/api/dub/webhook/lead-created.ts
- apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
- apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts
- apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts
- apps/web/app/(ee)/api/cron/welcome-user/route.ts
- apps/web/lib/actions/folders/request-folder-edit-access.ts
- apps/web/app/api/webhooks/route.ts
- packages/email/src/resend/client.ts
- apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
- apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx
- package.json
- apps/web/app/(ee)/api/cron/domains/transfer/utils.ts
- apps/web/app/(ee)/api/cron/import/bitly/utils.ts
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2835
File: apps/web/ui/analytics/feedback/action.ts:10-13
Timestamp: 2025-09-12T21:08:43.942Z
Learning: In the Dub email system, sendViaNodeMailer is intentionally designed as a simple backup mechanism with hard-coded sender fields, not as a full-featured email sender that preserves all metadata like from/replyTo fields. The primary email path uses Resend for full functionality.
🧬 Code graph analysis (2)
packages/email/src/index.ts (4)
packages/email/src/resend/types.ts (2)
ResendEmailOptions
(3-8)ResendBulkEmailOptions
(10-10)packages/email/src/resend/client.ts (1)
resend
(3-5)packages/email/src/send-via-nodemailer.ts (1)
sendViaNodeMailer
(7-36)packages/email/src/send-via-resend.ts (1)
sendBatchEmailViaResend
(54-71)
packages/email/src/send-via-resend.ts (2)
packages/email/src/resend/types.ts (2)
ResendEmailOptions
(3-8)ResendBulkEmailOptions
(10-10)packages/email/src/resend/client.ts (1)
resend
(3-5)
⏰ 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). (4)
- GitHub Check: api-tests
- GitHub Check: api-tests
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (8)
apps/web/lib/actions/send-invite-referral-email.ts (1)
38-38
: LGTM on recipient rename.Using to: email matches the new API.
apps/web/app/(ee)/api/cron/domains/verify/utils.ts (2)
132-139
: LGTM on to: migration and variant usage.Recipient field and notifications variant look consistent.
199-207
: LGTM on to: migration.Matches the single-recipient API shape.
apps/web/lib/tolt/import-commissions.ts (2)
128-137
: LGTM on recipient rename.to: workspaceUser.user.email is correct.
20-26
: Verify status mapping (approved → pending).Double-check that mapping “approved” commissions to “pending” is intentional.
Would you like a quick grep across consumers of CommissionStatus to confirm downstream expectations?
apps/web/lib/actions/partners/create-program.ts (1)
258-262
: LGTM: notifications variant + to: partner.email.Matches the transport-agnostic API. Note: SMTP fallback intentionally ignores variant/from (per team’s design).
packages/email/src/index.ts (1)
2-2
: Import style confirmed —resend
is a named exportpackages/email/src/resend/client.ts defines
export const resend
and packages/email/src/resend/index.ts re-exports it (export * from "./client"
), soimport { resend } from "./resend"
is correct.packages/email/src/send-via-resend.ts (1)
29-38
: LGTM: Marketing header merge correctly injects List-Unsubscribe.Good call to ensure unsubscribe headers on marketing mail while preserving custom headers.
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
🧹 Nitpick comments (3)
apps/web/lib/actions/partners/approve-bounty-submission.ts (1)
96-131
: Make the Promise shape explicit inPromise.allSettled
.
partner.email && sendEmail(...)
adds a boolean into the array whenpartner.email
is falsy. It’s valid (values are treated as fulfilled), but using a Promise keeps the array homogeneous and avoids confusion when inspecting results.- partner.email && - sendEmail({ + partner.email + ? sendEmail({ subject: "Bounty approved!", to: partner.email, variant: "notifications", react: BountyApproved({ email: partner.email, program: { name: program.name, slug: program.slug, }, bounty: { name: bounty.name, type: bounty.type, }, }), - }), + }) + : Promise.resolve(),apps/web/lib/actions/partners/create-bounty-submission.ts (2)
178-203
: Batch email payload looks good; add text fallback and a more specific subject.This improves deliverability (plain-text body) and clarity. SMTP fallback already accepts
text
.Apply this diff:
- await sendBatchEmail( + await sendBatchEmail( users.map((user) => ({ variant: "notifications", to: user.email, - subject: "New bounty submission", + subject: `New bounty submission: ${bounty.name}`, + text: `A new bounty submission was received for ${bounty.name} by ${partner.name ?? "a partner"}.`, react: NewBountySubmission({ 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, }, }), })), );Also,
variant
is ignored in SMTP fallback; ensure your NodeMailer path sets an appropriate default “from” for notifications.
206-221
: Add explicitvariant
for partner email for parity with owner emails.Keeps sender identity consistent across providers; ignored by SMTP but used by Resend.
Apply this diff:
if (partner.email && program) { await sendEmail({ + variant: "notifications", subject: "Bounty submitted!", to: partner.email, react: BountySubmitted({ email: partner.email, bounty: { name: bounty.name, }, program: { name: program.name, slug: program.slug, }, }), }); }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/lib/actions/partners/approve-bounty-submission.ts
(1 hunks)apps/web/lib/actions/partners/create-bounty-submission.ts
(3 hunks)apps/web/lib/actions/partners/reject-bounty-submission.ts
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/lib/actions/partners/reject-bounty-submission.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-12T17:31:10.509Z
Learnt from: devkiran
PR: dubinc/dub#2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.509Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).
Applied to files:
apps/web/lib/actions/partners/approve-bounty-submission.ts
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.
Applied to files:
apps/web/lib/actions/partners/create-bounty-submission.ts
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
⏰ 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). (3)
- GitHub Check: Vade Review
- GitHub Check: Socket Security: Pull Request Alerts
- GitHub Check: build
🔇 Additional comments (4)
apps/web/lib/actions/partners/approve-bounty-submission.ts (2)
59-61
: Reward amount precedence is correct (intentional).Keeping
const finalRewardAmount = bounty.rewardAmount ?? rewardAmount;
matches the established rule: fixed bounties use their preset amount; the override is only for custom bounties. Acknowledging retrieved learnings from PR #2833.
113-121
: LGTM — switched toto
and usingvariant
correctly.approve-bounty-submission.ts uses to: partner.email and keeps the template payload’s email prop; repo scan found no sendEmail calls with a top-level
email:
property and only packages/email/src/send-via-resend.ts callsresend.batch.send
(expected).apps/web/lib/actions/partners/create-bounty-submission.ts (2)
193-196
: Confirm partner email non-null invariant.
partner.email!
in the owner notification template assumes non-null. If partners can exist without an email, pass an optional value or guard to avoid “undefined” in templates.
171-175
: Incorrect — keep the existing destructuring; workspace.slug is defined.getWorkspaceUsers returns top-level fields { id, slug, name, program, users } (not a nested workspace). The current line
const { users, program, ...workspace } = await getWorkspaceUsers(...)
yields workspace = { id, slug, name }, so workspace.slug is defined. Changing to const { users, program, workspace } would make workspace undefined. NewBountySubmission expects workspace: { slug: string } and passing workspace: { slug: workspace.slug } is correct.apps/web/lib/actions/partners/create-bounty-submission.ts — leave as-is; refer to apps/web/lib/api/get-workspace-users.ts and packages/email/src/templates/bounty-new-submission.tsx.
Likely an incorrect or invalid review 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.
Incredible work, @ianmaccallum! Thank you 🙏
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 (1)
apps/web/app/(ee)/api/cron/partner-program-summary/route.ts (1)
301-315
: Stop logging raw partner emails (PII). Mask or drop the column.Console logging cleartext emails is a compliance/privacy risk and unnecessary for ops.
Apply this diff to anonymize:
- console.table( - summary.map((s) => ({ - partner: s.partner.email, + console.table( + summary.map((s) => ({ + partner: s.partner.email + ? s.partner.email.replace(/(.{2}).*(@.*)/, "$1***$2") + : "(none)", program: program.name, currentClicks: s.currentMonth.clicks, currentLeads: s.currentMonth.leads, currentSales: s.currentMonth.sales, currentEarnings: s.currentMonth.earnings, lifetimeClicks: s.lifetime.clicks, lifetimeLeads: s.lifetime.leads, lifetimeSales: s.lifetime.sales, lifetimeEarnings: s.lifetime.earnings, })), );
♻️ Duplicate comments (1)
apps/web/app/(ee)/api/cron/usage/utils.ts (1)
226-232
: Second-usage email should use the earliest firstUsageLimitEmail timestamp.Indexing sentEmails[0] can read the wrong createdAt. Filter for type === "firstUsageLimitEmail", pick the earliest, and guard if missing.
Apply:
- const daysSinceFirstEmail = Math.floor( - (new Date().getTime() - - new Date(sentEmails[0].createdAt).getTime()) / - (1000 * 3600 * 24), - ); - if (daysSinceFirstEmail >= 3) { + const firstUsageEmail = sentEmails + .filter((e) => e.type === "firstUsageLimitEmail") + .sort( + (a, b) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime(), + )[0]; + const daysSinceFirstEmail = firstUsageEmail + ? Math.floor( + (Date.now() - + new Date(firstUsageEmail.createdAt).getTime()) / + (1000 * 60 * 60 * 24), + ) + : -1; + if (firstUsageEmail && daysSinceFirstEmail >= 3) {
🧹 Nitpick comments (12)
apps/web/app/(ee)/api/cron/partner-program-summary/route.ts (1)
319-335
: Guard against null emails, chunk defensively, and surface batch errors.
- Avoid non-null assertions on partner.email.
- Send in chunks (≤ likely provider limit) to be safe.
- Log batch-level errors to your structured logger.
Apply this diff:
- await sendBatchEmail( - summary.map(({ partner, ...rest }) => ({ - subject: `Your ${reportingMonth} performance report for ${program.name} program`, - to: partner.email!, - react: PartnerProgramSummary({ - program, - partner, - ...rest, - reportingPeriod: { - month: reportingMonth, - start: currentMonth.toISOString(), - end: endOfMonth(currentMonth).toISOString(), - }, - }), - variant: "notifications", - })), - ); + const emails = summary + .filter(({ partner }) => typeof partner.email === "string" && partner.email) + .map(({ partner, ...rest }) => ({ + subject: `Your ${reportingMonth} performance report for ${program.name} program`, + to: partner.email as string, + react: PartnerProgramSummary({ + program, + partner, + ...rest, + reportingPeriod: { + month: reportingMonth, + start: currentMonth.toISOString(), + end: endOfMonth(currentMonth).toISOString(), + }, + }), + variant: "notifications" as const, + })); + + if (emails.length === 0) { + console.log("Skipping batch: no deliverable emails for this page."); + } else { + const MAX_BATCH = 100; // keep ≤ provider limit + for (let i = 0; i < emails.length; i += MAX_BATCH) { + const chunk = emails.slice(i, i + MAX_BATCH); + const { error } = await sendBatchEmail(chunk); + if (error) { + await log({ + message: `partner-program-summary batch send error: ${String(error)}`, + type: "cron", + }); + } + } + }apps/web/app/(ee)/api/cron/usage/utils.ts (3)
157-160
: Harden recipient list: filter falsy and dedupe emails.Guard against null/undefined/duplicate user emails before batching.
Apply:
- const emails = workspace.users.map( - (user) => user.user.email, - ) as string[]; + const recipientEmails = Array.from( + new Set( + workspace.users.map((u) => u.user.email).filter(Boolean) as string[], + ), + );And update the batch call below to iterate recipientEmails.
- emails.map((email) => ({ + recipientEmails.map((email) => ({
170-171
: Confirm intent: createdLinks uses pre-reset linksUsage.You reset linksUsage to 0 earlier (Lines 90–96) but pass workspace.linksUsage here, which still reflects the pre-reset value. If that’s meant to represent “last cycle’s created links,” great; otherwise snapshot before the update or compute explicitly.
Example for clarity:
- createdLinks: workspace.linksUsage, + // created from previous cycle + createdLinks: workspace.linksUsage,
161-174
: Log batch send failures and keep observability.Confirmed the email option field is "to" (packages/email/src/resend/types.ts, packages/email/src/send-via-nodemailer.ts). Apply:
- await sendBatchEmail( + const res = await sendBatchEmail( /* payload */ - ); + ); + if (res?.error) { + await log({ + type: "cron", + message: `30-day summary email failed for ${workspace.slug}: ${String( + res.error, + )}`, + mention: false, + }); + }apps/web/lib/cron/send-limit-email.ts (4)
27-30
: Defend against 0/undefined link limits and drop redundant toString.If
linksLimit
is 0/undefined,percentage
becomesInfinity/NaN
, leaking into the subject. Also,${percentage}
already stringifies.Apply:
- const percentage = Math.round( - (workspace.linksUsage / workspace.linksLimit) * 100, - ); + const percentage = + workspace.linksLimit && Number.isFinite(workspace.linksLimit) && workspace.linksLimit > 0 + ? Math.round((workspace.linksUsage / workspace.linksLimit) * 100) + : 100;- : `Dub Alert: ${workspace.name} has used ${percentage.toString()}% of its links limit for the month.`, + : `Dub Alert: ${workspace.name} has used ${percentage}% of its links limit for the month.`,
25-43
: Skip provider call on empty recipient list and dedupe emails.Avoid a no-op provider call and accidental duplicate sends.
- sendBatchEmail( - emails.map((email) => ({ + (emails.length + ? sendBatchEmail( + [...new Set(emails)].map((email) => ({ subject: type.endsWith("UsageLimitEmail") ? "Dub Alert: Clicks Limit Exceeded" : `Dub Alert: ${workspace.name} has used ${percentage}% of its links limit for the month.`, to: email, react: type.endsWith("UsageLimitEmail") ? ClicksExceeded({ email, workspace, type: type as "firstUsageLimitEmail" | "secondUsageLimitEmail", }) : LinksLimitAlert({ email, workspace, }), variant: "notifications", - })), - ), + })), + ) + : Promise.resolve({ data: null, error: null })),
25-43
: Heads-up: may record “sent” when nothing was sent if no transport is configured.Per
packages/email/src/index.ts
,sendBatchEmail
resolves with{ data: null, error: null }
even when both Resend and SMTP are missing (logging only). YourPromise.allSettled([...])
will still “succeed” and persistsentEmail
, which can skew metrics.Options:
- Gate the DB write on an affirmative send result once the email lib exposes a transport/sent flag.
- Alternatively, add an internal “transportUsed”/“skipped” marker in the DB (schema permitting) for observability.
- If changing the email lib is in scope, have it reject or set
error
when no transport is configured.
27-43
: Minor readability: avoid duplicate condition checks.Store once and reuse.
Add above the map:
const isUsageLimitEmail = type.endsWith("UsageLimitEmail");Then:
- subject: type.endsWith("UsageLimitEmail") + subject: isUsageLimitEmail ? "Dub Alert: Clicks Limit Exceeded" : `Dub Alert: ${workspace.name} has used ${percentage}% of its links limit for the month.`, - react: type.endsWith("UsageLimitEmail") + react: isUsageLimitEmail ? ClicksExceeded({ /* ... */ }) : LinksLimitAlert({ /* ... */ }),apps/web/app/(ee)/api/cron/domains/verify/utils.ts (4)
124-126
: Fix log message typo and content.Missing space (“andhas”). Also we didn’t check for “has links”; only “no link clicks”. Adjust wording.
- message: `Domain *${domain}* has been invalid for > 30 days andhas links but no link clicks, deleting.`, + message: `Domain *${domain}* has been invalid for > 30 days and has no link clicks, deleting.`,
127-138
: Batch send: de‑duplicate and null‑guard recipients.Protect against duplicate/null emails to avoid repeated sends and SMTP bounces.
- sendBatchEmail( - emails.map((email) => ({ + sendBatchEmail( + Array.from(new Set(emails.filter(Boolean))).map((email) => ({ subject: `Your domain ${domain} has been deleted`, to: email, react: DomainDeleted({ email, domain, workspaceSlug, }), variant: "notifications", })), ),Note:
variant
is fine; SMTP fallback ignores it by design (per our learnings).
188-191
: Log after send or clarify wording.Current log says “email sent” before the send settles. Either move the log after
sendBatchEmail
or change to “email queued”.- log({ - message: `Domain *${domain}* is invalid for ${invalidDays} days, email sent.`, + log({ + message: `Domain *${domain}* is invalid for ${invalidDays} days, email queued.`,
192-204
: Batch send: mirror the dedupe/null‑guard for invalid‑domain emails.Same recipient hygiene as above.
- sendBatchEmail( - emails.map((email) => ({ + sendBatchEmail( + Array.from(new Set(emails.filter(Boolean))).map((email) => ({ subject: `Your domain ${domain} needs to be configured`, to: email, react: InvalidDomain({ email, domain, workspaceSlug, invalidDays, }), variant: "notifications", })), ),
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
apps/web/app/(ee)/api/cron/domains/verify/utils.ts
(3 hunks)apps/web/app/(ee)/api/cron/partner-program-summary/route.ts
(2 hunks)apps/web/app/(ee)/api/cron/usage/utils.ts
(2 hunks)apps/web/app/(ee)/api/cron/year-in-review/route.ts
(2 hunks)apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts
(2 hunks)apps/web/lib/cron/send-limit-email.ts
(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/app/(ee)/api/cron/year-in-review/route.ts
- apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2835
File: apps/web/ui/analytics/feedback/action.ts:10-13
Timestamp: 2025-09-12T21:08:43.942Z
Learning: In the Dub email system, sendViaNodeMailer is intentionally designed as a simple backup mechanism with hard-coded sender fields, not as a full-featured email sender that preserves all metadata like from/replyTo fields. The primary email path uses Resend for full functionality.
🧬 Code graph analysis (4)
apps/web/app/(ee)/api/cron/partner-program-summary/route.ts (2)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)packages/email/src/templates/partner-program-summary.tsx (1)
PartnerProgramSummary
(74-264)
apps/web/lib/cron/send-limit-email.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
apps/web/app/(ee)/api/cron/domains/verify/utils.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
apps/web/app/(ee)/api/cron/usage/utils.ts (1)
packages/email/src/index.ts (1)
sendBatchEmail
(32-70)
⏰ 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 (6)
apps/web/app/(ee)/api/cron/partner-program-summary/route.ts (2)
6-6
: Import change looks correct.Using the new batch surface from @dub/email is consistent with the PR goals.
319-335
: Confirm Resend batch limit and SMTP fallback semantics.
- Resend emails.batch.send supports up to 100 items per request — ensure MAX_BATCH ≤ 100; if currently >100, lower it. Recommended sending rate ≈ 2 requests/sec — implement backoff on 429s.
- Verify SMTP fallback behavior (sendViaNodeMailer): per team note it may ignore variant/from/replyTo — confirm the fallback preserves the sender/headers required by this route; if not, either make fallback preserve those fields or disable fallback for these partner-program emails.
Location: apps/web/app/(ee)/api/cron/partner-program-summary/route.ts (around lines 319–335).apps/web/app/(ee)/api/cron/usage/utils.ts (1)
5-5
: LGTM: centralized batch email import.Using @dub/email.sendBatchEmail matches the transport-agnostic design and Resend-nullable client.
apps/web/lib/cron/send-limit-email.ts (1)
31-42
: Template selection and variant usage look correct.The
react
payloads match thetype
branches, andvariant: "notifications"
aligns with the new email surface.apps/web/app/(ee)/api/cron/domains/verify/utils.ts (2)
2-2
: Batch email import LGTM — confirm callers passto
(not
- packages/email/src/index.ts uses
to
for single and bulk sends (destructureconst { to, subject, text, react }
and batch usesto: p.to
).- Repo scan returned no caller key matches; verify all sendBatchEmail callers pass
to
to avoid SMTP fallback mismatches.
107-121
: Incorrect — no re‑promotion risk: deletion clears projectId and is awaitedmarkDomainAsDeleted (apps/web/lib/api/domains/mark-domain-deleted.ts) awaits a prisma.domain.update that sets projectId = null, and the Domain schema (packages/prisma/schema/domain.prisma) has no deletedAt; therefore prisma.domain.findFirst({ where: { projectId: workspace.id } }) cannot return the just‑deleted domain.
Likely an incorrect or invalid review comment.
@@ -1,9 +1,8 @@ | |||
import { sendEmail } from "@dub/email"; | |||
import { sendBatchEmail } from "@dub/email"; |
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.
🧩 Analysis chain
Batch import LGTM; verify rate limits/chunking after removing limiter.
Switch to sendBatchEmail
is clean. Since the limiter is gone, confirm the batch implementation handles provider batch-size caps and rate limits, or add chunking if needed.
Run this to inspect the batch implementation and call sites:
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect batch sender for chunking/rate limit logic
fd -t f -e ts -e tsx packages/email | xargs rg -nP 'sendBatchEmailViaResend|chunk|batch|rate.?limit'
# Find all call sites (sanity-check payload sizes/usage)
rg -nP '\bsendBatchEmail\s*\(' -g '!**/node_modules/**' -C2
Length of output: 23840
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== grep occurrences ==="
rg -nP --hidden --no-ignore 'sendBatchEmail(?:ViaResend)?|resend\.batch\.send' -S || true
echo
echo "=== packages/email/src/send-via-resend.ts ==="
sed -n '1,240p' packages/email/src/send-via-resend.ts || true
echo
echo "=== packages/email/src/index.ts ==="
sed -n '1,240p' packages/email/src/index.ts || true
echo
echo "=== packages/utils/src/functions/chunk.ts ==="
sed -n '1,200p' packages/utils/src/functions/chunk.ts || true
echo
echo "=== apps/web/app/(ee)/api/cron/year-in-review/route.ts ==="
sed -n '1,240p' apps/web/app/(ee)/api/cron/year-in-review/route.ts || true
echo
echo "=== apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts ==="
sed -n '1,240p' apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts || true
echo
echo "=== apps/web/lib/api/partners/notify-partner-application.ts ==="
sed -n '1,240p' apps/web/lib/api/partners/notify-partner-application.ts || true
echo
echo "=== apps/web/lib/cron/send-limit-email.ts ==="
sed -n '1,200p' apps/web/lib/cron/send-limit-email.ts || true
Length of output: 9840
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== all sendBatchEmail call sites (with context) ==="
rg -nP '\bsendBatchEmail\s*\(' -S -C3 -g '!**/node_modules/**' || true
echo
echo "=== inspect specific file: apps/web/lib/cron/send-limit-email.ts ==="
sed -n '1,240p' apps/web/lib/cron/send-limit-email.ts || true
echo
echo "=== inspect specific file: apps/web/app/(ee)/api/cron/year-in-review/route.ts (first 240 lines) ==="
sed -n '1,240p' "apps/web/app/(ee)/api/cron/year-in-review/route.ts" || true
Length of output: 5777
Action: Add chunking or enforce max batch size for sendBatchEmail
- packages/email/src/send-via-resend.ts → sendBatchEmailViaResend builds payload and calls resend.batch.send(payload) with no chunking; packages/email/src/index.ts forwards payload unchanged.
- apps/web/lib/cron/send-limit-email.ts calls sendBatchEmail(...) with an un-chunked emails array (risk of exceeding provider batch caps).
- Either implement chunking/rate-limit handling inside sendBatchEmail (recommended) or ensure every caller (e.g., this file) chunks to a safe batch size (many callers already use 100).
🤖 Prompt for AI Agents
In apps/web/lib/cron/send-limit-email.ts around line 1, this file calls
sendBatchEmail(...) with an un-chunked emails array which can exceed provider
batch caps; implement chunking inside the shared sendBatchEmail implementation
(recommended) or make this caller split the emails into safe batches (use 100 as
a sensible default). Update packages/email/src/send-via-resend.ts to split the
payload into chunks (e.g., batchSize = 100), and for each chunk call
resend.batch.send(chunkPayload) (either sequentially or with a small concurrency
limit) and aggregate results/errors; ensure packages/email/src/index.ts
forwards/returns the aggregated response and add tests or guards to prevent
sending >batchSize at once.
); | ||
|
||
if (smtpConfigured) { | ||
await Promise.all( |
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.
The sendBatchEmail
function uses Promise.all
for SMTP fallback, which will fail the entire batch if any single email fails to send.
View Details
📝 Patch Details
diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts
index 8c550397c..5fcd1dd54 100644
--- a/packages/email/src/index.ts
+++ b/packages/email/src/index.ts
@@ -42,7 +42,7 @@ export const sendBatchEmail = async (
);
if (smtpConfigured) {
- await Promise.all(
+ const results = await Promise.allSettled(
payload.map((p) =>
sendViaNodeMailer({
to: p.to,
@@ -53,6 +53,20 @@ export const sendBatchEmail = async (
),
);
+ // Check if any emails failed - if so, include error details
+ const failedEmails = results.filter((result) => result.status === 'rejected');
+
+ if (failedEmails.length > 0) {
+ const errorMessage = `Failed to send ${failedEmails.length} out of ${payload.length} emails`;
+ return {
+ data: null,
+ error: {
+ message: errorMessage,
+ name: 'application_error',
+ },
+ };
+ }
+
return {
data: null,
error: null,
Analysis
Promise.all in sendBatchEmail causes entire batch failure on single email error
What fails: sendBatchEmail()
function in packages/email/src/index.ts
uses Promise.all
for SMTP fallback (line 45), causing complete batch failure when any individual email fails
How to reproduce:
// With SMTP configured and one invalid email in batch:
await sendBatchEmail([
{ to: 'valid@example.com', subject: 'Test', text: 'Hello' },
{ to: 'invalid@example.com', subject: 'Test', text: 'Hello' }, // Fails
{ to: 'another@example.com', subject: 'Test', text: 'Hello' }
]);
Result: Entire batch throws error, 0 emails sent
Expected: Partial success with 2 emails sent, 1 failed
Technical cause: Promise.all fails immediately when any promise rejects, unlike Promise.allSettled which waits for all promises to complete
The Resend dependency is blowing up locally if
RESEND_API_KEY
is missing. Adding it filips several conditions that depend on it, causing mailhog emails not to be sent locally. The fix is making resend client nullable. This would result in all the resend calls needing a null check so instead of that existing everywhere resend is used, I extracted all batch calls into asendBatchEmail
similar tosendEmail
. This fixes the issue of emails not being sent to MailHog in local development and sets us up to actually be transport agnostic in the future.This PR:
resend.batch.send
into a singlesendBatchEmail
callNotes:
from: VARIANT_TO_FROM_MAP.notifications
is now unnecessary ifvariant
is providedto
has been replaced withemail
to make it consistent with sending a single emailSummary by CodeRabbit