From a4d8f97144e6cf2a578340e6ea1cb9f8d3a9041e Mon Sep 17 00:00:00 2001 From: Ian MacCallum Date: Tue, 16 Sep 2025 19:21:31 -0400 Subject: [PATCH 1/3] Refactors email and bulk email --- README.md | 12 ++++ .../cron/bounties/notify-partners/route.ts | 7 +- .../cron/domains/renewal-reminders/route.ts | 6 +- .../(ee)/api/cron/domains/transfer/utils.ts | 2 +- .../app/(ee)/api/cron/domains/verify/utils.ts | 4 +- .../app/(ee)/api/cron/import/bitly/utils.ts | 2 +- .../web/app/(ee)/api/cron/import/csv/utils.ts | 4 +- .../(ee)/api/cron/import/rebrandly/utils.ts | 2 +- .../app/(ee)/api/cron/import/short/utils.ts | 2 +- .../api/cron/merge-partner-accounts/route.ts | 10 +-- .../api/cron/messages/notify-partner/route.ts | 7 +- .../api/cron/messages/notify-program/route.ts | 7 +- .../api/cron/partner-program-summary/route.ts | 2 +- .../cron/payouts/balance-available/route.ts | 2 +- .../charge-succeeded/send-paypal-payouts.ts | 7 +- .../charge-succeeded/send-stripe-payouts.ts | 7 +- .../cron/payouts/process/process-payouts.ts | 6 +- .../cron/payouts/reminders/partners/route.ts | 6 +- .../payouts/reminders/program-owners/route.ts | 7 +- .../program-application-reminder/route.ts | 2 +- apps/web/app/(ee)/api/cron/usage/utils.ts | 2 +- .../app/(ee)/api/cron/welcome-user/route.ts | 2 +- .../app/(ee)/api/cron/year-in-review/route.ts | 8 +-- .../api/paypal/webhook/payouts-item-failed.ts | 2 +- .../api/stripe/connect/webhook/payout-paid.ts | 2 +- .../(ee)/api/stripe/webhook/charge-failed.ts | 14 ++-- .../api/stripe/webhook/charge-succeeded.ts | 7 +- .../webhook/checkout-session-completed.ts | 2 +- .../stripe/webhook/invoice-payment-failed.tsx | 2 +- apps/web/app/(ee)/api/stripe/webhook/utils.ts | 2 +- apps/web/app/api/auth/reset-password/route.ts | 2 +- apps/web/app/api/dub/webhook/lead-created.ts | 2 +- apps/web/app/api/tokens/route.ts | 2 +- apps/web/app/api/user/password/route.ts | 2 +- apps/web/app/api/user/set-password/route.ts | 2 +- apps/web/app/api/webhooks/route.ts | 2 +- .../confirm-email-change/[token]/page.tsx | 2 +- .../folders/request-folder-edit-access.ts | 2 +- .../partners/approve-bounty-submission.ts | 2 +- apps/web/lib/actions/partners/ban-partner.ts | 2 +- .../actions/partners/bulk-approve-partners.ts | 10 +-- .../lib/actions/partners/bulk-ban-partners.ts | 5 +- .../partners/create-bounty-submission.ts | 10 ++- .../lib/actions/partners/create-program.ts | 7 +- .../lib/actions/partners/invite-partner.ts | 5 +- .../partners/merge-partner-accounts.ts | 9 ++- .../partners/reject-bounty-submission.ts | 2 +- .../actions/partners/resend-program-invite.ts | 5 +- .../web/lib/actions/request-password-reset.ts | 2 +- .../lib/actions/send-invite-referral-email.ts | 2 +- apps/web/lib/actions/send-otp.ts | 2 +- .../lib/api/domains/claim-dot-link-domain.ts | 10 +-- .../partners/notify-partner-application.ts | 58 ++++++++-------- .../api/partners/notify-partner-commission.ts | 61 ++++++++++------- apps/web/lib/api/users.ts | 2 +- .../workflows/execute-award-bounty-action.ts | 2 +- apps/web/lib/auth/confirm-email-change.ts | 2 +- apps/web/lib/auth/options.ts | 2 +- apps/web/lib/cron/send-limit-email.ts | 2 +- .../lib/firstpromoter/import-commissions.ts | 2 +- apps/web/lib/integrations/install.ts | 2 +- .../partners/approve-partner-enrollment.ts | 7 +- .../partnerstack/update-stripe-customers.ts | 2 +- apps/web/lib/rewardful/import-commissions.ts | 2 +- apps/web/lib/tolt/import-commissions.ts | 2 +- apps/web/lib/webhook/failure.ts | 4 +- apps/web/scripts/send-emails.tsx | 2 +- .../web/scripts/unsubscribe-inactive-users.ts | 11 ++- apps/web/ui/analytics/feedback/action.ts | 4 +- package.json | 3 +- packages/email/src/index.ts | 52 ++++++++++++-- packages/email/src/resend/client.ts | 4 +- packages/email/src/resend/subscribe.ts | 2 +- packages/email/src/resend/types.ts | 4 +- packages/email/src/resend/unsubscribe.ts | 2 +- packages/email/src/send-via-nodemailer.ts | 6 +- packages/email/src/send-via-resend.ts | 68 +++++++++++++------ .../ui/src/icons/payout-platforms/stripe.tsx | 1 - 78 files changed, 314 insertions(+), 231 deletions(-) diff --git a/README.md b/README.md index 730e43b7e38..3f4f7ad3ce2 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,18 @@ We love our contributors! Here's how you can contribute: +### Recommended Versions + +| Package | Version | +| ------- | -------- | +| node | v23.11.0 | +| pnpm | 9.15.9 | + +## Common Issues + +- `The table 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 project is not building correctly locally - verify your versions of `node` and `pnpm` match the recommended versions above. Delete all `node_modules`, `.next`, and `.turbo` directories in the `apps` and `packages` directory. You may now reinstall `node_modules` by running `pnpm install` and attempt to rebuild the project with `pnpm build`. + ## Repo Activity ![Dub repo activity – generated by Axiom](https://repobeats.axiom.co/api/embed/6ac4c94a89ea20e2e10032b932a128b6d8442e66.svg "Repobeats analytics image") diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index fefd6299880..ac5124f2310 100644 --- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts @@ -1,8 +1,7 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import NewBountyAvailable from "@dub/email/templates/new-bounty-available"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; @@ -105,9 +104,9 @@ export async function POST(req: Request) { console.log( `Sending emails to ${programEnrollments.length} partners: ${programEnrollments.map(({ partner }) => partner.email).join(", ")}`, ); - await resend.batch.send( + await sendBatchEmail( programEnrollments.map(({ partner }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "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({ diff --git a/apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts b/apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts index 7053fdd6291..e9ca8b2e3df 100644 --- a/apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts +++ b/apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts @@ -1,7 +1,6 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import DomainRenewalReminder from "@dub/email/templates/domain-renewal-reminder"; import { prisma } from "@dub/prisma"; import { chunk, log } from "@dub/utils"; @@ -109,9 +108,8 @@ export async function GET(req: Request) { const reminderDomainsChunks = chunk(reminderDomains, 100); for (const reminderDomainsChunk of reminderDomainsChunks) { - const res = await resend.batch.send( + const res = await sendBatchEmail( reminderDomainsChunk.map(({ workspace, user, domain }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, to: user.email!, subject: "Your domain is expiring soon", variant: "notifications", diff --git a/apps/web/app/(ee)/api/cron/domains/transfer/utils.ts b/apps/web/app/(ee)/api/cron/domains/transfer/utils.ts index ca9c5bfb73c..31d3602f6dc 100644 --- a/apps/web/app/(ee)/api/cron/domains/transfer/utils.ts +++ b/apps/web/app/(ee)/api/cron/domains/transfer/utils.ts @@ -48,7 +48,7 @@ export const sendDomainTransferredEmail = async ({ await sendEmail({ subject: "Domain transfer completed", - email: ownerEmail, + to: ownerEmail, react: DomainTransferred({ email: ownerEmail, domain, diff --git a/apps/web/app/(ee)/api/cron/domains/verify/utils.ts b/apps/web/app/(ee)/api/cron/domains/verify/utils.ts index 68c3323eca2..7ead633689e 100644 --- a/apps/web/app/(ee)/api/cron/domains/verify/utils.ts +++ b/apps/web/app/(ee)/api/cron/domains/verify/utils.ts @@ -129,7 +129,7 @@ export const handleDomainUpdates = async ({ limiter.schedule(() => sendEmail({ subject: `Your domain ${domain} has been deleted`, - email, + to: email, react: DomainDeleted({ email, domain, @@ -196,7 +196,7 @@ const sendDomainInvalidEmail = async ({ limiter.schedule(() => sendEmail({ subject: `Your domain ${domain} needs to be configured`, - email, + to: email, react: InvalidDomain({ email, domain, diff --git a/apps/web/app/(ee)/api/cron/import/bitly/utils.ts b/apps/web/app/(ee)/api/cron/import/bitly/utils.ts index a91c6a12677..56308eb7c45 100644 --- a/apps/web/app/(ee)/api/cron/import/bitly/utils.ts +++ b/apps/web/app/(ee)/api/cron/import/bitly/utils.ts @@ -239,7 +239,7 @@ export const importLinksFromBitly = async ({ // send email to user sendEmail({ subject: `Your Bitly links have been imported!`, - email: ownerEmail, + to: ownerEmail, react: LinksImported({ email: ownerEmail, provider: "Bitly", diff --git a/apps/web/app/(ee)/api/cron/import/csv/utils.ts b/apps/web/app/(ee)/api/cron/import/csv/utils.ts index b560b129ef7..5b75ad8df92 100644 --- a/apps/web/app/(ee)/api/cron/import/csv/utils.ts +++ b/apps/web/app/(ee)/api/cron/import/csv/utils.ts @@ -65,7 +65,7 @@ export async function sendCsvImportEmails({ if (count > 0) { sendEmail({ subject: `Your CSV links have been imported!`, - email: ownerEmail, + to: ownerEmail, react: LinksImported({ email: ownerEmail, provider: "CSV", @@ -81,7 +81,7 @@ export async function sendCsvImportEmails({ if (errorLinks.length > 0) { sendEmail({ subject: `Some CSV links failed to import`, - email: ownerEmail, + to: ownerEmail, react: LinksImportErrors({ email: ownerEmail, provider: "CSV", diff --git a/apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts b/apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts index 527ba01527d..39c0a89fbd6 100644 --- a/apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts +++ b/apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts @@ -160,7 +160,7 @@ export const importLinksFromRebrandly = async ({ // send email to user sendEmail({ subject: `Your Rebrandly links have been imported!`, - email: ownerEmail, + to: ownerEmail, react: LinksImported({ email: ownerEmail, provider: "Rebrandly", diff --git a/apps/web/app/(ee)/api/cron/import/short/utils.ts b/apps/web/app/(ee)/api/cron/import/short/utils.ts index 05275f55bd8..e4fb7f384dd 100644 --- a/apps/web/app/(ee)/api/cron/import/short/utils.ts +++ b/apps/web/app/(ee)/api/cron/import/short/utils.ts @@ -216,7 +216,7 @@ export const importLinksFromShort = async ({ // send email to user sendEmail({ subject: `Your Short.io links have been imported!`, - email: ownerEmail, + to: ownerEmail, react: LinksImported({ email: ownerEmail, provider: "Short.io", diff --git a/apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts b/apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts index edf6895114c..8baa73e2181 100644 --- a/apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts +++ b/apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts @@ -5,8 +5,8 @@ import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { storage } from "@/lib/storage"; import { recordLink } from "@/lib/tinybird"; import { redis } from "@/lib/upstash"; -import { resend, unsubscribe } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; +import { unsubscribe } from "@dub/email/resend"; import PartnerAccountMerged from "@dub/email/templates/partner-account-merged"; import { prisma } from "@dub/prisma"; import { log, R2_URL } from "@dub/utils"; @@ -244,9 +244,9 @@ export async function POST(req: Request) { // Make sure the cache is cleared await redis.del(`${CACHE_KEY_PREFIX}:${userId}`); - await resend.batch.send([ + await sendBatchEmail([ { - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: sourceEmail, subject: "Your Dub partner accounts are now merged", react: PartnerAccountMerged({ @@ -256,7 +256,7 @@ export async function POST(req: Request) { }), }, { - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: targetEmail, subject: "Your Dub partner accounts are now merged", react: PartnerAccountMerged({ diff --git a/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts b/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts index 185be43311e..4bdca4bb8cf 100644 --- a/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts +++ b/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts @@ -1,7 +1,6 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import NewMessageFromProgram from "@dub/email/templates/new-message-from-program"; import { prisma } from "@dub/prisma"; import { NotificationEmailType } from "@dub/prisma/client"; @@ -102,10 +101,10 @@ export async function POST(req: Request) { const program = programEnrollment.program; - const { data, error } = await resend.batch.send( + const { data, error } = await sendBatchEmail( partnerEmailsToNotify.map((email) => ({ subject: `${program.name} sent ${unreadMessages.length === 1 ? "a message" : `${unreadMessages.length} messages`}`, - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: email, react: NewMessageFromProgram({ program: { diff --git a/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts b/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts index 1f31638444c..34900673d5e 100644 --- a/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts +++ b/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts @@ -1,7 +1,6 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import NewMessageFromPartner from "@dub/email/templates/new-message-from-partner"; import { prisma } from "@dub/prisma"; import { NotificationEmailType } from "@dub/prisma/client"; @@ -111,10 +110,10 @@ export async function POST(req: Request) { const { program, partner } = programEnrollment; - const { data, error } = await resend.batch.send( + const { data, error } = await sendBatchEmail( userEmailsToNotify.map((email) => ({ subject: `${unreadMessages.length === 1 ? "New message from" : `${unreadMessages.length} new messages from`} ${partner.name}`, - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: email, react: NewMessageFromPartner({ workspaceSlug: program.workspace.slug, diff --git a/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts b/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts index e91692f4f71..2aa0f3cdc61 100644 --- a/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts +++ b/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts @@ -322,7 +322,7 @@ async function handler(req: Request) { limiter.schedule(() => sendEmail({ subject: `Your ${reportingMonth} performance report for ${program.name} program`, - email: partner.email!, + to: partner.email!, react: PartnerProgramSummary({ program, partner, diff --git a/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts b/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts index 5c0e674f947..e6af75d5da6 100644 --- a/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts @@ -137,7 +137,7 @@ export async function POST(req: Request) { const sentEmail = await sendEmail({ variant: "notifications", subject: "Your funds are on their way to your bank", - email: partner.email, + to: partner.email, react: PartnerPayoutWithdrawalInitiated({ email: partner.email, payout: { diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts index 7bfe8762960..670fc2f0178 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts @@ -1,6 +1,5 @@ import { createPayPalBatchPayout } from "@/lib/paypal/create-batch-payout"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; @@ -48,11 +47,11 @@ export async function sendPaypalPayouts({ invoiceId }: { invoiceId: string }) { console.log("PayPal batch payout created", batchPayout); - const batchEmails = await resend.batch.send( + const batchEmails = await sendBatchEmail( payouts .filter((payout) => payout.partner.email) .map((payout) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: payout.partner.email!, subject: "You've been paid!", react: PartnerPayoutProcessed({ diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts index 80c5d86f6f5..348517704fa 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts @@ -1,6 +1,5 @@ import { createStripeTransfer } from "@/lib/partners/create-stripe-transfer"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { Prisma } from "@prisma/client"; @@ -92,12 +91,12 @@ export async function sendStripePayouts({ invoiceId }: { invoiceId: string }) { await new Promise((resolve) => setTimeout(resolve, 250)); } - const resendBatch = await resend.batch.send( + const resendBatch = await sendBatchEmail( currentInvoicePayouts .filter((p) => p.partner.email) .map((p) => { return { - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: p.partner.email!, subject: "You've been paid!", react: PartnerPayoutProcessed({ diff --git a/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts index f292981f9ac..8896e06e7a6 100644 --- a/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts @@ -12,8 +12,8 @@ import { calculatePayoutFeeForMethod } from "@/lib/payment-methods"; import { stripe } from "@/lib/stripe"; import { createFxQuote } from "@/lib/stripe/create-fx-quote"; import { PlanProps } from "@/lib/types"; +import { sendBatchEmail } from "@dub/email"; import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; import PartnerPayoutConfirmed from "@dub/email/templates/partner-payout-confirmed"; import { prisma } from "@dub/prisma"; import { chunk, currencyFormatter, log } from "@dub/utils"; @@ -236,9 +236,9 @@ export async function processPayouts({ ); for (const payoutChunk of payoutChunks) { - await resend.batch.send( + await sendBatchEmail( payoutChunk.map((payout) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: payout.partner.email!, subject: "You've got money coming your way!", react: PartnerPayoutConfirmed({ diff --git a/apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts b/apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts index cd1b82a0629..c5b94fbd620 100644 --- a/apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts @@ -1,7 +1,6 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import ConnectPayoutReminder from "@dub/email/templates/connect-payout-reminder"; import { prisma } from "@dub/prisma"; import { chunk } from "@dub/utils"; @@ -117,9 +116,8 @@ export async function GET(req: Request) { const connectPayoutsLastRemindedAt = new Date(); for (const partnerProgramsChunk of partnerProgramsChunks) { - await resend.batch.send( + await sendBatchEmail( partnerProgramsChunk.map(({ partner, programs }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, to: partner.email, subject: "Connect your payout details on Dub Partners", variant: "notifications", diff --git a/apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts b/apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts index 85d79aff234..b577e114b75 100644 --- a/apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts @@ -1,7 +1,6 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import ProgramPayoutReminder from "@dub/email/templates/program-payout-reminder"; import { prisma } from "@dub/prisma"; import { chunk, pluralize } from "@dub/utils"; @@ -184,9 +183,9 @@ export async function GET(req: Request) { const programOwnerChunks = chunk(programsWithPendingPayoutsToNotify, 100); for (const programOwnerChunk of programOwnerChunks) { - const res = await resend.batch.send( + const res = await sendBatchEmail( programOwnerChunk.map(({ workspace, user, program, payout }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: user.email!, subject: `${payout.partnersCount} ${pluralize( "partner", diff --git a/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts b/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts index f62cf6de087..7bd6bc1bf59 100644 --- a/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts +++ b/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts @@ -73,7 +73,7 @@ export async function POST(req: Request) { await sendEmail({ subject: `Complete your application for ${application.program.name}`, - email: application.email, + to: application.email, react: ProgramApplicationReminder({ email: application.email, program: { diff --git a/apps/web/app/(ee)/api/cron/usage/utils.ts b/apps/web/app/(ee)/api/cron/usage/utils.ts index 47c2d19cb2a..064836129f9 100644 --- a/apps/web/app/(ee)/api/cron/usage/utils.ts +++ b/apps/web/app/(ee)/api/cron/usage/utils.ts @@ -164,7 +164,7 @@ export const updateUsage = async () => { limiter.schedule(() => sendEmail({ subject: `Your 30-day ${process.env.NEXT_PUBLIC_APP_NAME} summary for ${workspace.name}`, - email, + to: email, react: ClicksSummary({ email, workspaceName: workspace.name, diff --git a/apps/web/app/(ee)/api/cron/welcome-user/route.ts b/apps/web/app/(ee)/api/cron/welcome-user/route.ts index b6ae3f7fe91..fb7a39b3e08 100644 --- a/apps/web/app/(ee)/api/cron/welcome-user/route.ts +++ b/apps/web/app/(ee)/api/cron/welcome-user/route.ts @@ -48,7 +48,7 @@ export async function POST(req: Request) { audience: isPartner ? "partners.dub.co" : "app.dub.co", }), sendEmail({ - email: user.email, + to: user.email, replyTo: "steven.tey@dub.co", subject: `Welcome to Dub${isPartner ? " Partners" : ""}!`, react: isPartner diff --git a/apps/web/app/(ee)/api/cron/year-in-review/route.ts b/apps/web/app/(ee)/api/cron/year-in-review/route.ts index d2385551fa5..9793df836db 100644 --- a/apps/web/app/(ee)/api/cron/year-in-review/route.ts +++ b/apps/web/app/(ee)/api/cron/year-in-review/route.ts @@ -1,6 +1,6 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; -import { resend } from "@dub/email/resend"; +import { sendBatchEmail } from "@dub/email"; import DubWrapped from "@dub/email/templates/dub-wrapped"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; @@ -16,10 +16,6 @@ export async function POST() { return new Response("Not available in production."); } - if (!resend) { - return new Response("Resend not initialized. Skipping..."); - } - const yearInReviews = await prisma.yearInReview.findMany({ where: { sentAt: null, @@ -116,7 +112,7 @@ export async function POST() { continue; } - const { data, error } = await resend.batch.send( + const { data, error } = await sendBatchEmail( // @ts-ignore batch.map((b) => b.email), ); diff --git a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts index b8bab9411c4..245e0e956ba 100644 --- a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts +++ b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts @@ -72,7 +72,7 @@ export async function payoutsItemFailed(event: any) { payout.partner.email ? sendEmail({ subject: `Your recent partner payout from ${payout.program.name} failed`, - email: payout.partner.email, + to: payout.partner.email, react: PartnerPaypalPayoutFailed({ email: payout.partner.email, program: { diff --git a/apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts b/apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts index 2af310f207e..d504af1b721 100644 --- a/apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts +++ b/apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts @@ -46,7 +46,7 @@ export async function payoutPaid(event: Stripe.Event) { const sentEmail = await sendEmail({ variant: "notifications", subject: "Your funds have been transferred to your bank account", - email: partner.email, + to: partner.email, react: PartnerPayoutWithdrawalCompleted({ email: partner.email, payout: { diff --git a/apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts b/apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts index 96fdad75fb8..64abf17554b 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts @@ -5,9 +5,7 @@ import { PAYOUT_FAILURE_FEE_CENTS, } from "@/lib/partners/constants"; import { createPaymentIntent } from "@/lib/stripe/create-payment-intent"; -import { sendEmail } from "@dub/email"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail, sendEmail } from "@dub/email"; import DomainExpired from "@dub/email/templates/domain-expired"; import DomainRenewalFailed from "@dub/email/templates/domain-renewal-failed"; import PartnerPayoutFailed from "@dub/email/templates/partner-payout-failed"; @@ -175,7 +173,7 @@ async function processPayoutInvoice({ emailData.map((data) => { sendEmail({ subject: "Partner payout failed", - email: data.email, + to: data.email, react: PartnerPayoutFailed(data), variant: "notifications", }); @@ -245,9 +243,9 @@ async function processDomainRenewalInvoice({ invoice }: { invoice: Invoice }) { ); if (workspaceOwners.length > 0) { - await resend.batch.send( + await sendBatchEmail( workspaceOwners.map(({ user }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: user.email!, subject: "Domain expired", react: DomainExpired({ @@ -275,9 +273,9 @@ async function processDomainRenewalInvoice({ invoice }: { invoice: Invoice }) { }); if (workspaceOwners.length > 0) { - await resend.batch.send( + await sendBatchEmail( workspaceOwners.map(({ user }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: user.email!, subject: "Domain renewal failed", react: DomainRenewalFailed({ diff --git a/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts b/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts index 59c5b831bbe..555711011cb 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts @@ -1,7 +1,6 @@ import { qstash } from "@/lib/cron"; import { setRenewOption } from "@/lib/dynadot/set-renew-option"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import DomainRenewed from "@dub/email/templates/domain-renewed"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; @@ -152,9 +151,9 @@ async function processDomainRenewalInvoice({ invoice }: { invoice: Invoice }) { return; } - await resend.batch.send( + await sendBatchEmail( workspaceOwners.map(({ user }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: user.email!, subject: `Your ${pluralize("domain", domains.length)} have been renewed`, react: DomainRenewed({ diff --git a/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts index f76907b82c1..5c23cd2fed4 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts @@ -108,7 +108,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { ...users.map((user) => { limiter.schedule(() => sendEmail({ - email: user.email as string, + to: user.email as string, replyTo: "steven.tey@dub.co", subject: `Thank you for upgrading to Dub ${plan.name}!`, react: UpgradeEmail({ diff --git a/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx b/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx index ac069d6a313..eaa27f86b53 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx +++ b/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx @@ -65,7 +65,7 @@ export async function invoicePaymentFailed(event: Stripe.Event) { }), ...workspace.users.map(({ user }) => sendEmail({ - email: user.email as string, + to: user.email as string, subject: `${ attemptCount == 2 ? "2nd notice: " diff --git a/apps/web/app/(ee)/api/stripe/webhook/utils.ts b/apps/web/app/(ee)/api/stripe/webhook/utils.ts index 5734da24c68..8094dc4c908 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/utils.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/utils.ts @@ -35,7 +35,7 @@ export async function sendCancellationFeedback({ (owner) => owner.email && sendEmail({ - email: owner.email, + to: owner.email, from: "Steven Tey ", replyTo: "steven.tey@dub.co", subject: "Feedback for Dub.co?", diff --git a/apps/web/app/api/auth/reset-password/route.ts b/apps/web/app/api/auth/reset-password/route.ts index 8dbbddd4f9d..fa7603cc32f 100644 --- a/apps/web/app/api/auth/reset-password/route.ts +++ b/apps/web/app/api/auth/reset-password/route.ts @@ -74,7 +74,7 @@ export async function POST(req: NextRequest) { waitUntil( sendEmail({ subject: `Your ${process.env.NEXT_PUBLIC_APP_NAME} account password has been reset`, - email: identifier, + to: identifier, react: PasswordUpdated({ email: identifier, verb: "reset", diff --git a/apps/web/app/api/dub/webhook/lead-created.ts b/apps/web/app/api/dub/webhook/lead-created.ts index ef6b4755830..034a92dafe1 100644 --- a/apps/web/app/api/dub/webhook/lead-created.ts +++ b/apps/web/app/api/dub/webhook/lead-created.ts @@ -55,7 +55,7 @@ export async function leadCreated(data: LeadCreatedEvent["data"]) { ({ user: owner }) => owner.email && sendEmail({ - email: owner.email, + to: owner.email, subject: "Someone signed up for Dub via your referral link!", react: NewReferralSignup({ email: owner.email, diff --git a/apps/web/app/api/tokens/route.ts b/apps/web/app/api/tokens/route.ts index 128b0acc4aa..35262582f9f 100644 --- a/apps/web/app/api/tokens/route.ts +++ b/apps/web/app/api/tokens/route.ts @@ -167,7 +167,7 @@ export const POST = withWorkspace( waitUntil( sendEmail({ - email: session.user.email, + 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, diff --git a/apps/web/app/api/user/password/route.ts b/apps/web/app/api/user/password/route.ts index dc9c71dbfc6..9ba152a1230 100644 --- a/apps/web/app/api/user/password/route.ts +++ b/apps/web/app/api/user/password/route.ts @@ -64,7 +64,7 @@ export const PATCH = withSession(async ({ req, session }) => { waitUntil( sendEmail({ subject: `Your ${process.env.NEXT_PUBLIC_APP_NAME} account password has been updated`, - email: session.user.email, + to: session.user.email, react: PasswordUpdated({ email: session.user.email, }), diff --git a/apps/web/app/api/user/set-password/route.ts b/apps/web/app/api/user/set-password/route.ts index ead590fb8ad..ed0a93ceba1 100644 --- a/apps/web/app/api/user/set-password/route.ts +++ b/apps/web/app/api/user/set-password/route.ts @@ -39,7 +39,7 @@ export const POST = withSession(async ({ session }) => { // Send email with password reset link await sendEmail({ subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Password reset instructions`, - email: session.user.email, + to: session.user.email, react: ResetPasswordLink({ email: session.user.email, url: `${process.env.NEXTAUTH_URL}/auth/reset-password/${token}`, diff --git a/apps/web/app/api/webhooks/route.ts b/apps/web/app/api/webhooks/route.ts index 14d6fc9976f..b5e1bbb66b9 100644 --- a/apps/web/app/api/webhooks/route.ts +++ b/apps/web/app/api/webhooks/route.ts @@ -165,7 +165,7 @@ export const POST = withWorkspace( workspaceId: workspace.id, }), sendEmail({ - email: session.user.email, + to: session.user.email, subject: "New webhook added", react: WebhookAdded({ email: session.user.email, diff --git a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx index d27cbf12b76..e3831de77a5 100644 --- a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx +++ b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx @@ -152,7 +152,7 @@ const VerifyEmailChange = async ({ sendEmail({ subject: "Your email address has been changed", - email: data.email, + to: data.email, react: EmailUpdated({ oldEmail: data.email, newEmail: data.newEmail, diff --git a/apps/web/lib/actions/folders/request-folder-edit-access.ts b/apps/web/lib/actions/folders/request-folder-edit-access.ts index 0fae004eeaa..8ae233b9123 100644 --- a/apps/web/lib/actions/folders/request-folder-edit-access.ts +++ b/apps/web/lib/actions/folders/request-folder-edit-access.ts @@ -71,7 +71,7 @@ export const requestFolderEditAccessAction = authActionClient await sendEmail({ subject: `Request to edit folder ${folder.name} on ${workspace.name}`, - email: folderOwnerEmail, + to: folderOwnerEmail, react: FolderEditAccessRequested({ email: folderOwnerEmail, folderUrl: `${APP_DOMAIN_WITH_NGROK}/${workspace.slug}/settings/library/folders/${folder.id}/members`, diff --git a/apps/web/lib/actions/partners/approve-bounty-submission.ts b/apps/web/lib/actions/partners/approve-bounty-submission.ts index a476fd76725..8694e8e5d6f 100644 --- a/apps/web/lib/actions/partners/approve-bounty-submission.ts +++ b/apps/web/lib/actions/partners/approve-bounty-submission.ts @@ -107,7 +107,7 @@ export const approveBountySubmissionAction = authActionClient partner.email && sendEmail({ subject: "Bounty approved!", - email: partner.email, + to: partner.email, variant: "notifications", react: BountyApproved({ email: partner.email, diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 104f6947bf7..304033ba1db 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -113,7 +113,7 @@ export const banPartnerAction = authActionClient await Promise.allSettled([ sendEmail({ subject: `You've been banned from the ${program.name} Partner Program`, - email: partner.email, + to: partner.email, replyTo: supportEmail, react: PartnerBanned({ partner: { diff --git a/apps/web/lib/actions/partners/bulk-approve-partners.ts b/apps/web/lib/actions/partners/bulk-approve-partners.ts index e39846dcc7b..d8ff1142c0f 100644 --- a/apps/web/lib/actions/partners/bulk-approve-partners.ts +++ b/apps/web/lib/actions/partners/bulk-approve-partners.ts @@ -11,8 +11,8 @@ import { EnrolledPartnerSchema, } from "@/lib/zod/schemas/partners"; import { ProgramRewardDescription } from "@/ui/partners/program-reward-description"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; +import { ResendBulkEmailOptions } from "@dub/email/resend/types"; import PartnerApplicationApproved from "@dub/email/templates/partner-application-approved"; import { prisma } from "@dub/prisma"; import { chunk, isFulfilled } from "@dub/utils"; @@ -109,7 +109,7 @@ export const bulkApprovePartnersAction = authActionClient }); // Create all emails first, then chunk them into batches of 100 - const allEmails = updatedEnrollments.flatMap( + const allEmails: ResendBulkEmailOptions = updatedEnrollments.flatMap( ({ partner, clickReward, leadReward, saleReward }) => { const partnerEmailsToNotify = partner.users .map(({ user }) => user.email) @@ -117,7 +117,7 @@ export const bulkApprovePartnersAction = authActionClient return partnerEmailsToNotify.map((email) => ({ subject: `Your application to join ${program.name} partner program has been approved!`, - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: email, react: PartnerApplicationApproved({ program: { @@ -191,7 +191,7 @@ export const bulkApprovePartnersAction = authActionClient await Promise.allSettled([ // Send approval emails - ...emailChunks.map((emailChunk) => resend.batch.send(emailChunk)), + ...emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), // Send enrolled webhooks ...updatedEnrollments.map(({ partner, ...enrollment }) => diff --git a/apps/web/lib/actions/partners/bulk-ban-partners.ts b/apps/web/lib/actions/partners/bulk-ban-partners.ts index 3ebf5b2560f..257da56322c 100644 --- a/apps/web/lib/actions/partners/bulk-ban-partners.ts +++ b/apps/web/lib/actions/partners/bulk-ban-partners.ts @@ -8,8 +8,8 @@ import { BAN_PARTNER_REASONS, bulkBanPartnersSchema, } from "@/lib/zod/schemas/partners"; +import { sendBatchEmail } from "@dub/email"; import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; import PartnerBanned from "@dub/email/templates/partner-banned"; import { prisma } from "@dub/prisma"; import { ProgramEnrollmentStatus } from "@prisma/client"; @@ -161,11 +161,10 @@ export const bulkBanPartnersAction = authActionClient return; } - await resend.batch.send( + await sendBatchEmail( programEnrollments .filter(({ partner }) => partner.email) .map(({ partner }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, to: partner.email!, subject: `You've been banned from the ${program.name} Partner Program`, variant: "notifications", diff --git a/apps/web/lib/actions/partners/create-bounty-submission.ts b/apps/web/lib/actions/partners/create-bounty-submission.ts index 9a42a05df1e..23751cd69d6 100644 --- a/apps/web/lib/actions/partners/create-bounty-submission.ts +++ b/apps/web/lib/actions/partners/create-bounty-submission.ts @@ -9,9 +9,7 @@ import { MAX_SUBMISSION_URLS, submissionRequirementsSchema, } from "@/lib/zod/schemas/bounties"; -import { sendEmail } from "@dub/email"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail, sendEmail } from "@dub/email"; import BountyPendingReview from "@dub/email/templates/bounty-pending-review"; import BountySubmitted from "@dub/email/templates/bounty-submitted"; import { prisma } from "@dub/prisma"; @@ -141,9 +139,9 @@ export const createBountySubmissionAction = authPartnerActionClient }); if (users.length > 0) { - await resend.batch.send( + await sendBatchEmail( users.map((user) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: user.email, subject: "Pending bounty review", react: BountyPendingReview({ @@ -172,7 +170,7 @@ export const createBountySubmissionAction = authPartnerActionClient if (partner.email && program) { await sendEmail({ subject: "Bounty submitted!", - email: partner.email, + to: partner.email, react: BountySubmitted({ email: partner.email, bounty: { diff --git a/apps/web/lib/actions/partners/create-program.ts b/apps/web/lib/actions/partners/create-program.ts index 91d8f570f5e..6255ead72da 100644 --- a/apps/web/lib/actions/partners/create-program.ts +++ b/apps/web/lib/actions/partners/create-program.ts @@ -8,7 +8,6 @@ import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { programDataSchema } from "@/lib/zod/schemas/program-onboarding"; import { REWARD_EVENT_COLUMN_MAPPING } from "@/lib/zod/schemas/rewards"; import { sendEmail } from "@dub/email"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; import PartnerInvite from "@dub/email/templates/partner-invite"; import ProgramWelcome from "@dub/email/templates/program-welcome"; import { prisma } from "@dub/prisma"; @@ -197,7 +196,7 @@ export const createProgram = async ({ // send email about the new program sendEmail({ subject: `Your program ${program.name} is created and ready to share with your partners.`, - email: user.email!, + to: user.email!, react: ProgramWelcome({ email: user.email!, workspace, @@ -258,8 +257,8 @@ async function invitePartner({ waitUntil( sendEmail({ subject: `${program.name} invited you to join Dub Partners`, - from: VARIANT_TO_FROM_MAP.notifications, - email: partner.email, + variant: "notifications", + to: partner.email, react: PartnerInvite({ email: partner.email, program: { diff --git a/apps/web/lib/actions/partners/invite-partner.ts b/apps/web/lib/actions/partners/invite-partner.ts index b1bfbcb4096..4519d9f9c29 100644 --- a/apps/web/lib/actions/partners/invite-partner.ts +++ b/apps/web/lib/actions/partners/invite-partner.ts @@ -5,7 +5,6 @@ import { createAndEnrollPartner } from "@/lib/api/partners/create-and-enroll-par import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { invitePartnerSchema } from "@/lib/zod/schemas/partners"; import { sendEmail } from "@dub/email"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; import PartnerInvite from "@dub/email/templates/partner-invite"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; @@ -73,8 +72,8 @@ export const invitePartnerAction = authActionClient Promise.allSettled([ sendEmail({ subject: `${program.name} invited you to join Dub Partners`, - from: VARIANT_TO_FROM_MAP.notifications, - email, + variant: "notifications", + to: email, react: PartnerInvite({ email, program: { diff --git a/apps/web/lib/actions/partners/merge-partner-accounts.ts b/apps/web/lib/actions/partners/merge-partner-accounts.ts index 46967004210..42f8e98368d 100644 --- a/apps/web/lib/actions/partners/merge-partner-accounts.ts +++ b/apps/web/lib/actions/partners/merge-partner-accounts.ts @@ -4,8 +4,7 @@ import { generateOTP } from "@/lib/auth/utils"; import { qstash } from "@/lib/cron"; import { ratelimit, redis } from "@/lib/upstash"; import { emailSchema } from "@/lib/zod/schemas/auth"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import VerifyEmailForAccountMerge from "@dub/email/templates/verify-email-for-account-merge"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; @@ -171,9 +170,9 @@ const sendTokens = async ({ ], }); - await resend.batch.send([ + await sendBatchEmail([ { - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: sourceEmail, subject: "Verify your email to merge your Dub Partners accounts", react: VerifyEmailForAccountMerge({ @@ -183,7 +182,7 @@ const sendTokens = async ({ }), }, { - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: targetEmail, subject: "Verify your email to merge your Dub Partners accounts", react: VerifyEmailForAccountMerge({ diff --git a/apps/web/lib/actions/partners/reject-bounty-submission.ts b/apps/web/lib/actions/partners/reject-bounty-submission.ts index dff78f60266..6568099a9d8 100644 --- a/apps/web/lib/actions/partners/reject-bounty-submission.ts +++ b/apps/web/lib/actions/partners/reject-bounty-submission.ts @@ -89,7 +89,7 @@ export const rejectBountySubmissionAction = authActionClient partner.email && sendEmail({ subject: "Bounty rejected", - email: partner.email, + to: partner.email, variant: "notifications", react: BountyRejected({ email: partner.email, diff --git a/apps/web/lib/actions/partners/resend-program-invite.ts b/apps/web/lib/actions/partners/resend-program-invite.ts index 72f24fe9aa1..0e38c301065 100644 --- a/apps/web/lib/actions/partners/resend-program-invite.ts +++ b/apps/web/lib/actions/partners/resend-program-invite.ts @@ -3,7 +3,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { sendEmail } from "@dub/email"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; import PartnerInvite from "@dub/email/templates/partner-invite"; import { prisma } from "@dub/prisma"; import z from "../../zod"; @@ -53,8 +52,8 @@ export const resendProgramInviteAction = authActionClient await Promise.allSettled([ sendEmail({ subject: `${program.name} invited you to join Dub Partners`, - from: VARIANT_TO_FROM_MAP.notifications, - email: partner.email!, + variant: "notifications", + to: partner.email!, react: PartnerInvite({ email: partner.email!, program: { diff --git a/apps/web/lib/actions/request-password-reset.ts b/apps/web/lib/actions/request-password-reset.ts index d9bf938b9bd..48b021cae53 100644 --- a/apps/web/lib/actions/request-password-reset.ts +++ b/apps/web/lib/actions/request-password-reset.ts @@ -62,7 +62,7 @@ export const requestPasswordResetAction = actionClient await sendEmail({ subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Password reset instructions`, - email, + to: email, react: ResetPasswordLink({ email, url: `${process.env.NEXTAUTH_URL}/auth/reset-password/${token}`, diff --git a/apps/web/lib/actions/send-invite-referral-email.ts b/apps/web/lib/actions/send-invite-referral-email.ts index f40dc773cf6..2eb0c8d65e3 100644 --- a/apps/web/lib/actions/send-invite-referral-email.ts +++ b/apps/web/lib/actions/send-invite-referral-email.ts @@ -35,7 +35,7 @@ export const sendInviteReferralEmail = authActionClient try { return await sendEmail({ subject: `You've been invited to start using ${process.env.NEXT_PUBLIC_APP_NAME}`, - email, + to: email, react: ReferralInvite({ email, url: `https://refer.dub.co/${workspace.slug}`, diff --git a/apps/web/lib/actions/send-otp.ts b/apps/web/lib/actions/send-otp.ts index defc408e5c1..eb85df6264c 100644 --- a/apps/web/lib/actions/send-otp.ts +++ b/apps/web/lib/actions/send-otp.ts @@ -120,7 +120,7 @@ export const sendOtpAction = actionClient sendEmail({ subject: `${process.env.NEXT_PUBLIC_APP_NAME}: OTP to verify your account`, - email, + to: email, react: VerifyEmail({ email, code, diff --git a/apps/web/lib/api/domains/claim-dot-link-domain.ts b/apps/web/lib/api/domains/claim-dot-link-domain.ts index 0ed50b25c87..46725ec8a09 100644 --- a/apps/web/lib/api/domains/claim-dot-link-domain.ts +++ b/apps/web/lib/api/domains/claim-dot-link-domain.ts @@ -2,8 +2,8 @@ import { DubApiError } from "@/lib/api/errors"; import { createLink } from "@/lib/api/links"; import { registerDomain } from "@/lib/dynadot/register-domain"; import { WorkspaceWithUsers } from "@/lib/types"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; +import { ResendBulkEmailOptions } from "@dub/email/resend/types"; import DomainClaimed from "@dub/email/templates/domain-claimed"; import { prisma } from "@dub/prisma"; import { DEFAULT_LINK_PROPS } from "@dub/utils"; @@ -175,10 +175,10 @@ export const sendDomainClaimedEmails = async ({ }, }); - const emails = workspaceWithOwner.users + const emails: ResendBulkEmailOptions = workspaceWithOwner.users .filter(({ user }) => user.email) .map(({ user }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: user.email!, subject: "Successfully claimed your .link domain!", react: DomainClaimed({ @@ -189,7 +189,7 @@ export const sendDomainClaimedEmails = async ({ })); if (emails.length > 0) { - return await resend.batch.send(emails); + return await sendBatchEmail(emails); } return null; diff --git a/apps/web/lib/api/partners/notify-partner-application.ts b/apps/web/lib/api/partners/notify-partner-application.ts index 54e34213e83..e704da9a3d4 100644 --- a/apps/web/lib/api/partners/notify-partner-application.ts +++ b/apps/web/lib/api/partners/notify-partner-application.ts @@ -1,5 +1,5 @@ -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; +import { ResendBulkEmailOptions } from "@dub/email/resend/types"; import PartnerApplicationReceived from "@dub/email/templates/partner-application-received"; import { prisma } from "@dub/prisma"; import { Partner, Program, ProgramApplication } from "@dub/prisma/client"; @@ -41,37 +41,39 @@ export async function notifyPartnerApplication({ }); // Create all emails first, then chunk them into batches of 100 - const allEmails = workspaceUsers.map(({ user, project }) => ({ - subject: `New partner application for ${program.name}`, - from: VARIANT_TO_FROM_MAP.notifications, - to: user.email!, - react: PartnerApplicationReceived({ - email: user.email!, - partner: { - id: partner.id, - name: partner.name, - email: partner.email!, - image: partner.image, - country: partner.country, - proposal: application.proposal, - comments: application.comments, - }, - program: { - name: program.name, - autoApprovePartners: program.autoApprovePartnersEnabledAt - ? true - : false, - }, - workspace: { - slug: project.slug, - }, + const allEmails: ResendBulkEmailOptions = workspaceUsers.map( + ({ user, project }) => ({ + subject: `New partner application for ${program.name}`, + variant: "notifications", + to: user.email!, + react: PartnerApplicationReceived({ + email: user.email!, + partner: { + id: partner.id, + name: partner.name, + email: partner.email!, + image: partner.image, + country: partner.country, + proposal: application.proposal, + comments: application.comments, + }, + program: { + name: program.name, + autoApprovePartners: program.autoApprovePartnersEnabledAt + ? true + : false, + }, + workspace: { + slug: project.slug, + }, + }), }), - })); + ); const emailChunks = chunk(allEmails, 100); // Send all emails in batches await Promise.all( - emailChunks.map((emailChunk) => resend.batch.send(emailChunk)), + emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), ); } diff --git a/apps/web/lib/api/partners/notify-partner-commission.ts b/apps/web/lib/api/partners/notify-partner-commission.ts index 5a27d900973..22d9e3352f2 100644 --- a/apps/web/lib/api/partners/notify-partner-commission.ts +++ b/apps/web/lib/api/partners/notify-partner-commission.ts @@ -1,5 +1,8 @@ -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; +import { + ResendBulkEmailOptions, + ResendEmailOptions, +} from "@dub/email/resend/types"; import NewCommissionAlertPartner from "@dub/email/templates/new-commission-alert-partner"; import NewSaleAlertProgramOwner from "@dub/email/templates/new-sale-alert-program-owner"; import { prisma } from "@dub/prisma"; @@ -107,32 +110,38 @@ export async function notifyPartnerCommission({ .filter(Boolean) as string[]; // Create all emails first, then chunk them into batches of 100 - const allEmails = [ + const allEmails: ResendBulkEmailOptions = [ // Partner emails (for all commission types) - ...partnerEmailsToNotify.map((email) => ({ - subject: "You just made a commission via Dub Partners!", - from: VARIANT_TO_FROM_MAP.notifications, - to: email, - react: NewCommissionAlertPartner({ - email, - ...data, - }), - })), - // Workspace owner emails (only for sale commissions) - ...(commission.type === "sale" - ? workspaceUsers.map(({ user }) => ({ - subject: `New commission for ${partner.name}`, - from: VARIANT_TO_FROM_MAP.notifications, - to: user.email!, - react: NewSaleAlertProgramOwner({ + ...partnerEmailsToNotify.map( + (email) => + ({ + subject: "You just made a commission via Dub Partners!", + variant: "notifications", + to: email, + react: NewCommissionAlertPartner({ + email, ...data, - user: { - name: user.name, - email: user.email!, - }, - workspace, }), - })) + }) as ResendEmailOptions, + ), + // Workspace owner emails (only for sale commissions) + ...(commission.type === "sale" + ? 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, + ) : []), ]; @@ -140,6 +149,6 @@ export async function notifyPartnerCommission({ // Send all emails in batches await Promise.all( - emailChunks.map((emailChunk) => resend.batch.send(emailChunk)), + emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)), ); } diff --git a/apps/web/lib/api/users.ts b/apps/web/lib/api/users.ts index 66197dfb2ff..015e85e6da9 100644 --- a/apps/web/lib/api/users.ts +++ b/apps/web/lib/api/users.ts @@ -61,7 +61,7 @@ export async function inviteUser({ return await sendEmail({ subject: `You've been invited to join a workspace on ${process.env.NEXT_PUBLIC_APP_NAME}`, - email, + to: email, react: WorkspaceInvite({ email, url, diff --git a/apps/web/lib/api/workflows/execute-award-bounty-action.ts b/apps/web/lib/api/workflows/execute-award-bounty-action.ts index eb008d19fea..db39c6a7a7c 100644 --- a/apps/web/lib/api/workflows/execute-award-bounty-action.ts +++ b/apps/web/lib/api/workflows/execute-award-bounty-action.ts @@ -121,7 +121,7 @@ export const executeAwardBountyAction = async ({ if (partner.email) { await sendEmail({ subject: "Bounty completed!", - email: partner.email, + to: partner.email, variant: "notifications", react: BountyCompleted({ email: partner.email, diff --git a/apps/web/lib/auth/confirm-email-change.ts b/apps/web/lib/auth/confirm-email-change.ts index ed2bf6a2ded..c99dab9debf 100644 --- a/apps/web/lib/auth/confirm-email-change.ts +++ b/apps/web/lib/auth/confirm-email-change.ts @@ -68,7 +68,7 @@ export const confirmEmailChange = async ({ waitUntil( sendEmail({ subject: "Confirm your email address change", - email: newEmail, + to: newEmail, react: ConfirmEmailChange({ email, newEmail, diff --git a/apps/web/lib/auth/options.ts b/apps/web/lib/auth/options.ts index 0fe2565be46..a9ea7b3a670 100644 --- a/apps/web/lib/auth/options.ts +++ b/apps/web/lib/auth/options.ts @@ -54,7 +54,7 @@ export const authOptions: NextAuthOptions = { return; } else { sendEmail({ - email: identifier, + to: identifier, subject: `Your ${process.env.NEXT_PUBLIC_APP_NAME} Login Link`, react: LoginLink({ url, email: identifier }), }); diff --git a/apps/web/lib/cron/send-limit-email.ts b/apps/web/lib/cron/send-limit-email.ts index b9233b6f716..30e0602b5e7 100644 --- a/apps/web/lib/cron/send-limit-email.ts +++ b/apps/web/lib/cron/send-limit-email.ts @@ -29,7 +29,7 @@ export const sendLimitEmail = async ({ subject: type.endsWith("UsageLimitEmail") ? "Dub Alert: Clicks Limit Exceeded" : `Dub Alert: ${workspace.name} has used ${percentage.toString()}% of its links limit for the month.`, - email, + to: email, react: type.endsWith("UsageLimitEmail") ? ClicksExceeded({ email, diff --git a/apps/web/lib/firstpromoter/import-commissions.ts b/apps/web/lib/firstpromoter/import-commissions.ts index 75543a9aa6a..1800e4c660b 100644 --- a/apps/web/lib/firstpromoter/import-commissions.ts +++ b/apps/web/lib/firstpromoter/import-commissions.ts @@ -118,7 +118,7 @@ export async function importCommissions(payload: FirstPromoterImportPayload) { if (workspaceUser && workspaceUser.user.email) { await sendEmail({ - email: workspaceUser.user.email, + to: workspaceUser.user.email, subject: "FirstPromoter campaign imported", react: ProgramImported({ email: workspaceUser.user.email, diff --git a/apps/web/lib/integrations/install.ts b/apps/web/lib/integrations/install.ts index e1c598b2f9d..cece17efd44 100644 --- a/apps/web/lib/integrations/install.ts +++ b/apps/web/lib/integrations/install.ts @@ -80,7 +80,7 @@ export const installIntegration = async ({ if (email && integration) { await sendEmail({ - email: email!, + to: email!, subject: `The "${integration.name}" integration has been added to your workspace`, react: IntegrationInstalled({ email: email!, diff --git a/apps/web/lib/partners/approve-partner-enrollment.ts b/apps/web/lib/partners/approve-partner-enrollment.ts index fc29c069802..1219cc2cb9d 100644 --- a/apps/web/lib/partners/approve-partner-enrollment.ts +++ b/apps/web/lib/partners/approve-partner-enrollment.ts @@ -1,6 +1,5 @@ import { ProgramRewardDescription } from "@/ui/partners/program-reward-description"; -import { resend } from "@dub/email/resend"; -import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import { sendBatchEmail } from "@dub/email"; import PartnerApplicationApproved from "@dub/email/templates/partner-application-approved"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; @@ -134,10 +133,10 @@ export async function approvePartnerEnrollment({ await Promise.allSettled([ ...(partnerEmailsToNotify.length ? [ - resend.batch.send( + sendBatchEmail( partnerEmailsToNotify.map((email) => ({ subject: `Your application to join ${program.name} partner program has been approved!`, - from: VARIANT_TO_FROM_MAP.notifications, + variant: "notifications", to: email, react: PartnerApplicationApproved({ program: { diff --git a/apps/web/lib/partnerstack/update-stripe-customers.ts b/apps/web/lib/partnerstack/update-stripe-customers.ts index 63a90c5d994..9bb87e5c317 100644 --- a/apps/web/lib/partnerstack/update-stripe-customers.ts +++ b/apps/web/lib/partnerstack/update-stripe-customers.ts @@ -119,7 +119,7 @@ export async function updateStripeCustomers( if (workspaceUser && workspaceUser.user.email) { await sendEmail({ - email: workspaceUser.user.email, + to: workspaceUser.user.email, subject: "PartnerStack program imported", react: ProgramImported({ email: workspaceUser.user.email, diff --git a/apps/web/lib/rewardful/import-commissions.ts b/apps/web/lib/rewardful/import-commissions.ts index b830a571e43..9b1f8e40b79 100644 --- a/apps/web/lib/rewardful/import-commissions.ts +++ b/apps/web/lib/rewardful/import-commissions.ts @@ -116,7 +116,7 @@ export async function importCommissions(payload: RewardfulImportPayload) { if (workspaceUser && workspaceUser.user.email) { await sendEmail({ - email: workspaceUser.user.email, + to: workspaceUser.user.email, subject: "Rewardful campaign imported", react: ProgramImported({ email: workspaceUser.user.email, diff --git a/apps/web/lib/tolt/import-commissions.ts b/apps/web/lib/tolt/import-commissions.ts index 520f7f5c531..c9f14d4bf19 100644 --- a/apps/web/lib/tolt/import-commissions.ts +++ b/apps/web/lib/tolt/import-commissions.ts @@ -125,7 +125,7 @@ export async function importCommissions(payload: ToltImportPayload) { if (workspaceUser && workspaceUser.user.email) { await sendEmail({ - email: workspaceUser.user.email, + to: workspaceUser.user.email, subject: "Tolt program imported", react: ProgramImported({ email: workspaceUser.user.email, diff --git a/apps/web/lib/webhook/failure.ts b/apps/web/lib/webhook/failure.ts index 474123fae09..28600dfbce4 100644 --- a/apps/web/lib/webhook/failure.ts +++ b/apps/web/lib/webhook/failure.ts @@ -108,7 +108,7 @@ const notifyWebhookFailure = async ( sendEmail({ subject: "Webhook is failing to deliver", - email, + to: email, react: WebhookFailed({ email, workspace: { @@ -155,7 +155,7 @@ const notifyWebhookDisabled = async ( sendEmail({ subject: "Webhook has been disabled", - email, + to: email, react: WebhookDisabled({ email, workspace: { diff --git a/apps/web/scripts/send-emails.tsx b/apps/web/scripts/send-emails.tsx index 936a0cc80fc..23e279512c7 100644 --- a/apps/web/scripts/send-emails.tsx +++ b/apps/web/scripts/send-emails.tsx @@ -15,7 +15,7 @@ const workspace = { async function main() { const res = await sendEmail({ - email: user.email as string, + to: user.email as string, from: "steven@dub.co", subject: `${ attemptCount == 2 diff --git a/apps/web/scripts/unsubscribe-inactive-users.ts b/apps/web/scripts/unsubscribe-inactive-users.ts index 96d0930622f..dbb34cd6ea2 100644 --- a/apps/web/scripts/unsubscribe-inactive-users.ts +++ b/apps/web/scripts/unsubscribe-inactive-users.ts @@ -6,9 +6,18 @@ import { Prisma } from "@prisma/client"; import "dotenv-flow/config"; import { Resend } from "resend"; -const resend = new Resend(process.env.RESEND_API_KEY); +const resend = process.env.RESEND_API_KEY + ? new Resend(process.env.RESEND_API_KEY) + : null; async function main() { + if (!resend) { + console.error( + "No RESEND_API_KEY is set in the environment variables. Skipping.", + ); + return; + } + const where: Prisma.UserWhereInput = { email: { not: null, diff --git a/apps/web/ui/analytics/feedback/action.ts b/apps/web/ui/analytics/feedback/action.ts index 17e26b7d9ae..6064da2b48f 100644 --- a/apps/web/ui/analytics/feedback/action.ts +++ b/apps/web/ui/analytics/feedback/action.ts @@ -1,13 +1,13 @@ "use server"; -import { resend } from "@dub/email/resend"; +import { sendEmail } from "@dub/email"; import FeedbackEmail from "@dub/email/templates/feedback-email"; export async function submitFeedback(data: FormData) { const email = data.get("email") as string; const feedback = data.get("feedback") as string; - return await resend.emails.send({ + return await sendEmail({ from: "feedback@dub.co", to: "steven@dub.co", ...(email && { replyTo: email }), diff --git a/package.json b/package.json index 790341ed293..c0222508feb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "license": "AGPL-3.0-or-later", "scripts": { "build": "turbo build", + "build:packages": "pnpm -r --filter \"./packages/**\" build", "dev": "turbo dev", "lint": "turbo lint", "clean": "turbo clean && find . -name '.next' -type d -prune -exec rm -rf {} + 2>/dev/null || true", @@ -34,4 +35,4 @@ "chrono-node": "2.7.5" }, "packageManager": "pnpm@8.6.10" -} +} \ No newline at end of file diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index a2d197ed261..8c550397c29 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -1,9 +1,11 @@ -import { ResendEmailOptions } from "./resend/types"; +import { CreateBatchResponse } from "resend"; +import { resend } from "./resend"; +import { ResendBulkEmailOptions, ResendEmailOptions } from "./resend/types"; import { sendViaNodeMailer } from "./send-via-nodemailer"; -import { sendEmailViaResend } from "./send-via-resend"; +import { sendBatchEmailViaResend, sendEmailViaResend } from "./send-via-resend"; export const sendEmail = async (opts: ResendEmailOptions) => { - if (process.env.RESEND_API_KEY) { + if (resend) { return await sendEmailViaResend(opts); } @@ -13,9 +15,9 @@ export const sendEmail = async (opts: ResendEmailOptions) => { ); if (smtpConfigured) { - const { email, subject, text, react } = opts; + const { to, subject, text, react } = opts; return await sendViaNodeMailer({ - email, + to, subject, text, react, @@ -26,3 +28,43 @@ export const sendEmail = async (opts: ResendEmailOptions) => { "Email sending failed: Neither SMTP nor Resend is configured. Please set up at least one email service to send emails.", ); }; + +export const sendBatchEmail = async ( + payload: ResendBulkEmailOptions, +): Promise => { + if (resend) { + return await sendBatchEmailViaResend(payload); + } + + // Fallback to SMTP if Resend is not configured + const smtpConfigured = Boolean( + process.env.SMTP_HOST && process.env.SMTP_PORT, + ); + + 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, + }; + } + + 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, + }; +}; diff --git a/packages/email/src/resend/client.ts b/packages/email/src/resend/client.ts index 6ed938e8dd0..7ed3e93912b 100644 --- a/packages/email/src/resend/client.ts +++ b/packages/email/src/resend/client.ts @@ -1,3 +1,5 @@ import { Resend } from "resend"; -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; diff --git a/packages/email/src/resend/subscribe.ts b/packages/email/src/resend/subscribe.ts index 1e291bcfe2e..ec9377ff2f4 100644 --- a/packages/email/src/resend/subscribe.ts +++ b/packages/email/src/resend/subscribe.ts @@ -10,7 +10,7 @@ export async function subscribe({ name?: string | null; audience?: keyof typeof RESEND_AUDIENCES; }) { - if (!process.env.RESEND_API_KEY) { + if (!resend) { console.error( "No RESEND_API_KEY is set in the environment variables. Skipping.", ); diff --git a/packages/email/src/resend/types.ts b/packages/email/src/resend/types.ts index 63b35317a96..051c0ae375d 100644 --- a/packages/email/src/resend/types.ts +++ b/packages/email/src/resend/types.ts @@ -2,7 +2,9 @@ import { CreateEmailOptions } from "resend"; export interface ResendEmailOptions extends Omit { - email: string; + to: string; from?: string; variant?: "primary" | "notifications" | "marketing"; } + +export type ResendBulkEmailOptions = ResendEmailOptions[]; diff --git a/packages/email/src/resend/unsubscribe.ts b/packages/email/src/resend/unsubscribe.ts index 8926504ac03..5232f5efe55 100644 --- a/packages/email/src/resend/unsubscribe.ts +++ b/packages/email/src/resend/unsubscribe.ts @@ -8,7 +8,7 @@ export async function unsubscribe({ email: string; audience?: keyof typeof RESEND_AUDIENCES; }) { - if (!process.env.RESEND_API_KEY) { + if (!resend) { console.error( "No RESEND_API_KEY is set in the environment variables. Skipping.", ); diff --git a/packages/email/src/send-via-nodemailer.ts b/packages/email/src/send-via-nodemailer.ts index e9d1a55f6f9..a2ce5c49f20 100644 --- a/packages/email/src/send-via-nodemailer.ts +++ b/packages/email/src/send-via-nodemailer.ts @@ -5,12 +5,12 @@ import { CreateEmailOptions } from "resend"; // Send email using NodeMailer (Recommended for local development) export const sendViaNodeMailer = async ({ - email, + to, subject, text, react, }: Pick & { - email: string; + to: string; }) => { const transporter = nodemailer.createTransport({ // @ts-ignore (Fix this) @@ -28,7 +28,7 @@ export const sendViaNodeMailer = async ({ return await transporter.sendMail({ from: "noreply@example.com", - to: email, + to, subject, text, html: render(react as ReactElement), diff --git a/packages/email/src/send-via-resend.ts b/packages/email/src/send-via-resend.ts index 22e222c9afd..328c1241bde 100644 --- a/packages/email/src/send-via-resend.ts +++ b/packages/email/src/send-via-resend.ts @@ -1,18 +1,11 @@ +import { CreateBatchResponse } from "resend"; import { resend } from "./resend"; import { VARIANT_TO_FROM_MAP } from "./resend/constants"; -import { ResendEmailOptions } from "./resend/types"; - -// Send email using Resend (Recommended for production) -export const sendEmailViaResend = async (opts: ResendEmailOptions) => { - if (!resend) { - console.info( - "RESEND_API_KEY is not set in the .env. Skipping sending email.", - ); - return; - } +import { ResendBulkEmailOptions, ResendEmailOptions } from "./resend/types"; +const resendEmailForOptions = (opts: ResendEmailOptions) => { const { - email, + to, from, variant = "primary", bcc, @@ -21,10 +14,11 @@ export const sendEmailViaResend = async (opts: ResendEmailOptions) => { text, react, scheduledAt, + headers, } = opts; - return await resend.emails.send({ - to: email, + return { + to, from: from || VARIANT_TO_FROM_MAP[variant], bcc: bcc, replyTo: replyTo || "support@dub.co", @@ -32,10 +26,46 @@ export const sendEmailViaResend = async (opts: ResendEmailOptions) => { text, react, scheduledAt, - ...(variant === "marketing" && { - headers: { - "List-Unsubscribe": "https://app.dub.co/account/settings", - }, - }), - }); + ...(variant === "marketing" + ? { + headers: { + ...(headers || {}), + "List-Unsubscribe": "https://app.dub.co/account/settings", + }, + } + : { + headers, + }), + }; +}; + +// Send email using Resend (Recommended for production) +export const sendEmailViaResend = async (opts: ResendEmailOptions) => { + if (!resend) { + console.info( + "RESEND_API_KEY is not set in the .env. Skipping sending email.", + ); + return; + } + + return await resend.emails.send(resendEmailForOptions(opts)); +}; + +export const sendBatchEmailViaResend = async ( + opts: ResendBulkEmailOptions, +): Promise => { + if (!resend) { + console.info( + "RESEND_API_KEY is not set in the .env. Skipping sending email.", + ); + + return { + data: null, + error: null, + }; + } + + const payload = opts.map(resendEmailForOptions); + + return await resend.batch.send(payload); }; diff --git a/packages/ui/src/icons/payout-platforms/stripe.tsx b/packages/ui/src/icons/payout-platforms/stripe.tsx index 4797dff7612..c5d778a8f91 100644 --- a/packages/ui/src/icons/payout-platforms/stripe.tsx +++ b/packages/ui/src/icons/payout-platforms/stripe.tsx @@ -17,4 +17,3 @@ export function Stripe({ className }: { className?: string }) { ); } - From b66953bb32b4225449f11dffaf9872651a092ce5 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 16 Sep 2025 16:57:39 -0700 Subject: [PATCH 2/3] replace all limiter.schedule with sendBatchEmail --- .../app/(ee)/api/cron/domains/verify/utils.ts | 49 +++++++++---------- .../api/cron/partner-program-summary/route.ts | 39 +++++++-------- apps/web/app/(ee)/api/cron/usage/utils.ts | 35 ++++++------- .../app/(ee)/api/cron/year-in-review/route.ts | 6 +-- .../webhook/checkout-session-completed.ts | 29 +++++------ apps/web/lib/cron/send-limit-email.ts | 43 ++++++++-------- 6 files changed, 88 insertions(+), 113 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/domains/verify/utils.ts b/apps/web/app/(ee)/api/cron/domains/verify/utils.ts index 7ead633689e..81ce83f0368 100644 --- a/apps/web/app/(ee)/api/cron/domains/verify/utils.ts +++ b/apps/web/app/(ee)/api/cron/domains/verify/utils.ts @@ -1,6 +1,5 @@ import { markDomainAsDeleted } from "@/lib/api/domains/mark-domain-deleted"; -import { limiter } from "@/lib/cron/limiter"; -import { sendEmail } from "@dub/email"; +import { sendBatchEmail } from "@dub/email"; import DomainDeleted from "@dub/email/templates/domain-deleted"; import InvalidDomain from "@dub/email/templates/invalid-domain"; import { prisma } from "@dub/prisma"; @@ -125,19 +124,17 @@ export const handleDomainUpdates = async ({ message: `Domain *${domain}* has been invalid for > 30 days andhas links but no link clicks, deleting.`, type: "cron", }), - emails.map((email) => - limiter.schedule(() => - sendEmail({ - subject: `Your domain ${domain} has been deleted`, - to: email, - react: DomainDeleted({ - email, - domain, - workspaceSlug, - }), - variant: "notifications", + sendBatchEmail( + emails.map((email) => ({ + subject: `Your domain ${domain} has been deleted`, + to: email, + react: DomainDeleted({ + email, + domain, + workspaceSlug, }), - ), + variant: "notifications", + })), ), ]); } @@ -192,20 +189,18 @@ const sendDomainInvalidEmail = async ({ message: `Domain *${domain}* is invalid for ${invalidDays} days, email sent.`, type: "cron", }), - emails.map((email) => - limiter.schedule(() => - sendEmail({ - subject: `Your domain ${domain} needs to be configured`, - to: email, - react: InvalidDomain({ - email, - domain, - workspaceSlug, - invalidDays, - }), - variant: "notifications", + sendBatchEmail( + emails.map((email) => ({ + subject: `Your domain ${domain} needs to be configured`, + to: email, + react: InvalidDomain({ + email, + domain, + workspaceSlug, + invalidDays, }), - ), + variant: "notifications", + })), ), prisma.sentEmail.create({ data: { diff --git a/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts b/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts index 2aa0f3cdc61..61c99be3753 100644 --- a/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts +++ b/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts @@ -1,10 +1,9 @@ import { getAnalytics } from "@/lib/analytics/get-analytics"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; -import { limiter } from "@/lib/cron/limiter"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; -import { sendEmail } from "@dub/email"; +import { sendBatchEmail } from "@dub/email"; import PartnerProgramSummary from "@dub/email/templates/partner-program-summary"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; @@ -317,26 +316,22 @@ async function handler(req: Request) { const reportingMonth = format(currentMonth, "MMM yyyy"); - 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 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", + })), ); } diff --git a/apps/web/app/(ee)/api/cron/usage/utils.ts b/apps/web/app/(ee)/api/cron/usage/utils.ts index 064836129f9..78a01a6033d 100644 --- a/apps/web/app/(ee)/api/cron/usage/utils.ts +++ b/apps/web/app/(ee)/api/cron/usage/utils.ts @@ -1,9 +1,8 @@ import { getAnalytics } from "@/lib/analytics/get-analytics"; import { qstash } from "@/lib/cron"; -import { limiter } from "@/lib/cron/limiter"; import { sendLimitEmail } from "@/lib/cron/send-limit-email"; import { WorkspaceProps } from "@/lib/types"; -import { sendEmail } from "@dub/email"; +import { sendBatchEmail } from "@dub/email"; import ClicksSummary from "@dub/email/templates/clicks-summary"; import { prisma } from "@dub/prisma"; import { @@ -159,24 +158,20 @@ export const updateUsage = async () => { (user) => user.user.email, ) as string[]; - await Promise.allSettled( - emails.map((email) => { - limiter.schedule(() => - sendEmail({ - subject: `Your 30-day ${process.env.NEXT_PUBLIC_APP_NAME} summary for ${workspace.name}`, - to: email, - react: ClicksSummary({ - email, - workspaceName: workspace.name, - workspaceSlug: workspace.slug, - totalClicks, - createdLinks: workspace.linksUsage, - topLinks: topFiveLinks, - }), - variant: "notifications", - }), - ); - }), + await sendBatchEmail( + emails.map((email) => ({ + subject: `Your 30-day ${process.env.NEXT_PUBLIC_APP_NAME} summary for ${workspace.name}`, + to: email, + react: ClicksSummary({ + email, + workspaceName: workspace.name, + workspaceSlug: workspace.slug, + totalClicks, + createdLinks: workspace.linksUsage, + topLinks: topFiveLinks, + }), + variant: "notifications", + })), ); } }), diff --git a/apps/web/app/(ee)/api/cron/year-in-review/route.ts b/apps/web/app/(ee)/api/cron/year-in-review/route.ts index 9793df836db..1e44b2f028d 100644 --- a/apps/web/app/(ee)/api/cron/year-in-review/route.ts +++ b/apps/web/app/(ee)/api/cron/year-in-review/route.ts @@ -104,7 +104,6 @@ export async function POST() { console.log( `📨 Recipients:`, - // @ts-ignore batch.map((b) => b.email.to), ); @@ -112,10 +111,7 @@ export async function POST() { continue; } - const { data, error } = await sendBatchEmail( - // @ts-ignore - batch.map((b) => b.email), - ); + const { data, error } = await sendBatchEmail(batch.map((b) => b.email)); console.log("🚀 ~ data:", data); if (error) { diff --git a/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts index 5c23cd2fed4..1a38673aae8 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts @@ -1,12 +1,11 @@ import { claimDotLinkDomain } from "@/lib/api/domains/claim-dot-link-domain"; import { inviteUser } from "@/lib/api/users"; import { tokenCache } from "@/lib/auth/token-cache"; -import { limiter } from "@/lib/cron/limiter"; import { stripe } from "@/lib/stripe"; import { WorkspaceProps } from "@/lib/types"; import { redis } from "@/lib/upstash"; import { Invite } from "@/lib/zod/schemas/invites"; -import { sendEmail } from "@dub/email"; +import { sendBatchEmail } from "@dub/email"; import UpgradeEmail from "@dub/email/templates/upgrade-email"; import { prisma } from "@dub/prisma"; import { User } from "@dub/prisma/client"; @@ -105,21 +104,19 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { await Promise.allSettled([ completeOnboarding({ users, workspaceId }), - ...users.map((user) => { - limiter.schedule(() => - sendEmail({ - to: user.email as string, - replyTo: "steven.tey@dub.co", - subject: `Thank you for upgrading to Dub ${plan.name}!`, - react: UpgradeEmail({ - name: user.name, - email: user.email as string, - plan: plan.name, - }), - variant: "marketing", + sendBatchEmail( + users.map((user) => ({ + to: user.email as string, + replyTo: "steven.tey@dub.co", + subject: `Thank you for upgrading to Dub ${plan.name}!`, + react: UpgradeEmail({ + name: user.name, + email: user.email as string, + plan: plan.name, }), - ); - }), + variant: "marketing", + })), + ), // update rate limits for restricted tokens for the workspace prisma.restrictedToken.updateMany({ where: { diff --git a/apps/web/lib/cron/send-limit-email.ts b/apps/web/lib/cron/send-limit-email.ts index 30e0602b5e7..2bed69e5bbd 100644 --- a/apps/web/lib/cron/send-limit-email.ts +++ b/apps/web/lib/cron/send-limit-email.ts @@ -1,9 +1,8 @@ -import { sendEmail } from "@dub/email"; +import { sendBatchEmail } from "@dub/email"; import ClicksExceeded from "@dub/email/templates/clicks-exceeded"; import LinksLimitAlert from "@dub/email/templates/links-limit"; import { prisma } from "@dub/prisma"; import { WorkspaceProps } from "../types"; -import { limiter } from "./limiter"; export const sendLimitEmail = async ({ emails, @@ -23,27 +22,25 @@ export const sendLimitEmail = async ({ ); 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", - }), - ); - }), + sendBatchEmail( + emails.map((email) => ({ + 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, From b0031408e63599c8e8e0e1eaf4907994aee366f8 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 16 Sep 2025 17:05:13 -0700 Subject: [PATCH 3/3] ts-ignore old cron route --- apps/web/app/(ee)/api/cron/year-in-review/route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/cron/year-in-review/route.ts b/apps/web/app/(ee)/api/cron/year-in-review/route.ts index 1e44b2f028d..9793df836db 100644 --- a/apps/web/app/(ee)/api/cron/year-in-review/route.ts +++ b/apps/web/app/(ee)/api/cron/year-in-review/route.ts @@ -104,6 +104,7 @@ export async function POST() { console.log( `📨 Recipients:`, + // @ts-ignore batch.map((b) => b.email.to), ); @@ -111,7 +112,10 @@ export async function POST() { continue; } - const { data, error } = await sendBatchEmail(batch.map((b) => b.email)); + const { data, error } = await sendBatchEmail( + // @ts-ignore + batch.map((b) => b.email), + ); console.log("🚀 ~ data:", data); if (error) {