Skip to content
Merged
16 changes: 12 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@
FROM_EMAIL=email@yourdomain.com
TO_EMAIL=contact@yourdomain.com,other@yourdomain.com

# Multi-provider email config
EMAIL_PROVIDER=resend # resend
RESEND_API_KEY=re_xxxxxxxxxx # Resend API key

# CORS (comma-separated)
ALLOWED_ORIGINS=https://www.yourdomain.com,https://www.otherdomain.com

# Multi-provider email config
# Choose a provider, then uncomment and fill in its variables.

# resend
# EMAIL_PROVIDER=resend
# RESEND_API_KEY=re_xxxxxxxxxx # Resend API key

# nodemailer
# EMAIL_PROVIDER=nodemailer
# SMTP_CONFIG={"host":"smtp.gmail.com","port":587,"secure":false,"auth":{"user":"your@gmail.com","pass":"app-password"}}

52 changes: 31 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Contact API
Deployable Resend contact form 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)
Expand All @@ -10,15 +11,20 @@ Deployable Resend contact form API
- [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.
- CORS support via `ALLOWED_ORIGINS` env var.
- 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, ensure your frontend includes a hidden input field named `[fax_number]` that remains empty during submission.
- 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
Expand All @@ -29,7 +35,8 @@ await fetch("https://your-deployment.vercel.app/api/contact", {
email: "sender@example.com", // required
message: "Your message here", // required
subject: "Hello", // optional
name: "Your name" // optional
name: "Your name", // optional
fax_number: "" // optional; must be empty
})
});
```
Expand All @@ -42,16 +49,18 @@ await fetch("https://your-deployment.vercel.app/api/contact", {
| 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." } |
| 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+
- Resend API key & verified domain
- 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
Expand All @@ -61,30 +70,31 @@ npm install
```

### 2. Configure `.env`
Copy `.env.example` to `.env` and fill Environment Variables. All values are **required**.
Copy `.env.example` to `.env` and fill Environment Variables. Shared values are **required**; provider-specific values depend on `EMAIL_PROVIDER`.

| Variable | Description |
| ----------------- | ----------- |
| `EMAIL_PROVIDER` | `resend` |
| `RESEND_API_KEY` | Your Resend API key (using `resend` provider) |
| `FROM_EMAIL` | Sender address |
| `TO_EMAIL` | Recipients (comma-separated) |
| `ALLOWED_ORIGINS` | CORS origins (comma-separated) empty blocks all requests. |
| `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`. |

### Deploy
### Deploying

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/contact-api&env=EMAIL_PROVIDER,RESEND_API_KEY,FROM_EMAIL,TO_EMAIL,ALLOWED_ORIGINS&envDescription[EMAIL_PROVIDER]=resend&envDescription[RESEND_API_KEY]=Your%20Resend%20API%20key&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)
#### 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)

```bash
vercel deploy
```
#### 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 # Vitest check
npm run test:watch # Vitest watch mode
npm run test:coverage # Vitest coverage mode
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
Expand Down
6 changes: 3 additions & 3 deletions api/contact/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default async (req: VercelRequest, res: VercelResponse): Promise<void> =>
return;
}

