Skip to content

Commit 431c68b

Browse files
committed
feat: add email feature flag, staging bypass, basic auth, and postinstall for vercel deployment
1 parent a44211b commit 431c68b

12 files changed

Lines changed: 154 additions & 40 deletions

File tree

.env.local.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,19 @@ FROM_EMAIL=noreply@example.com
2020
# Not required for local dev - uses built-in dev secret
2121
# PRFC_PORTAL_SECRET=your-32-char-minimum-secret-here
2222

23+
# Email Feature Flag (disabled by default)
24+
EMAIL_ENABLED=false
25+
# When set, all emails redirect to this address instead of real recipients
26+
# EMAIL_REDIRECT_TO=your-test-email@example.com
27+
2328
# SMS Feature Flag (disabled by default for MVP)
2429
SMS_ENABLED=false
2530

31+
# Staging Mode (allows mock portal access when NODE_ENV=production)
32+
STAGING=false
33+
STAGING_USERNAME=admin
34+
STAGING_PASSWORD=your-staging-password
35+
2636
# Member Portal API Integration
2737
# Set to true for local development with mock data
2838
# Set to false for production with real Member Portal API

.env.test

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ NODE_ENV="test"
99
UNSUBSCRIBE_SECRET="test-secret-key-must-be-at-least-32-chars"
1010
FIELD_ENCRYPTION_KEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1111
BLIND_INDEX_KEY="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
12+
EMAIL_ENABLED=true
13+
STAGING=false
14+
STAGING_USERNAME=test
15+
STAGING_PASSWORD=test
1216
APP_URL="http://localhost:3000"

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"test:e2e:ui": "playwright test --ui",
2222
"test:e2e:debug": "playwright test --debug",
2323
"prepare": "husky",
24+
"postinstall": "prisma generate",
2425
"docker:up": "docker-compose up -d",
2526
"docker:down": "docker-compose down",
2627
"docker:logs": "docker-compose logs -f",

src/actions/referral.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
44
import { ReferralFormSchema } from "@/schema/api";
55
import { createManyReferrals, toggleReferralRedeemed } from "@/services/referral";
66
import { sendReferralEmails } from "@/services/email";
7+
import { env } from "@/env";
78
import { transformError } from "@/utils/errors";
89
import { verifySession, requireAdmin } from "@/lib/dal";
910
import type { ActionResult } from "@/lib/action-types";
@@ -28,7 +29,9 @@ export async function submitReferrals(formData: FormData): Promise<ActionResult>
2829

2930
const { memberName, memberEmail, referralCode, prospects } = ReferralFormSchema.parse(rawData);
3031

31-
await sendReferralEmails({ prospects, referralCode, memberName });
32+
if (env.EMAIL_ENABLED) {
33+
await sendReferralEmails({ prospects, referralCode, memberName });
34+
}
3235

