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
10 changes: 10 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/actions/referral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,7 +29,9 @@ export async function submitReferrals(formData: FormData): Promise<ActionResult>

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,
Expand Down
4 changes: 2 additions & 2 deletions src/app/(dev)/dev/mock-portal/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<main className="flex flex-1 items-center justify-center">
<p>Not available in production</p>
Expand All @@ -28,7 +28,7 @@ export default function MockPortalPage() {
}
}

async function handleLogin(e: React.FormEvent) {
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");

Expand Down
2 changes: 1 addition & 1 deletion src/app/api/dev/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
5 changes: 4 additions & 1 deletion src/app/api/referrals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 17 additions & 4 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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()
Expand Down
48 changes: 43 additions & 5 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -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).*)"],
};
74 changes: 50 additions & 24 deletions src/services/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: [
{
Expand All @@ -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", {
Expand Down Expand Up @@ -145,11 +169,13 @@ export async function sendGroupEmails(
</p>
`;

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",
Expand Down
12 changes: 10 additions & 2 deletions src/services/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down
8 changes: 8 additions & 0 deletions test/services/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}));
Expand All @@ -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", () => ({
Expand Down Expand Up @@ -157,6 +163,7 @@ describe("sendGroupMessage", () => {

beforeEach(() => {
vi.clearAllMocks();
envMock.EMAIL_ENABLED = true;
envMock.SMS_ENABLED = false;
mockInteractiveTransaction();
});
Expand Down Expand Up @@ -255,6 +262,7 @@ describe("sendBlastMessage", () => {

beforeEach(() => {
vi.clearAllMocks();
envMock.EMAIL_ENABLED = true;
envMock.SMS_ENABLED = false;
mockInteractiveTransaction();
});
Expand Down
Loading