Skip to content
104 changes: 7 additions & 97 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 0 additions & 56 deletions api/contact/index.ts

This file was deleted.

13 changes: 13 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions core/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface ContactRequest {
method: string;
headers: Record<string, string | undefined>;
body: unknown;
}

export interface ContactResult {
status: number;
body: unknown;
}
25 changes: 25 additions & 0 deletions core/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ContactRequest } from "./contact.js";

export interface CorsResult {
outcome: "preflight" | "forbidden" | "ok";
headers: Record<string, string>;
status?: number;
}

export function evaluateCors(req: ContactRequest, allowedOrigins: string[]): CorsResult {
const headers: Record<string, string> = { "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 };
}
16 changes: 3 additions & 13 deletions src/email.ts → core/email.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
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;
from: string;
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<void> {
const safeSubject = body.subject?.replace(/[\r\n]+/g, " ").trim() ?? "New message";
const safeName = body.name?.replace(/[\r\n]+/g, " ").trim();
Expand Down
44 changes: 44 additions & 0 deletions core/handler.ts
Original file line number Diff line number Diff line change
@@ -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<ContactResult> {
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<string, unknown> | 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" } };
}
}
2 changes: 1 addition & 1 deletion src/types.ts → core/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface ContactBody {
export interface EmailBody {
email: string;
message: string;
subject?: string;
Expand Down
4 changes: 2 additions & 2 deletions src/validation.ts → core/validation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const { email, message, subject, name, fax_number } = record;
Expand Down
17 changes: 17 additions & 0 deletions nodemailer/README.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion src/providers/nodemailer.ts → nodemailer/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}
}
16 changes: 16 additions & 0 deletions resend/README.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion src/providers/resend.ts → resend/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}
}
Loading
Loading