From 4e292a854d89a81863c364899c0e3b050162a06d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 17:43:14 -0400 Subject: [PATCH 01/13] chore: add nodemailer --- package-lock.json | 10 ++++++++++ package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index c973698..049685a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@vercel/firewall": "^1.1.2", "@vercel/node": "^5.7.17", + "nodemailer": "^8.0.3", "resend": "^6.9.3" }, "devDependencies": { @@ -2516,6 +2517,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz", + "integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", diff --git a/package.json b/package.json index 65c7db7..256505b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@vercel/firewall": "^1.1.2", "@vercel/node": "^5.7.17", + "nodemailer": "^8.0.3", "resend": "^6.9.3" }, "devDependencies": { From 65c593649236be463ab3ae2367a25ac3a660bda3 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 17:57:30 -0400 Subject: [PATCH 02/13] feat: integrate nodemailer to config --- .env.example | 5 +++-- package-lock.json | 11 +++++++++++ package.json | 1 + src/config.ts | 10 ++++++++++ src/providers/nodemailer.ts | 22 ++++++++++++++++++++++ 5 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/providers/nodemailer.ts diff --git a/.env.example b/.env.example index 7fe9660..f23d374 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ 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 +EMAIL_PROVIDER=provider # resend | nodemailer +# RESEND_API_KEY=re_xxxxxxxxxx # Resend API key +# SMTP_CONFIG={"host":"smtp.gmail.com","port":587,"secure":false,"auth":{"user":"your@gmail.com","pass":"app-password"}} # CORS (comma-separated) ALLOWED_ORIGINS=https://www.yourdomain.com,https://www.otherdomain.com diff --git a/package-lock.json b/package-lock.json index 049685a..27267cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ }, "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" @@ -1100,6 +1101,16 @@ "undici-types": "~7.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vercel/build-utils": { "version": "13.22.1", "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.22.1.tgz", diff --git a/package.json b/package.json index 256505b..1d89536 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "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" diff --git a/src/config.ts b/src/config.ts index 1264700..f3b9073 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; @@ -27,6 +28,15 @@ function createProvider(): EmailProvider | null { return new ResendProvider(apiKey); } + if (providerName === "nodemailer") { + const smtpConfig = process.env["SMTP_CONFIG"]; + if (!smtpConfig) { + console.warn("SMTP_CONFIG missing for nodemailer"); + return null; + } + return new NodemailerProvider(smtpConfig); + } + console.warn(`Unknown EMAIL_PROVIDER: "${providerName}"`); return null; } diff --git a/src/providers/nodemailer.ts b/src/providers/nodemailer.ts new file mode 100644 index 0000000..8466b87 --- /dev/null +++ b/src/providers/nodemailer.ts @@ -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 { + await this.transporter.sendMail({ + from: payload.from, + to: payload.to.join(","), + replyTo: payload.replyTo, + subject: payload.subject, + text: payload.text + }); + } +} From d7739d87ab81912557a11693d1b4035b171f1a93 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 22:44:21 -0400 Subject: [PATCH 03/13] docs: add multi-provider details and instructions --- README.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d0b1b2d..63e84b1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # 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) @@ -10,10 +10,15 @@ 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. +- Multi-provider support: Resend, Nodemailer (SMTP). - CORS support via `ALLOWED_ORIGINS` env var. - Input validation with descriptive error responses. - Rate limiting via Vercel WAF to prevent spam and abuse. @@ -29,7 +34,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 }) }); ``` @@ -50,8 +56,10 @@ await fetch("https://your-deployment.vercel.app/api/contact", { ### 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, user, pass). ### 1. Clone & Install ```bash @@ -65,19 +73,20 @@ Copy `.env.example` to `.env` and fill Environment Variables. All values are **r | 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. | +| `ALLOWED_ORIGINS` | CORS origins (comma-separated); empty blocks all requests. | +| `EMAIL_PROVIDER` | `resend` or `nodemailer`. | +| `RESEND_API_KEY` | Your Resend API key (using `resend` provider) | +| `SMTP_CONFIG` | JSON string of Nodemailer config (using `nodemailer` provider) | -### 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 Nodemailer](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%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]=nodemailer&envDescription[SMTP_CONFIG]=JSON%20string%20of%20SMTP%20settings) ### Local Development ```bash From 0a9f283ec4a8c66d28072cae495388206f48f746 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 18:03:11 -0400 Subject: [PATCH 04/13] chore: cleanup .env.example --- .env.example | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index f23d374..f4d50ab 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,15 @@ FROM_EMAIL=email@yourdomain.com TO_EMAIL=contact@yourdomain.com,other@yourdomain.com +# CORS (comma-separated) +ALLOWED_ORIGINS=https://www.yourdomain.com,https://www.otherdomain.com + # Multi-provider email config -EMAIL_PROVIDER=provider # resend | nodemailer +# 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"}} -# CORS (comma-separated) -ALLOWED_ORIGINS=https://www.yourdomain.com,https://www.otherdomain.com From 02f8349d1963c9243422702e1ce67328c98f265c Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 18:13:40 -0400 Subject: [PATCH 05/13] chore: cleanup readme --- .env.example | 2 ++ README.md | 33 +++++++++++++++++---------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index f4d50ab..b5505e5 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ TO_EMAIL=contact@yourdomain.com,other@yourdomain.com 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 diff --git a/README.md b/README.md index 63e84b1..b2c66f2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # 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) @@ -18,12 +19,12 @@ Deployable **multi-provider** contact form API ## Features - Single `POST /api/contact` endpoint - drop into any project. -- Multi-provider support: Resend, Nodemailer (SMTP). -- 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 @@ -58,8 +59,8 @@ await fetch("https://your-deployment.vercel.app/api/contact", { - Node.js 20+ - Vercel - An email provider - - **Resend:** API key and verified domain - - **Nodemailer:** Valid SMTP settings (host, port, user, pass). + - **Resend:** API key and verified domain. + - **Nodemailer:** Valid SMTP settings (`host`, `port`, `auth.user`, `auth.pass`, and `secure` when needed). ### 1. Clone & Install ```bash @@ -69,16 +70,16 @@ 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 | | ----------------- | ----------- | | `FROM_EMAIL` | Sender address | -| `TO_EMAIL` | Recipients (comma-separated) | -| `ALLOWED_ORIGINS` | CORS origins (comma-separated); empty blocks all requests. | -| `EMAIL_PROVIDER` | `resend` or `nodemailer`. | -| `RESEND_API_KEY` | Your Resend API key (using `resend` provider) | -| `SMTP_CONFIG` | JSON string of Nodemailer config (using `nodemailer` provider) | +| `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 @@ -86,14 +87,14 @@ Copy `.env.example` to `.env` and fill Environment Variables. All values are **r [![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 Nodemailer](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%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]=nodemailer&envDescription[SMTP_CONFIG]=JSON%20string%20of%20SMTP%20settings) +[![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 From 48b0d9aa57b7f6f50052ffb64c5b8440059d5319 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 18:18:56 -0400 Subject: [PATCH 06/13] fix: add missing RESEND_API_KEY warning --- src/config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index f3b9073..0aef4b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,7 +24,10 @@ function createProvider(): EmailProvider | null { if (providerName === "resend") { const apiKey = process.env["RESEND_API_KEY"]; - if (!apiKey) return null; + if (!apiKey) { + console.warn("RESEND_API_KEY missing for resend"); + return null; + } return new ResendProvider(apiKey); } @@ -34,6 +37,7 @@ function createProvider(): EmailProvider | null { console.warn("SMTP_CONFIG missing for nodemailer"); return null; } + return new NodemailerProvider(smtpConfig); } From 8f5137c790eb64fadb57640407163d923efc69f8 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 18:19:44 -0400 Subject: [PATCH 07/13] fix: validate fax number --- src/types.ts | 1 + src/validation.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/types.ts b/src/types.ts index 3cd00f6..a9e97c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ export interface ContactBody { message: string; subject?: string; name?: string; + fax_number?: string; } export interface EmailPayload { diff --git a/src/validation.ts b/src/validation.ts index b1a6654..07b3004 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -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; - 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") ); } From a49131eef191ae5f18607b4be0cb6114fe97564b Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 18:20:47 -0400 Subject: [PATCH 08/13] fix: safe name --- src/email.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/email.ts b/src/email.ts index a29f3c9..2d79ecd 100644 --- a/src/email.ts +++ b/src/email.ts @@ -20,14 +20,15 @@ export async function sendEmail( config: EmailConfig, body: ContactBody ): Promise { - 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()}` } From 547be9bb9cd7ae48f4fb7aa9e45768e77adcc918 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 18:23:09 -0400 Subject: [PATCH 09/13] fix: guard provider initialization --- src/config.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 0aef4b7..20dcc87 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,7 +28,12 @@ function createProvider(): EmailProvider | null { console.warn("RESEND_API_KEY missing for resend"); return null; } - return new ResendProvider(apiKey); + + try { return new ResendProvider(apiKey); } + catch (e) { + console.error("Failed to initialize Resend provider:", e); + return null; + } } if (providerName === "nodemailer") { @@ -38,7 +43,11 @@ function createProvider(): EmailProvider | null { return null; } - return new NodemailerProvider(smtpConfig); + try { return new NodemailerProvider(smtpConfig); } + catch (e) { + console.error("Failed to initialize Nodemailer provider:", e); + return null; + } } console.warn(`Unknown EMAIL_PROVIDER: "${providerName}"`); From 8afaacbfc2d31140903c24ea51315d1b2c16da61 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 19:05:14 -0400 Subject: [PATCH 10/13] fix: align readme with error msgs --- README.md | 4 ++-- api/contact/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b2c66f2..7a46fe6 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ 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 diff --git a/api/contact/index.ts b/api/contact/index.ts index 3d50751..3d09a19 100644 --- a/api/contact/index.ts +++ b/api/contact/index.ts @@ -42,7 +42,7 @@ export default async (req: VercelRequest, res: VercelResponse): Promise => 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; } @@ -51,6 +51,6 @@ export default async (req: VercelRequest, res: VercelResponse): Promise => 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" }); } }; From bf808672c699c35fd1576495cb90a668a5b0c8a8 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 19:09:28 -0400 Subject: [PATCH 11/13] fix: trim fax_number --- api/contact/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/contact/index.ts b/api/contact/index.ts index 3d09a19..0337cb6 100644 --- a/api/contact/index.ts +++ b/api/contact/index.ts @@ -23,7 +23,7 @@ export default async (req: VercelRequest, res: VercelResponse): Promise => 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; From 50ac5f5908916b00fff8e33bad3f50fb5bca35a8 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 19:22:44 -0400 Subject: [PATCH 12/13] feat: test Nodemailer provider --- tests/api/contact/index.test.ts | 2 +- tests/src/providers/nodemailer.test.ts | 70 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/src/providers/nodemailer.test.ts diff --git a/tests/api/contact/index.test.ts b/tests/api/contact/index.test.ts index 13e58c1..2083c1b 100644 --- a/tests/api/contact/index.test.ts +++ b/tests/api/contact/index.test.ts @@ -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(); }); }); diff --git a/tests/src/providers/nodemailer.test.ts b/tests/src/providers/nodemailer.test.ts new file mode 100644 index 0000000..04e25a8 --- /dev/null +++ b/tests/src/providers/nodemailer.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +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(() => ({ + sendMail: mockSend + })); + + return { + default: { + createTransport: mockCreateTransport + } + }; +}); + +describe("NodemailerProvider", () => { + const smtpConfig = `{"host":"smtp.test.com","port":587,"secure":false}`; + + const getMockSend = () => { + const transporter = nodemailer.createTransport({}); + return transporter.sendMail as any; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sets id to nodemailer", () => { + const provider = new NodemailerProvider(smtpConfig); + + expect(provider.id).toBe("nodemailer"); + expect(nodemailer.createTransport).toHaveBeenCalledWith({ + host: "smtp.test.com", + port: 587, + secure: false + }); + }); + + it("sends mapped email payload", async () => { + const provider = new NodemailerProvider(smtpConfig); + const mockSend = getMockSend(); + mockSend.mockResolvedValue({ messageId: "msg_123" }); + + const payload: EmailPayload = { + from: "from@test.com", + to: ["to1@test.com", "to2@test.com"], + replyTo: "reply@test.com", + subject: "Test", + text: "Hello" + }; + + await expect(provider.send(payload)).resolves.toBeUndefined(); + + expect(mockSend).toHaveBeenCalledWith({ + from: "from@test.com", + to: "to1@test.com,to2@test.com", + replyTo: "reply@test.com", + subject: "Test", + text: "Hello" + }); + }); + + it("throws on invalid smtp config json", () => { + expect(() => new NodemailerProvider("{bad json")).toThrow(); + }) +}) From ad32bdb36d4d2abbbb2b01d816a13feed2751203 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 12 May 2026 19:24:02 -0400 Subject: [PATCH 13/13] fix: client -> provider --- tests/api/contact/index.test.ts | 2 +- tsconfig.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/api/contact/index.test.ts b/tests/api/contact/index.test.ts index 2083c1b..0f63502 100644 --- a/tests/api/contact/index.test.ts +++ b/tests/api/contact/index.test.ts @@ -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); }); diff --git a/tsconfig.json b/tsconfig.json index 978df89..4b32cdc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,7 +47,8 @@ }, "include": [ "./api/**/*.ts", - "./src/**/*.ts" + "./src/**/*.ts", + "./tests/**/*.ts" ], "exclude": [ "dist/",