+
Skip to content

Conversation

steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Sep 4, 2025

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of email notifications for bounties, domain renewals/failures, payouts (Stripe/PayPal), partner workflows (applications, approvals, commissions, merges), domain claims, and analytics feedback.
    • Prevents silent skips when the email service is configured.
    • Skips reminder emails for programs with no pending payouts to avoid noise.
  • Chores

    • Standardized email provider configuration via environment variables for consistent behavior across environments.
    • Simplified email client initialization for more predictable operations.

Copy link
Contributor

vercel bot commented Sep 4, 2025

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

Project Deployment Preview Updated (UTC)
dub Canceled Canceled Sep 4, 2025 4:44pm

Copy link
Contributor

coderabbitai bot commented Sep 4, 2025

Walkthrough

This PR removes optional chaining on the Resend client across many email-sending call sites, making sends unconditional. It updates the email package to always export a Resend client and to gate usage via the RESEND_API_KEY environment variable, with SMTP fallback. One route adds a guard to skip zero-sum pending payouts.

Changes

Cohort / File(s) Summary
Web API cron routes — remove optional chaining on Resend
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts, .../domains/renewal-reminders/route.ts, .../merge-partner-accounts/route.ts
Replace resend?.batch.send(...) with resend.batch.send(...) in cron routes; no payload/control-flow changes.
Payouts processing — direct Resend usage
.../payouts/charge-succeeded/send-paypal-payouts.ts, .../payouts/charge-succeeded/send-stripe-payouts.ts
Remove optional chaining on resend.batch.send(...); logic for payouts unchanged.
Payout reminders — guard adjustments
.../payouts/reminders/partners/route.ts, .../payouts/reminders/program-owners/route.ts
Partners: remove resend-config guard; always call resend.batch.send. Program owners: add continue to skip programs with falsy pending sum.
Stripe webhooks — direct Resend calls
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts, .../charge-succeeded.ts
Replace resend?.batch.send(...) with resend.batch.send(...) in renewal invoice processing branches.
Web lib actions/APIs — direct Resend calls
apps/web/lib/actions/partners/bulk-approve-partners.ts, .../create-bounty-submission.ts, .../merge-partner-accounts.ts, apps/web/lib/api/domains/claim-dot-link-domain.ts, apps/web/lib/api/partners/notify-partner-application.ts, .../notify-partner-commission.ts, apps/web/lib/partners/approve-partner-enrollment.ts, apps/web/ui/analytics/feedback/action.ts
Replace resend?.batch.send(...) or resend?.emails.send(...) with direct calls; surrounding logic unchanged.
Email package — client/export and gating
packages/email/src/index.ts, packages/email/src/resend/client.ts, packages/email/src/resend/subscribe.ts, packages/email/src/resend/unsubscribe.ts
Always export a Resend instance; gate usage via process.env.RESEND_API_KEY. Update subscribe/unsubscribe guards to check env var. Add index routing: Resend if API key, otherwise SMTP fallback, else log.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Caller
  participant EmailIndex as packages/email/src/index.ts
  participant Resend as Resend Client
  participant SMTP as NodeMailer

  Caller->>EmailIndex: sendEmail(opts)
  alt RESEND_API_KEY set
    EmailIndex->>Resend: sendEmailViaResend(opts)
    Resend-->>EmailIndex: result
  else SMTP_HOST and SMTP_PORT set
    EmailIndex->>SMTP: sendViaNodeMailer(opts)
    SMTP-->>EmailIndex: result
  else
    Note over EmailIndex: Log: no email provider configured
  end
  EmailIndex-->>Caller: return/throw
Loading
sequenceDiagram
  autonumber
  actor Cron as Program Owners Cron
  participant DB as DB
  participant Resend as Resend.batch

  Cron->>DB: fetch programs with custom min payouts
  loop programs
    Cron->>DB: compute pendingPayout._sum.amount
    alt amount is falsy (0/undefined)
      Note over Cron: continue (skip program)
    else
      Cron->>Resend: batch.send(emails)
      Resend-->>Cron: result
    end
  end
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

Poem

Hop hop! I send with gusto now,
No timid ?. to furrow my brow.
ENV whispers, paths align,
Resend or SMTP—by design.
Carrot stamps on batches fly,
Emails soar across the sky. ✉️🥕

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-payout-reminder-cron

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

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

