+
Skip to content

Conversation

ianmaccallum
Copy link
Contributor

@ianmaccallum ianmaccallum commented Sep 12, 2025

Screenshot 2025-09-12 at 8 42 52 AM

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 a sendBatchEmail similar to sendEmail. 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:

  • Makes resend optional in the case that no resend api key is setup (as is the case with local development via SMTP)
  • Extracts all resend.batch.send into a single sendBatchEmail call
  • Includes a small fix for setting up domains in local development (add domain to vercel function)

Notes:

  • from: VARIANT_TO_FROM_MAP.notifications is now unnecessary if variant is provided
  • for bulk emails, to has been replaced with email to make it consistent with sending a single email

Summary by CodeRabbit

  • New Features
    • Unified email APIs for single and bulk sends with automatic SMTP fallback; improved batch notifications for partners, programs, payouts, domains, and system events.
  • Refactor
    • Standardized email payloads (recipient field renamed to "to") and consolidated variant-based delivery across the app.
  • Documentation
    • Added recommended Node/pnpm versions and common troubleshooting to README.
  • Chores
    • Added monorepo package build script (build:packages).

Copy link
Contributor

vercel bot commented Sep 12, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 17, 2025 0:10am

@CLAassistant
Copy link

CLAassistant commented Sep 12, 2025

CLA assistant check
All committers have signed the CLA.

Copy link
Contributor

coderabbitai bot commented Sep 12, 2025

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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.

📥 Commits

Reviewing files that changed from the base of the PR and between b66953b and b003140.

📒 Files selected for processing (1)
  • apps/web/app/(ee)/api/cron/year-in-review/route.ts (2 hunks)

Walkthrough

Replaces direct Resend usage with a new sendBatchEmail/sendEmail surface from @dub/email, standardizes recipient prop from emailto, replaces from/VARIANT_TO_FROM_MAP with variant: "notifications", adds Resend nullable client and SMTP fallback, and introduces batch sending support across many cron, action, webhook, and library files.

Changes

Cohort / File(s) Summary
Email core & resend plumbing
packages/email/src/index.ts, packages/email/src/send-via-resend.ts, packages/email/src/send-via-nodemailer.ts, packages/email/src/resend/client.ts, packages/email/src/resend/types.ts, packages/email/src/resend/subscribe.ts, packages/email/src/resend/unsubscribe.ts
Adds sendBatchEmail and ResendBulkEmailOptions; makes resend client nullable; maps variant→from in helper; implements Resend batch path and SMTP fallback; renames single/bulk option fields (emailto / to in types).
Batch cron & notification routes
apps/web/app/(ee)/api/cron/.../notify-*/*.ts, .../payouts/**/send-*payouts*.ts, .../payouts/process/process-payouts.ts, .../payouts/reminders/**/route.ts, .../bounties/notify-partners/route.ts, .../domains/renewal-reminders/route.ts, .../year-in-review/route.ts, .../messages/notify-*/route.ts, apps/web/app/(ee)/api/cron/partner-program-summary/route.ts, apps/web/lib/cron/send-limit-email.ts, apps/web/app/(ee)/api/cron/usage/utils.ts, apps/web/app/(ee)/api/cron/welcome-user/route.ts
Replaces resend.batch.send with sendBatchEmail(...); drops VARIANT_TO_FROM_MAP imports; batch payloads use variant: "notifications" and to (or emailto) for recipients; some template payloads extended with explicit email/workspace fields.
Webhooks & Stripe/PayPal flows
apps/web/app/(ee)/api/stripe/webhook/*.ts, apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts, apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts, apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts, apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts, apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx, apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts
Switches batch sends to sendBatchEmail and single sends to the updated sendEmail signature (to); replaces from mapping with variant: "notifications" and adjusts template props where noted.
Partner flows & bulk actions
apps/web/lib/actions/partners/*, apps/web/lib/actions/partners/bulk-approve-partners.ts, apps/web/lib/actions/partners/bulk-ban-partners.ts, apps/web/lib/actions/partners/merge-partner-accounts.ts, apps/web/lib/actions/partners/create-bounty-submission.ts, apps/web/lib/actions/partners/create-program.ts, apps/web/lib/actions/partners/invite-partner.ts, apps/web/lib/actions/partners/resend-program-invite.ts, apps/web/lib/actions/partners/approve-partner-enrollment.ts, apps/web/lib/actions/partners/approve-bounty-submission.ts, apps/web/lib/actions/partners/ban-partner.ts, apps/web/lib/actions/partners/reject-bounty-submission.ts
Migrates bulk paths to sendBatchEmail, replaces recipient emailto, replaces VARIANT_TO_FROM_MAP usage with variant: "notifications", and aligns template payloads as required.
APIs, actions, integrations, scripts
apps/web/lib/api/*, apps/web/lib/actions/*, apps/web/lib/integrations/*, apps/web/lib/*/import-commissions.ts, apps/web/scripts/*, apps/web/app/api/**/route.ts, apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx
Renames recipient field in many sendEmail call sites from emailto; swaps some per-recipient limiter sends to batched sendBatchEmail. scripts/unsubscribe-inactive-users.ts now guards nullable resend.
Analytics UI feedback
apps/web/ui/analytics/feedback/action.ts
Replaces resend.emails.send call with new sendEmail import from @dub/email; payload unchanged aside from API surface.
Docs & tooling
README.md, package.json
Adds recommended Node/pnpm versions and common troubleshooting; adds build:packages script.

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

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