3336
const referrals = prospects.map((prospect) => ({
3437
memberName,

src/app/(dev)/dev/mock-portal/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default function MockPortalPage() {
1111
const [loading, setLoading] = useState(false);
1212
const [error, setError] = useState("");
1313

14-
if (process.env.NODE_ENV === "production") {
14+
if (process.env.NODE_ENV === "production" && process.env.STAGING !== "true") {
1515
return (
1616
<main className="flex flex-1 items-center justify-center">
1717
<p>Not available in production</p>
@@ -28,7 +28,7 @@ export default function MockPortalPage() {
2828
}
2929
}
3030

31-
async function handleLogin(e: React.FormEvent) {
31+
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
3232
e.preventDefault();
3333
setError("");
3434

src/app/api/dev/token/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
22
import { generateToken } from "@/lib/dal";
33

44
export async function POST(req: NextRequest) {
5-
if (process.env.NODE_ENV === "production") {
5+
if (process.env.NODE_ENV === "production" && process.env.STAGING !== "true") {
66
return NextResponse.json({ error: "Not available" }, { status: 404 });
77
}
88

src/app/api/referrals/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
22
import { ReferralFormSchema } from "@/schema/api";
33
import { createManyReferrals, getAllReferrals } from "@/services/referral";
44
import { sendReferralEmails } from "@/services/email";
5+
import { env } from "@/env";
56
import { rateLimiter } from "@/lib/rate-limit";
67
import { getIdempotentResponse, setIdempotentResponse } from "@/lib/idempotency";
78
import { validateOrigin } from "@/lib/csrf";
@@ -47,7 +48,9 @@ export async function POST(req: NextRequest) {
4748
const body = await req.json();
4849
const { memberName, memberEmail, referralCode, prospects } = ReferralFormSchema.parse(body);
4950

50-
await sendReferralEmails({ prospects, referralCode, memberName });
51+
if (env.EMAIL_ENABLED) {
52+
await sendReferralEmails({ prospects, referralCode, memberName });
53+
}
5154

5255
const referrals = prospects.map((prospect) => ({
5356
memberName,

src/env.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { z } from "zod";
33
const envSchema = z.object({
44
DATABASE_URL: z.url(),
55

6-
SMTP_HOST: z.string().min(1),
6+
SMTP_HOST: z.string().min(1).optional(),
77
SMTP_PORT: z.coerce.number().default(587),
88
SMTP_SECURE: z
99
.string()
1010
.default("false")
1111
.transform((v) => v === "true"),
12-
SMTP_USER: z.string().min(1),
13-
SMTP_PASS: z.string().min(1),
14-
FROM_EMAIL: z.email(),
12+
SMTP_USER: z.string().min(1).optional(),
13+
SMTP_PASS: z.string().min(1).optional(),
14+
FROM_EMAIL: z.email().optional(),
1515

1616
UPSTASH_REDIS_REST_URL: z.url().optional(),
1717
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
@@ -20,11 +20,24 @@ const envSchema = z.object({
2020
PRFC_PORTAL_SECRET: z.string().min(32).optional(),
2121

2222
// SMS feature flag (disabled by default)
23+
EMAIL_ENABLED: z
24+
.string()
25+
.default("false")
26+
.transform((v) => v === "true"),
27+
EMAIL_REDIRECT_TO: z.email().optional(),
28+
2329
SMS_ENABLED: z
2430
.string()
2531
.default("false")
2632
.transform((v) => v === "true"),
2733

34+
STAGING: z
35+
.string()
36+
.default("false")
37+
.transform((v) => v === "true"),
38+
STAGING_USERNAME: z.string().min(1).optional(),
39+
STAGING_PASSWORD: z.string().min(1).optional(),
40+
2841
// Member Portal API integration toggle
2942
USE_MOCK_MEMBER_API: z
3043
.string()

src/proxy.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,56 @@
11
import { NextResponse } from "next/server";
22
import type { NextRequest } from "next/server";
3+
import { timingSafeEqual } from "crypto";
34

45
const AUTH_COOKIE = "prfc_auth";
6+
const PROTECTED_PATHS = ["/referral-database", "/groups"];
7+
8+
function isBasicAuthValid(request: NextRequest): boolean {
9+
const authHeader = request.headers.get("authorization");
10+
if (!authHeader?.startsWith("Basic ")) return false;
11+
12+
const decoded = atob(authHeader.slice(6));
13+
const separatorIndex = decoded.indexOf(":");
14+
if (separatorIndex === -1) return false;
15+
16+
const username = decoded.slice(0, separatorIndex);
17+
const password = decoded.slice(separatorIndex + 1);
18+
19+
const expectedUsername = process.env.STAGING_USERNAME ?? "";
20+
const expectedPassword = process.env.STAGING_PASSWORD ?? "";
21+
if (!expectedUsername || !expectedPassword) return false;
22+
23+
if (username.length !== expectedUsername.length || password.length !== expectedPassword.length) return false;
24+
25+
const usernameMatch = timingSafeEqual(Buffer.from(username), Buffer.from(expectedUsername));
26+
const passwordMatch = timingSafeEqual(Buffer.from(password), Buffer.from(expectedPassword));
27+
28+
return usernameMatch && passwordMatch;
29+
}
530

631
export async function proxy(request: NextRequest) {
7-
const hasSession = request.cookies.get(AUTH_COOKIE);
32+
if (process.env.STAGING === "true") {
33+
if (!isBasicAuthValid(request)) {
34+
return new NextResponse("Authentication required", {
35+
status: 401,
36+
headers: { "WWW-Authenticate": 'Basic realm="Staging"' },
37+
});
38+
}
39+
}
40+
41+
const { pathname } = request.nextUrl;
42+
const isProtectedPath = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
843

9-
if (hasSession?.value) {
10-
return NextResponse.next();
44+
if (isProtectedPath) {
45+
const hasSession = request.cookies.get(AUTH_COOKIE);
46+
if (!hasSession?.value) {
47+
return NextResponse.redirect(new URL("/", request.url));
48+
}
1149
}
1250

13-
return NextResponse.redirect(new URL("/", request.url));
51+
return NextResponse.next();
1452
}
1553

1654
export const config = {
17-
matcher: ["/referral-database/:path*", "/groups/:path*"],
55+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
1856
};

src/services/email.ts

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,49 @@ import { env } from "@/env";
77
import { generateUnsubscribeToken } from "@/lib/unsubscribe-tokens";
88
import { filterSuppressedEmails } from "./email-suppression";
99

10-
const transport = nodemailer.createTransport({
11-
host: env.SMTP_HOST,
12-
port: env.SMTP_PORT,
13-
secure: env.SMTP_SECURE,
14-
auth: {
15-
user: env.SMTP_USER,
16-
pass: env.SMTP_PASS,
17-
},
18-
pool: true,
19-
maxConnections: 5,
20-
maxMessages: 100,
21-
rateLimit: 10,
22-
rateDelta: 1000,
23-
socketTimeout: 45000,
24-
connectionTimeout: 30000,
25-
});
10+
let _transport: nodemailer.Transporter | null = null;
11+
12+
function getTransport(): nodemailer.Transporter {
13+
if (_transport) return _transport;
14+
_transport = nodemailer.createTransport({
15+
host: env.SMTP_HOST,
16+
port: env.SMTP_PORT,
17+
secure: env.SMTP_SECURE,
18+
auth: {
19+
user: env.SMTP_USER,
20+
pass: env.SMTP_PASS,
21+
},
22+
pool: true,
23+
maxConnections: 5,
24+
maxMessages: 100,
25+
rateLimit: 10,
26+
rateDelta: 1000,
27+
socketTimeout: 45000,
28+
connectionTimeout: 30000,
29+
});
30+
return _transport;
31+
}
2632

2733
process.on("SIGTERM", () => {
28-
console.log("Closing email transport...");
29-
transport.close();
34+
if (_transport) {
35+
console.log("Closing email transport...");
36+
_transport.close();
37+
}
3038
});
3139

40+
export function validateEmailAllowed(): void {
41+
if (!env.EMAIL_ENABLED) {
42+
throw new AppError("FORBIDDEN", "Email functionality is currently disabled", { reason: "EMAIL_DISABLED" });
43+
}
44+
}
45+
46+
function applyRedirect(to: string, subject: string): { to: string; subject: string } {
47+
if (env.EMAIL_REDIRECT_TO) {
48+
return { to: env.EMAIL_REDIRECT_TO, subject: `[TEST to: ${to}] ${subject}` };
49+
}
50+
return { to, subject };
51+
}
52+
3253
interface SendReferralEmailParams {
3354
prospects: Prospect[];
3455
referralCode: string;
@@ -44,10 +65,13 @@ export async function sendReferralEmails({
4465

4566
try {
4667
for (const prospect of prospects) {
68+
const originalSubject = "You've Been Invited!";
69+
const { to, subject } = applyRedirect(prospect.prospectEmail, originalSubject);
70+
4771
const mail = {
4872
from: env.FROM_EMAIL,
49-
to: prospect.prospectEmail,
50-
subject: "You've Been Invited!",
73+
to,
74+
subject,
5175
html: generateEmailHtml(prospect.prospectName, memberName, referralCode),
5276
attachments: [
5377
{
@@ -58,7 +82,7 @@ export async function sendReferralEmails({
5882
],
5983
};
6084

61-
await transport.sendMail(mail);
85+
await getTransport().sendMail(mail);
6286
}
6387
} catch (error) {
6488
throw new AppError("EMAIL_ERROR", "Failed to send referral emails", {
@@ -145,11 +169,13 @@ export async function sendGroupEmails(
145169
</p>
146170
`;
147171

148-
await transport.sendMail({
172+
const { to, subject: redirectedSubject } = applyRedirect(recipient.email, subject);
173+
174+
await getTransport().sendMail({
149175
from: `${senderName} <${env.FROM_EMAIL}>`,
150-
to: recipient.email,
176+
to,
151177
replyTo,
152-
subject,
178+
subject: redirectedSubject,
153179
html: htmlWithFooter,
154180
headers: {
155181
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",

0 commit comments

Comments
 (0)