⚠️ Outside diff range comments (6)
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)

111-129: Make batch send resilient and avoid returning PII.

  • Catch and log per-batch failures so one bad batch doesn’t fail the whole cron.
  • Don’t return the full recipient payload (emails) in the HTTP response; return counts only. Also avoid logging full provider responses.
-    for (const reminderDomainsChunk of reminderDomainsChunks) {
-      const res = await resend.batch.send(
+    for (const reminderDomainsChunk of reminderDomainsChunks) {
+      try {
+        const res = await resend.batch.send(
         reminderDomainsChunk.map(({ workspace, user, domain }) => ({
           from: VARIANT_TO_FROM_MAP.notifications,
           to: user.email!,
           subject: "Your domain is expiring soon",
           variant: "notifications",
           react: DomainRenewalReminder({
             email: user.email!,
             workspace,
             domain,
           }),
         })),
-      );
-      console.log(`Sent ${reminderDomainsChunk.length} emails`, res);
+        );
+        console.log(`Sent ${reminderDomainsChunk.length} emails`);
+      } catch (err) {
+        await log({
+          message: `Domain renewal reminder batch failed: ${String(err)}`,
+          type: "errors",
+        });
+      }
     }
-
-    return NextResponse.json(reminderDomains);
+    return NextResponse.json({
+      batches: reminderDomainsChunks.length,
+      recipients: reminderDomains.length,
+    });
apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)

109-121: Correct per-domain expiry
Applying a single newExpiresAt from the earliest domain overwrites later expiry dates. In apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (lines 109–121), replace:

-  const newExpiresAt = addDays(domains[0].expiresAt, 365);
-
-  await prisma.registeredDomain.updateMany({
-    where: {
-      id: {
-        in: domains.map(({ id }) => id),
-      },
-    },
-    data: {
-      expiresAt: newExpiresAt,
-      autoRenewalDisabledAt: null,
-    },
-  });

with:

+  await Promise.all(
+    domains.map(d =>
+      prisma.registeredDomain.update({
+        where: { id: d.id },
+        data: {
+          expiresAt: addDays(d.expiresAt, 365),
+          autoRenewalDisabledAt: null,
+        },
+      }),
+    ),
+  );

Verify whether the email template or user-facing UX should display per-domain renewed dates rather than a single date.

apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)

51-65: Add idempotency key and guard empty recipient list before batch send

Prevents duplicate emails on retries and avoids calling Resend with an empty array.

-  const batchEmails = await resend.batch.send(
-    payouts
-      .filter((payout) => payout.partner.email)
-      .map((payout) => ({
-        from: VARIANT_TO_FROM_MAP.notifications,
-        to: payout.partner.email!,
-        subject: "You've been paid!",
-        react: PartnerPayoutProcessed({
-          email: payout.partner.email!,
-          program: payout.program,
-          payout,
-          variant: "paypal",
-        }),
-      })),
-  );
+  const emails = payouts
+    .filter((payout) => payout.partner.email)
+    .map((payout) => ({
+      from: VARIANT_TO_FROM_MAP.notifications,
+      to: payout.partner.email!,
+      subject: "You've been paid!",
+      react: PartnerPayoutProcessed({
+        email: payout.partner.email!,
+        program: payout.program,
+        payout,
+        variant: "paypal",
+      }),
+    }));
+
+  if (emails.length === 0) {
+    console.log("No recipients for PayPal payout emails, skipping send.");
+    return;
+  }
+
+  const { data: sent } = await resend.batch.send(emails, {
+    idempotencyKey: `payouts/paypal/${invoiceId}`,
+  });

Resend’s batch API supports up to 100 messages and idempotency keys to prevent duplicate sends. (resend.com)

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

20-45: Filter out zero-sum groups at the DB level

Prevents emailing partners who have $0 pending across grouped payouts.

     const pendingPayouts = await prisma.payout.groupBy({
       by: ["partnerId", "programId"],
       where: {
         status: "pending",
         partner: {
           payoutsEnabledAt: null,
           OR: [
             { connectPayoutsLastRemindedAt: null },
             {
               connectPayoutsLastRemindedAt: {
                 lte: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Last notified was at least 3 days ago
               },
             },
           ],
         },
       },
       _sum: {
         amount: true,
       },
+      having: {
+        amount: {
+          _sum: {
+            gt: 0,
+          },
+        },
+      },
       orderBy: {
         _sum: {
           amount: "desc",
         },
       },
     });