A rabbit taps “send” with a twinkling nose,
Batch or single, wherever it goes.
If Resend’s awake, off emails fly—
Else SMTP sails the midnight sky.
From email to to, neat and bright, we hop—notifications take flight. 🐇✉️

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title concisely and accurately summarizes the PR’s primary changes: fixing email behavior for local development (making the Resend client optional / restoring MailHog) and extracting batch email sends into a single function (sendBatchEmail). It is specific, relevant to the diff and PR objectives, and clear for a teammate scanning history.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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 batch

Verified: 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 retries

Stripe/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 emails

Webhook 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-Key

Prevents 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 emails

Cron 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 idempotency

Large 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 recipients

Currently 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 sending

workspace 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 block

error 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 payload

Remove variant from the object sent to resend.emails.send / resend.batch.send and use it only to derive from / headers. Also applies to: 51-52, 66-69

  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,
-      }),
+    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 script

Running 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 type

This 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 sense

Good 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 key

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

The 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’s

Same 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 types

Prefer 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 failure

One 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 nit

Minor 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 keys

The 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 layer

If 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 lists

Partner 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 call

Passing 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 LGTM

Chunking 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 verified

Avoid 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5c0c661 and 9007a33.

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

Nothing to review here.


6-6: Sanity-check Turbopack locally — sandbox couldn't verify

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

This 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 sender

Good 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 API

apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)

10-10: LGTM on unified email import

apps/web/lib/actions/partners/bulk-approve-partners.ts (2)

51-53: LGTM on group fetch usage

Consistent with getGroupOrThrow contract.


162-165: Nice: chunked and allSettled fan-out

Good resilience pattern.

apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)

3-3: LGTM on API switch

apps/web/lib/actions/partners/bulk-ban-partners.ts (1)

16-16: LGTM on switching to sendBatchEmail

apps/web/lib/partners/approve-partner-enrollment.ts (3)

12-12: LGTM on unified email import


41-44: Consistent findUniqueOrThrow usage

Looks good; ensures explicit error on invalid linkId.


106-121: Update payload includes tags; good for downstream consumers

No issues spotted.

apps/web/lib/api/domains/claim-dot-link-domain.ts (1)

178-193: LGTM on batch email migration

Type-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 notifications

Chunking 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 undefined

Spreading 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 to null 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).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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-circuit

Prevents 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 logging

Current 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 header

Resend 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 misconfig

Returning Response.json(...) violates the declared Promise<CustomResponse> (you’re returning a Response), and unconditionally succeeding hides missing credentials in production. Gate by NODE_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, ... } required

