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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
pull_request:
push:
branches:
- main
- feature-hydration-fix

jobs:
test-and-build:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Run tests with coverage
run: npm run test:coverage

- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
coverage
if-no-files-found: error

- name: Build static site
env:
NEXT_PUBLIC_INQUIRY_API_URL: ${{ vars.NEXT_PUBLIC_INQUIRY_API_URL }}
run: npm run build
56 changes: 56 additions & 0 deletions .github/workflows/deploy-github-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Deploy GitHub Pages

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: "pages"
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Build static site
env:
NEXT_PUBLIC_INQUIRY_API_URL: ${{ vars.NEXT_PUBLIC_INQUIRY_API_URL }}
run: npm run build

- name: Setup Pages
uses: actions/configure-pages@v5

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./out

deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
16 changes: 15 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,16 @@
.env

AGENTS.md
CONTEXT.md
.codex/
.cursor/

node_modules/
.next/
coverage/
out/
build.log
tsconfig.tsbuildinfo

.vscode/
.DS_Store
.vscode
13 changes: 13 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[build]
publish = "netlify/static"
functions = "netlify/functions"

[[redirects]]
from = "/api/inquiry"
to = "/.netlify/functions/inquiry"
status = 200

[[headers]]
for = "/*"
[headers.values]
X-Robots-Tag = "noindex, nofollow"
194 changes: 194 additions & 0 deletions netlify/functions/inquiry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
const DEFAULT_ALLOWED_ORIGINS = [
"https://nayeyar.github.io",
"http://localhost:3000",
"http://localhost:8888",
];

function parseAllowedOrigins() {
const raw = process.env.INQUIRY_ALLOWED_ORIGINS;
if (!raw) {
return DEFAULT_ALLOWED_ORIGINS;
}

return raw
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}

function getCorsHeaders(origin, allowedOrigins) {
const allowedOrigin = origin && allowedOrigins.includes(origin) ? origin : "";

return {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
Vary: "Origin",
};
}

function json(statusCode, body, origin, allowedOrigins) {
return {
statusCode,
headers: {
"Content-Type": "application/json",
...getCorsHeaders(origin, allowedOrigins),
},
body: JSON.stringify(body),
};
}

function isValidEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

function escapeHtml(value) {
return value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

function parseInquiryPayload(payload) {
if (!payload || typeof payload !== "object") {
return null;
}

const name = String(payload.name ?? "").trim();
const email = String(payload.email ?? "").trim();
const project = String(payload.project ?? "").trim();

if (!name || name.length > 120) {
return null;
}
if (!isValidEmail(email) || email.length > 320) {
return null;
}
if (!project || project.length > 5000) {
return null;
}

return { name, email, project };
}

export async function handler(event) {
const allowedOrigins = parseAllowedOrigins();
const origin = event.headers.origin || event.headers.Origin || "";

if (event.httpMethod === "OPTIONS") {
const headers = getCorsHeaders(origin, allowedOrigins);
const statusCode = headers["Access-Control-Allow-Origin"] ? 204 : 403;
return {
statusCode,
headers,
body: "",
};
}

if (!origin || !allowedOrigins.includes(origin)) {
return json(403, { error: "Inquiry origin is not allowed." }, origin, allowedOrigins);
}

if (event.httpMethod !== "POST") {
return json(405, { error: "Method not allowed." }, origin, allowedOrigins);
}

const resendApiKey = process.env.RESEND_API_KEY;
if (!resendApiKey) {
return json(
503,
{
error:
"Inquiry service is not configured. Set RESEND_API_KEY on the server to enable submissions.",
},
origin,
allowedOrigins,
);
}

let body;
try {
body = JSON.parse(event.body || "{}");
} catch {
return json(400, { error: "Invalid JSON payload." }, origin, allowedOrigins);
}

const inquiry = parseInquiryPayload(body);
if (!inquiry) {
return json(400, { error: "Invalid inquiry fields." }, origin, allowedOrigins);
}

const to = process.env.INQUIRY_TO_EMAIL || "nayayeyar2230@gmail.com";
const from = process.env.INQUIRY_FROM_EMAIL || "Portfolio Inquiry <onboarding@resend.dev>";

const subject = `Project inquiry from ${inquiry.name}`;
const text = [
"New project inquiry",
"",
`Name: ${inquiry.name}`,
`Email: ${inquiry.email}`,
"",
"Project details:",
inquiry.project,
].join("\n");

const html = `
<h2>New project inquiry</h2>
<p><strong>Name:</strong> ${escapeHtml(inquiry.name)}</p>
<p><strong>Email:</strong> ${escapeHtml(inquiry.email)}</p>
<p><strong>Project details:</strong></p>
<pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(inquiry.project)}</pre>
`;

try {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${resendApiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from,
to: [to],
reply_to: inquiry.email,
subject,
text,
html,
}),
});

if (!response.ok) {
const errorBody = await response
.json()
.catch(async () => ({ message: await response.text() }));
const resendMessage =
typeof errorBody?.message === "string" && errorBody.message.trim().length > 0
? errorBody.message.trim()
: "Unknown provider error.";

const normalized = resendMessage.toLowerCase();
const guidance =
normalized.includes("testing emails") || normalized.includes("verify a domain")
? " Configure INQUIRY_FROM_EMAIL with a verified sender and INQUIRY_TO_EMAIL with an allowed recipient."
: "";

return json(
502,
{ error: `Email delivery failed: ${resendMessage}.${guidance}` },
origin,
allowedOrigins,
);
}

return json(200, { success: true }, origin, allowedOrigins);
} catch {
return json(
500,
{ error: "Unexpected error while sending inquiry email." },
origin,
allowedOrigins,
);
}
}
68 changes: 68 additions & 0 deletions netlify/functions/inquiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { handler } from "./inquiry";

describe("Netlify inquiry handler", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it("rejects requests from disallowed origins", async () => {
const response = await handler({
httpMethod: "POST",
headers: { origin: "https://example.com" },
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
project: "Test project",
}),
});

expect(response.statusCode).toBe(403);
expect(JSON.parse(response.body)).toEqual({
error: "Inquiry origin is not allowed.",
});
});

it("returns a configuration error when the Resend key is missing", async () => {
vi.stubEnv("RESEND_API_KEY", "");

const response = await handler({
httpMethod: "POST",
headers: { origin: "https://nayeyar.github.io" },
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
project: "Test project",
}),
});

expect(response.statusCode).toBe(503);
expect(JSON.parse(response.body)).toEqual({
error:
"Inquiry service is not configured. Set RESEND_API_KEY on the server to enable submissions.",
});
});

it("rejects invalid inquiry payloads before calling Resend", async () => {
vi.stubEnv("RESEND_API_KEY", "test-key");
const fetchSpy = vi.spyOn(global, "fetch");

const response = await handler({
httpMethod: "POST",
headers: { origin: "https://nayeyar.github.io" },
body: JSON.stringify({
name: "",
email: "bad-email",
project: "",
}),
});

expect(response.statusCode).toBe(400);
expect(JSON.parse(response.body)).toEqual({
error: "Invalid inquiry fields.",
});
expect(fetchSpy).not.toHaveBeenCalled();
});
});
Loading
Loading