Skip to content

feat: two emails for abandoned checkout#2020

Open
shnai0 wants to merge 1 commit intomainfrom
emails
Open

feat: two emails for abandoned checkout#2020
shnai0 wants to merge 1 commit intomainfrom
emails

Conversation

@shnai0
Copy link
Collaborator

@shnai0 shnai0 commented Jan 29, 2026

Here two emails, one after 1h when person was already in checkout, and other after 3 times opened the

Summary by CodeRabbit

Release Notes

  • New Features
    • Added upgrade click tracking to monitor user engagement with billing upgrades
    • Implemented abandoned checkout reminder emails to re-engage users who initiate the upgrade process
    • Added upgrade intent email notifications triggered by repeated user engagement signals within a timeframe

✏️ Tip: You can customize this high-level summary in your review settings.

@shnai0 shnai0 requested a review from mfts as a code owner January 29, 2026 03:18
@vercel
Copy link

vercel bot commented Jan 29, 2026

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

Project Deployment Review Updated (UTC)
papermark Ready Ready Preview, Comment Jan 29, 2026 3:19am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 29, 2026

Walkthrough

This PR implements an email-based upgrade intent system that tracks upgrade modal clicks, detects upgrade interest patterns, and sends targeted abandoned-checkout and upgrade-intent emails. It includes new email components, sending utilities, scheduled task definitions, and API endpoints for tracking clicks and orchestrating email delivery.

Changes

Cohort / File(s) Summary
Billing Modal Tracking
components/billing/upgrade-plan-modal-old.tsx, components/billing/upgrade-plan-modal-with-discount.tsx, components/billing/upgrade-plan-modal.tsx
Added POST request to /api/teams/{teamId}/billing/track-upgrade-click on modal open, including trigger information; extended effect dependency array to include teamId for proper re-evaluation.
Email Components
components/emails/abandoned-checkout.tsx, components/emails/upgrade-intent.tsx
Added two new React email components: AbandonedCheckoutEmail with optional recipient name, and UpgradeIntentEmail with feature name mapping and formatted feature lists from triggers.
Email Sending Utilities
lib/emails/send-abandoned-checkout.ts, lib/emails/send-upgrade-intent.ts
Added utility functions to send abandoned-checkout and upgrade-intent emails via Resend service, extracting recipient name and handling development test mode.
Scheduled Email Tasks
lib/trigger/send-scheduled-email.ts
Added two new Trigger task definitions: sendAbandonedCheckoutEmailTask (sends email if team exists and unpaid) and sendUpgradeIntentEmailTask (sends email if unpaid and checkout not started, clears Redis keys after); both include retry logic and error handling.
Upgrade Click Tracking
pages/api/teams/[teamId]/billing/track-upgrade-click.ts
New POST endpoint to track upgrade clicks per team using Redis sorted sets and trigger sets; schedules upgrade-intent email after 3 clicks within 3 days; includes authorization, plan status checks, and email-scheduling guards.
Checkout Flow
pages/api/teams/[teamId]/billing/upgrade.ts
Added Redis key marking checkout start with 3-day expiry; schedules abandoned-checkout email task to run 1 hour after checkout initialization to detect if upgrade was abandoned.

Possibly related PRs

  • Improvement: Unify upgrade buttons everywhere #1733: Introduces or uses UpgradeButton that passes trigger information to UpgradePlanModal; this PR's tracking changes depend on receiving the trigger parameter.
  • New pricing plan #1935: Modifies components/billing/upgrade-plan-modal.tsx for plan-selection UI/state refactoring; overlaps component scope with tracking additions in this PR.
  • feat: add ratelimiting #1874: Adds rate-limiting checks to pages/api/teams/[teamId]/billing/upgrade.ts; overlaps the same endpoint modified here for checkout-start marking and email scheduling.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: two emails for abandoned checkout' accurately describes the main change: adding two email functionalities related to abandoned checkouts.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

