Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/app/api/mobile-reminder/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { DesktopReminderEmail } from "@/components/email/desktop-reminder-email";
import { NextRequest, NextResponse } from "next/server";
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);
const WINDOW_MS = 60 * 60 * 1000;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

function isRateLimited(key: string, maxAttempts: number): boolean {
const now = Date.now();
const entry = rateLimitMap.get(key);

if (!entry || now > entry.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + WINDOW_MS });
if (rateLimitMap.size > 100) {
for (const [existingKey, value] of rateLimitMap) {
if (now > value.resetAt) {
rateLimitMap.delete(existingKey);
}
}
}
return false;
}

if (entry.count >= maxAttempts) {
return true;
}

entry.count++;
return false;
}

export async function POST(request: NextRequest) {
try {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid request body" },
{ status: 400 },
);
}

const { email } = body as { email?: unknown };

@cubic-dev-ai cubic-dev-ai Bot Apr 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Guard that the parsed JSON body is an object before destructuring email; otherwise malformed JSON values can trigger a 500 instead of a 400.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/mobile-reminder/route.ts, line 45:

<comment>Guard that the parsed JSON body is an object before destructuring `email`; otherwise malformed JSON values can trigger a 500 instead of a 400.</comment>

<file context>
@@ -25,21 +32,38 @@ function isRateLimited(key: string, maxAttempts: number): boolean {
+      );
+    }
+
+    const { email } = body as { email?: unknown };
 
     if (!email || typeof email !== "string") {
</file context>
Suggested change
const { email } = body as { email?: unknown };
if (!body || typeof body !== "object") {
return NextResponse.json(
{ error: "Invalid request body" },
{ status: 400 },
);
}
const { email } = body as { email?: unknown };
Fix with Cubic


if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}

const normalizedEmail = email.trim().toLowerCase();
if (normalizedEmail.length > 254) {
return NextResponse.json(
{ error: "Invalid email address" },
{ status: 400 },
);
}

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
return NextResponse.json(
{ error: "Invalid email address" },
{ status: 400 },
);
}

const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";

if (
isRateLimited(`email:${normalizedEmail}`, 2) ||
isRateLimited(`ip:${ipAddress}`, 10)
) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 },
);
}

const { error } = await resend.emails.send({
Comment thread
capy-ai[bot] marked this conversation as resolved.

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: This public email-sending endpoint lacks anti-abuse controls (auth/rate-limit/challenge), so it can be abused for spam.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/mobile-reminder/route.ts, line 25:

<comment>This public email-sending endpoint lacks anti-abuse controls (auth/rate-limit/challenge), so it can be abused for spam.</comment>

<file context>
@@ -0,0 +1,48 @@
+
+    const normalizedEmail = email.trim().toLowerCase();
+
+    const { error } = await resend.emails.send({
+      from: "ThinkEx <hello@thinkex.app>",
+      to: [normalizedEmail],
</file context>
Fix with Cubic

from: "ThinkEx <hello@thinkex.app>",
to: [normalizedEmail],
subject: "Your ThinkEx link — open on desktop",
react: DesktopReminderEmail(),
});
Comment on lines +33 to +86

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Unauthenticated endpoint allows email abuse

This route accepts any email address and triggers a Resend email send with no authentication check, no rate limiting, and no verification that the requester owns the target address. Anyone who discovers the endpoint can call it in a tight loop to spam arbitrary inboxes via ThinkEx's sending infrastructure, exhaust the Resend quota, and risk getting the domain flagged for abuse.

At minimum, add per-IP rate limiting (e.g. with @upstash/ratelimit or a simple in-memory counter) and optionally require the caller to be authenticated before sending.

Fix in Cursor


if (error) {
console.error("[mobile-reminder] Resend error:", error);
return NextResponse.json(
{ error: "Failed to send email" },
{ status: 500 },
);
}

return NextResponse.json({ success: true });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
console.error("[mobile-reminder] Error:", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
124 changes: 124 additions & 0 deletions src/components/email/desktop-reminder-email.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";

export function DesktopReminderEmail() {
return (
<Html lang="en">
<Head />
<Body
style={{
backgroundColor: "#f6f9fc",
fontFamily:
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
margin: 0,
padding: 0,
}}
>
<Preview>Open ThinkEx on your desktop to get started</Preview>
<Container
style={{
backgroundColor: "#ffffff",
margin: "40px auto",
maxWidth: "465px",
borderRadius: "8px",
border: "1px solid #eaeaea",
padding: "20px",
}}
>
<Section style={{ marginTop: "32px", textAlign: "center" }}>
<Img
src="https://thinkex.app/email-logo.png"
width="40"
height="40"
alt="ThinkEx"
style={{ margin: "0 auto" }}
/>
</Section>
<Heading
style={{
fontSize: "24px",
fontWeight: "normal",
textAlign: "center",
margin: "30px 0",
color: "#000",
padding: 0,
}}
>
Continue on your desktop
</Heading>
<Text
style={{ fontSize: "14px", lineHeight: "24px", color: "#525f7f" }}
>
ThinkEx works best on a desktop or laptop computer. Click the button
below to open ThinkEx and start organizing your knowledge.
</Text>
<Section
style={{
textAlign: "center",
marginTop: "32px",
marginBottom: "32px",
}}
>
<Button
href="https://thinkex.app"
style={{
backgroundColor: "#000000",
borderRadius: "6px",
color: "#ffffff",
fontSize: "14px",
fontWeight: 600,
textDecoration: "none",
textAlign: "center",
padding: "12px 24px",
display: "inline-block",
}}
>
Open ThinkEx
</Button>
</Section>
<Text
style={{ fontSize: "14px", lineHeight: "24px", color: "#525f7f" }}
>
or visit:{" "}
<Link
href="https://thinkex.app"
style={{ color: "#556cd6", textDecoration: "none" }}
>
thinkex.app
</Link>
</Text>
<Hr style={{ border: "1px solid #eaeaea", margin: "26px 0" }} />
<Text
style={{
fontSize: "12px",
lineHeight: "24px",
color: "#8898aa",
fontStyle: "italic",
}}
>
The Workspace That Thinks With You
</Text>
<Text
style={{ fontSize: "11px", color: "#8898aa", marginTop: "12px" }}
>
© {new Date().getFullYear()} ThinkEx
</Text>
</Container>
</Body>
</Html>
);
}

export default DesktopReminderEmail;
Loading
Loading