if(req.body["fax_number"]) {
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;
Expand All @@ -42,7 +42,7 @@ export default async (req: VercelRequest, res: VercelResponse): Promise<void> =>

const { rateLimited } = await checkRateLimit("contact-form-limit");
if (rateLimited) {
res.status(429).json({ error: "Too many requests. Please try again later." });
res.status(429).json({ error: "Too many requests. Please try again later" });
return;
}

Expand All @@ -51,6 +51,6 @@ export default async (req: VercelRequest, res: VercelResponse): Promise<void> =>
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." });
res.status(500).json({ error: "Message delivery failed. Please try again later" });
}
};
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
"dependencies": {
"@vercel/firewall": "^1.1.2",
"@vercel/node": "^5.7.17",
"nodemailer": "^8.0.3",
"resend": "^6.9.3"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@types/nodemailer": "^7.0.11",
"@vitest/coverage-v8": "^4.1.0",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
Expand Down
27 changes: 25 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { EmailProvider } from "./types.js";
import { ResendProvider } from "./providers/resend.js";
import { NodemailerProvider } from "./providers/nodemailer.js";

export interface Config {
provider: EmailProvider | null;
Expand All @@ -23,8 +24,30 @@ function createProvider(): EmailProvider | null {

if (providerName === "resend") {
const apiKey = process.env["RESEND_API_KEY"];
if (!apiKey) return null;
return new ResendProvider(apiKey);
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}"`);
Expand Down
7 changes: 4 additions & 3 deletions src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ export async function sendEmail(
config: EmailConfig,
body: ContactBody
): Promise<void> {
const subjectLine = body.subject?.replace(/[\r\n]+/g, " ").trim() ?? "New message";
const fromLine = body.name ? `${body.name} <${body.email}>` : body.email;
const safeSubject = body.subject?.replace(/[\r\n]+/g, " ").trim() ?? "New message";
const safeName = body.name?.replace(/[\r\n]+/g, " ").trim();
const fromLine = safeName ? `${safeName} <${body.email}>` : body.email;

const payload: EmailPayload = {
from: config.from,
to: config.to,
replyTo: body.email,
subject: `Contact form: ${subjectLine}`,
subject: `Contact form: ${safeSubject}`,
text: `From: ${fromLine}\n\n${body.message.trim()}`
}

Expand Down
22 changes: 22 additions & 0 deletions src/providers/nodemailer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import nodemailer from "nodemailer";
import type { EmailProvider, EmailPayload } from "../types.js";

export class NodemailerProvider implements EmailProvider {
readonly id = "nodemailer";
private transporter: nodemailer.Transporter;

constructor(smtpJson: string) {
const config = JSON.parse(smtpJson);
this.transporter = nodemailer.createTransport(config);
}

async send(payload: EmailPayload): Promise<void> {
await this.transporter.sendMail({
from: payload.from,
to: payload.to.join(","),
replyTo: payload.replyTo,
subject: payload.subject,
text: payload.text
});
}
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface ContactBody {
message: string;
subject?: string;
name?: string;
fax_number?: string;
}

export interface EmailPayload {
Expand Down
14 changes: 9 additions & 5 deletions src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidBody(body: unknown): body is ContactBody {
if (body === null || typeof body !== "object") return false;
const record = body as Record<string, unknown>;
const { email, message, subject, name } = record;
const { email, message, subject, name, fax_number } = record;
return (
typeof email === "string" && EMAIL_REGEX.test(email) &&
typeof message === "string" && !!message.trim() && message.length <= 2000 &&
(subject === undefined || (typeof subject === "string" && subject.length <= 200)) &&
(name === undefined || (typeof name === "string" && name.length <= 100))
typeof email === "string"
&& EMAIL_REGEX.test(email)
&& typeof message === "string"
&& !!message.trim()
&& message.length <= 2000
&& (subject === undefined || (typeof subject === "string" && subject.length <= 200))
&& (name === undefined || (typeof name === "string" && name.length <= 100))
&& (fax_number === undefined || typeof fax_number === "string")
);
}

4 changes: 2 additions & 2 deletions tests/api/contact/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("contact handler (index.ts)", () => {
vi.clearAllMocks();
vi.mocked(setCorsHeaders).mockReturnValue("ok");
vi.mocked(checkRateLimit).mockResolvedValue({ rateLimited: false } as any);
vi.mocked(getEmailConfig).mockReturnValue({ client: {} as any, from: "from@test.com", to: ["to@test.com"] });
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);
});
Expand Down Expand Up @@ -114,7 +114,7 @@ describe("contact handler (index.ts)", () => {
const res = makeRes();
await handler(makeReq(), res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: "Message delivery failed. Please try again later." });
expect(res.json).toHaveBeenCalledWith({ error: "Message delivery failed. Please try again later" });
errorSpy.mockRestore();
});
});
Loading
Loading