From 323575a7357467860afdfd6e40352bd1099d6647 Mon Sep 17 00:00:00 2001 From: Durga Ghimeray Date: Sun, 13 Sep 2026 10:17:34 -0600 Subject: [PATCH 1/2] contact: add Resend email delivery and a visible fallback address The enquiry route accepted only a bearer-token webhook, and none was configured in production, so every real submission ended in a 503 with no other way to reach us. This adds a second delivery mode that emails each enquiry through Resend (RESEND_API_KEY + ENQUIRY_INBOX, optional ENQUIRY_FROM), keeps the webhook as the preferred mode when both are set, and still fails closed without a shared rate limiter. hello@ghimtech.org now appears in the contact aside, the footer, and under any failed-submission status so visitors always have a channel. Tests: 21 passing (4 new for Resend mode, precedence, and fail-closed). --- .env.example | 9 +++- apps/web/src/app/api/project/route.ts | 62 ++++++++++++++++++--- apps/web/src/app/contact/page.tsx | 5 +- apps/web/src/app/globals.css | 10 ++++ apps/web/src/components/primitives.tsx | 2 + apps/web/src/components/project-form.tsx | 7 +++ apps/web/src/lib/site.ts | 1 + apps/web/tests/project-route.test.mjs | 68 ++++++++++++++++++++++++ 8 files changed, 156 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index cb1c373..4775fcc 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,14 @@ NEXT_PUBLIC_SITE_URL=https://ghimtech.org -# Server-only HTTPS endpoint that durably receives project enquiries. +# Enquiry delivery: configure ONE of the two options below. The webhook wins if both are set. +# Option A: server-only HTTPS endpoint that durably receives project enquiries. PROJECT_WEBHOOK_URL= PROJECT_WEBHOOK_TOKEN= +# Option B: email each enquiry through Resend (https://resend.com) to ENQUIRY_INBOX. +# ENQUIRY_FROM is optional. The default onboarding@resend.dev sender only delivers to the +# Resend account owner's own email; set a verified-domain sender to deliver anywhere else. +RESEND_API_KEY= +ENQUIRY_INBOX= +ENQUIRY_FROM= # Shared rate limiter. Required for submissions. UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= diff --git a/apps/web/src/app/api/project/route.ts b/apps/web/src/app/api/project/route.ts index 35a0228..4b3a20d 100644 --- a/apps/web/src/app/api/project/route.ts +++ b/apps/web/src/app/api/project/route.ts @@ -43,6 +43,34 @@ async function readBody(request: Request) { } return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } +type Enquiry = ReturnType["data"]; +const labels: [keyof Enquiry, string][] = [ + ["name", "Name"], + ["email", "Email"], + ["company", "Company"], + ["website", "Website"], + ["budget", "Budget"], + ["timeline", "Timeline"], + ["business", "What the company does"], + ["bottleneck", "What is slowing them down"], + ["currentProcess", "How the process is handled today"], + ["idealSystem", "What the ideal system would do"], + ["context", "Additional context"], +]; +// Header values must stay on one line; enquiry text may legitimately contain newlines. +const line = (value: string) => value.replace(/\s+/g, " ").trim(); +function renderEnquiry(id: string, data: Enquiry, receivedAt: string) { + const sections = labels + .filter(([key]) => data[key]) + .map(([key, label]) => label + "\n" + data[key]); + return [ + "New project enquiry from " + siteUrl + "/contact", + "Received " + receivedAt, + "Reference " + id, + "", + ...sections.flatMap((section) => [section, ""]), + ].join("\n"); +} export async function POST(request: Request) { const origin = request.headers.get("origin"); const expected = new URL(siteUrl).origin; @@ -76,15 +104,23 @@ export async function POST(request: Request) { const { data, errors } = validateEnquiry(input); if (Object.keys(errors).length) return reply(422, "Please check the highlighted fields.", { errors }); + // Delivery is either a durable HTTPS webhook or an email through Resend. + // The webhook wins when both are configured. const webhook = process.env.PROJECT_WEBHOOK_URL; const token = process.env.PROJECT_WEBHOOK_TOKEN; + const resendKey = process.env.RESEND_API_KEY; + const inbox = process.env.ENQUIRY_INBOX; + const sender = process.env.ENQUIRY_FROM || "GhimTech Enquiries "; const redis = process.env.UPSTASH_REDIS_REST_URL; const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN; const secret = process.env.RATE_LIMIT_SECRET; - if (!webhook || !token || !redis || !redisToken || !secret) return reply(503, unavailable); + const viaWebhook = !!(webhook && token); + const viaResend = !!(resendKey && inbox && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inbox)); + if ((!viaWebhook && !viaResend) || !redis || !redisToken || !secret) + return reply(503, unavailable); try { - if (new URL(webhook).protocol !== "https:" || new URL(redis).protocol !== "https:") - return reply(503, unavailable); + if (viaWebhook && new URL(webhook!).protocol !== "https:") return reply(503, unavailable); + if (new URL(redis).protocol !== "https:") return reply(503, unavailable); const hash = (value: string) => createHmac("sha256", secret).update(value).digest("hex"); // Only trust the platform-controlled client IP header on Vercel. // Other hosts use a shared bucket until an explicit trusted proxy is configured. @@ -115,14 +151,28 @@ export async function POST(request: Request) { "Too many enquiries have been sent. Please wait an hour before trying again.", ); const receipt = hash(input.requestId + JSON.stringify(data)); - const delivered = await fetch(webhook, { + const receivedAt = new Date().toISOString(); + const delivery = viaWebhook + ? { url: webhook!, auth: token!, body: { id: receipt, ...data, receivedAt } } + : { + url: "https://api.resend.com/emails", + auth: resendKey!, + body: { + from: sender, + to: [inbox!], + reply_to: data.email, + subject: line("Project enquiry: " + data.name + " at " + data.company), + text: renderEnquiry(receipt, data, receivedAt), + }, + }; + const delivered = await fetch(delivery.url, { method: "POST", headers: { - Authorization: "Bearer " + token, + Authorization: "Bearer " + delivery.auth, "Content-Type": "application/json", "Idempotency-Key": receipt, }, - body: JSON.stringify({ id: receipt, ...data, receivedAt: new Date().toISOString() }), + body: JSON.stringify(delivery.body), redirect: "error", signal: AbortSignal.timeout(10000), cache: "no-store", diff --git a/apps/web/src/app/contact/page.tsx b/apps/web/src/app/contact/page.tsx index b0d1fda..da03bda 100644 --- a/apps/web/src/app/contact/page.tsx +++ b/apps/web/src/app/contact/page.tsx @@ -1,6 +1,6 @@ import { PageIntro } from "@/components/primitives"; import { ProjectForm } from "@/components/project-form"; -import { pageMetadata } from "@/lib/site"; +import { contactEmail, pageMetadata } from "@/lib/site"; export const metadata = pageMetadata( "Start a Project", "Tell GhimTech about the workflow, manual process, or administrative bottleneck that is slowing your business down.", @@ -31,6 +31,9 @@ export default function Contact() {
  • We review where a system could help.
  • We discuss scope, fit, and a practical next step.
  • +

    + Prefer email? Write to {contactEmail}. +

    diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 5505f2a..1cd583c 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1230,6 +1230,16 @@ a:hover { .contact-aside li { padding: 10px 0; } +.contact-email { + margin-top: 20px; + font-size: 13px; + color: var(--muted); +} +.contact-email a { + color: var(--ink); + text-decoration: underline; + text-underline-offset: 3px; +} .project-form { min-width: 0; } diff --git a/apps/web/src/components/primitives.tsx b/apps/web/src/components/primitives.tsx index 32c1091..adfe24d 100644 --- a/apps/web/src/components/primitives.tsx +++ b/apps/web/src/components/primitives.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { contactEmail } from "@/lib/site"; import type { ReactNode } from "react"; export function Action({ href, @@ -88,6 +89,7 @@ export function Footer() {
    © {new Date().getFullYear()} GhimTech
    + {contactEmail} GitHub ↗ Privacy Built by GhimTech. diff --git a/apps/web/src/components/project-form.tsx b/apps/web/src/components/project-form.tsx index 45bd132..6be56c9 100644 --- a/apps/web/src/components/project-form.tsx +++ b/apps/web/src/components/project-form.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useRef, useState } from "react"; import type { FormEvent } from "react"; import { budgets, timelines, validateEnquiry } from "@/lib/enquiry.mjs"; +import { contactEmail } from "@/lib/site"; type Errors = Record; export function ProjectForm() { const [errors, setErrors] = useState({}); @@ -186,6 +187,12 @@ export function ProjectForm() { > {success &&

    A useful conversation starts here.

    } {status &&

    {status}

    } + {status && !success && ( +

    + You can also send the same details by email to{" "} + {contactEmail}. +

    + )}
    ); diff --git a/apps/web/src/lib/site.ts b/apps/web/src/lib/site.ts index 5e12647..de66803 100644 --- a/apps/web/src/lib/site.ts +++ b/apps/web/src/lib/site.ts @@ -1,5 +1,6 @@ import type { Metadata } from "next"; export const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://ghimtech.org"; +export const contactEmail = "hello@ghimtech.org"; export function pageMetadata(title: string, description: string, path: string): Metadata { return { title, diff --git a/apps/web/tests/project-route.test.mjs b/apps/web/tests/project-route.test.mjs index e87445d..9fa1352 100644 --- a/apps/web/tests/project-route.test.mjs +++ b/apps/web/tests/project-route.test.mjs @@ -36,6 +36,9 @@ const keys = [ "UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN", "RATE_LIMIT_SECRET", + "RESEND_API_KEY", + "ENQUIRY_INBOX", + "ENQUIRY_FROM", ]; const original = Object.fromEntries(keys.map((k) => [k, process.env[k]])); function request(data = valid, headers = {}) { @@ -137,3 +140,68 @@ test("network failures preserve an explicit failure response", async () => { assert.equal(response.status, 503); assert.ok(!(await response.text()).includes("Internal network details")); }); +function setupResend() { + process.env.RESEND_API_KEY = "re_test_only"; + process.env.ENQUIRY_INBOX = "owner@example.com"; + process.env.UPSTASH_REDIS_REST_URL = "https://redis.example.com"; + process.env.UPSTASH_REDIS_REST_TOKEN = "test-only"; + process.env.RATE_LIMIT_SECRET = "test-only-random-secret"; +} +test("resend delivery emails the inbox with reply-to and an idempotency key", async () => { + setupResend(); + const sent = []; + globalThis.fetch = async (url, options) => { + if (String(url).includes("redis")) return Response.json({ result: 1 }); + sent.push({ url: String(url), options }); + return Response.json({ id: "email-id" }); + }; + const response = await POST(request({ ...valid, name: "Line\nBreak", context: "More\ndetail" })); + assert.equal(response.status, 200); + assert.equal(sent.length, 1); + assert.equal(sent[0].url, "https://api.resend.com/emails"); + assert.equal(sent[0].options.headers.Authorization, "Bearer re_test_only"); + assert.match(sent[0].options.headers["Idempotency-Key"], /^[0-9a-f]{64}$/); + assert.equal(sent[0].options.redirect, "error"); + const body = JSON.parse(sent[0].options.body); + assert.deepEqual(body.to, ["owner@example.com"]); + assert.equal(body.reply_to, valid.email); + assert.equal(body.from, "GhimTech Enquiries "); + assert.equal(body.subject, "Project enquiry: Line Break at Example"); + assert.ok(body.text.includes("What is slowing them down\nManual intake")); + assert.ok(body.text.includes("Additional context\nMore\ndetail")); + assert.ok(!body.text.includes("undefined")); +}); +test("resend mode honours a configured sender and fails closed on rejection", async () => { + setupResend(); + process.env.ENQUIRY_FROM = "GhimTech "; + let from; + globalThis.fetch = async (url, options) => { + if (String(url).includes("redis")) return Response.json({ result: 1 }); + from = JSON.parse(options.body).from; + return Response.json({ message: "invalid key" }, { status: 401 }); + }; + assert.equal((await POST(request())).status, 503); + assert.equal(from, "GhimTech "); +}); +test("resend mode requires a valid inbox and the shared limiter", async () => { + globalThis.fetch = () => { + throw new Error("Must not send"); + }; + setupResend(); + process.env.ENQUIRY_INBOX = "not-an-email"; + assert.equal((await POST(request())).status, 503); + setupResend(); + delete process.env.UPSTASH_REDIS_REST_URL; + assert.equal((await POST(request())).status, 503); +}); +test("webhook takes precedence when both deliveries are configured", async () => { + setup(); + setupResend(); + const urls = []; + globalThis.fetch = async (url) => { + urls.push(String(url)); + return Response.json({ result: 1 }); + }; + assert.equal((await POST(request())).status, 200); + assert.deepEqual(urls, ["https://redis.example.com", "https://receiver.example.com"]); +}); From f555a5c2c264c7fdd117492d0b1f58fba721b7b0 Mon Sep 17 00:00:00 2001 From: Durga Ghimeray Date: Sun, 13 Sep 2026 10:25:41 -0600 Subject: [PATCH 2/2] contact: accept Vercel Marketplace KV variables and derive the hashing secret The Upstash integration installed through the Vercel Marketplace injects KV_REST_API_URL and KV_REST_API_TOKEN rather than the UPSTASH_REDIS_REST_* names. Accept either pair. When RATE_LIMIT_SECRET is unset, derive the key-hashing secret from the Redis token instead of failing closed: the token already grants full access to the keys it would protect. --- .env.example | 4 +++- apps/web/src/app/api/project/route.ts | 13 +++++++++---- apps/web/tests/project-route.test.mjs | 25 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 4775fcc..ba448e4 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,9 @@ PROJECT_WEBHOOK_TOKEN= RESEND_API_KEY= ENQUIRY_INBOX= ENQUIRY_FROM= -# Shared rate limiter. Required for submissions. +# Shared rate limiter. Required for submissions. The Upstash integration on the Vercel +# Marketplace injects KV_REST_API_URL / KV_REST_API_TOKEN instead, which are accepted as-is. UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +# Optional. Secret for hashing rate-limit keys; derived from the Redis token when unset. RATE_LIMIT_SECRET= diff --git a/apps/web/src/app/api/project/route.ts b/apps/web/src/app/api/project/route.ts index 4b3a20d..ff5916c 100644 --- a/apps/web/src/app/api/project/route.ts +++ b/apps/web/src/app/api/project/route.ts @@ -1,4 +1,4 @@ -import { createHmac } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; import { validateEnquiry } from "@/lib/enquiry.mjs"; import { siteUrl } from "@/lib/site"; export const runtime = "nodejs"; @@ -111,9 +111,14 @@ export async function POST(request: Request) { const resendKey = process.env.RESEND_API_KEY; const inbox = process.env.ENQUIRY_INBOX; const sender = process.env.ENQUIRY_FROM || "GhimTech Enquiries "; - const redis = process.env.UPSTASH_REDIS_REST_URL; - const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN; - const secret = process.env.RATE_LIMIT_SECRET; + // Upstash via the Vercel Marketplace injects KV_REST_API_*; a direct Upstash setup uses UPSTASH_REDIS_REST_*. + const redis = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL; + const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN; + // Hashing secret keeps stored IP and email keys unlinkable. Without an explicit one it is + // derived from the Redis token, which anyone able to read the keys already holds. + const secret = + process.env.RATE_LIMIT_SECRET || + (redisToken && createHash("sha256").update("ghimtech-rate-limit:" + redisToken).digest("hex")); const viaWebhook = !!(webhook && token); const viaResend = !!(resendKey && inbox && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inbox)); if ((!viaWebhook && !viaResend) || !redis || !redisToken || !secret) diff --git a/apps/web/tests/project-route.test.mjs b/apps/web/tests/project-route.test.mjs index 9fa1352..192d982 100644 --- a/apps/web/tests/project-route.test.mjs +++ b/apps/web/tests/project-route.test.mjs @@ -39,6 +39,8 @@ const keys = [ "RESEND_API_KEY", "ENQUIRY_INBOX", "ENQUIRY_FROM", + "KV_REST_API_URL", + "KV_REST_API_TOKEN", ]; const original = Object.fromEntries(keys.map((k) => [k, process.env[k]])); function request(data = valid, headers = {}) { @@ -205,3 +207,26 @@ test("webhook takes precedence when both deliveries are configured", async () => assert.equal((await POST(request())).status, 200); assert.deepEqual(urls, ["https://redis.example.com", "https://receiver.example.com"]); }); +test("accepts the Vercel Marketplace KV variable names and derives the hashing secret", async () => { + setupResend(); + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; + delete process.env.RATE_LIMIT_SECRET; + process.env.KV_REST_API_URL = "https://kv.example.com"; + process.env.KV_REST_API_TOKEN = "kv-test-only"; + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url: String(url), options }); + return String(url).includes("kv.example") ? Response.json({ result: 1 }) : Response.json({ id: "x" }); + }; + assert.equal((await POST(request())).status, 200); + assert.equal(calls[0].url, "https://kv.example.com"); + assert.equal(calls[0].options.headers.Authorization, "Bearer kv-test-only"); + const keysUsed = JSON.parse(calls[0].options.body).slice(3); + assert.match(keysUsed[0], /^ghimtech:ip:[0-9a-f]{64}$/); + assert.match(keysUsed[1], /^ghimtech:email:[0-9a-f]{64}$/); + // Still fails closed with no Redis at all. + delete process.env.KV_REST_API_URL; + delete process.env.KV_REST_API_TOKEN; + assert.equal((await POST(request())).status, 503); +});