Skip to content

Commit 64c80d0

Browse files
committed
fix(email): resolve dynamic production baseUrl to prevent localhost links in transactional emails
1 parent 5034f8c commit 64c80d0

5 files changed

Lines changed: 58 additions & 16 deletions

File tree

src/app/api/billing/checkout/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createClient } from "@/lib/supabase/server";
66
import { db } from "@/db";
77
import { creators } from "@/db/schema";
88
import { eq } from "drizzle-orm";
9+
import { getBaseUrl } from "@/lib/email";
910

1011
import { PRO_PLAN } from "@/lib/config/pricing";
1112

@@ -14,7 +15,7 @@ const stripe = stripeSecret
1415
? new Stripe(stripeSecret, { apiVersion: "2025-02-24.acacia" as any })
1516
: null;
1617

17-
export async function POST() {
18+
export async function POST(req: Request) {
1819
try {
1920
const supabase = createClient();
2021
const {
@@ -41,7 +42,7 @@ export async function POST() {
4142
.returning();
4243
}
4344

44-
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
45+
const appUrl = getBaseUrl(req);
4546

4647
if (!stripe) {
4748
console.log("[DEV MOCK STRIPE CHECKOUT] Simulating Stripe Checkout upgrade for creator:", user.id);

src/app/api/billing/portal/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import { createClient } from "@/lib/supabase/server";
66
import { db } from "@/db";
77
import { creators } from "@/db/schema";
88
import { eq } from "drizzle-orm";
9+
import { getBaseUrl } from "@/lib/email";
910

1011
const stripeSecret = process.env.STRIPE_SECRET_KEY;
1112
const stripe = stripeSecret
1213
? new Stripe(stripeSecret, { apiVersion: "2025-02-24.acacia" as any })
1314
: null;
1415

15-
export async function POST() {
16+
export async function POST(req: Request) {
1617
try {
1718
const supabase = createClient();
1819
const {
@@ -32,7 +33,7 @@ export async function POST() {
3233
return NextResponse.json({ error: "Creator profile not found" }, { status: 404 });
3334
}
3435

35-
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
36+
const appUrl = getBaseUrl(req);
3637

3738
if (!stripe || !creator.stripeCustomerId) {
3839
console.log("[DEV MOCK STRIPE PORTAL] Returning mock portal URL for creator:", user.id);

src/app/api/testimonials/magic-link/route.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { testimonials, magicLinkTokens, widgets, creators } from "@/db/schema";
99
import { magicLinkRequestSchema } from "@/lib/validation/schemas";
1010
import { sanitizeHtml, sanitizePlainText } from "@/lib/security/sanitizer";
1111
import { generateMagicLinkToken } from "@/lib/tokens/magic-link";
12-
import { sendMagicLinkApprovalEmail } from "@/lib/email";
12+
import { sendMagicLinkApprovalEmail, getBaseUrl } from "@/lib/email";
1313
import { eq, and } from "drizzle-orm";
1414

1515
export async function POST(req: Request) {
@@ -99,6 +99,10 @@ export async function POST(req: Request) {
9999
return t;
100100
});
101101

102+
// Construct approval URL for creator reference and clipboard copy
103+
const appUrl = getBaseUrl(req);
104+
const approvalUrl = `${appUrl}/approve-testimonial?token=${encodeURIComponent(rawToken)}`;
105+
102106
// Send email with raw token link in a safe try-catch
103107
let emailResult: { success: boolean; error?: string } = { success: false };
104108
try {
@@ -108,16 +112,14 @@ export async function POST(req: Request) {
108112
creatorEmail: user.email || undefined,
109113
rawToken,
110114
promptMessage: cleanPrompt,
115+
appUrl,
111116
});
112117
} catch (emailErr: any) {
113118
console.error("[MAGIC_LINK_EMAIL_DISPATCH_ERROR]", emailErr);
114119
emailResult = { success: false, error: emailErr?.message || "Failed to dispatch email" };
115120
}
116121

117-
// Construct approval URL for creator reference and clipboard copy
118-
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
119-
const approvalUrl = `${appUrl}/approve-testimonial?token=${encodeURIComponent(rawToken)}`;
120-
console.log(`\n=========================================\n[DEV MAGIC LINK GENERATED]\nRecipient: ${data.clientEmail}\nApproval URL: ${approvalUrl}\n=========================================\n`);
122+
console.log(`\n=========================================\n[MAGIC LINK GENERATED]\nRecipient: ${data.clientEmail}\nApproval URL: ${approvalUrl}\n=========================================\n`);
121123

122124
return NextResponse.json({
123125
success: true,

src/app/api/testimonials/resend-magic-link/route.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createClient } from "@/lib/supabase/server";
33
import { db } from "@/db";
44
import { testimonials, magicLinkTokens } from "@/db/schema";
55
import { generateMagicLinkToken } from "@/lib/tokens/magic-link";
6-
import { sendMagicLinkApprovalEmail } from "@/lib/email";
6+
import { sendMagicLinkApprovalEmail, getBaseUrl } from "@/lib/email";
77
import { eq, and } from "drizzle-orm";
88

99
export const runtime = "nodejs";
@@ -77,6 +77,9 @@ export async function POST(req: Request) {
7777
const meta = (testimonial.metadata || {}) as Record<string, any>;
7878
const promptMessage = meta.promptMessage || undefined;
7979

80+
const appUrl = getBaseUrl(req);
81+
const approvalUrl = `${appUrl}/approve-testimonial?token=${encodeURIComponent(rawToken)}`;
82+
8083
// Send email with new token in a safe try-catch
8184
let emailResult: { success: boolean; error?: string } = { success: false };
8285
try {
@@ -86,15 +89,13 @@ export async function POST(req: Request) {
8689
creatorEmail: user.email || undefined,
8790
rawToken,
8891
promptMessage,
92+
appUrl,
8993
});
9094
} catch (emailErr: any) {
9195
console.error("[RESEND_MAGIC_LINK_EMAIL_DISPATCH_ERROR]", emailErr);
9296
emailResult = { success: false, error: emailErr?.message || "Failed to dispatch email" };
9397
}
9498

95-
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
96-
const approvalUrl = `${appUrl}/approve-testimonial?token=${encodeURIComponent(rawToken)}`;
97-
9899
return NextResponse.json({
99100
success: true,
100101
emailSent: emailResult.success,

src/lib/email/index.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,40 @@ function getSmtpTransporter() {
2727
return null;
2828
}
2929

30+
export function getBaseUrl(req?: Request): string {
31+
// 1. Explicit environment variable configured by user
32+
if (process.env.NEXT_PUBLIC_APP_URL && !process.env.NEXT_PUBLIC_APP_URL.includes("localhost")) {
33+
return process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, "");
34+
}
35+
36+
// 2. Request context (Headers from incoming HTTP request)
37+
if (req) {
38+
try {
39+
const proto = req.headers.get("x-forwarded-proto") || "https";
40+
const host = req.headers.get("x-forwarded-host") || req.headers.get("host");
41+
if (host && !host.includes("localhost")) {
42+
return `${proto}://${host}`.replace(/\/$/, "");
43+
}
44+
} catch {}
45+
}
46+
47+
// 3. Vercel Production / Deployment URLs
48+
if (process.env.VERCEL_PROJECT_PRODUCTION_URL) {
49+
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`.replace(/\/$/, "");
50+
}
51+
if (process.env.VERCEL_URL) {
52+
return `https://${process.env.VERCEL_URL}`.replace(/\/$/, "");
53+
}
54+
55+
// 4. Fallback default production domain
56+
if (process.env.NODE_ENV === "production") {
57+
return "https://client-echo-web.vercel.app";
58+
}
59+
60+
// 5. Development localhost fallback
61+
return process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
62+
}
63+
3064
export function getFromAddress(): string {
3165
const gmailUser = process.env.GMAIL_USER || process.env.SMTP_USER;
3266
if (gmailUser) {
@@ -138,8 +172,9 @@ export async function sendMagicLinkApprovalEmail(params: {
138172
replyToEmail?: string;
139173
rawToken: string;
140174
promptMessage?: string;
175+
appUrl?: string;
141176
}): Promise<{ success: boolean; error?: string }> {
142-
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
177+
const appUrl = params.appUrl || getBaseUrl();
143178
const approvalUrl = `${appUrl}/approve-testimonial?token=${encodeURIComponent(params.rawToken)}`;
144179
const replyTo = params.replyToEmail || params.creatorEmail;
145180

@@ -325,8 +360,9 @@ export async function sendSupportEmail(params: {
325360
export async function sendPasswordResetEmail(params: {
326361
toEmail: string;
327362
rawToken: string;
363+
appUrl?: string;
328364
}): Promise<{ success: boolean; error?: string }> {
329-
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
365+
const appUrl = params.appUrl || getBaseUrl();
330366
const resetUrl = `${appUrl}/reset-password?token=${encodeURIComponent(params.rawToken)}`;
331367

332368
return sendEmailMessage({
@@ -354,8 +390,9 @@ export async function sendPasswordResetEmail(params: {
354390
export async function sendEmailVerificationLink(params: {
355391
toEmail: string;
356392
rawToken: string;
393+
appUrl?: string;
357394
}): Promise<{ success: boolean; error?: string }> {
358-
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
395+
const appUrl = params.appUrl || getBaseUrl();
359396
const verifyUrl = `${appUrl}/api/auth/verify-email?token=${encodeURIComponent(params.rawToken)}`;
360397

361398
return sendEmailMessage({

0 commit comments

Comments
 (0)