You’re still passing { from, to, reply_to, subject, text, react } objects into sendBatchEmail, which expects items with an email field (recipient) and optional variant, 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 timeout

Currently errors just fall through as JSON; add res.ok handling and a short timeout to avoid hanging calls. Maintain CustomResponse 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 now

Readable 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 drop headers. If you rely on this header for dedupe, it won’t apply locally.

Option A (preferred): update sendViaNodeMailer path to accept and forward headers 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 forward headers 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 message

Build 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 gating

You 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 boundaries

Improves 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) : null
apps/web/lib/actions/partners/create-bounty-submission.ts (1)

142-166: Filter null emails before batching

Prevents 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 counts

Avoid 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 logs

Log 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 emails

Improves 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 chunks

Prevents 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 fallback

Add 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 wrapper

The 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 chunks

Avoids 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 configured

Right 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5c0c661 and abd6fa6.

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

Switching 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 of variant: "notifications" is consistent with new API

Matches the transport-agnostic send API. No further changes needed here.

apps/web/lib/actions/partners/create-program.ts (1)

261-261: Consistent variant usage

Setting variant: "notifications" for invites matches the new conventions.

apps/web/lib/actions/partners/resend-program-invite.ts (1)

55-55: Consistent variant usage

Looks good and aligns with other invite flows.

apps/web/app/(ee)/api/cron/year-in-review/route.ts (1)

3-3: Importing sendBatchEmail is correct for the new batch surface

Good 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 API

Replacing direct Resend usage with sendBatchEmail keeps the route transport-agnostic.


186-201: Batch payload matches new contract

Using { 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 import

Removes direct Resend coupling.

apps/web/lib/api/domains/claim-dot-link-domain.ts (1)

13-15: LGTM on batch migration and typing

Clean 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 migration

Owners 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 move

apps/web/lib/api/partners/notify-partner-application.ts (2)

1-2: Correct imports for new email API


44-49: Payload shape migration looks correct

Using 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 cleanly

Passing program.id explicitly is clearer.


105-115: Email payload shape and typing LGTM

Correctly builds ResendBulkEmailOptions with variant/email.


164-164: Nice: email sends inside waitUntil with Promise.allSettled

Good isolation from request lifecycle.

apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)

8-9: Imports align with refactor

apps/web/lib/partners/approve-partner-enrollment.ts (4)

12-12: Centralized email sending — good


41-45: Correct use of findUniqueOrThrow

Clearer failure mode.


106-121: Link update + tag selection LGTM


178-201: Email send migration looks correct

Single-partner fanout, variant/email payload, and content are correct.

apps/web/ui/analytics/feedback/action.ts (1)

3-3: Correct import for single-email API

apps/web/lib/api/partners/notify-partner-commission.ts (2)

1-2: Imports are correct


112-121: Payload shape migration looks good

Correct 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 wiring

Mapping 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 API

Filters owners with an email, uses variant/email/React template correctly.


276-289: Batch domain-renewal-failed emails: looks consistent with the new API

