From 27e11de3ac0c30a35fcf2de323545f74f06a88d9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 30 Jun 2026 00:34:25 -0400 Subject: [PATCH 1/8] chore: ContactBody -> EmailBody --- src/config.ts | 2 +- src/email.ts | 8 ++++---- src/types.ts | 2 +- src/validation.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/config.ts b/src/config.ts index 20dcc87..68d7e8a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import type { EmailProvider } from "./types.js"; -import { ResendProvider } from "./providers/resend.js"; +import { ResendProvider } from "./providers/resend.js"; import { NodemailerProvider } from "./providers/nodemailer.js"; export interface Config { diff --git a/src/email.ts b/src/email.ts index 2d79ecd..86fe45d 100644 --- a/src/email.ts +++ b/src/email.ts @@ -1,5 +1,5 @@ -import type { EmailProvider, EmailPayload, ContactBody } from "./types.js"; -import type { Config } from "./config.js"; +import type { EmailProvider, EmailPayload, EmailBody } from "./types.js"; +import type { Config } from "./config.js"; export interface EmailConfig { provider: EmailProvider; @@ -17,8 +17,8 @@ export function getEmailConfig(config: Config): EmailConfig | null { } export async function sendEmail( - config: EmailConfig, - body: ContactBody + config: EmailConfig, + body: EmailBody ): Promise { const safeSubject = body.subject?.replace(/[\r\n]+/g, " ").trim() ?? "New message"; const safeName = body.name?.replace(/[\r\n]+/g, " ").trim(); diff --git a/src/types.ts b/src/types.ts index a9e97c2..27db4a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export interface ContactBody { +export interface EmailBody { email: string; message: string; subject?: string; diff --git a/src/validation.ts b/src/validation.ts index 07b3004..4927990 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,8 +1,8 @@ -import type { ContactBody } from "./types.js"; +import type { EmailBody } from "./types.js"; export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export function isValidBody(body: unknown): body is ContactBody { +export function isValidBody(body: unknown): body is EmailBody { if (body === null || typeof body !== "object") return false; const record = body as Record; const { email, message, subject, name, fax_number } = record; From 54671487f154ef9d04cf1a2d551df4c201f08672 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 30 Jun 2026 00:40:53 -0400 Subject: [PATCH 2/8] refactor: extract platform-agnostic CORS evaluation from Vercel handler --- api/contact/index.ts | 25 ++++--- src/contact.ts | 11 +++ src/cors.ts | 41 +++++------ src/email.ts | 2 +- tests/api/contact/index.test.ts | 17 ++--- tests/src/cors.test.ts | 97 ++++++++------------------ tests/src/email.test.ts | 4 +- tests/src/providers/nodemailer.test.ts | 1 - 8 files changed, 89 insertions(+), 109 deletions(-) create mode 100644 src/contact.ts diff --git a/api/contact/index.ts b/api/contact/index.ts index 0337cb6..5e1e819 100644 --- a/api/contact/index.ts +++ b/api/contact/index.ts @@ -1,14 +1,23 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { checkRateLimit } from "@vercel/firewall" -import { setCorsHeaders } from "../../src/cors.js"; -import { isValidBody } from "../../src/validation.js"; -import { getEmailConfig, sendEmail } from "../../src/email.js"; -import { config } from "../../src/config.js"; +import { checkRateLimit } from "@vercel/firewall" +import { evaluateCors } from "../../src/cors.js"; +import { isValidBody } from "../../src/validation.js"; +import { getEmailConfig, sendEmail } from "../../src/email.js"; +import { config } from "../../src/config.js"; export default async (req: VercelRequest, res: VercelResponse): Promise => { - const cors = setCorsHeaders(req, res, config.allowedOrigins); - if (cors === "preflight") return; - if (cors === "forbidden") { + const corsResult = evaluateCors( + { method: req.method ?? "", headers: req.headers as Record, body: req.body }, + config.allowedOrigins + ); + + for (const [key, value] of Object.entries(corsResult.headers)) res.setHeader(key, value); + + if (corsResult.outcome === "preflight") { + res.status(corsResult.status!).end(); + return; + } + if (corsResult.outcome === "forbidden") { res.status(403).json({ error: "Forbidden" }); return; } diff --git a/src/contact.ts b/src/contact.ts new file mode 100644 index 0000000..04af8c5 --- /dev/null +++ b/src/contact.ts @@ -0,0 +1,11 @@ +export interface ContactRequest { + method: string; + headers: Record; + body: unknown; +} + +export interface ContactResult { + status: number; + body: unknown; + headers?: Record; +} diff --git a/src/cors.ts b/src/cors.ts index dc496f2..73fa8a2 100644 --- a/src/cors.ts +++ b/src/cors.ts @@ -1,30 +1,25 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; +import type { ContactRequest } from "./contact.js"; -export function setCorsHeaders( - req: VercelRequest, - res: VercelResponse, - allowedOrigins: string[] -): "preflight" | "forbidden" | "ok" { - res.setHeader("X-Content-Type-Options", "nosniff"); +export interface CorsResult { + outcome: "preflight" | "forbidden" | "ok"; + headers: Record; + status?: number; +} + +export function evaluateCors(req: ContactRequest, allowedOrigins: string[]): CorsResult { + const headers: Record = { "X-Content-Type-Options": "nosniff" }; const origin = req.headers["origin"]; - const isAllowed = origin && allowedOrigins.includes(origin); - + const isAllowed = !!origin && allowedOrigins.includes(origin); + if (!isAllowed) { - if (req.method === "OPTIONS") { - res.status(403).end(); - return "preflight"; - } - return "forbidden"; + if (req.method === "OPTIONS") return { outcome: "preflight", headers, status: 403 }; + return { outcome: "forbidden", headers }; } - res.setHeader("Access-Control-Allow-Origin", origin); - res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); - - if (req.method === "OPTIONS") { - res.status(204).end(); - return "preflight"; - } + headers["Access-Control-Allow-Origin"] = origin; + headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"; + headers["Access-Control-Allow-Headers"] = "Content-Type"; - return "ok"; + if (req.method === "OPTIONS") return { outcome: "preflight", headers, status: 204 }; + return { outcome: "ok", headers }; } diff --git a/src/email.ts b/src/email.ts index 86fe45d..69ab39c 100644 --- a/src/email.ts +++ b/src/email.ts @@ -1,5 +1,5 @@ import type { EmailProvider, EmailPayload, EmailBody } from "./types.js"; -import type { Config } from "./config.js"; +import type { Config } from "./config.js"; export interface EmailConfig { provider: EmailProvider; diff --git a/tests/api/contact/index.test.ts b/tests/api/contact/index.test.ts index 0f63502..e12b5c8 100644 --- a/tests/api/contact/index.test.ts +++ b/tests/api/contact/index.test.ts @@ -2,13 +2,13 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import type { VercelRequest, VercelResponse } from "@vercel/node"; vi.mock("@vercel/firewall", () => ({ checkRateLimit: vi.fn() })); -vi.mock("../../../src/cors.js", () => ({ setCorsHeaders: vi.fn() })); +vi.mock("../../../src/cors.js", () => ({ evaluateCors: vi.fn() })); vi.mock("../../../src/validation.js", () => ({ isValidBody: vi.fn() })); vi.mock("../../../src/email.js", () => ({ getEmailConfig: vi.fn(), sendEmail: vi.fn() })); vi.mock("../../../src/config.js", () => ({ config: { allowedOrigins: ["https://example.com"] } })); import { checkRateLimit } from "@vercel/firewall"; -import { setCorsHeaders } from "../../../src/cors.js"; +import { evaluateCors } from "../../../src/cors.js"; import { isValidBody } from "../../../src/validation.js"; import { getEmailConfig, sendEmail } from "../../../src/email.js"; import handler from "../../../api/contact/index.js"; @@ -22,6 +22,7 @@ const makeReq = (overrides: Partial = {}): VercelRequest => ({ const makeRes = (): VercelResponse => { const res = { + setHeader: vi.fn(), status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), end: vi.fn().mockReturnThis(), @@ -32,24 +33,24 @@ const makeRes = (): VercelResponse => { describe("contact handler (index.ts)", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(setCorsHeaders).mockReturnValue("ok"); + vi.mocked(evaluateCors).mockReturnValue({ outcome: "ok", headers: {} }); vi.mocked(checkRateLimit).mockResolvedValue({ rateLimited: false } as any); vi.mocked(getEmailConfig).mockReturnValue({ provider: {} as any, from: "from@test.com", to: ["to@test.com"] }); vi.mocked(isValidBody).mockReturnValue(true); vi.mocked(sendEmail).mockResolvedValue(undefined); }); - it("returns early when setCorsHeaders returns 'preflight'", async () => { - vi.mocked(setCorsHeaders).mockReturnValue("preflight"); + it("returns early when evaluateCors returns 'preflight'", async () => { + vi.mocked(evaluateCors).mockReturnValue({ outcome: "preflight", headers: {}, status: 204 }); const req = makeReq({ method: "OPTIONS" }); const res = makeRes(); await handler(req, res); - expect(res.status).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(204); expect(sendEmail).not.toHaveBeenCalled(); }); - it("returns 403 when setCorsHeaders returns 'forbidden'", async () => { - vi.mocked(setCorsHeaders).mockReturnValue("forbidden"); + it("returns 403 when evaluateCors returns 'forbidden'", async () => { + vi.mocked(evaluateCors).mockReturnValue({ outcome: "forbidden", headers: {} }); const res = makeRes(); await handler(makeReq(), res); expect(res.status).toHaveBeenCalledWith(403); diff --git a/tests/src/cors.test.ts b/tests/src/cors.test.ts index 508c8af..d04e308 100644 --- a/tests/src/cors.test.ts +++ b/tests/src/cors.test.ts @@ -1,87 +1,52 @@ -import { describe, it, expect, vi } from "vitest"; -import { setCorsHeaders } from "../../src/cors.js"; -import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { describe, it, expect } from "vitest"; +import { evaluateCors } from "../../src/cors.js"; +import type { ContactRequest } from "../../src/contact.js"; -const makeReq = (origin?: string, method = "POST") => ({ - headers: { origin }, +const makeReq = (origin?: string, method = "POST"): ContactRequest => ({ method, -}) as unknown as VercelRequest; - -const makeRes = () => { - const headers: Record = {}; - return { - setHeader: vi.fn((k: string, v: string) => { headers[k] = v; }), - status: vi.fn().mockReturnThis(), - end: vi.fn(), - _headers: headers, - } as unknown as VercelResponse; -}; + headers: { origin }, + body: undefined, +}); -describe("setCorsHeaders", () => { +describe("evaluateCors", () => { it("should always set X-Content-Type-Options", () => { - const req = makeReq(); - const res = makeRes(); - setCorsHeaders(req, res, []); - expect(res.setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); + const result = evaluateCors(makeReq(), []); + expect(result.headers["X-Content-Type-Options"]).toBe("nosniff"); }); it("should set CORS headers when origin is allowed", () => { - const req = makeReq("https://example.com"); - const res = makeRes(); - setCorsHeaders(req, res, ["https://example.com"]); - expect(res.setHeader).toHaveBeenCalledWith("Access-Control-Allow-Origin", "https://example.com"); - expect(res.setHeader).toHaveBeenCalledWith("Access-Control-Allow-Methods", "POST, OPTIONS"); - expect(res.setHeader).toHaveBeenCalledWith("Access-Control-Allow-Headers", "Content-Type"); + const result = evaluateCors(makeReq("https://example.com"), ["https://example.com"]); + expect(result.headers["Access-Control-Allow-Origin"]).toBe("https://example.com"); + expect(result.headers["Access-Control-Allow-Methods"]).toBe("POST, OPTIONS"); + expect(result.headers["Access-Control-Allow-Headers"]).toBe("Content-Type"); + expect(result.outcome).toBe("ok"); }); it("should not set CORS headers when origin is undefined", () => { - const req = makeReq(undefined); - const res = makeRes(); - setCorsHeaders(req, res, ["https://example.com"]); - expect(res.setHeader).not.toHaveBeenCalledWith("Access-Control-Allow-Origin", expect.anything()); - }); - - it("should not set CORS headers when origin is not allowed", () => { - const req = makeReq("https://other.com"); - const res = makeRes(); - setCorsHeaders(req, res, ["https://example.com"]); - expect(res.setHeader).not.toHaveBeenCalledWith("Access-Control-Allow-Origin", expect.anything()); - }); - - it("should return 'preflight' and 204 on OPTIONS from allowed origin", () => { - const req = makeReq("https://example.com", "OPTIONS"); - const res = makeRes(); - const result = setCorsHeaders(req, res, ["https://example.com"]); - expect(res.status).toHaveBeenCalledWith(204); - expect(res.end).toHaveBeenCalled(); - expect(result).toBe("preflight"); + const result = evaluateCors(makeReq(undefined), ["https://example.com"]); + expect(result.headers["Access-Control-Allow-Origin"]).toBeUndefined(); }); - it("should return 'preflight' and 403 on OPTIONS from a disallowed origin" , () => { - const req = makeReq("https://other.com", "OPTIONS"); - const res = makeRes(); - const result = setCorsHeaders(req, res, ["https://example.com"]); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.setHeader).not.toHaveBeenCalledWith("Access-Control-Allow-Origin", expect.anything()); - expect(res.end).toHaveBeenCalled(); - expect(result).toBe("preflight"); + it("should return 'forbidden' when origin is not allowed", () => { + const result = evaluateCors(makeReq("https://other.com"), ["https://example.com"]); + expect(result.outcome).toBe("forbidden"); + expect(result.headers["Access-Control-Allow-Origin"]).toBeUndefined(); }); - it("should return 'forbidden' on non-OPTIONS requests from disallowed origin", () => { - const req = makeReq("https://example.com", "POST"); - const res = makeRes(); - expect(setCorsHeaders(req, res, ["https://other.com"])).toBe("forbidden"); + it("should return 'preflight' with 204 on OPTIONS from allowed origin", () => { + const result = evaluateCors(makeReq("https://example.com", "OPTIONS"), ["https://example.com"]); + expect(result.outcome).toBe("preflight"); + expect(result.status).toBe(204); }); - it("should return 'ok' on allowed POST request", () => { - const req = makeReq("https://example.com", "POST"); - const res = makeRes(); - expect(setCorsHeaders(req, res, ["https://example.com"])).toBe("ok"); + it("should return 'preflight' with 403 on OPTIONS from disallowed origin", () => { + const result = evaluateCors(makeReq("https://other.com", "OPTIONS"), ["https://example.com"]); + expect(result.outcome).toBe("preflight"); + expect(result.status).toBe(403); + expect(result.headers["Access-Control-Allow-Origin"]).toBeUndefined(); }); it("should return 'forbidden' when allowedOrigins is empty", () => { - const req = makeReq("https://example.com", "POST"); - const res = makeRes(); - expect(setCorsHeaders(req, res, [])).toBe("forbidden"); + expect(evaluateCors(makeReq("https://example.com"), []).outcome).toBe("forbidden"); }); }); diff --git a/tests/src/email.test.ts b/tests/src/email.test.ts index e359a54..f526001 100644 --- a/tests/src/email.test.ts +++ b/tests/src/email.test.ts @@ -1,5 +1,5 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; -import type { EmailProvider, ContactBody } from "../../src/types.js"; +import type { EmailProvider, EmailBody } from "../../src/types.js"; import { getEmailConfig, sendEmail, type EmailConfig } from "../../src/email.js"; const mockProvider: EmailProvider = { @@ -13,7 +13,7 @@ const mockEmailConfig: EmailConfig = { to: ["to@test.com"] }; -const body: ContactBody = { +const body: EmailBody = { email: "user@test.com", message: "Hello" }; diff --git a/tests/src/providers/nodemailer.test.ts b/tests/src/providers/nodemailer.test.ts index 04e25a8..2abf808 100644 --- a/tests/src/providers/nodemailer.test.ts +++ b/tests/src/providers/nodemailer.test.ts @@ -3,7 +3,6 @@ import nodemailer from "nodemailer"; import { NodemailerProvider } from "../../../src/providers/nodemailer.js"; import type { EmailPayload } from "../../../src/types.js"; - vi.mock("nodemailer", () => { const mockSend = vi.fn(); const mockCreateTransport = vi.fn().mockImplementation(() => ({ From 5b0e5528c5087bdfd9d335668de67376a8a8edcf Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 30 Jun 2026 00:50:15 -0400 Subject: [PATCH 3/8] refactor: extract handleContact flow into platform-agnostic handler --- api/contact/index.ts | 62 +++++++--------------- src/contact.ts | 1 - src/handler.ts | 44 +++++++++++++++ tests/api/contact/index.test.ts | 94 ++++++++++----------------------- tests/src/handler.test.ts | 69 ++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 112 deletions(-) create mode 100644 src/handler.ts create mode 100644 tests/src/handler.test.ts diff --git a/api/contact/index.ts b/api/contact/index.ts index 5e1e819..ec5be85 100644 --- a/api/contact/index.ts +++ b/api/contact/index.ts @@ -1,65 +1,39 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { checkRateLimit } from "@vercel/firewall" -import { evaluateCors } from "../../src/cors.js"; -import { isValidBody } from "../../src/validation.js"; -import { getEmailConfig, sendEmail } from "../../src/email.js"; -import { config } from "../../src/config.js"; +import { checkRateLimit } from "@vercel/firewall"; +import { evaluateCors } from "../../src/cors.js"; +import { handleContact } from "../../src/handler.js"; +import { getEmailConfig } from "../../src/email.js"; +import { config } from "../../src/config.js"; export default async (req: VercelRequest, res: VercelResponse): Promise => { - const corsResult = evaluateCors( + const cors = evaluateCors( { method: req.method ?? "", headers: req.headers as Record, body: req.body }, config.allowedOrigins ); - for (const [key, value] of Object.entries(corsResult.headers)) res.setHeader(key, value); + for (const [key, value] of Object.entries(cors.headers)) res.setHeader(key, value); - if (corsResult.outcome === "preflight") { - res.status(corsResult.status!).end(); + if (cors.outcome === "preflight") { + res.status(cors.status!).end(); return; } - if (corsResult.outcome === "forbidden") { + if (cors.outcome === "forbidden") { res.status(403).json({ error: "Forbidden" }); return; } - if (req.method !== "POST") { - res.status(405).json({ error: "Method not allowed" }); - return; - } - - if (!req.headers["content-type"]?.startsWith("application/json")) { - res.status(415).json({ error: "Unsupported Media Type" }); - return; - } - - if(typeof req.body?.fax_number === "string" ? req.body.fax_number.trim() : "") { - console.warn("Honeypot triggered:", req.headers["x-forwarded-for"] ?? "unknown"); - res.json({ success: true, message: "Message sent successfully" }); - return; - } - - const emailConfig = getEmailConfig(config); - if (!emailConfig) { - res.status(503).json({ error: "Service temporarily unavailable" }); - return; - } - - if (!isValidBody(req.body)) { - res.status(400).json({ error: "Invalid or missing fields" }); - return; - } - const { rateLimited } = await checkRateLimit("contact-form-limit"); if (rateLimited) { res.status(429).json({ error: "Too many requests. Please try again later" }); return; } - try { - await sendEmail(emailConfig, req.body); - res.json({ success: true, message: "Message sent successfully" }); - } catch (error) { - console.error("Email error:", error); - res.status(500).json({ error: "Message delivery failed. Please try again later" }); - } + const result = await handleContact( + { method: req.method ?? "", headers: req.headers as Record, body: req.body }, + { emailConfig: getEmailConfig(config) } + ); + + res.status(result.status); + if (result.body !== null) res.json(result.body); + else res.end(); }; diff --git a/src/contact.ts b/src/contact.ts index 04af8c5..7ea88d3 100644 --- a/src/contact.ts +++ b/src/contact.ts @@ -7,5 +7,4 @@ export interface ContactRequest { export interface ContactResult { status: number; body: unknown; - headers?: Record; } diff --git a/src/handler.ts b/src/handler.ts new file mode 100644 index 0000000..1d9e227 --- /dev/null +++ b/src/handler.ts @@ -0,0 +1,44 @@ +import type { ContactRequest, ContactResult } from "./contact.js"; +import type { EmailConfig } from "./email.js"; +import { isValidBody } from "./validation.js"; +import { sendEmail } from "./email.js"; + +export interface HandleContactDeps { + emailConfig: EmailConfig | null; +} + +export async function handleContact( + req: ContactRequest, + deps: HandleContactDeps +): Promise { + if (req.method !== "POST") { + return { status: 405, body: { error: "Method not allowed" } }; + } + + if (!req.headers["content-type"]?.startsWith("application/json")) { + return { status: 415, body: { error: "Unsupported Media Type" } }; + } + + const body = req.body as Record | undefined; + const faxNumber = typeof body?.["fax_number"] === "string" ? (body["fax_number"] as string).trim() : ""; + if (faxNumber) { + console.warn("Honeypot triggered"); + return { status: 200, body: { success: true, message: "Message sent successfully" } }; + } + + if (!deps.emailConfig) { + return { status: 503, body: { error: "Service temporarily unavailable" } }; + } + + if (!isValidBody(req.body)) { + return { status: 400, body: { error: "Invalid or missing fields" } }; + } + + try { + await sendEmail(deps.emailConfig, req.body); + return { status: 200, body: { success: true, message: "Message sent successfully" } }; + } catch (error) { + console.error("Email error:", error); + return { status: 500, body: { error: "Message delivery failed. Please try again later" } }; + } +} diff --git a/tests/api/contact/index.test.ts b/tests/api/contact/index.test.ts index e12b5c8..caaeb48 100644 --- a/tests/api/contact/index.test.ts +++ b/tests/api/contact/index.test.ts @@ -3,32 +3,29 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; vi.mock("@vercel/firewall", () => ({ checkRateLimit: vi.fn() })); vi.mock("../../../src/cors.js", () => ({ evaluateCors: vi.fn() })); -vi.mock("../../../src/validation.js", () => ({ isValidBody: vi.fn() })); -vi.mock("../../../src/email.js", () => ({ getEmailConfig: vi.fn(), sendEmail: vi.fn() })); +vi.mock("../../../src/handler.js", () => ({ handleContact: vi.fn() })); +vi.mock("../../../src/email.js", () => ({ getEmailConfig: vi.fn() })); vi.mock("../../../src/config.js", () => ({ config: { allowedOrigins: ["https://example.com"] } })); import { checkRateLimit } from "@vercel/firewall"; import { evaluateCors } from "../../../src/cors.js"; -import { isValidBody } from "../../../src/validation.js"; -import { getEmailConfig, sendEmail } from "../../../src/email.js"; +import { handleContact } from "../../../src/handler.js"; +import { getEmailConfig } from "../../../src/email.js"; import handler from "../../../api/contact/index.js"; const makeReq = (overrides: Partial = {}): VercelRequest => ({ headers: { origin: "https://example.com", "content-type": "application/json" }, method: "POST", - body: { subject: "Hello", email: "user@example.com", message: "HellO" }, + body: { subject: "Hello", email: "user@example.com", message: "Hello" }, ...overrides, } as unknown as VercelRequest); -const makeRes = (): VercelResponse => { - const res = { - setHeader: vi.fn(), - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - end: vi.fn().mockReturnThis(), - }; - return res as unknown as VercelResponse; -}; +const makeRes = (): VercelResponse => ({ + setHeader: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + end: vi.fn().mockReturnThis(), +} as unknown as VercelResponse); describe("contact handler (index.ts)", () => { beforeEach(() => { @@ -36,25 +33,25 @@ describe("contact handler (index.ts)", () => { vi.mocked(evaluateCors).mockReturnValue({ outcome: "ok", headers: {} }); vi.mocked(checkRateLimit).mockResolvedValue({ rateLimited: false } as any); vi.mocked(getEmailConfig).mockReturnValue({ provider: {} as any, from: "from@test.com", to: ["to@test.com"] }); - vi.mocked(isValidBody).mockReturnValue(true); - vi.mocked(sendEmail).mockResolvedValue(undefined); + vi.mocked(handleContact).mockResolvedValue({ status: 200, body: { success: true, message: "Message sent successfully" } }); }); - it("returns early when evaluateCors returns 'preflight'", async () => { - vi.mocked(evaluateCors).mockReturnValue({ outcome: "preflight", headers: {}, status: 204 }); - const req = makeReq({ method: "OPTIONS" }); + it("applies cors headers and returns early on 'preflight'", async () => { + vi.mocked(evaluateCors).mockReturnValue({ outcome: "preflight", headers: { "X-Foo": "bar" }, status: 204 }); const res = makeRes(); - await handler(req, res); + await handler(makeReq({ method: "OPTIONS" }), res); + expect(res.setHeader).toHaveBeenCalledWith("X-Foo", "bar"); expect(res.status).toHaveBeenCalledWith(204); - expect(sendEmail).not.toHaveBeenCalled(); + expect(handleContact).not.toHaveBeenCalled(); }); - it("returns 403 when evaluateCors returns 'forbidden'", async () => { + it("returns 403 on 'forbidden'", async () => { vi.mocked(evaluateCors).mockReturnValue({ outcome: "forbidden", headers: {} }); const res = makeRes(); await handler(makeReq(), res); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ error: "Forbidden" }); + expect(handleContact).not.toHaveBeenCalled(); }); it("returns 429 when rate limited", async () => { @@ -62,60 +59,23 @@ describe("contact handler (index.ts)", () => { const res = makeRes(); await handler(makeReq(), res); expect(res.status).toHaveBeenCalledWith(429); + expect(handleContact).not.toHaveBeenCalled(); }); - it("returns 405 when method is not POST", async () => { - const res = makeRes(); - await handler(makeReq({ method: "GET" }), res); - expect(res.status).toHaveBeenCalledWith(405); - }); - - it("returns 415 when content-type is not application/json", async () => { - const res = makeRes(); - await handler(makeReq({ headers: { origin: "https://example.com", "content-type": "text/plain" } }), res); - expect(res.status).toHaveBeenCalledWith(415); - }); - - it("returns 503 when server is misconfigured", async () => { - vi.mocked(getEmailConfig).mockReturnValue(null); - const res = makeRes(); - await handler(makeReq(), res); - expect(res.status).toHaveBeenCalledWith(503); - expect(res.json).toHaveBeenCalledWith({ error: "Service temporarily unavailable" }); - }); - - it("returns fake success and does not send email when honeypot is triggered", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const req = makeReq({ body: { subject: "Hi", email: "user@example.com", message: "Hello", fax_number: "1234567890" } }); - const res = makeRes(); - await handler(req, res); - expect(sendEmail).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ success: true, message: "Message sent successfully" }); - warnSpy.mockRestore(); - }); - - it("returns 400 when body is invalid", async () => { - vi.mocked(isValidBody).mockReturnValue(false); + it("delegates to handleContact and writes its result", async () => { + vi.mocked(handleContact).mockResolvedValue({ status: 400, body: { error: "Invalid or missing fields" } }); const res = makeRes(); await handler(makeReq(), res); + expect(handleContact).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: "Invalid or missing fields" }); }); - it("returns success when email sends successfully", async () => { - const res = makeRes(); - await handler(makeReq(), res); - expect(sendEmail).toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ success: true, message: "Message sent successfully" }); - }); - - it("returns 500 when sendEmail throws", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.mocked(sendEmail).mockRejectedValue(new Error("Failure")); + it("calls res.end() when handleContact returns null body", async () => { + vi.mocked(handleContact).mockResolvedValue({ status: 204, body: null }); const res = makeRes(); await handler(makeReq(), res); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: "Message delivery failed. Please try again later" }); - errorSpy.mockRestore(); + expect(res.end).toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); }); }); diff --git a/tests/src/handler.test.ts b/tests/src/handler.test.ts new file mode 100644 index 0000000..6f6894d --- /dev/null +++ b/tests/src/handler.test.ts @@ -0,0 +1,69 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { handleContact } from "../../src/handler.js"; +import { isValidBody } from "../../src/validation.js"; +import { sendEmail } from "../../src/email.js"; +import type { ContactRequest } from "../../src/contact.js"; +import type { EmailConfig } from "../../src/email.js"; + +vi.mock("../../src/validation.js", () => ({ isValidBody: vi.fn() })); +vi.mock("../../src/email.js", () => ({ sendEmail: vi.fn() })); + +const makeReq = (overrides: Partial = {}): ContactRequest => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: { subject: "Hello", email: "user@example.com", message: "Hello" }, + ...overrides, +}); + +const emailConfig: EmailConfig = { provider: {} as any, from: "from@test.com", to: ["to@test.com"] }; + +describe("handleContact", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isValidBody).mockReturnValue(true); + vi.mocked(sendEmail).mockResolvedValue(undefined); + }); + + it("returns 405 when method is not POST", async () => { + const result = await handleContact(makeReq({ method: "GET" }), { emailConfig }); + expect(result.status).toBe(405); + }); + + it("returns 415 when content-type is not application/json", async () => { + const result = await handleContact(makeReq({ headers: { "content-type": "text/plain" } }), { emailConfig }); + expect(result.status).toBe(415); + }); + + it("returns fake success and skips sendEmail when honeypot triggered", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await handleContact(makeReq({ body: { fax_number: "12345" } }), { emailConfig }); + expect(sendEmail).not.toHaveBeenCalled(); + expect(result).toEqual({ status: 200, body: { success: true, message: "Message sent successfully" } }); + warnSpy.mockRestore(); + }); + + it("returns 503 when emailConfig is null", async () => { + const result = await handleContact(makeReq(), { emailConfig: null }); + expect(result.status).toBe(503); + }); + + it("returns 400 when body is invalid", async () => { + vi.mocked(isValidBody).mockReturnValue(false); + const result = await handleContact(makeReq(), { emailConfig }); + expect(result.status).toBe(400); + }); + + it("returns 200 and calls sendEmail on success", async () => { + const result = await handleContact(makeReq(), { emailConfig }); + expect(sendEmail).toHaveBeenCalled(); + expect(result.status).toBe(200); + }); + + it("returns 500 when sendEmail throws", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(sendEmail).mockRejectedValue(new Error("fail")); + const result = await handleContact(makeReq(), { emailConfig }); + expect(result.status).toBe(500); + errorSpy.mockRestore(); + }); +}); From 6f9916e4b3278daf44ad088b99fca9dfc6d96dde Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 30 Jun 2026 11:52:10 -0400 Subject: [PATCH 4/8] refactor: move provider initialization from config.ts into provider modules --- src/config.ts | 43 ++++---------------------- src/providers/nodemailer.ts | 13 ++++++++ src/providers/resend.ts | 13 ++++++++ tests/src/providers/nodemailer.test.ts | 32 +++++++++++++++++-- tests/src/providers/resend.test.ts | 24 ++++++++++++-- 5 files changed, 84 insertions(+), 41 deletions(-) diff --git a/src/config.ts b/src/config.ts index 68d7e8a..9958ed6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ -import type { EmailProvider } from "./types.js"; -import { ResendProvider } from "./providers/resend.js"; -import { NodemailerProvider } from "./providers/nodemailer.js"; +import { createNodemailerProvider } from "./providers/nodemailer.js"; +import { createResendProvider } from "./providers/resend.js"; +import type { EmailProvider } from "./types.js"; export interface Config { provider: EmailProvider | null; @@ -17,40 +17,9 @@ const allowedOrigins = allowedOriginsRaw.split(",").map(o => o.trim()).filter(Bo function createProvider(): EmailProvider | null { const providerName = process.env["EMAIL_PROVIDER"]?.toLowerCase(); - if (!providerName) { - console.error("EMAIL_PROVIDER is not set"); - return null; - } - - if (providerName === "resend") { - const apiKey = process.env["RESEND_API_KEY"]; - if (!apiKey) { - console.warn("RESEND_API_KEY missing for resend"); - return null; - } - - try { return new ResendProvider(apiKey); } - catch (e) { - console.error("Failed to initialize Resend provider:", e); - return null; - } - } - - if (providerName === "nodemailer") { - const smtpConfig = process.env["SMTP_CONFIG"]; - if (!smtpConfig) { - console.warn("SMTP_CONFIG missing for nodemailer"); - return null; - } - - try { return new NodemailerProvider(smtpConfig); } - catch (e) { - console.error("Failed to initialize Nodemailer provider:", e); - return null; - } - } - - console.warn(`Unknown EMAIL_PROVIDER: "${providerName}"`); + if (providerName === "resend") return createResendProvider(); + if (providerName === "nodemailer") return createNodemailerProvider(); + console.warn(providerName ? `Unknown EMAIL_PROVIDER: "${providerName}"` : "EMAIL_PROVIDER is not set"); return null; } diff --git a/src/providers/nodemailer.ts b/src/providers/nodemailer.ts index 8466b87..2fb4d32 100644 --- a/src/providers/nodemailer.ts +++ b/src/providers/nodemailer.ts @@ -20,3 +20,16 @@ export class NodemailerProvider implements EmailProvider { }); } } + +export function createNodemailerProvider(): EmailProvider | null { + const smtpConfig = process.env["SMTP_CONFIG"]; + if (!smtpConfig) { + console.warn("SMTP_CONFIG missing for nodemailer"); + return null; + } + try { return new NodemailerProvider(smtpConfig); } + catch (e) { + console.error("Failed to initialize Nodemailer provider:", e); + return null; + } +} diff --git a/src/providers/resend.ts b/src/providers/resend.ts index e1ed45b..9207a14 100644 --- a/src/providers/resend.ts +++ b/src/providers/resend.ts @@ -17,3 +17,16 @@ export class ResendProvider implements EmailProvider { } } } + +export function createResendProvider(): EmailProvider | null { + const apiKey = process.env["RESEND_API_KEY"]; + if (!apiKey) { + console.warn("RESEND_API_KEY missing for resend"); + return null; + } + try { return new ResendProvider(apiKey); } + catch (e) { + console.error("Failed to initialize Resend provider:", e); + return null; + } +} diff --git a/tests/src/providers/nodemailer.test.ts b/tests/src/providers/nodemailer.test.ts index 2abf808..d39bf39 100644 --- a/tests/src/providers/nodemailer.test.ts +++ b/tests/src/providers/nodemailer.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import nodemailer from "nodemailer"; -import { NodemailerProvider } from "../../../src/providers/nodemailer.js"; +import { createNodemailerProvider, NodemailerProvider } from "../../../src/providers/nodemailer.js"; import type { EmailPayload } from "../../../src/types.js"; vi.mock("nodemailer", () => { @@ -67,3 +67,31 @@ describe("NodemailerProvider", () => { expect(() => new NodemailerProvider("{bad json")).toThrow(); }) }) + +describe("createNodemailerProvider", () => { + const originalEnv = process.env; + beforeEach(() => { process.env = { ...originalEnv }; }); + afterEach(() => { process.env = originalEnv; }); + + it("returns null and warns when SMTP_CONFIG is missing", () => { + delete process.env["SMTP_CONFIG"]; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(createNodemailerProvider()).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith("SMTP_CONFIG missing for nodemailer"); + warnSpy.mockRestore(); + }); + + it("returns null and logs an error when SMTP_CONFIG is invalid JSON", () => { + process.env["SMTP_CONFIG"] = "{not valid json"; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(createNodemailerProvider()).toBeNull(); + expect(errorSpy).toHaveBeenCalledWith("Failed to initialize Nodemailer provider:", expect.any(Error)); + errorSpy.mockRestore(); + }); + + it("returns a NodemailerProvider instance when SMTP_CONFIG is valid", () => { + process.env["SMTP_CONFIG"] = JSON.stringify({ host: "smtp.test.com", port: 587, auth: { user: "u", pass: "p" } }); + const provider = createNodemailerProvider(); + expect(provider).toBeInstanceOf(NodemailerProvider); + }); +}); diff --git a/tests/src/providers/resend.test.ts b/tests/src/providers/resend.test.ts index 77c4d1b..0d81a79 100644 --- a/tests/src/providers/resend.test.ts +++ b/tests/src/providers/resend.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Resend } from "resend"; -import { ResendProvider } from "../../../src/providers/resend.js"; +import { createResendProvider, ResendProvider } from "../../../src/providers/resend.js"; import type { EmailPayload } from "../../../src/types.js"; vi.mock("resend", () => { @@ -68,3 +68,23 @@ describe("ResendProvider", () => { consoleSpy.mockRestore(); }); }); + +describe("createResendProvider", () => { + const originalEnv = process.env; + beforeEach(() => { process.env = { ...originalEnv }; }); + afterEach(() => { process.env = originalEnv; }); + + it("returns null and warns when RESEND_API_KEY is missing", () => { + delete process.env["RESEND_API_KEY"]; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(createResendProvider()).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith("RESEND_API_KEY missing for resend"); + warnSpy.mockRestore(); + }); + + it("returns a ResendProvider instance when RESEND_API_KEY is set", () => { + process.env["RESEND_API_KEY"] = "test-key"; + const provider = createResendProvider(); + expect(provider).toBeInstanceOf(ResendProvider); + }); +}); From a5a8c8ffc67e905e59affa56e23e120672263e39 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 30 Jun 2026 12:16:49 -0400 Subject: [PATCH 5/8] refactor: restructure into core/, providers/, vercel/ to mirror future split --- {src => core}/contact.ts | 0 {src => core}/cors.ts | 0 {src => core}/email.ts | 10 --- {src => core}/handler.ts | 0 {src => core}/types.ts | 0 {src => core}/validation.ts | 0 {src/providers => providers}/nodemailer.ts | 2 +- {src/providers => providers}/resend.ts | 2 +- tests/{src => core}/cors.test.ts | 4 +- tests/core/email.test.ts | 53 +++++++++++++++ tests/{src => core}/handler.test.ts | 14 ++-- tests/{src => core}/validation.test.ts | 2 +- tests/{src => }/providers/nodemailer.test.ts | 4 +- tests/{src => }/providers/resend.test.ts | 4 +- tests/src/email.test.ts | 69 -------------------- tests/{ => vercel}/api/contact/index.test.ts | 18 ++--- tests/vercel/config.test.ts | 26 ++++++++ tsconfig.json | 7 +- {api => vercel/api}/contact/index.ts | 7 +- {src => vercel}/config.ts | 11 +++- 20 files changed, 120 insertions(+), 113 deletions(-) rename {src => core}/contact.ts (100%) rename {src => core}/cors.ts (100%) rename {src => core}/email.ts (70%) rename {src => core}/handler.ts (100%) rename {src => core}/types.ts (100%) rename {src => core}/validation.ts (100%) rename {src/providers => providers}/nodemailer.ts (93%) rename {src/providers => providers}/resend.ts (92%) rename tests/{src => core}/cors.test.ts (95%) create mode 100644 tests/core/email.test.ts rename tests/{src => core}/handler.test.ts (85%) rename tests/{src => core}/validation.test.ts (98%) rename tests/{src => }/providers/nodemailer.test.ts (96%) rename tests/{src => }/providers/resend.test.ts (94%) delete mode 100644 tests/src/email.test.ts rename tests/{ => vercel}/api/contact/index.test.ts (85%) create mode 100644 tests/vercel/config.test.ts rename {api => vercel/api}/contact/index.ts (84%) rename {src => vercel}/config.ts (65%) diff --git a/src/contact.ts b/core/contact.ts similarity index 100% rename from src/contact.ts rename to core/contact.ts diff --git a/src/cors.ts b/core/cors.ts similarity index 100% rename from src/cors.ts rename to core/cors.ts diff --git a/src/email.ts b/core/email.ts similarity index 70% rename from src/email.ts rename to core/email.ts index 69ab39c..478dee8 100644 --- a/src/email.ts +++ b/core/email.ts @@ -1,5 +1,4 @@ import type { EmailProvider, EmailPayload, EmailBody } from "./types.js"; -import type { Config } from "./config.js"; export interface EmailConfig { provider: EmailProvider; @@ -7,15 +6,6 @@ export interface EmailConfig { to: string[]; } -export function getEmailConfig(config: Config): EmailConfig | null { - if ( - !config.provider || - !config.fromEmail?.trim() || - !config.toEmails?.length - ) return null; - return { provider: config.provider, from: config.fromEmail, to: config.toEmails }; -} - export async function sendEmail( config: EmailConfig, body: EmailBody diff --git a/src/handler.ts b/core/handler.ts similarity index 100% rename from src/handler.ts rename to core/handler.ts diff --git a/src/types.ts b/core/types.ts similarity index 100% rename from src/types.ts rename to core/types.ts diff --git a/src/validation.ts b/core/validation.ts similarity index 100% rename from src/validation.ts rename to core/validation.ts diff --git a/src/providers/nodemailer.ts b/providers/nodemailer.ts similarity index 93% rename from src/providers/nodemailer.ts rename to providers/nodemailer.ts index 2fb4d32..36a9c90 100644 --- a/src/providers/nodemailer.ts +++ b/providers/nodemailer.ts @@ -1,5 +1,5 @@ import nodemailer from "nodemailer"; -import type { EmailProvider, EmailPayload } from "../types.js"; +import type { EmailProvider, EmailPayload } from "../core/types.js"; export class NodemailerProvider implements EmailProvider { readonly id = "nodemailer"; diff --git a/src/providers/resend.ts b/providers/resend.ts similarity index 92% rename from src/providers/resend.ts rename to providers/resend.ts index 9207a14..ab253b3 100644 --- a/src/providers/resend.ts +++ b/providers/resend.ts @@ -1,5 +1,5 @@ import { Resend } from "resend"; -import type { EmailProvider, EmailPayload } from "../types.js"; +import type { EmailProvider, EmailPayload } from "../core/types.js"; export class ResendProvider implements EmailProvider { readonly id = "resend"; diff --git a/tests/src/cors.test.ts b/tests/core/cors.test.ts similarity index 95% rename from tests/src/cors.test.ts rename to tests/core/cors.test.ts index d04e308..55c17d3 100644 --- a/tests/src/cors.test.ts +++ b/tests/core/cors.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { evaluateCors } from "../../src/cors.js"; -import type { ContactRequest } from "../../src/contact.js"; +import { evaluateCors } from "../../core/cors.js"; +import type { ContactRequest } from "../../core/contact.js"; const makeReq = (origin?: string, method = "POST"): ContactRequest => ({ method, diff --git a/tests/core/email.test.ts b/tests/core/email.test.ts new file mode 100644 index 0000000..81fa35b --- /dev/null +++ b/tests/core/email.test.ts @@ -0,0 +1,53 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import type { EmailProvider, EmailBody } from "../../core/types.js"; +import { sendEmail, type EmailConfig } from "../../core/email.js"; + +const mockProvider: EmailProvider = { + id: "mock", + send: vi.fn() +}; + +const mockEmailConfig: EmailConfig = { + provider: mockProvider, + from: "from@test.com", + to: ["to@test.com"] +}; + +const body: EmailBody = { + email: "user@test.com", + message: "Hello" +}; + +beforeEach(() => vi.clearAllMocks()); + +describe("sendEmail", () => { + it("calls provider with sanitized payload", async () => { + await sendEmail(mockEmailConfig, { ...body, subject: "Test\nSubject" }); + expect(mockProvider.send).toHaveBeenCalledWith({ + from: mockEmailConfig.from, + to: mockEmailConfig.to, + replyTo: body.email, + subject: "Contact form: Test Subject", + text: `From: ${body.email}\n\n${body.message}` + }); + }); + + it("formats fromLine with name when provided", async () => { + await sendEmail(mockEmailConfig, { ...body, name: "Tester" }); + expect(mockProvider.send).toHaveBeenCalledWith( + expect.objectContaining({ text: `From: Tester <${body.email}>\n\n${body.message}` }) + ); + }); + + it("uses default subject when not provided", async () => { + await sendEmail(mockEmailConfig, body); + expect(mockProvider.send).toHaveBeenCalledWith( + expect.objectContaining({ subject: "Contact form: New message" }) + ); + }); + + it("throws when provider.send rejects", async () => { + (mockProvider.send as any).mockRejectedValue(new Error("Send failed")); + await expect(sendEmail(mockEmailConfig, body)).rejects.toThrow("Send failed"); + }); +}); diff --git a/tests/src/handler.test.ts b/tests/core/handler.test.ts similarity index 85% rename from tests/src/handler.test.ts rename to tests/core/handler.test.ts index 6f6894d..ab0c23c 100644 --- a/tests/src/handler.test.ts +++ b/tests/core/handler.test.ts @@ -1,12 +1,12 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; -import { handleContact } from "../../src/handler.js"; -import { isValidBody } from "../../src/validation.js"; -import { sendEmail } from "../../src/email.js"; -import type { ContactRequest } from "../../src/contact.js"; -import type { EmailConfig } from "../../src/email.js"; +import { handleContact } from "../../core/handler.js"; +import { isValidBody } from "../../core/validation.js"; +import { sendEmail } from "../../core/email.js"; +import type { ContactRequest } from "../../core/contact.js"; +import type { EmailConfig } from "../../core/email.js"; -vi.mock("../../src/validation.js", () => ({ isValidBody: vi.fn() })); -vi.mock("../../src/email.js", () => ({ sendEmail: vi.fn() })); +vi.mock("../../core/validation.js", () => ({ isValidBody: vi.fn() })); +vi.mock("../../core/email.js", () => ({ sendEmail: vi.fn() })); const makeReq = (overrides: Partial = {}): ContactRequest => ({ method: "POST", diff --git a/tests/src/validation.test.ts b/tests/core/validation.test.ts similarity index 98% rename from tests/src/validation.test.ts rename to tests/core/validation.test.ts index 44084f2..22c5497 100644 --- a/tests/src/validation.test.ts +++ b/tests/core/validation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { EMAIL_REGEX, isValidBody } from "../../src/validation.js"; +import { EMAIL_REGEX, isValidBody } from "../../core/validation.js"; describe("EMAIL_REGEX", () => { const validate = (email: string) => EMAIL_REGEX.test(email); diff --git a/tests/src/providers/nodemailer.test.ts b/tests/providers/nodemailer.test.ts similarity index 96% rename from tests/src/providers/nodemailer.test.ts rename to tests/providers/nodemailer.test.ts index d39bf39..0bc6c1e 100644 --- a/tests/src/providers/nodemailer.test.ts +++ b/tests/providers/nodemailer.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import nodemailer from "nodemailer"; -import { createNodemailerProvider, NodemailerProvider } from "../../../src/providers/nodemailer.js"; -import type { EmailPayload } from "../../../src/types.js"; +import { createNodemailerProvider, NodemailerProvider } from "../../providers/nodemailer.js"; +import type { EmailPayload } from "../../core/types.js"; vi.mock("nodemailer", () => { const mockSend = vi.fn(); diff --git a/tests/src/providers/resend.test.ts b/tests/providers/resend.test.ts similarity index 94% rename from tests/src/providers/resend.test.ts rename to tests/providers/resend.test.ts index 0d81a79..1064c86 100644 --- a/tests/src/providers/resend.test.ts +++ b/tests/providers/resend.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Resend } from "resend"; -import { createResendProvider, ResendProvider } from "../../../src/providers/resend.js"; -import type { EmailPayload } from "../../../src/types.js"; +import { createResendProvider, ResendProvider } from "../../providers/resend.js"; +import type { EmailPayload } from "../../core/types.js"; vi.mock("resend", () => { const mockSend = vi.fn(); diff --git a/tests/src/email.test.ts b/tests/src/email.test.ts deleted file mode 100644 index f526001..0000000 --- a/tests/src/email.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; -import type { EmailProvider, EmailBody } from "../../src/types.js"; -import { getEmailConfig, sendEmail, type EmailConfig } from "../../src/email.js"; - -const mockProvider: EmailProvider = { - id: "mock", - send: vi.fn() -}; - -const mockEmailConfig: EmailConfig = { - provider: mockProvider, - from: "from@test.com", - to: ["to@test.com"] -}; - -const body: EmailBody = { - email: "user@test.com", - message: "Hello" -}; - -beforeEach(() => vi.clearAllMocks()); - -describe("email.ts", () => { - describe("getEmailConfig", () => { - const base = { provider: mockProvider, fromEmail: mockEmailConfig.from, toEmails: mockEmailConfig.to, allowedOrigins: [] }; - - it("returns null if config missing or empty props", () => { - expect(getEmailConfig({ ...base, provider: null })).toBeNull(); - expect(getEmailConfig({ ...base, fromEmail: "" })).toBeNull(); - expect(getEmailConfig({ ...base, toEmails: [] })).toBeNull(); - }); - - it("returns EmailConfig when valid", () => { - expect(getEmailConfig({ ...base })).toMatchObject(mockEmailConfig); - }); - }); - - describe("sendEmail", () => { - it("calls resend with sanitized payload", async () => { - await sendEmail(mockEmailConfig, { ...body, subject: "Test\nSubject" }); - expect(mockProvider.send).toHaveBeenCalledWith({ - from: mockEmailConfig.from, - to: mockEmailConfig.to, - replyTo: body.email, - subject: "Contact form: Test Subject", - text: `From: ${body.email}\n\n${body.message}` - }); - }); - - it("formats fromLine with name when provided", async () => { - await sendEmail(mockEmailConfig, { ...body, name: "Tester" }); - expect(mockProvider.send).toHaveBeenCalledWith( - expect.objectContaining({ text: `From: Tester <${body.email}>\n\n${body.message}` }) - ); - }); - - it("uses default subject when not provided", async () => { - await sendEmail(mockEmailConfig, body); - expect(mockProvider.send).toHaveBeenCalledWith( - expect.objectContaining({ subject: "Contact form: New message" }) - ); - }); - - it("throws when provider.send rejects", async () => { - (mockProvider.send as any).mockRejectedValue(new Error("Send failed")); - await expect(sendEmail(mockEmailConfig, body)).rejects.toThrow("Send failed"); - }); - }); -}); diff --git a/tests/api/contact/index.test.ts b/tests/vercel/api/contact/index.test.ts similarity index 85% rename from tests/api/contact/index.test.ts rename to tests/vercel/api/contact/index.test.ts index caaeb48..96237a6 100644 --- a/tests/api/contact/index.test.ts +++ b/tests/vercel/api/contact/index.test.ts @@ -2,16 +2,18 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import type { VercelRequest, VercelResponse } from "@vercel/node"; vi.mock("@vercel/firewall", () => ({ checkRateLimit: vi.fn() })); -vi.mock("../../../src/cors.js", () => ({ evaluateCors: vi.fn() })); -vi.mock("../../../src/handler.js", () => ({ handleContact: vi.fn() })); -vi.mock("../../../src/email.js", () => ({ getEmailConfig: vi.fn() })); -vi.mock("../../../src/config.js", () => ({ config: { allowedOrigins: ["https://example.com"] } })); +vi.mock("../../../../core/cors.js", () => ({ evaluateCors: vi.fn() })); +vi.mock("../../../../core/handler.js", () => ({ handleContact: vi.fn() })); +vi.mock("../../../../vercel/config.js", () => ({ + getEmailConfig: vi.fn(), + config: { allowedOrigins: ["https://example.com"] } +})); import { checkRateLimit } from "@vercel/firewall"; -import { evaluateCors } from "../../../src/cors.js"; -import { handleContact } from "../../../src/handler.js"; -import { getEmailConfig } from "../../../src/email.js"; -import handler from "../../../api/contact/index.js"; +import { evaluateCors } from "../../../../core/cors.js"; +import { handleContact } from "../../../../core/handler.js"; +import handler from "../../../../vercel/api/contact/index.js"; +import { getEmailConfig } from "../../../../vercel/config.js"; const makeReq = (overrides: Partial = {}): VercelRequest => ({ headers: { origin: "https://example.com", "content-type": "application/json" }, diff --git a/tests/vercel/config.test.ts b/tests/vercel/config.test.ts new file mode 100644 index 0000000..1f34f5c --- /dev/null +++ b/tests/vercel/config.test.ts @@ -0,0 +1,26 @@ +import { vi, describe, it, expect } from "vitest"; +import type { EmailProvider } from "../../core/types.js"; +import { getEmailConfig } from "../../vercel/config.js"; + +const mockProvider: EmailProvider = { + id: "mock", + send: vi.fn() +}; + +describe("getEmailConfig", () => { + const base = { provider: mockProvider, fromEmail: "from@test.com", toEmails: ["to@test.com"], allowedOrigins: [] }; + + it("returns null if config missing or empty props", () => { + expect(getEmailConfig({ ...base, provider: null })).toBeNull(); + expect(getEmailConfig({ ...base, fromEmail: "" })).toBeNull(); + expect(getEmailConfig({ ...base, toEmails: [] })).toBeNull(); + }); + + it("returns EmailConfig when valid", () => { + expect(getEmailConfig({ ...base })).toMatchObject({ + provider: mockProvider, + from: "from@test.com", + to: ["to@test.com"] + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4b32cdc..6c65dac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,9 +46,10 @@ "noEmit": true }, "include": [ - "./api/**/*.ts", - "./src/**/*.ts", - "./tests/**/*.ts" + "./core/**/*.ts", + "./providers/**/*.ts", + "./vercel/**/*.ts", + "./tests/**/*.ts", ], "exclude": [ "dist/", diff --git a/api/contact/index.ts b/vercel/api/contact/index.ts similarity index 84% rename from api/contact/index.ts rename to vercel/api/contact/index.ts index ec5be85..28b67dc 100644 --- a/api/contact/index.ts +++ b/vercel/api/contact/index.ts @@ -1,9 +1,8 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import { checkRateLimit } from "@vercel/firewall"; -import { evaluateCors } from "../../src/cors.js"; -import { handleContact } from "../../src/handler.js"; -import { getEmailConfig } from "../../src/email.js"; -import { config } from "../../src/config.js"; +import { evaluateCors } from "../../../core/cors.js"; +import { handleContact } from "../../../core/handler.js"; +import { getEmailConfig, config } from "../../../vercel/config.js"; export default async (req: VercelRequest, res: VercelResponse): Promise => { const cors = evaluateCors( diff --git a/src/config.ts b/vercel/config.ts similarity index 65% rename from src/config.ts rename to vercel/config.ts index 9958ed6..db9249c 100644 --- a/src/config.ts +++ b/vercel/config.ts @@ -1,6 +1,7 @@ -import { createNodemailerProvider } from "./providers/nodemailer.js"; -import { createResendProvider } from "./providers/resend.js"; -import type { EmailProvider } from "./types.js"; +import type { EmailProvider } from "../core/types.js"; +import type { EmailConfig } from "../core/email.js"; +import { createNodemailerProvider } from "../providers/nodemailer.js"; +import { createResendProvider } from "../providers/resend.js"; export interface Config { provider: EmailProvider | null; @@ -30,3 +31,7 @@ export const config: Config = { allowedOrigins } +export function getEmailConfig(config: Config): EmailConfig | null { + if (!config.provider || !config.fromEmail?.trim() || !config.toEmails?.length) return null; + return { provider: config.provider, from: config.fromEmail, to: config.toEmails }; +} From aa9e2b20dabbf81b2d716dcc0ed6f66cafbd268b Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 30 Jun 2026 12:51:41 -0400 Subject: [PATCH 6/8] refactor: split providers/ into resend/ and nodemailer/ to mirror future repos --- providers/nodemailer.ts => nodemailer/index.ts | 0 providers/resend.ts => resend/index.ts | 0 tests/{providers/nodemailer.test.ts => nodemailer/index.ts} | 2 +- tests/{providers/resend.test.ts => resend/index.ts} | 2 +- tsconfig.json | 3 ++- vercel/config.ts | 4 ++-- 6 files changed, 6 insertions(+), 5 deletions(-) rename providers/nodemailer.ts => nodemailer/index.ts (100%) rename providers/resend.ts => resend/index.ts (100%) rename tests/{providers/nodemailer.test.ts => nodemailer/index.ts} (99%) rename tests/{providers/resend.test.ts => resend/index.ts} (97%) diff --git a/providers/nodemailer.ts b/nodemailer/index.ts similarity index 100% rename from providers/nodemailer.ts rename to nodemailer/index.ts diff --git a/providers/resend.ts b/resend/index.ts similarity index 100% rename from providers/resend.ts rename to resend/index.ts diff --git a/tests/providers/nodemailer.test.ts b/tests/nodemailer/index.ts similarity index 99% rename from tests/providers/nodemailer.test.ts rename to tests/nodemailer/index.ts index 0bc6c1e..bd7647c 100644 --- a/tests/providers/nodemailer.test.ts +++ b/tests/nodemailer/index.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import nodemailer from "nodemailer"; -import { createNodemailerProvider, NodemailerProvider } from "../../providers/nodemailer.js"; +import { createNodemailerProvider, NodemailerProvider } from "../../nodemailer/index.js"; import type { EmailPayload } from "../../core/types.js"; vi.mock("nodemailer", () => { diff --git a/tests/providers/resend.test.ts b/tests/resend/index.ts similarity index 97% rename from tests/providers/resend.test.ts rename to tests/resend/index.ts index 1064c86..27f9782 100644 --- a/tests/providers/resend.test.ts +++ b/tests/resend/index.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Resend } from "resend"; -import { createResendProvider, ResendProvider } from "../../providers/resend.js"; +import { createResendProvider, ResendProvider } from "../../nodemailer/resend.js"; import type { EmailPayload } from "../../core/types.js"; vi.mock("resend", () => { diff --git a/tsconfig.json b/tsconfig.json index 6c65dac..2380c6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,7 +47,8 @@ }, "include": [ "./core/**/*.ts", - "./providers/**/*.ts", + "./nodemailer/**/*.ts", + "./resend/**/*.ts", "./vercel/**/*.ts", "./tests/**/*.ts", ], diff --git a/vercel/config.ts b/vercel/config.ts index db9249c..9d8c635 100644 --- a/vercel/config.ts +++ b/vercel/config.ts @@ -1,7 +1,7 @@ import type { EmailProvider } from "../core/types.js"; import type { EmailConfig } from "../core/email.js"; -import { createNodemailerProvider } from "../providers/nodemailer.js"; -import { createResendProvider } from "../providers/resend.js"; +import { createNodemailerProvider } from "../nodemailer/index.js"; +import { createResendProvider } from "../nodemailer/resend.js"; export interface Config { provider: EmailProvider | null; From e7bb49ac10281677934d9370452fa71bbbf154e6 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 30 Jun 2026 12:51:50 -0400 Subject: [PATCH 7/8] docs: split README into per-repo files in preparation for org split --- core/README.md | 13 +++++++++++++ nodemailer/README.md | 17 +++++++++++++++++ resend/README.md | 16 ++++++++++++++++ tests/nodemailer/{index.ts => index.test.ts} | 0 tests/resend/{index.ts => index.test.ts} | 2 +- vercel/api/contact/index.ts | 2 +- vercel/config.ts | 2 +- 7 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 core/README.md create mode 100644 nodemailer/README.md create mode 100644 resend/README.md rename tests/nodemailer/{index.ts => index.test.ts} (100%) rename tests/resend/{index.ts => index.test.ts} (97%) diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..a204fc0 --- /dev/null +++ b/core/README.md @@ -0,0 +1,13 @@ +# Contact API Core + +Contact form logic: CORS evaluation, validation, honeypot handling, and email orchestration. Will be paired with one or more providers, (`Resend` or `Nodemailer`), and a platform, (`Vercel`). + +## Exports +- `handleContact(req, deps)` — orchestrates the full contact-form flow +- `evaluateCors(req, allowedOrigins)` — CORS decision logic +- `sendEmail(config, body)` — dispatches to a given `EmailProvider` +- `isValidBody(body)` — input validation +- Types: `ContactRequest`, `ContactResult`, `EmailProvider`, `EmailPayload`, `EmailBody` + +## License +MIT License - see [LICENSE](./LICENSE) for details. diff --git a/nodemailer/README.md b/nodemailer/README.md new file mode 100644 index 0000000..e8f1160 --- /dev/null +++ b/nodemailer/README.md @@ -0,0 +1,17 @@ +# Contact API Nodemailer + +Nodemailer (SMTP) email provider for `@contact-api/core`. + +## Usage +```ts +import { createNodemailerProvider } from "@contact-api/nodemailer"; +const provider = createNodemailerProvider(); // reads SMTP_CONFIG +``` + +## Environment Variables +| Variable | Description | +| --- | --- | +| `SMTP_CONFIG` | JSON string of SMTP settings (`host`, `port`, `auth.user`, `auth.pass`, `secure`) | + +## License +MIT License - see [LICENSE](./LICENSE) for details. diff --git a/resend/README.md b/resend/README.md new file mode 100644 index 0000000..ed24068 --- /dev/null +++ b/resend/README.md @@ -0,0 +1,16 @@ +# Contact API Resend +Resend email provider for `@contact-api/core`. + +## Usage +```ts +import { createResendProvider } from "@contact-api/resend"; +const provider = createResendProvider(); // reads RESEND_API_KEY +``` + +## Environment Variables +| Variable | Description | +| --- | --- | +| `RESEND_API_KEY` | Resend API key | + +## License +MIT License - see [LICENSE](./LICENSE) for details. diff --git a/tests/nodemailer/index.ts b/tests/nodemailer/index.test.ts similarity index 100% rename from tests/nodemailer/index.ts rename to tests/nodemailer/index.test.ts diff --git a/tests/resend/index.ts b/tests/resend/index.test.ts similarity index 97% rename from tests/resend/index.ts rename to tests/resend/index.test.ts index 27f9782..5b92e42 100644 --- a/tests/resend/index.ts +++ b/tests/resend/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Resend } from "resend"; -import { createResendProvider, ResendProvider } from "../../nodemailer/resend.js"; +import { createResendProvider, ResendProvider } from "../../resend/index.js"; import type { EmailPayload } from "../../core/types.js"; vi.mock("resend", () => { diff --git a/vercel/api/contact/index.ts b/vercel/api/contact/index.ts index 28b67dc..e0cb5a7 100644 --- a/vercel/api/contact/index.ts +++ b/vercel/api/contact/index.ts @@ -2,7 +2,7 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import { checkRateLimit } from "@vercel/firewall"; import { evaluateCors } from "../../../core/cors.js"; import { handleContact } from "../../../core/handler.js"; -import { getEmailConfig, config } from "../../../vercel/config.js"; +import { getEmailConfig, config } from "../../config.js"; export default async (req: VercelRequest, res: VercelResponse): Promise => { const cors = evaluateCors( diff --git a/vercel/config.ts b/vercel/config.ts index 9d8c635..d20e17a 100644 --- a/vercel/config.ts +++ b/vercel/config.ts @@ -1,7 +1,7 @@ import type { EmailProvider } from "../core/types.js"; import type { EmailConfig } from "../core/email.js"; import { createNodemailerProvider } from "../nodemailer/index.js"; -import { createResendProvider } from "../nodemailer/resend.js"; +import { createResendProvider } from "../resend/index.js"; export interface Config { provider: EmailProvider | null; From 8f1c68e3fe7cc793800bf1205063f96269cf9258 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 30 Jun 2026 14:42:43 -0400 Subject: [PATCH 8/8] docs: rewrite root README as index, vercel README --- README.md | 104 ++++------------------------------------------- vercel/README.md | 78 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 97 deletions(-) create mode 100644 vercel/README.md diff --git a/README.md b/README.md index 7a46fe6..259241a 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,11 @@ # Contact API - -Deployable **multi-provider** contact form API - -[![Tests](https://github.com/masonlet/contact-api/actions/workflows/ci.yml/badge.svg)](https://github.com/masonlet/contact-api/actions/workflows/ci.yml) -![License](https://img.shields.io/badge/License-MIT-green) -![Node](https://img.shields.io/badge/Node.js-20+-green) - -## Table of Contents -- [Features](#features) -- [Usage](#usage) -- [Response](#response) -- [Deployment & Configuration](#deployment--configuration) - - [Prerequisites](#prerequisites) - - [Configure `.env`](#2-configure-env) - - [Deploying](#deploying) - - [Local Development](#local-development) -- [License](#license) - -## Features -- Single `POST /api/contact` endpoint - drop into any project. -- Multi-provider support: Resend and Nodemailer (SMTP). -- CORS support via `ALLOWED_ORIGINS`. -- Input validation with descriptive error responses. -- Rate limiting via Vercel WAF to prevent spam and abuse. -- Honeypot protection. -> **Note:** To utilize the honeypot, include a hidden input field named `fax_number` in your frontend and keep it empty when submitting the form. - -## Usage -```js -await fetch("https://your-deployment.vercel.app/api/contact", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: "sender@example.com", // required - message: "Your message here", // required - subject: "Hello", // optional - name: "Your name", // optional - fax_number: "" // optional; must be empty - }) -}); -``` - -## Response -| Status | Body | -| ------ | ---- | -| 200 | { success: true, message: "Message sent successfully" } | -| 400 | { error: "Invalid or missing fields" } | -| 403 | { error: "Forbidden" } | -| 405 | { error: "Method not allowed" } | -| 415 | { error: "Unsupported Media Type" } | -| 429 | { error: "Too many requests. Please try again later" } | -| 500 | { error: "Message delivery failed. Please try again later" } | -| 503 | { error: "Service temporarily unavailable" } | - -## Deployment & Configuration - -### Prerequisites -- Node.js 20+ -- Vercel -- An email provider - - **Resend:** API key and verified domain. - - **Nodemailer:** Valid SMTP settings (`host`, `port`, `auth.user`, `auth.pass`, and `secure` when needed). - -### 1. Clone & Install -```bash -git clone https://github.com/masonlet/contact-api.git -cd contact-api -npm install -``` - -### 2. Configure `.env` -Copy `.env.example` to `.env` and fill Environment Variables. Shared values are **required**; provider-specific values depend on `EMAIL_PROVIDER`. - -| Variable | Description | -| ----------------- | ----------- | -| `FROM_EMAIL` | Sender address | -| `TO_EMAIL` | Recipient email addresses, comma-separated. | -| `ALLOWED_ORIGINS` | Allowed CORS origins, comma-separated. Leave empty to block all cross-origin requests. | -| `EMAIL_PROVIDER` | Email provider to use: `resend` or `nodemailer`. | -| `RESEND_API_KEY` | Resend API key, required when `EMAIL_PROVIDER=resend`. | -| `SMTP_CONFIG` | JSON string of Nodemailer SMTP config, required when `EMAIL_PROVIDER=nodemailer`. | - -### Deploying - -#### Deploy with Vercel -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/contact-api&env=FROM_EMAIL,TO_EMAIL,ALLOWED_ORIGINS,EMAIL_PROVIDER,RESEND_API_KEY&envDescription[FROM_EMAIL]=Sender%20address%20(must%20be%20a%20verified%20Resend%20domain)&envDescription[TO_EMAIL]=Delivery%20address&envDescription[ALLOWED_ORIGINS]=Comma-separated%20list%20of%20allowed%20CORS%20origins&envDescription[EMAIL_PROVIDER]=resend&envDescription[RESEND_API_KEY]=Your%20Resend%20API%20key) - -#### Deploy with Nodemailer -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/contact-api&env=FROM_EMAIL,TO_EMAIL,ALLOWED_ORIGINS,EMAIL_PROVIDER,SMTP_CONFIG&envDescription[FROM_EMAIL]=Sender%20address%20accepted%20by%20your%20SMTP%20provider&envDescription[TO_EMAIL]=Delivery%20address&envDescription[ALLOWED_ORIGINS]=Comma-separated%20list%20of%20allowed%20CORS%20origins&envDescription[EMAIL_PROVIDER]=nodemailer&envDescription[SMTP_CONFIG]=JSON%20string%20of%20SMTP%20settings) - -### Local Development -```bash -npm run typecheck # TypeScript type check -npm run test # Run Vitest tests -npm run test:watch # Run Vitest in watch mode -npm run test:coverage # Run Vitest in coverage mode -``` + +This repository contains the full Contact API project, organized in preparation for a split into separate repositories under the [contact-api](https://github.com/contact-api) org. + +- [`core/`](./core/README.md) — platform-agnostic contact form logic +- [`resend/`](./resend/README.md) — Resend email provider +- [`nodemailer/`](./nodemailer/README.md) — Nodemailer (SMTP) email provider +- [`vercel/`](./vercel/README.md) — Vercel deployment (uses core + both providers) ## License MIT License - see [LICENSE](./LICENSE) for details. diff --git a/vercel/README.md b/vercel/README.md new file mode 100644 index 0000000..867739a --- /dev/null +++ b/vercel/README.md @@ -0,0 +1,78 @@ +# Contact API Vercel + +Deployable **multi-provider** contact form API. + +## Features +- Single `POST /api/contact` endpoint - drop into any project. +- Multi-provider support: Resend and Nodemailer (SMTP). +- CORS support via `ALLOWED_ORIGINS`. +- Input validation with descriptive error responses. +- Rate limiting via Vercel WAF to prevent spam and abuse. +- Honeypot protection. +> **Note:** To utilize the honeypot, include a hidden input field named `fax_number` in your frontend and keep it empty when submitting the form. + +## Usage +```js +await fetch("https://your-deployment.vercel.app/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "sender@example.com", // required + message: "Your message here", // required + subject: "Hello", // optional + name: "Your name", // optional + fax_number: "" // optional; must be empty + }) +}); +``` + +## Response +| Status | Body | +| ------ | ---- | +| 200 | { success: true, message: "Message sent successfully" } | +| 400 | { error: "Invalid or missing fields" } | +| 403 | { error: "Forbidden" } | +| 405 | { error: "Method not allowed" } | +| 415 | { error: "Unsupported Media Type" } | +| 429 | { error: "Too many requests. Please try again later" } | +| 500 | { error: "Message delivery failed. Please try again later" } | +| 503 | { error: "Service temporarily unavailable" } | + +## Deployment & Configuration + +### Prerequisites +- Node.js 20+ +- Vercel +- An email provider + - **Resend:** API key and verified domain. + - **Nodemailer:** Valid SMTP settings (`host`, `port`, `auth.user`, `auth.pass`, and `secure` when needed). + +### 1. Clone & Install +```bash +git clone https://github.com/masonlet/contact-api.git +cd contact-api +npm install +``` + +### 2. Configure `.env` +Copy `.env.example` to `.env` and fill Environment Variables. Shared values are **required**; provider-specific values depend on `EMAIL_PROVIDER`. + +| Variable | Description | +| ----------------- | ----------- | +| `FROM_EMAIL` | Sender address | +| `TO_EMAIL` | Recipient email addresses, comma-separated. | +| `ALLOWED_ORIGINS` | Allowed CORS origins, comma-separated. Leave empty to block all cross-origin requests. | +| `EMAIL_PROVIDER` | Email provider to use: `resend` or `nodemailer`. | +| `RESEND_API_KEY` | Resend API key, required when `EMAIL_PROVIDER=resend`. | +| `SMTP_CONFIG` | JSON string of Nodemailer SMTP config, required when `EMAIL_PROVIDER=nodemailer`. | + +### Local Development +```bash +npm run typecheck # TypeScript type check +npm run test # Run Vitest tests +npm run test:watch # Run Vitest in watch mode +npm run test:coverage # Run Vitest in coverage mode +``` + +## License +MIT License - see [LICENSE](./LICENSE) for details.