Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

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

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

- name: Install dependencies
run: npm install

- name: Type check
run: npm run typecheck

- name: Run tests
run: npm run test:coverage
26 changes: 26 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

coverage/

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions api/contact/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { checkRateLimit } from "@vercel/firewall";
import { evaluateCors } from "@contact-api/core/cors";
import { handleContact } from "@contact-api/core/handler";
import { getEmailConfig, config } from "../../src/config.js";

export default async (req: VercelRequest, res: VercelResponse): Promise<void> => {
const cors = evaluateCors(
{ method: req.method ?? "", headers: req.headers as Record<string, string | undefined>, 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<string, string | undefined>, body: req.body },
{ emailConfig: getEmailConfig(config) }
);

res.status(result.status);
if (result.body !== null) res.json(result.body);
else res.end();
};
Loading
Loading