Same as above; payload is correct.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (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 coercing false 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 by resend.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

📥 Commits

Reviewing files that changed from the base of the PR and between abd6fa6 and 5dd5620.

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

Scoping experimental.esmExternals to production reduces dev-time module resolution surprises. Nice tightening.


20-22: Verify Next.js esmExternals support and environment gating

Found 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 under experimental 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 set NODE_ENV=production. Use process.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.

Copy link
Collaborator

@TWilson023 TWilson023 left a 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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 id

Location: 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.all

workspace.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 uses to; 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 send undefined into the template. Guard or omit the email (e.g. only include when present or use a fallback) and scan other partner.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 or sendEmail 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 to count.

count += importedLinks.length inflates totals when some links are deduped. Use linksToCreate.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 when users 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 awaited

Inside summary.map, the body doesn’t return the limiter.schedule promise, so Promise.allSettled receives an array of undefined 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 promises

Currently, 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 is async but both sendEmail 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 be undefined 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 supports variant, 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 identifiers

Current 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 up

If 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 installation

If 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 days

The 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 readability

Creates 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 owners

If 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_KEY

If subscribe() throws in local dev (no Resend key), Promise.all will reject and prevent sendEmail 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 handling

No 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 lookup

Two 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-blocking

The 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 subject

Avoid “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 email

Storing 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 shape

sendBatchEmail 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 to to 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 references metadata.tolt_referral while the logic checks metadata.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. Prefer findUnique 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.

hasExistingApplicaitonshasExistingApplications.

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

Using 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 using workspace.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 email instead of to, swap the key accordingly after verifying the API.


132-188: Wrap waitUntil 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 shadowing links 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 failure

Map currently returns false for owners without email and Promise.all will reject on first failure. Filter first and use allSettled 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 percentage

If workspace.linksLimit is 0, this will Infinity/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 sends

These 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 delivery

Mirror 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 === for attemptCount.

-        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 with ResendEmailOptions, 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 to to 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);

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (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 request

Unchunked 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 fallback

sendBatchEmail (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: approvedpending is likely incorrect.

FirstPromoter maps approvedprocessed; 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 on data?.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 limits

Resend 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 full commission 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 on as 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: Audit metadata for PII; avoid passing arbitrary commission.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 using allSettled 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 compute isPartner.

-    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 return in_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

📥 Commits

Reviewing files that changed from the base of the PR and between 48eec1e and 9a9bfe0.

📒 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" and to aligns with the new email API and removes the need for from/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 to to (matches sendEmail) — LGTM.
Aligns call sites with the unified email helper. Verification script returned "No files were searched" — re-run or confirm no remaining email: 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 to to:; ensure no lingering email: 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=C

echo "rg version:"
rg --version || true

echo
echo "1) search for sendEmail/sendBatchEmail calls (ignore .gitignore):"
rg -n --hidden -uu -S -C5 '\b(sendEmail|sendBatchEmail)\s*(' || true

echo
echo "2) search for 'email:' object keys:"
rg -n --hidden -uu -S -C3 '\bemail\s*:' || true

echo
echo "3) search for 'from:' or 'replyTo:' object keys:"
rg -n --hidden -uu -S -C3 '\b(from|replyTo)\s*:' || true

echo
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*=' || true

echo
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."
fi

echo
echo "6) search for ProgramImported usage/definition:"
rg -n --hidden -uu -S 'ProgramImported' || true

echo
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."
fi

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

Root 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: Keep to: string — batching covers multi-recipient; no array usage found.

Confirmed: packages/email/src/resend/types.ts defines to: string and ResendBulkEmailOptions = ResendEmailOptions[]; callsites pass single-string to (packages/email/src/send-via-nodemailer.ts, packages/email/src/index.ts). No to: string[], Array<string>, or object-literal to: [ ... ] 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.send

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (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: Remove scheduledAt 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 unnecessary return 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9a9bfe0 and a4d8f97.

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

packages/email/src/resend/client.ts defines export const resend and packages/email/src/resend/index.ts re-exports it (export * from "./client"), so import { 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/web/lib/actions/partners/approve-bounty-submission.ts (1)

96-131: Make the Promise shape explicit in Promise.allSettled.

partner.email && sendEmail(...) adds a boolean into the array when partner.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 explicit variant 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

📥 Commits

Reviewing files that changed from the base of the PR and between a4d8f97 and ceef140.

📒 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 to to and using variant 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 calls resend.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.

Copy link
Collaborator

@steven-tey steven-tey left a 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 🙏

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (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 becomes Infinity/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). Your Promise.allSettled([...]) will still “succeed” and persist sentEmail, 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

📥 Commits

Reviewing files that changed from the base of the PR and between ceef140 and b66953b.

📒 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 the type branches, and variant: "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 pass to (not email)

  • packages/email/src/index.ts uses to for single and bulk sends (destructure const { to, subject, text, react } and batch uses to: 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 awaited

markDomainAsDeleted (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";
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

🧩 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(
Copy link
Contributor

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

@steven-tey steven-tey merged commit 129bb6d into main Sep 17, 2025
9 checks passed
@steven-tey steven-tey deleted the fix-emails-for-local-development branch September 17, 2025 00:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载