This matches Prisma’s groupBy “having” pattern for aggregate filters. (prisma.io)


133-133: Remove PII-heavy logging

This logs full partner+program structures including emails. Log counts/ids instead.

-      console.info(partnerProgramsChunk);
+      console.info("Sent payout reminders", { chunkSize: partnerProgramsChunk.length });
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)

46-51: Redact PII in logs

Avoid logging user emails in plaintext.

-    console.log({
-      userId,
-      sourceEmail,
-      targetEmail,
-    });
+    console.log("Merging partner accounts", { userId, sourceDomain: sourceEmail.split("@")[1], targetDomain: targetEmail.split("@")[1] });
🧹 Nitpick comments (27)
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)

37-45: Use addDays for clarity (equivalent, less surprising).

subDays(now, -days) works but is harder to read. Prefer addDays(now, days).

-    const targetDates = REMINDER_WINDOWS.map((days) => {
-      const date = subDays(now, -days);
+    const targetDates = REMINDER_WINDOWS.map((days) => {
+      const date = addDays(now, days);

Add import:

 import {
   differenceInCalendarDays,
   endOfDay,
   formatDistanceStrict,
   startOfDay,
-  subDays,
+  addDays,
+  subDays,
 } from "date-fns";
apps/web/lib/api/domains/claim-dot-link-domain.ts (1)

191-193: Unconditional resend: guard failures so background work isn’t marked failed.

Wrap the send to avoid bubbling an email error out of waitUntil’s Promise.all.

-  if (emails.length > 0) {
-    return await resend.batch.send(emails);
-  }
+  if (emails.length > 0) {
+    try {
+      return await resend.batch.send(emails);
+    } catch (err) {
+      console.error("sendDomainClaimedEmails failed:", err);
+      return null;
+    }
+  }
apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (2)

67-86: Confirm custom min-payout semantics (filter vs. threshold on total).

Current query sums only payouts whose individual amount >= minPayoutAmount. If the intent is “notify when total pending >= min,” sum all pending amounts and compare the total to the threshold instead.

       const pendingPayout = await prisma.payout.aggregate({
         where: {
           programId: program.id,
           status: "pending",
-          amount: {
-            gte: program.minPayoutAmount,
-          },
           partner: {
             payoutsEnabledAt: {
               not: null,
             },
           },
         },
         _sum: { amount: true },
         _count: { _all: true },
       });
+
+      // Only notify if total pending meets the custom minimum
+      if ((pendingPayout._sum?.amount ?? 0) < program.minPayoutAmount) {
+        continue;
+      }

116-121: Comment says “last week” but code uses 14 days.

Align the comment with the logic.

-    // only send notifications for programs that have not paid out any invoices in the last week
+    // only send notifications for programs that have not paid out any invoices in the last 2 weeks
apps/web/lib/partners/approve-partner-enrollment.ts (1)

179-202: Unconditional batch send—consider batch size and partial-failure handling.

  • If partnerEmailsToNotify > 100, chunk to stay within provider limits.
  • Optional: wrap per-batch in try/catch so one failure doesn’t mask others (you’re already using Promise.allSettled, which helps).

Example (outside this hunk):

import { chunk } from "@dub/utils";

for (const emails of chunk(partnerEmailsToNotify, 100)) {
  await resend.batch.send(
    emails.map((email) => ({
      subject: `Your application to join ${program.name} partner program has been approved!`,
      from: VARIANT_TO_FROM_MAP.notifications,
      to: email,
      react: PartnerApplicationApproved(/* ... */),
    })),
  );
}
packages/email/src/resend/client.ts (1)

3-3: Non-null assertion for TS strictness.

Avoid “string | undefined” arg errors in strict mode.

-export const resend = new Resend(process.env.RESEND_API_KEY);
+export const resend = new Resend(process.env.RESEND_API_KEY!);
apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)

155-169: Guard batch email send or centralize fallback to avoid runtime failures without RESEND_API_KEY

Direct resend.batch.send will throw in dev/preview where the key isn’t set. Either short-circuit here or route through a wrapper that no-ops/falls back when missing.

Minimal inline guard:

-  await resend.batch.send(
+  if (!process.env.RESEND_API_KEY) {
+    console.warn("RESEND_API_KEY not set. Skipping domain renewal emails.");
+    return;
+  }
+  await resend.batch.send(

Preferable: add a safeBatchSend(emails) in @dub/email/resend and call that here.

apps/web/lib/actions/partners/merge-partner-accounts.ts (1)

174-195: Add resilience: gate or wrap batch send to support dev/preview without RESEND_API_KEY

Unconditional resend.batch.send can throw when the key is absent. Keep behavior consistent with unsubscribe() which guards on env.

Quick guard:

-  await resend.batch.send([
+  if (!process.env.RESEND_API_KEY) {
+    console.warn("RESEND_API_KEY not set. Skipping verification emails.");
+    return;
+  }
+  await resend.batch.send([

Longer-term: export a safeBatchSend() helper from @dub/email/resend and use it here.

packages/email/src/resend/unsubscribe.ts (2)

20-23: Nit: avoid unnecessary return await

return await resend.contacts.remove(...) adds an extra microtask without benefit here.

-  return await resend.contacts.remove({
+  return resend.contacts.remove({
     email,
     audienceId,
   });

1-24: Unify send behavior behind a safe wrapper to remove per-callsite guards

To keep all call sites consistent (batch sends, subscribe/unsubscribe, etc.), expose a safeBatchSend/safeSend that gates on RESEND_API_KEY and delegates to SMTP fallback when available. Then replace direct resend.batch.send usages across the app.

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

143-144: Harden batch dispatch: use a safe sender and tolerate per-chunk failures

  • Add env gating or use a wrapper to avoid crashes without RESEND_API_KEY.
  • Prefer Promise.allSettled so one bad chunk doesn’t fail the entire flow.
-  await Promise.all(
-    emailChunks.map((emailChunk) => resend.batch.send(emailChunk)),
-  );
+  if (!process.env.RESEND_API_KEY) {
+    console.warn("RESEND_API_KEY not set. Skipping commission emails.");
+    return;
+  }
+  const results = await Promise.allSettled(
+    emailChunks.map((emailChunk) => resend.batch.send(emailChunk)),
+  );
+  const rejected = results.filter((r) => r.status === "rejected");
+  if (rejected.length) {
+    console.error(`Failed to send ${rejected.length} email chunk(s).`);
+  }

Also confirm 100 is within the current Resend batch limit.

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

143-169: Guard batch send or use a helper; optionally chunk recipients

  • Add env check or route through a safe helper to avoid throwing without RESEND_API_KEY.
  • If owners could exceed 100, chunk to match batch limits (optional).

Minimal guard:

-        if (users.length > 0) {
-          await resend.batch.send(
+        if (users.length > 0) {
+          if (!process.env.RESEND_API_KEY) {
+            console.warn("RESEND_API_KEY not set. Skipping owner notifications.");
+          } else {
+            await resend.batch.send(
               users.map((user) => ({
                 from: VARIANT_TO_FROM_MAP.notifications,
                 to: user.email,
                 subject: "Pending bounty review",
                 react: BountyPendingReview({
                   email: user.email,
                   workspace: {
                     slug: workspace.slug,
                   },
                   bounty: {
                     id: bounty.id,
                     name: bounty.name,
                   },
                   partner: {
                     name: partner.name,
                     image: partner.image,
                     email: partner.email!,
                   },
                   submission: {
                     id: submission.id,
                   },
                 }),
               })),
-          );
+            );
+          }
         }
apps/web/ui/analytics/feedback/action.ts (1)

10-16: Harden the send path: guard against transient failures (and missing RESEND_API_KEY) instead of letting the server action throw.

Direct resend.emails.send will now throw on bad/missing credentials or network blips. Catch and log so the action doesn’t surface a 500 to users.

-  return await resend.emails.send({
-    from: "feedback@dub.co",
-    to: "steven@dub.co",
-    ...(email && { replyTo: email }),
-    subject: "🎉 New Feedback Received!",
-    react: FeedbackEmail({ email, feedback }),
-  });
+  try {
+    return await resend.emails.send({
+      from: "feedback@dub.co",
+      to: "steven@dub.co",
+      ...(email && { replyTo: email }),
+      subject: "🎉 New Feedback Received!",
+      react: FeedbackEmail({ email, feedback }),
+    });
+  } catch (err) {
+    console.error("submitFeedback: email send failed", err);
+  }
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (2)

247-263: Don’t block the webhook; send in the background with idempotency and error handling.

Wrap this batch in waitUntil, add per-recipient Idempotency-Key, and catch errors so renewal handling isn’t aborted by email failures.

-    if (workspaceOwners.length > 0) {
-      await resend.batch.send(
-        workspaceOwners.map(({ user }) => ({
-          from: VARIANT_TO_FROM_MAP.notifications,
-          to: user.email!,
-          subject: "Domain expired",
-          react: DomainExpired({
-            email: user.email!,
-            workspace: {
-              name: workspace.name,
-              slug: workspace.slug,
-            },
-            domains,
-          }),
-        })),
-      );
-    }
+    if (workspaceOwners.length > 0) {
+      waitUntil(
+        (async () => {
+          try {
+            await resend.batch.send(
+              workspaceOwners.map(({ user }) => ({
+                from: VARIANT_TO_FROM_MAP.notifications,
+                to: user.email!,
+                subject: "Domain expired",
+                react: DomainExpired({
+                  email: user.email!,
+                  workspace: { name: workspace.name, slug: workspace.slug },
+                  domains,
+                }),
+                headers: {
+                  "Idempotency-Key": `domain-expired-${invoice.id}-${user.id}`,
+                },
+              })),
+            );
+          } catch (err) {
+            console.error("Failed to send 'Domain expired' emails:", err);
+          }
+        })(),
+      );
+    }

277-292: Same here: background send + idempotency for the “renewal failed” emails.

-    if (workspaceOwners.length > 0) {
-      await resend.batch.send(
-        workspaceOwners.map(({ user }) => ({
-          from: VARIANT_TO_FROM_MAP.notifications,
-          to: user.email!,
-          subject: "Domain renewal failed",
-          react: DomainRenewalFailed({
-            email: user.email!,
-            workspace: {
-              slug: workspace.slug,
-            },
-            domains,
-          }),
-        })),
-      );
-    }
+    if (workspaceOwners.length > 0) {
+      waitUntil(
+        (async () => {
+          try {
+            await resend.batch.send(
+              workspaceOwners.map(({ user }) => ({
+                from: VARIANT_TO_FROM_MAP.notifications,
+                to: user.email!,
+                subject: "Domain renewal failed",
+                react: DomainRenewalFailed({
+                  email: user.email!,
+                  workspace: { slug: workspace.slug },
+                  domains,
+                }),
+                headers: {
+                  "Idempotency-Key": `domain-renewal-failed-${invoice.id}-${user.id}`,
+                },
+              })),
+            );
+          } catch (err) {
+            console.error("Failed to send 'Domain renewal failed' emails:", err);
+          }
+        })(),
+      );
+    }
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)

101-129: Make the cron robust to single-batch failures; continue on error and log.

A transient Resend error currently aborts the whole page. Catch per-batch and proceed.

-      await resend.batch.send(
-        programEnrollmentChunk.map(({ partner }) => ({
+      try {
+        await resend.batch.send(
+          programEnrollmentChunk.map(({ partner }) => ({
             from: VARIANT_TO_FROM_MAP.notifications,
             to: partner.email!, // coerce the type here because we've already filtered out partners with no email in the prisma query
             subject: `New bounty available for ${bounty.program.name}`,
             react: NewBountyAvailable({
               email: partner.email!,
               bounty: {
                 name: bounty.name,
                 type: bounty.type,
                 endsAt: bounty.endsAt,
                 description: bounty.description,
               },
               program: {
                 name: bounty.program.name,
                 slug: bounty.program.slug,
               },
             }),
             headers: {
               "Idempotency-Key": `${bountyId}-${partner.id}`,
             },
-          })),
-      );
+          })),
+        );
+      } catch (err: any) {
+        await log({
+          message: `Resend batch failed for bounty ${bountyId}, page ${page}: ${err?.message ?? err}`,
+          type: "errors",
+        });
+      }
apps/web/lib/api/partners/notify-partner-application.ts (1)

74-76: Avoid failing the whole send on a single chunk; use allSettled.

This keeps the rest of the notifications flowing and you can still inspect failures.

-  await Promise.all(
-    emailChunks.map((emailChunk) => resend.batch.send(emailChunk)),
-  );
+  await Promise.allSettled(
+    emailChunks.map((emailChunk) => resend.batch.send(emailChunk)),
+  );
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1)

95-111: Harden payout emails: try/catch and per-email Idempotency-Key to dedupe retries.

Prevents the payout flow from erroring on email issues and avoids duplicates on retries.

-  const resendBatch = await resend.batch.send(
+  let resendBatch;
+  try {
+    resendBatch = await resend.batch.send(
     currentInvoicePayouts
       .filter((p) => p.partner.email)
       .map((p) => {
         return {
           from: VARIANT_TO_FROM_MAP.notifications,
           to: p.partner.email!,
           subject: "You've been paid!",
           react: PartnerPayoutProcessed({
             email: p.partner.email!,
             program: p.program,
             payout: p,
             variant: "stripe",
           }),
+          headers: {
+            "Idempotency-Key": `payout-processed-${p.id}`,
+          },
         };
       }),
-  );
+    );
+  } catch (err) {
+    console.error("Failed to send PartnerPayoutProcessed emails:", err);
+  }
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)

49-50: Redact logs to avoid leaking payout/email payloads

Prefer logging counts/ids instead of full objects that may contain PII or payout metadata.

-  console.log("PayPal batch payout created", batchPayout);
+  console.log("PayPal batch payout created", { invoiceId, count: payouts.length });-  console.log("Resend batch emails sent", batchEmails);
+  console.log("Resend batch emails sent", { count: sent?.length ?? 0 });

Also applies to: 67-67

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

119-131: Add idempotency keys per batch to avoid duplicate reminders on retries

Safer for cron replays and transient failures.

-    for (const partnerProgramsChunk of partnerProgramsChunks) {
-      await resend.batch.send(
+    for (const [i, partnerProgramsChunk] of partnerProgramsChunks.entries()) {
+      await resend.batch.send(
         partnerProgramsChunk.map(({ partner, programs }) => ({
           from: VARIANT_TO_FROM_MAP.notifications,
           to: partner.email,
           subject: "Connect your payout details on Dub Partners",
           variant: "notifications",
           react: ConnectPayoutReminder({
             email: partner.email,
             programs,
           }),
         })),
-      );
+        { idempotencyKey: `payout-reminders/${connectPayoutsLastRemindedAt.toISOString()}/${i}` },
+      );

Resend batch supports idempotency keys; use a deterministic key per chunk. (resend.com)

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

136-137: Optional: add idempotency keys to batch sends

Helps dedupe on retries; consider hashing recipient emails to form a stable key per chunk.

-          ...emailChunks.map((emailChunk) => resend.batch.send(emailChunk)),
+          ...emailChunks.map((emailChunk, i) =>
+            resend.batch.send(emailChunk, {
+              // e.g., approvals/<program>/<chunk-index>
+              idempotencyKey: `approvals/${program.id}/${i}`,
+            }),
+          ),

Resend batch idempotency reference. (resend.com)

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

247-268: Add idempotency key to batch email after merge

Prevents duplicate notifications if the job is retried.

-    await resend.batch.send([
+    await resend.batch.send(
+      [
       {
         from: VARIANT_TO_FROM_MAP.notifications,
         to: sourceEmail,
         subject: "Your Dub partner accounts are now merged",
         react: PartnerAccountMerged({
           email: sourceEmail,
           sourceEmail,
           targetEmail,
         }),
       },
       {
         from: VARIANT_TO_FROM_MAP.notifications,
         to: targetEmail,
         subject: "Your Dub partner accounts are now merged",
         react: PartnerAccountMerged({
           email: targetEmail,
           sourceEmail,
           targetEmail,
         }),
       },
-    ]);
+      ],
+      { idempotencyKey: `partners/merge/${sourceEmail}->${targetEmail}` },
+    );

Resend batch idempotency reference. (resend.com)

packages/email/src/resend/subscribe.ts (1)

27-35: Handle unauthorized keys and normalize names

Adds a clearer log when the API key is present but invalid, and trims name parts.

-  return await resend.contacts.create({
-    email,
-    ...(name && {
-      firstName: name.split(" ")[0],
-      lastName: name.split(" ").slice(1).join(" "),
-    }),
-    audienceId,
-  });
+  try {
+    return await resend.contacts.create({
+      email,
+      ...(name && {
+        firstName: name.trim().split(/\s+/)[0],
+        lastName: name.trim().split(/\s+/).slice(1).join(" "),
+      }),
+      audienceId,
+    });
+  } catch (err: any) {
+    // Common failure: 401 Unauthorized due to bad key or domain issues
+    console.warn("Resend subscribe failed", {
+      status: err?.status,
+      code: err?.code,
+      audience,
+    });
+    return;
+  }

Resend contact creation flow reference. (resend.com)

packages/email/src/index.ts (4)

6-8: Harden the Resend config check (trim whitespace).

Guard against cases where RESEND_API_KEY is set to whitespace.

-  if (process.env.RESEND_API_KEY) {
+  const resendConfigured = Boolean(process.env.RESEND_API_KEY?.trim());
+  if (resendConfigured) {
     return await sendEmailViaResend(opts);
   }

7-7: Drop redundant await on returned promises.

Minor micro-optimization; preserves behavior while avoiding an extra microtask hop.

-    return await sendEmailViaResend(opts);
+    return sendEmailViaResend(opts);
-    return await sendViaNodeMailer({
+    return sendViaNodeMailer({
       email,
       subject,
       text,
       react,
     });

Also applies to: 17-17


25-28: Elevate log level for missing email configuration.

Use warn to make misconfiguration more visible in logs. Consider throwing if callers should handle this explicitly.

-  console.info(
+  console.warn(
     "Email sending failed: Neither SMTP nor Resend is configured. Please set up at least one email service to send emails.",
   );

1-5: Annotate a concrete return type to improve DX and safety.

Unify the return type across transports so call sites know what to expect.

 import { ResendEmailOptions } from "./resend/types";
 import { sendViaNodeMailer } from "./send-via-nodemailer";
 import { sendEmailViaResend } from "./send-via-resend";
 
-export const sendEmail = async (opts: ResendEmailOptions) => {
+type ResendResult = Awaited<ReturnType<typeof sendEmailViaResend>>;
+type SmtpResult = Awaited<ReturnType<typeof sendViaNodeMailer>>;
+export type EmailSendResult = ResendResult | SmtpResult | undefined;
+
+export const sendEmail = async (opts: ResendEmailOptions): Promise<EmailSendResult> => {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between fb2cacb and be30988.

📒 Files selected for processing (21)
  • apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1 hunks)
  • apps/web/lib/actions/partners/bulk-approve-partners.ts (1 hunks)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (1 hunks)
  • apps/web/lib/actions/partners/merge-partner-accounts.ts (1 hunks)
  • apps/web/lib/api/domains/claim-dot-link-domain.ts (1 hunks)
  • apps/web/lib/api/partners/notify-partner-application.ts (1 hunks)
  • apps/web/lib/api/partners/notify-partner-commission.ts (1 hunks)
  • apps/web/lib/partners/approve-partner-enrollment.ts (1 hunks)
  • apps/web/ui/analytics/feedback/action.ts (1 hunks)
  • packages/email/src/index.ts (1 hunks)
  • packages/email/src/resend/client.ts (1 hunks)
  • packages/email/src/resend/subscribe.ts (1 hunks)
  • packages/email/src/resend/unsubscribe.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (15)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/ui/analytics/feedback/action.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/lib/actions/partners/bulk-approve-partners.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/lib/actions/partners/merge-partner-accounts.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/lib/api/partners/notify-partner-commission.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/lib/partners/approve-partner-enrollment.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/lib/api/partners/notify-partner-application.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
apps/web/lib/api/domains/claim-dot-link-domain.ts (1)
packages/email/src/resend/client.ts (1)
  • resend (3-3)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (7)
apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (1)

88-91: Good guard—prevents zero-amount reminders.

This avoids noise for programs with no payable amount. LGTM.

packages/email/src/resend/client.ts (1)

3-3: Ensure RESEND_API_KEY coverage or use sendEmail for SMTP fallback
Direct imports of @dub/email/resend detected across numerous modules bypass the SMTP fallback—confirm that RESEND_API_KEY is set in every target runtime, or replace those imports with sendEmail from @dub/email to guarantee fallback.

packages/email/src/resend/unsubscribe.ts (1)

11-16: LGTM: environment-gated unsubscribe path

Good central guard on RESEND_API_KEY with a clear log and early return.

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

107-107: Confirm Resend batch API support and env variable

  • Verified the project uses Resend SDK v^4.0.0 in packages/email/package.json (so batch.send is available in v4.x) and all usages import the same client instance; SDK docs confirm resend.batch.send exists in v4.x.
  • Ensured RESEND_API_KEY is referenced in the email client (packages/email/src/resend/client.ts) and guarded in scripts—verify your deployment envs set this variable where these cron routes run.
apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (1)

7-7: Import change looks good

Removing the unused log import is fine.

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

164-165: Unconditional send looks fine; background failure won’t break the action

Given waitUntil and Promise.allSettled, email failures won’t block the user flow.

packages/email/src/resend/subscribe.ts (1)

13-18: Env-var gate is correct

Switching to an env check is consistent with always exporting a Resend client.

@steven-tey
Copy link
Collaborator Author

/bug0 run

export const resend = process.env.RESEND_API_KEY
? new Resend(process.env.RESEND_API_KEY)
: null;
export const resend = new Resend(process.env.RESEND_API_KEY);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
export const resend = new Resend(process.env.RESEND_API_KEY);
export const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;

The Resend client will be instantiated with undefined when RESEND_API_KEY is not set, which will likely cause runtime errors when the client is used.

View Details

Analysis

Resend Client Instantiation Bug: Runtime Error When API Key Not Set

Issue Summary

The Resend email client in packages/email/src/resend/client.ts is instantiated unconditionally with process.env.RESEND_API_KEY, which causes immediate runtime errors when the environment variable is undefined. This creates application crashes at startup in environments where the API key is not configured.

Technical Details

Current Problematic Code

// packages/email/src/resend/client.ts
export const resend = new Resend(process.env.RESEND_API_KEY);

Error Behavior

When process.env.RESEND_API_KEY is undefined, the Resend constructor immediately throws:

Error: Missing API key. Pass it to the constructor `new Resend("re_123")`

This error occurs at module import time, not when the client is used, meaning any application importing this module will crash during startup if the environment variable is not set.

Codebase Inconsistency

The codebase shows an inconsistent approach to handling the missing API key scenario:

  1. Environment variable checks: Some files properly check process.env.RESEND_API_KEY:

    // packages/email/src/index.ts
    if (process.env.RESEND_API_KEY) {
      return await sendEmailViaResend(opts);
    }
  2. Client null checks: Other files attempt to check the resend client itself:

    // packages/email/src/send-via-resend.ts
    if (!resend) {
      console.info("RESEND_API_KEY is not set in the .env. Skipping sending email.");
      return;
    }

However, the client null checks are unreachable code because the constructor throws an error before the client variable can be assigned a value.

Impact

This bug manifests as:

  • Application crashes during startup/import in development environments without API keys
  • Deployment failures in environments where Resend is not configured
  • Inconsistent error handling where some parts of the application expect graceful degradation but get hard crashes instead

Verification Evidence

Through concrete testing with Resend v4.0.0, I confirmed:

  1. new Resend(undefined) immediately throws an error
  2. Module import fails when the constructor is called with undefined
  3. The error occurs before any null checks can be executed
  4. The conditional pattern process.env.RESEND_API_KEY ? new Resend(...) : null works correctly

Recommended Fix

Use conditional instantiation to allow graceful handling of missing API keys:

export const resend = process.env.RESEND_API_KEY 
  ? new Resend(process.env.RESEND_API_KEY) 
  : null;

This approach:

  • Prevents startup crashes when API key is not configured
  • Makes the existing null checks functional
  • Maintains consistency with the codebase's defensive programming patterns
  • Allows applications to gracefully degrade or use alternative email providers

@steven-tey steven-tey merged commit 8298eb4 into main Sep 4, 2025
9 of 11 checks passed
@steven-tey steven-tey deleted the fix-payout-reminder-cron branch September 4, 2025 16:44
@coderabbitai coderabbitai bot mentioned this pull request Oct 4, 2025
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.

1 participant

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