From 431c68b2ad3f192ef5faf47821f17cb729eddf06 Mon Sep 17 00:00:00 2001 From: Kevin Rutledge Date: Fri, 27 Mar 2026 18:28:36 -0700 Subject: [PATCH] feat: add email feature flag, staging bypass, basic auth, and postinstall for vercel deployment --- .env.local.example | 10 ++++ .env.test | 4 ++ package.json | 1 + src/actions/referral.ts | 5 +- src/app/(dev)/dev/mock-portal/page.tsx | 4 +- src/app/api/dev/token/route.ts | 2 +- src/app/api/referrals/route.ts | 5 +- src/env.ts | 21 ++++++-- src/proxy.ts | 48 +++++++++++++++-- src/services/email.ts | 74 +++++++++++++++++--------- src/services/message.ts | 12 ++++- test/services/message.test.ts | 8 +++ 12 files changed, 154 insertions(+), 40 deletions(-) diff --git a/.env.local.example b/.env.local.example index 79e8df8..0e91703 100644 --- a/.env.local.example +++ b/.env.local.example @@ -20,9 +20,19 @@ FROM_EMAIL=noreply@example.com # Not required for local dev - uses built-in dev secret # PRFC_PORTAL_SECRET=your-32-char-minimum-secret-here +# Email Feature Flag (disabled by default) +EMAIL_ENABLED=false +# When set, all emails redirect to this address instead of real recipients +# EMAIL_REDIRECT_TO=your-test-email@example.com + # SMS Feature Flag (disabled by default for MVP) SMS_ENABLED=false +# Staging Mode (allows mock portal access when NODE_ENV=production) +STAGING=false +STAGING_USERNAME=admin +STAGING_PASSWORD=your-staging-password + # Member Portal API Integration # Set to true for local development with mock data # Set to false for production with real Member Portal API diff --git a/.env.test b/.env.test index e02404c..ee40446 100644 --- a/.env.test +++ b/.env.test @@ -9,4 +9,8 @@ NODE_ENV="test" UNSUBSCRIBE_SECRET="test-secret-key-must-be-at-least-32-chars" FIELD_ENCRYPTION_KEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" BLIND_INDEX_KEY="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +EMAIL_ENABLED=true +STAGING=false +STAGING_USERNAME=test +STAGING_PASSWORD=test APP_URL="http://localhost:3000" diff --git a/package.json b/package.json index 15eb8c3..89f0a26 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "prepare": "husky", + "postinstall": "prisma generate", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down", "docker:logs": "docker-compose logs -f", diff --git a/src/actions/referral.ts b/src/actions/referral.ts index 5340d40..23162f4 100644 --- a/src/actions/referral.ts +++ b/src/actions/referral.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { ReferralFormSchema } from "@/schema/api"; import { createManyReferrals, toggleReferralRedeemed } from "@/services/referral"; import { sendReferralEmails } from "@/services/email"; +import { env } from "@/env"; import { transformError } from "@/utils/errors"; import { verifySession, requireAdmin } from "@/lib/dal"; import type { ActionResult } from "@/lib/action-types"; @@ -28,7 +29,9 @@ export async function submitReferrals(formData: FormData): Promise const { memberName, memberEmail, referralCode, prospects } = ReferralFormSchema.parse(rawData); - await sendReferralEmails({ prospects, referralCode, memberName }); + if (env.EMAIL_ENABLED) { + await sendReferralEmails({ prospects, referralCode, memberName }); + } const referrals = prospects.map((prospect) => ({ memberName, diff --git a/src/app/(dev)/dev/mock-portal/page.tsx b/src/app/(dev)/dev/mock-portal/page.tsx index ed18063..cb5bafb 100644 --- a/src/app/(dev)/dev/mock-portal/page.tsx +++ b/src/app/(dev)/dev/mock-portal/page.tsx @@ -11,7 +11,7 @@ export default function MockPortalPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - if (process.env.NODE_ENV === "production") { + if (process.env.NODE_ENV === "production" && process.env.STAGING !== "true") { return (