❤️ Share

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@lib/emails/send-abandoned-checkout.ts`:
- Around line 10-21: The code currently casts params.user.email to string and
calls sendEmail without checking for null/undefined — before calling sendEmail
in the AbandonedCheckout flow, validate params.user.email (from
CreateUserEmailProps) is present and a non-empty string (e.g., if (!email)
return or log and bail), and only then call sendEmail with the validated email;
ensure AbandonedCheckoutEmail still receives the derived firstName and handle
the missing-email case by early return or appropriate error/logging so sendEmail
is never called with a nullish address.

In `@lib/emails/send-upgrade-intent.ts`:
- Around line 5-11: The SendUpgradeIntentEmailProps interface allows user.email
to be null/undefined but the code later casts it to string; update the code that
sends the email (the function handling SendUpgradeIntentEmailProps — e.g.,
sendUpgradeIntentEmail or whatever uses SendUpgradeIntentEmailProps) to validate
user.email is present and a non-empty string before using it (return/throw or
skip sending if missing), and update the type to reuse or extend
CreateUserEmailProps (replace the inline user type with "extends
CreateUserEmailProps" and keep optional triggers) so callers must provide a
validated email; ensure any places that currently cast user.email to string are
changed to use the validated value.

In `@pages/api/teams/`[teamId]/billing/track-upgrade-click.ts:
- Around line 110-136: The current get → set race around emailScheduledKey can
cause duplicate scheduled emails; replace the read-then-write with an atomic SET
NX call on emailScheduledKey (use redis.set(emailScheduledKey, "1", { ex:
THREE_DAYS_IN_SECONDS, nx: true }) which returns "OK" when the key was set or
null if it already exists) and only proceed to read upgradeTriggersKey, call
sendUpgradeIntentEmailTask.trigger and waitUntil when the SET returned "OK";
keep REQUIRED_CLICKS, upgradeTriggersKey, sendUpgradeIntentEmailTask.trigger,
waitUntil and THREE_DAYS_IN_SECONDS as the referenced symbols.

In `@pages/api/teams/`[teamId]/billing/upgrade.ts:
- Around line 186-197: Guard against null/undefined session.user fields before
scheduling the task: before calling
waitUntil/sendAbandonedCheckoutEmailTask.trigger, read session.user.email and
session.user.name into userEmail/userName, validate they are non-empty strings
(not null/undefined/"null"/"undefined"), and only call
sendAbandonedCheckoutEmailTask.trigger when both are valid; otherwise skip
scheduling (or log/return an error) so the task never receives invalid string
values. Use the existing symbols sendAbandonedCheckoutEmailTask.trigger,
waitUntil, session.user, userEmail and userName to locate and update the code.
🧹 Nitpick comments (1)
lib/trigger/send-scheduled-email.ts (1)

196-202: Consider extracting duplicate paidPlans array to a shared constant.

The paidPlans array is duplicated in sendAbandonedCheckoutEmailTask (lines 196-202), sendUpgradeIntentEmailTask (lines 247-253), and also appears in existing tasks like sendDataroomTrialExpiredEmailTask and sendUpgradeOneMonthCheckinEmailTask.

♻️ Proposed refactor to extract shared constant

Add at the top of the file:

const PAID_PLANS = [
  "pro",
  "business",
  "datarooms",
  "datarooms-plus",
  "datarooms-premium",
] as const;

Then use it in each task:

-      const paidPlans = [
-        "pro",
-        "business",
-        "datarooms",
-        "datarooms-plus",
-        "datarooms-premium",
-      ];
-      const isPaidPlan = paidPlans.some((plan) => team.plan.includes(plan));
+      const isPaidPlan = PAID_PLANS.some((plan) => team.plan.includes(plan));

Also applies to: 247-253

Comment on lines +10 to +21
const { name, email } = params.user;

// Get the first name from the full name
const firstName = name ? name.split(" ")[0] : null;

const emailTemplate = AbandonedCheckoutEmail({
name: firstName,
});

try {
await sendEmail({
to: email as string,
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add validation for null/undefined email before sending.

The email field can be null | undefined per CreateUserEmailProps, but it's cast directly to string on line 21. If email is nullish, this will either throw an error in sendEmail or attempt to send to an invalid address.

🛡️ Proposed fix to add email validation
 export const sendAbandonedCheckoutEmail = async (
   params: CreateUserEmailProps,
 ) => {
   const { name, email } = params.user;
 
+  if (!email) {
+    console.error("Cannot send abandoned checkout email: no email address");
+    return;
+  }
+
   // Get the first name from the full name
   const firstName = name ? name.split(" ")[0] : null;
 
   const emailTemplate = AbandonedCheckoutEmail({
     name: firstName,
   });
 
   try {
     await sendEmail({
-      to: email as string,
+      to: email,
       subject: "Did something block checkout?",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { name, email } = params.user;
// Get the first name from the full name
const firstName = name ? name.split(" ")[0] : null;
const emailTemplate = AbandonedCheckoutEmail({
name: firstName,
});
try {
await sendEmail({
to: email as string,
const { name, email } = params.user;
if (!email) {
console.error("Cannot send abandoned checkout email: no email address");
return;
}
// Get the first name from the full name
const firstName = name ? name.split(" ")[0] : null;
const emailTemplate = AbandonedCheckoutEmail({
name: firstName,
});
try {
await sendEmail({
to: email,
🤖 Prompt for AI Agents
In `@lib/emails/send-abandoned-checkout.ts` around lines 10 - 21, The code
currently casts params.user.email to string and calls sendEmail without checking
for null/undefined — before calling sendEmail in the AbandonedCheckout flow,
validate params.user.email (from CreateUserEmailProps) is present and a
non-empty string (e.g., if (!email) return or log and bail), and only then call
sendEmail with the validated email; ensure AbandonedCheckoutEmail still receives
the derived firstName and handle the missing-email case by early return or
appropriate error/logging so sendEmail is never called with a nullish address.

Comment on lines +5 to +11
interface SendUpgradeIntentEmailProps {
user: {
name: string | null | undefined;
email: string | null | undefined;
};
triggers?: string[];
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add validation for null/undefined email before sending.

Same issue as send-abandoned-checkout.ts: the email field can be null | undefined, but it's cast to string without validation.

🛡️ Proposed fix to add email validation
 export const sendUpgradeIntentEmail = async (
   params: SendUpgradeIntentEmailProps,
 ) => {
   const { name, email } = params.user;
   const triggers = params.triggers || [];
 
+  if (!email) {
+    console.error("Cannot send upgrade intent email: no email address");
+    return;
+  }
+
   // Get the first name from the full name
   const firstName = name ? name.split(" ")[0] : null;

Optional: Consider reusing CreateUserEmailProps from lib/types.ts and extending it with triggers:

import { CreateUserEmailProps } from "../types";

export interface SendUpgradeIntentEmailProps extends CreateUserEmailProps {
  triggers?: string[];
}

Also applies to: 16-29

🤖 Prompt for AI Agents
In `@lib/emails/send-upgrade-intent.ts` around lines 5 - 11, The
SendUpgradeIntentEmailProps interface allows user.email to be null/undefined but
the code later casts it to string; update the code that sends the email (the
function handling SendUpgradeIntentEmailProps — e.g., sendUpgradeIntentEmail or
whatever uses SendUpgradeIntentEmailProps) to validate user.email is present and
a non-empty string before using it (return/throw or skip sending if missing),
and update the type to reuse or extend CreateUserEmailProps (replace the inline
user type with "extends CreateUserEmailProps" and keep optional triggers) so
callers must provide a validated email; ensure any places that currently cast
user.email to string are changed to use the validated value.

Comment on lines +110 to +136
// If this is the 3rd click (or more) within 3 days, schedule the email
if (totalRecentClicks >= REQUIRED_CLICKS) {
// Check if we already scheduled an email for this session
const emailScheduledKey = `upgrade:email-scheduled:${teamId}`;
const alreadyScheduled = await redis.get(emailScheduledKey);

if (!alreadyScheduled) {
// Get all triggers the user clicked on
const triggers = (await redis.smembers(upgradeTriggersKey)) as string[];

// Mark as scheduled
await redis.set(emailScheduledKey, "1", { ex: THREE_DAYS_IN_SECONDS });

// Schedule email for 1 day after the last click
waitUntil(
sendUpgradeIntentEmailTask.trigger(
{
to: userEmail as string,
name: userName as string,
teamId,
triggers,
},
{
delay: "1d",
},
),
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "redis" --path -e ts -e js -e json

Repository: mfts/papermark

Length of output: 291


🏁 Script executed:

find . -type f -name "*redis*" | head -20

Repository: mfts/papermark

Length of output: 129


🏁 Script executed:

rg -l "redis" --type ts --type js | grep -E "(lib|config)" | head -10

Repository: mfts/papermark

Length of output: 337


🏁 Script executed:

cat -n lib/redis.ts

Repository: mfts/papermark

Length of output: 1093


🏁 Script executed:

wc -l pages/api/teams/[teamId]/billing/track-upgrade-click.ts

Repository: mfts/papermark

Length of output: 117


🏁 Script executed:

cat -n pages/api/teams/[teamId]/billing/track-upgrade-click.ts

Repository: mfts/papermark

Length of output: 5615


🌐 Web query:

@upstash/redis SET nx option atomic support

💡 Result:

Yes—@upstash/redis supports Redis SET with the NX option (and XX, GET, EX, PX, EXAT, PXAT, keepTtl) via the opts object, e.g.:

await redis.set("lock:key", "token", { nx: true, ex: 30 });

This maps to Redis SET key value [EX|PX|...] [NX|XX] options, where NX is part of the atomic SET command itself. In Redis, SET ... NX (optionally with EX/PX) is a single command and is atomic. [1][2]

If you’re combining multiple commands (e.g., SETNX then EXPIRE as two calls), that’s not atomic unless you use a transaction (redis.multi() ... exec()). Pipelines are explicitly not atomic in Upstash. [3][4]

Sources: Upstash SET command docs (options incl. nx) [1], Redis SET command reference [2], Upstash pipeline/transaction atomicity docs [3], Upstash REST API pipeline atomicity note [4].


🌐 Web query:

@upstash/redis SET nx return value true false null

💡 Result:

In @upstash/redis (TypeScript), redis.set() does not return true/false for nx.

  • await redis.set(key, value, { nx: true }) returns:
    • "OK" if the key was set
    • null if the key already existed (so nothing was written) — because Redis returns a nil reply for SET ... NX when it doesn’t set, and Upstash’s REST API represents nil as JSON null. [1][2]

Also:

  • { get: true } changes the return value to the previous value (string) or null if it didn’t exist. [1]
  • Redis does not support combining NX and GET on SET (it’s a known limitation discussed upstream), so expect that combination to fail. [3]

Example

const r1 = await redis.set("k", "v", { nx: true }); // "OK" | null
const wasSet = r1 === "OK";

Sources: [1] Upstash TS SET docs, [2] Upstash REST API response format, [3] Redis issue about SET + NX + GET.


Prevent duplicate upgrade-intent emails under concurrent clicks.

The getset sequence on upgrade:email-scheduled:* is racy. Between the get check (line 114) and set (line 121), concurrent requests can both pass the check and schedule duplicate emails. Use atomic SET ... NX, which returns "OK" if set or null if the key already exists—@upstash/redis supports this.

🛠️ Proposed fix (atomic scheduling)
-      const emailScheduledKey = `upgrade:email-scheduled:${teamId}`;
-      const alreadyScheduled = await redis.get(emailScheduledKey);
-
-      if (!alreadyScheduled) {
-        // Get all triggers the user clicked on
-        const triggers = (await redis.smembers(upgradeTriggersKey)) as string[];
-
-        // Mark as scheduled
-        await redis.set(emailScheduledKey, "1", { ex: THREE_DAYS_IN_SECONDS });
-
-        // Schedule email for 1 day after the last click
-        waitUntil(
-          sendUpgradeIntentEmailTask.trigger(
-            {
-              to: userEmail as string,
-              name: userName as string,
-              teamId,
-              triggers,
-            },
-            {
-              delay: "1d",
-            },
-          ),
-        );
-
-        return res.status(200).json({
-          tracked: true,
-          clicks: totalRecentClicks,
-          triggers,
-          emailScheduled: true,
-        });
-      }
+      const emailScheduledKey = `upgrade:email-scheduled:${teamId}`;
+      const scheduled = await redis.set(emailScheduledKey, "1", {
+        nx: true,
+        ex: THREE_DAYS_IN_SECONDS,
+      });
+
+      if (scheduled) {
+        const triggers = (await redis.smembers(upgradeTriggersKey)) as string[];
+
+        waitUntil(
+          sendUpgradeIntentEmailTask.trigger(
+            {
+              to: userEmail as string,
+              name: userName as string,
+              teamId,
+              triggers,
+            },
+            { delay: "1d" },
+          ),
+        );
+
+        return res.status(200).json({
+          tracked: true,
+          clicks: totalRecentClicks,
+          triggers,
+          emailScheduled: true,
+        });
+      }
🤖 Prompt for AI Agents
In `@pages/api/teams/`[teamId]/billing/track-upgrade-click.ts around lines 110 -
136, The current get → set race around emailScheduledKey can cause duplicate
scheduled emails; replace the read-then-write with an atomic SET NX call on
emailScheduledKey (use redis.set(emailScheduledKey, "1", { ex:
THREE_DAYS_IN_SECONDS, nx: true }) which returns "OK" when the key was set or
null if it already exists) and only proceed to read upgradeTriggersKey, call
sendUpgradeIntentEmailTask.trigger and waitUntil when the SET returned "OK";
keep REQUIRED_CLICKS, upgradeTriggersKey, sendUpgradeIntentEmailTask.trigger,
waitUntil and THREE_DAYS_IN_SECONDS as the referenced symbols.

Comment on lines +186 to +197
waitUntil(
sendAbandonedCheckoutEmailTask.trigger(
{
to: userEmail as string,
name: userName as string,
teamId,
},
{
delay: "1h",
},
),
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard against null userEmail or userName before scheduling the task.

session.user fields (email, name) may be null or undefined depending on the authentication provider. Casting them directly to string could result in the string "null" or "undefined" being passed to the task.

🛡️ Proposed fix to add null checks
     // Schedule abandoned checkout email to be sent in 1 hour
     // The task will check if the team has already upgraded before sending
+    if (userEmail) {
       waitUntil(
         sendAbandonedCheckoutEmailTask.trigger(
           {
-            to: userEmail as string,
-            name: userName as string,
+            to: userEmail,
+            name: userName ?? "",
             teamId,
           },
           {
             delay: "1h",
           },
         ),
       );
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
waitUntil(
sendAbandonedCheckoutEmailTask.trigger(
{
to: userEmail as string,
name: userName as string,
teamId,
},
{
delay: "1h",
},
),
);
if (userEmail) {
waitUntil(
sendAbandonedCheckoutEmailTask.trigger(
{
to: userEmail,
name: userName ?? "",
teamId,
},
{
delay: "1h",
},
),
);
}
🤖 Prompt for AI Agents
In `@pages/api/teams/`[teamId]/billing/upgrade.ts around lines 186 - 197, Guard
against null/undefined session.user fields before scheduling the task: before
calling waitUntil/sendAbandonedCheckoutEmailTask.trigger, read
session.user.email and session.user.name into userEmail/userName, validate they
are non-empty strings (not null/undefined/"null"/"undefined"), and only call
sendAbandonedCheckoutEmailTask.trigger when both are valid; otherwise skip
scheduling (or log/return an error) so the task never receives invalid string
values. Use the existing symbols sendAbandonedCheckoutEmailTask.trigger,
waitUntil, session.user, userEmail and userName to locate and update the code.

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