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/api/contact/index.ts b/api/contact/index.ts deleted file mode 100644 index 0337cb6..0000000 --- a/api/contact/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -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"; - -export default async (req: VercelRequest, res: VercelResponse): Promise => { - const cors = setCorsHeaders(req, res, config.allowedOrigins); - if (cors === "preflight") return; - if (cors === "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" }); - } -}; 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/core/contact.ts b/core/contact.ts new file mode 100644 index 0000000..7ea88d3 --- /dev/null +++ b/core/contact.ts @@ -0,0 +1,10 @@ +export interface ContactRequest { + method: string; + headers: Record; + body: unknown; +} + +export interface ContactResult { + status: number; + body: unknown; +} diff --git a/core/cors.ts b/core/cors.ts new file mode 100644 index 0000000..73fa8a2 --- /dev/null +++ b/core/cors.ts @@ -0,0 +1,25 @@ +import type { ContactRequest } from "./contact.js"; + +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); + + if (!isAllowed) { + if (req.method === "OPTIONS") return { outcome: "preflight", headers, status: 403 }; + return { outcome: "forbidden", headers }; + } + + headers["Access-Control-Allow-Origin"] = origin; + headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"; + headers["Access-Control-Allow-Headers"] = "Content-Type"; + + if (req.method === "OPTIONS") return { outcome: "preflight", headers, status: 204 }; + return { outcome: "ok", headers }; +} diff --git a/src/email.ts b/core/email.ts similarity index 58% rename from src/email.ts rename to core/email.ts index 2d79ecd..478dee8 100644 --- a/src/email.ts +++ b/core/email.ts @@ -1,5 +1,4 @@ -import type { EmailProvider, EmailPayload, ContactBody } from "./types.js"; -import type { Config } from "./config.js"; +import type { EmailProvider, EmailPayload, EmailBody } from "./types.js"; export interface EmailConfig { provider: EmailProvider; @@ -7,18 +6,9 @@ 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: 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/core/handler.ts b/core/handler.ts new file mode 100644 index 0000000..1d9e227 --- /dev/null +++ b/core/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/src/types.ts b/core/types.ts similarity index 91% rename from src/types.ts rename to core/types.ts index a9e97c2..27db4a6 100644 --- a/src/types.ts +++ b/core/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/core/validation.ts similarity index 84% rename from src/validation.ts rename to core/validation.ts index 07b3004..4927990 100644 --- a/src/validation.ts +++ b/core/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; 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/src/providers/nodemailer.ts b/nodemailer/index.ts similarity index 56% rename from src/providers/nodemailer.ts rename to nodemailer/index.ts index 8466b87..36a9c90 100644 --- a/src/providers/nodemailer.ts +++ b/nodemailer/index.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"; @@ -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/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/src/providers/resend.ts b/resend/index.ts similarity index 53% rename from src/providers/resend.ts rename to resend/index.ts index e1ed45b..ab253b3 100644 --- a/src/providers/resend.ts +++ b/resend/index.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"; @@ -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/src/config.ts b/src/config.ts deleted file mode 100644 index 20dcc87..0000000 --- a/src/config.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { EmailProvider } from "./types.js"; -import { ResendProvider } from "./providers/resend.js"; -import { NodemailerProvider } from "./providers/nodemailer.js"; - -export interface Config { - provider: EmailProvider | null; - fromEmail: string | undefined; - toEmails: string[]; - allowedOrigins: string[]; -} - -const fromEmail = process.env["FROM_EMAIL"]; -const toEmailsRaw = process.env["TO_EMAIL"] ?? ""; -const toEmails = toEmailsRaw.split(",").map(o => o.trim()).filter(Boolean); -const allowedOriginsRaw = process.env["ALLOWED_ORIGINS"] ?? ""; -const allowedOrigins = allowedOriginsRaw.split(",").map(o => o.trim()).filter(Boolean); - -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}"`); - return null; -} - -export const config: Config = { - provider: createProvider(), - fromEmail, - toEmails, - allowedOrigins -} - diff --git a/src/cors.ts b/src/cors.ts deleted file mode 100644 index dc496f2..0000000 --- a/src/cors.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; - -export function setCorsHeaders( - req: VercelRequest, - res: VercelResponse, - allowedOrigins: string[] -): "preflight" | "forbidden" | "ok" { - res.setHeader("X-Content-Type-Options", "nosniff"); - const origin = req.headers["origin"]; - const isAllowed = origin && allowedOrigins.includes(origin); - - if (!isAllowed) { - if (req.method === "OPTIONS") { - res.status(403).end(); - return "preflight"; - } - return "forbidden"; - } - - 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"; - } - - return "ok"; -} diff --git a/tests/api/contact/index.test.ts b/tests/api/contact/index.test.ts deleted file mode 100644 index 0f63502..0000000 --- a/tests/api/contact/index.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -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/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 { isValidBody } from "../../../src/validation.js"; -import { getEmailConfig, sendEmail } 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" }, - ...overrides, -} as unknown as VercelRequest); - -const makeRes = (): VercelResponse => { - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - end: vi.fn().mockReturnThis(), - }; - return res as unknown as VercelResponse; -}; - -describe("contact handler (index.ts)", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(setCorsHeaders).mockReturnValue("ok"); - 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"); - const req = makeReq({ method: "OPTIONS" }); - const res = makeRes(); - await handler(req, res); - expect(res.status).not.toHaveBeenCalled(); - expect(sendEmail).not.toHaveBeenCalled(); - }); - - it("returns 403 when setCorsHeaders returns 'forbidden'", async () => { - vi.mocked(setCorsHeaders).mockReturnValue("forbidden"); - const res = makeRes(); - await handler(makeReq(), res); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: "Forbidden" }); - }); - - it("returns 429 when rate limited", async () => { - vi.mocked(checkRateLimit).mockResolvedValue({ rateLimited: true } as any); - const res = makeRes(); - await handler(makeReq(), res); - expect(res.status).toHaveBeenCalledWith(429); - }); - - 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); - const res = makeRes(); - await handler(makeReq(), res); - 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")); - 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(); - }); -}); diff --git a/tests/core/cors.test.ts b/tests/core/cors.test.ts new file mode 100644 index 0000000..55c17d3 --- /dev/null +++ b/tests/core/cors.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { evaluateCors } from "../../core/cors.js"; +import type { ContactRequest } from "../../core/contact.js"; + +const makeReq = (origin?: string, method = "POST"): ContactRequest => ({ + method, + headers: { origin }, + body: undefined, +}); + +describe("evaluateCors", () => { + it("should always set X-Content-Type-Options", () => { + const result = evaluateCors(makeReq(), []); + expect(result.headers["X-Content-Type-Options"]).toBe("nosniff"); + }); + + it("should set CORS headers when origin is allowed", () => { + 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 result = evaluateCors(makeReq(undefined), ["https://example.com"]); + expect(result.headers["Access-Control-Allow-Origin"]).toBeUndefined(); + }); + + 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 '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 '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", () => { + expect(evaluateCors(makeReq("https://example.com"), []).outcome).toBe("forbidden"); + }); +}); 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/core/handler.test.ts b/tests/core/handler.test.ts new file mode 100644 index 0000000..ab0c23c --- /dev/null +++ b/tests/core/handler.test.ts @@ -0,0 +1,69 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +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("../../core/validation.js", () => ({ isValidBody: vi.fn() })); +vi.mock("../../core/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(); + }); +}); 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/nodemailer/index.test.ts similarity index 52% rename from tests/src/providers/nodemailer.test.ts rename to tests/nodemailer/index.test.ts index 04e25a8..bd7647c 100644 --- a/tests/src/providers/nodemailer.test.ts +++ b/tests/nodemailer/index.test.ts @@ -1,8 +1,7 @@ -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 type { EmailPayload } from "../../../src/types.js"; - +import { createNodemailerProvider, NodemailerProvider } from "../../nodemailer/index.js"; +import type { EmailPayload } from "../../core/types.js"; vi.mock("nodemailer", () => { const mockSend = vi.fn(); @@ -68,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/resend/index.test.ts similarity index 66% rename from tests/src/providers/resend.test.ts rename to tests/resend/index.test.ts index 77c4d1b..5b92e42 100644 --- a/tests/src/providers/resend.test.ts +++ b/tests/resend/index.test.ts @@ -1,7 +1,7 @@ -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 type { EmailPayload } from "../../../src/types.js"; +import { createResendProvider, ResendProvider } from "../../resend/index.js"; +import type { EmailPayload } from "../../core/types.js"; vi.mock("resend", () => { const mockSend = vi.fn(); @@ -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); + }); +}); diff --git a/tests/src/cors.test.ts b/tests/src/cors.test.ts deleted file mode 100644 index 508c8af..0000000 --- a/tests/src/cors.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { setCorsHeaders } from "../../src/cors.js"; -import type { VercelRequest, VercelResponse } from "@vercel/node"; - -const makeReq = (origin?: string, method = "POST") => ({ - headers: { origin }, - 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; -}; - -describe("setCorsHeaders", () => { - 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"); - }); - - 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"); - }); - - 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"); - }); - - 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' 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 '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 'forbidden' when allowedOrigins is empty", () => { - const req = makeReq("https://example.com", "POST"); - const res = makeRes(); - expect(setCorsHeaders(req, res, [])).toBe("forbidden"); - }); -}); diff --git a/tests/src/email.test.ts b/tests/src/email.test.ts deleted file mode 100644 index e359a54..0000000 --- a/tests/src/email.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; -import type { EmailProvider, ContactBody } 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: ContactBody = { - 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/vercel/api/contact/index.test.ts b/tests/vercel/api/contact/index.test.ts new file mode 100644 index 0000000..96237a6 --- /dev/null +++ b/tests/vercel/api/contact/index.test.ts @@ -0,0 +1,83 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +vi.mock("@vercel/firewall", () => ({ checkRateLimit: vi.fn() })); +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 "../../../../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" }, + method: "POST", + body: { subject: "Hello", email: "user@example.com", message: "Hello" }, + ...overrides, +} as unknown as VercelRequest); + +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(() => { + vi.clearAllMocks(); + 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(handleContact).mockResolvedValue({ status: 200, body: { success: true, message: "Message sent successfully" } }); + }); + + 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(makeReq({ method: "OPTIONS" }), res); + expect(res.setHeader).toHaveBeenCalledWith("X-Foo", "bar"); + expect(res.status).toHaveBeenCalledWith(204); + expect(handleContact).not.toHaveBeenCalled(); + }); + + 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 () => { + vi.mocked(checkRateLimit).mockResolvedValue({ rateLimited: true } as any); + const res = makeRes(); + await handler(makeReq(), res); + expect(res.status).toHaveBeenCalledWith(429); + expect(handleContact).not.toHaveBeenCalled(); + }); + + 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("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.end).toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); +}); 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..2380c6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,9 +46,11 @@ "noEmit": true }, "include": [ - "./api/**/*.ts", - "./src/**/*.ts", - "./tests/**/*.ts" + "./core/**/*.ts", + "./nodemailer/**/*.ts", + "./resend/**/*.ts", + "./vercel/**/*.ts", + "./tests/**/*.ts", ], "exclude": [ "dist/", 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. diff --git a/vercel/api/contact/index.ts b/vercel/api/contact/index.ts new file mode 100644 index 0000000..e0cb5a7 --- /dev/null +++ b/vercel/api/contact/index.ts @@ -0,0 +1,38 @@ +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 "../../config.js"; + +export default async (req: VercelRequest, res: VercelResponse): Promise => { + const cors = evaluateCors( + { method: req.method ?? "", headers: req.headers as Record, body: req.body }, + config.allowedOrigins + ); + + for (const [key, value] of Object.entries(cors.headers)) res.setHeader(key, value); + + if (cors.outcome === "preflight") { + res.status(cors.status!).end(); + return; + } + if (cors.outcome === "forbidden") { + res.status(403).json({ error: "Forbidden" }); + return; + } + + const { rateLimited } = await checkRateLimit("contact-form-limit"); + if (rateLimited) { + res.status(429).json({ error: "Too many requests. Please try again later" }); + return; + } + + 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/vercel/config.ts b/vercel/config.ts new file mode 100644 index 0000000..d20e17a --- /dev/null +++ b/vercel/config.ts @@ -0,0 +1,37 @@ +import type { EmailProvider } from "../core/types.js"; +import type { EmailConfig } from "../core/email.js"; +import { createNodemailerProvider } from "../nodemailer/index.js"; +import { createResendProvider } from "../resend/index.js"; + +export interface Config { + provider: EmailProvider | null; + fromEmail: string | undefined; + toEmails: string[]; + allowedOrigins: string[]; +} + +const fromEmail = process.env["FROM_EMAIL"]; +const toEmailsRaw = process.env["TO_EMAIL"] ?? ""; +const toEmails = toEmailsRaw.split(",").map(o => o.trim()).filter(Boolean); +const allowedOriginsRaw = process.env["ALLOWED_ORIGINS"] ?? ""; +const allowedOrigins = allowedOriginsRaw.split(",").map(o => o.trim()).filter(Boolean); + +function createProvider(): EmailProvider | null { + const providerName = process.env["EMAIL_PROVIDER"]?.toLowerCase(); + if (providerName === "resend") return createResendProvider(); + if (providerName === "nodemailer") return createNodemailerProvider(); + console.warn(providerName ? `Unknown EMAIL_PROVIDER: "${providerName}"` : "EMAIL_PROVIDER is not set"); + return null; +} + +export const config: Config = { + provider: createProvider(), + fromEmail, + toEmails, + 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 }; +}