Not available in production

@@ -28,7 +28,7 @@ export default function MockPortalPage() { } } - async function handleLogin(e: React.FormEvent) { + async function handleLogin(e: React.FormEvent) { e.preventDefault(); setError(""); diff --git a/src/app/api/dev/token/route.ts b/src/app/api/dev/token/route.ts index 5398db2..5d90417 100644 --- a/src/app/api/dev/token/route.ts +++ b/src/app/api/dev/token/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { generateToken } from "@/lib/dal"; export async function POST(req: NextRequest) { - if (process.env.NODE_ENV === "production") { + if (process.env.NODE_ENV === "production" && process.env.STAGING !== "true") { return NextResponse.json({ error: "Not available" }, { status: 404 }); } diff --git a/src/app/api/referrals/route.ts b/src/app/api/referrals/route.ts index bf93e24..c05c16d 100644 --- a/src/app/api/referrals/route.ts +++ b/src/app/api/referrals/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { ReferralFormSchema } from "@/schema/api"; import { createManyReferrals, getAllReferrals } from "@/services/referral"; import { sendReferralEmails } from "@/services/email"; +import { env } from "@/env"; import { rateLimiter } from "@/lib/rate-limit"; import { getIdempotentResponse, setIdempotentResponse } from "@/lib/idempotency"; import { validateOrigin } from "@/lib/csrf"; @@ -47,7 +48,9 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { memberName, memberEmail, referralCode, prospects } = ReferralFormSchema.parse(body); - await sendReferralEmails({ prospects, referralCode, memberName }); + if (env.EMAIL_ENABLED) { + await sendReferralEmails({ prospects, referralCode, memberName }); + } const referrals = prospects.map((prospect) => ({ memberName, diff --git a/src/env.ts b/src/env.ts index 003eb30..e617689 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,15 +3,15 @@ import { z } from "zod"; const envSchema = z.object({ DATABASE_URL: z.url(), - SMTP_HOST: z.string().min(1), + SMTP_HOST: z.string().min(1).optional(), SMTP_PORT: z.coerce.number().default(587), SMTP_SECURE: z .string() .default("false") .transform((v) => v === "true"), - SMTP_USER: z.string().min(1), - SMTP_PASS: z.string().min(1), - FROM_EMAIL: z.email(), + SMTP_USER: z.string().min(1).optional(), + SMTP_PASS: z.string().min(1).optional(), + FROM_EMAIL: z.email().optional(), UPSTASH_REDIS_REST_URL: z.url().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(), @@ -20,11 +20,24 @@ const envSchema = z.object({ PRFC_PORTAL_SECRET: z.string().min(32).optional(), // SMS feature flag (disabled by default) + EMAIL_ENABLED: z + .string() + .default("false") + .transform((v) => v === "true"), + EMAIL_REDIRECT_TO: z.email().optional(), + SMS_ENABLED: z .string() .default("false") .transform((v) => v === "true"), + STAGING: z + .string() + .default("false") + .transform((v) => v === "true"), + STAGING_USERNAME: z.string().min(1).optional(), + STAGING_PASSWORD: z.string().min(1).optional(), + // Member Portal API integration toggle USE_MOCK_MEMBER_API: z .string() diff --git a/src/proxy.ts b/src/proxy.ts index df2d4d4..0d55e22 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,18 +1,56 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { timingSafeEqual } from "crypto"; const AUTH_COOKIE = "prfc_auth"; +const PROTECTED_PATHS = ["/referral-database", "/groups"]; + +function isBasicAuthValid(request: NextRequest): boolean { + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Basic ")) return false; + + const decoded = atob(authHeader.slice(6)); + const separatorIndex = decoded.indexOf(":"); + if (separatorIndex === -1) return false; + + const username = decoded.slice(0, separatorIndex); + const password = decoded.slice(separatorIndex + 1); + + const expectedUsername = process.env.STAGING_USERNAME ?? ""; + const expectedPassword = process.env.STAGING_PASSWORD ?? ""; + if (!expectedUsername || !expectedPassword) return false; + + if (username.length !== expectedUsername.length || password.length !== expectedPassword.length) return false; + + const usernameMatch = timingSafeEqual(Buffer.from(username), Buffer.from(expectedUsername)); + const passwordMatch = timingSafeEqual(Buffer.from(password), Buffer.from(expectedPassword)); + + return usernameMatch && passwordMatch; +} export async function proxy(request: NextRequest) { - const hasSession = request.cookies.get(AUTH_COOKIE); + if (process.env.STAGING === "true") { + if (!isBasicAuthValid(request)) { + return new NextResponse("Authentication required", { + status: 401, + headers: { "WWW-Authenticate": 'Basic realm="Staging"' }, + }); + } + } + + const { pathname } = request.nextUrl; + const isProtectedPath = PROTECTED_PATHS.some((p) => pathname.startsWith(p)); - if (hasSession?.value) { - return NextResponse.next(); + if (isProtectedPath) { + const hasSession = request.cookies.get(AUTH_COOKIE); + if (!hasSession?.value) { + return NextResponse.redirect(new URL("/", request.url)); + } } - return NextResponse.redirect(new URL("/", request.url)); + return NextResponse.next(); } export const config = { - matcher: ["/referral-database/:path*", "/groups/:path*"], + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }; diff --git a/src/services/email.ts b/src/services/email.ts index 064159b..9f575a1 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -7,28 +7,49 @@ import { env } from "@/env"; import { generateUnsubscribeToken } from "@/lib/unsubscribe-tokens"; import { filterSuppressedEmails } from "./email-suppression"; -const transport = nodemailer.createTransport({ - host: env.SMTP_HOST, - port: env.SMTP_PORT, - secure: env.SMTP_SECURE, - auth: { - user: env.SMTP_USER, - pass: env.SMTP_PASS, - }, - pool: true, - maxConnections: 5, - maxMessages: 100, - rateLimit: 10, - rateDelta: 1000, - socketTimeout: 45000, - connectionTimeout: 30000, -}); +let _transport: nodemailer.Transporter | null = null; + +function getTransport(): nodemailer.Transporter { + if (_transport) return _transport; + _transport = nodemailer.createTransport({ + host: env.SMTP_HOST, + port: env.SMTP_PORT, + secure: env.SMTP_SECURE, + auth: { + user: env.SMTP_USER, + pass: env.SMTP_PASS, + }, + pool: true, + maxConnections: 5, + maxMessages: 100, + rateLimit: 10, + rateDelta: 1000, + socketTimeout: 45000, + connectionTimeout: 30000, + }); + return _transport; +} process.on("SIGTERM", () => { - console.log("Closing email transport..."); - transport.close(); + if (_transport) { + console.log("Closing email transport..."); + _transport.close(); + } }); +export function validateEmailAllowed(): void { + if (!env.EMAIL_ENABLED) { + throw new AppError("FORBIDDEN", "Email functionality is currently disabled", { reason: "EMAIL_DISABLED" }); + } +} + +function applyRedirect(to: string, subject: string): { to: string; subject: string } { + if (env.EMAIL_REDIRECT_TO) { + return { to: env.EMAIL_REDIRECT_TO, subject: `[TEST to: ${to}] ${subject}` }; + } + return { to, subject }; +} + interface SendReferralEmailParams { prospects: Prospect[]; referralCode: string; @@ -44,10 +65,13 @@ export async function sendReferralEmails({ try { for (const prospect of prospects) { + const originalSubject = "You've Been Invited!"; + const { to, subject } = applyRedirect(prospect.prospectEmail, originalSubject); + const mail = { from: env.FROM_EMAIL, - to: prospect.prospectEmail, - subject: "You've Been Invited!", + to, + subject, html: generateEmailHtml(prospect.prospectName, memberName, referralCode), attachments: [ { @@ -58,7 +82,7 @@ export async function sendReferralEmails({ ], }; - await transport.sendMail(mail); + await getTransport().sendMail(mail); } } catch (error) { throw new AppError("EMAIL_ERROR", "Failed to send referral emails", { @@ -145,11 +169,13 @@ export async function sendGroupEmails(

`; - await transport.sendMail({ + const { to, subject: redirectedSubject } = applyRedirect(recipient.email, subject); + + await getTransport().sendMail({ from: `${senderName} <${env.FROM_EMAIL}>`, - to: recipient.email, + to, replyTo, - subject, + subject: redirectedSubject, html: htmlWithFooter, headers: { "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", diff --git a/src/services/message.ts b/src/services/message.ts index c812d5e..abe0f27 100644 --- a/src/services/message.ts +++ b/src/services/message.ts @@ -3,7 +3,7 @@ import prisma from "@/lib/db"; import { env } from "@/env"; import { AppError, transformError } from "@/utils/errors"; import { getGroupRecipients } from "@/services/contact-group"; -import { sendGroupEmails } from "@/services/email"; +import { sendGroupEmails, validateEmailAllowed } from "@/services/email"; import { getMemberDetails, getAllActiveMemberIds } from "@/lib/api/member-api"; import type { ComposeMessage, BlastMessage } from "@/schema/contact-group"; import type { MockMember } from "@/lib/mock-members"; @@ -99,7 +99,7 @@ async function sendEmailsForMessage( subject, body, senderName: "Paso Robles Food Co-op", - replyTo: env.FROM_EMAIL, + replyTo: env.FROM_EMAIL ?? "", groupId: groupId ?? 0, }); @@ -144,6 +144,10 @@ export async function sendGroupMessage(input: ComposeMessage, senderId: number): throw new AppError("VALIDATION_ERROR", "At least one delivery method (email or SMS) must be selected"); } + if (sendEmail) { + validateEmailAllowed(); + } + if (sendSms) { validateSmsAllowed(); } @@ -236,6 +240,10 @@ export async function sendBlastMessage(input: BlastMessage, senderId: number): P throw new AppError("VALIDATION_ERROR", "At least one delivery method (email or SMS) must be selected"); } + if (sendEmail) { + validateEmailAllowed(); + } + if (sendSms) { validateSmsAllowed(); } diff --git a/test/services/message.test.ts b/test/services/message.test.ts index 0ebbbb1..516d635 100644 --- a/test/services/message.test.ts +++ b/test/services/message.test.ts @@ -33,6 +33,7 @@ const testBlastMessage: Message = { }; const envMock = vi.hoisted(() => ({ + EMAIL_ENABLED: false as boolean, SMS_ENABLED: false as boolean, FROM_EMAIL: "no-reply@prfc.coop", })); @@ -44,6 +45,11 @@ vi.mock("@/services/contact-group", () => ({ vi.mock("@/services/email", () => ({ sendGroupEmails: vi.fn(), + validateEmailAllowed: vi.fn(() => { + if (!envMock.EMAIL_ENABLED) { + throw new AppError("FORBIDDEN", "Email functionality is currently disabled", { reason: "EMAIL_DISABLED" }); + } + }), })); vi.mock("@/lib/api/member-api", () => ({ @@ -157,6 +163,7 @@ describe("sendGroupMessage", () => { beforeEach(() => { vi.clearAllMocks(); + envMock.EMAIL_ENABLED = true; envMock.SMS_ENABLED = false; mockInteractiveTransaction(); }); @@ -255,6 +262,7 @@ describe("sendBlastMessage", () => { beforeEach(() => { vi.clearAllMocks(); + envMock.EMAIL_ENABLED = true; envMock.SMS_ENABLED = false; mockInteractiveTransaction(); });