From f6a7edf8e861d68208c61a37c3478cc1721109e7 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 14:55:44 -0400 Subject: [PATCH 1/4] feat: add name, make subject optional --- api/contact/email.ts | 12 +++++++++--- api/contact/types.ts | 3 ++- api/contact/validation.ts | 7 ++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/api/contact/email.ts b/api/contact/email.ts index 7a0a24d..cb86e1b 100644 --- a/api/contact/email.ts +++ b/api/contact/email.ts @@ -17,13 +17,19 @@ export function getEmailConfig(config: Config): EmailConfig | null { return { client: config.resend, from: config.fromEmail, to: config.toEmails }; } -export async function sendEmail(config: EmailConfig, body: ContactBody): Promise { +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 result = await config.client.emails.send({ from: config.from, to: config.to, replyTo: body.email, - subject: `Contact form: ${body.subject.replace(/[\r\n]+/g, " ").trim()}`, - text: `From: ${body.email}\n\n${body.message.trim()}` + subject: `Contact form: ${subjectLine}`, + text: `From: ${fromLine}\n\n${body.message.trim()}` }); if (result.error) { diff --git a/api/contact/types.ts b/api/contact/types.ts index 5a7b8c8..1dcbbab 100644 --- a/api/contact/types.ts +++ b/api/contact/types.ts @@ -1,5 +1,6 @@ export interface ContactBody { - subject: string; email: string; message: string; + subject?: string; + name?: string; } diff --git a/api/contact/validation.ts b/api/contact/validation.ts index e14df05..b1a6654 100644 --- a/api/contact/validation.ts +++ b/api/contact/validation.ts @@ -5,11 +5,12 @@ 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 { subject, email, message } = record as Record; + const { email, message, subject, name } = record; return ( - typeof subject === "string" && !!subject.trim() && subject.length <= 200 && typeof email === "string" && EMAIL_REGEX.test(email) && - typeof message === "string" && !!message.trim() && message.length <= 2000 + typeof message === "string" && !!message.trim() && message.length <= 2000 && + (subject === undefined || (typeof subject === "string" && subject.length <= 200)) && + (name === undefined || (typeof name === "string" && name.length <= 100)) ); } From 6068f1ed2fab40d43500621a5cc1c42f9f392368 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 15:12:18 -0400 Subject: [PATCH 2/4] test: update validation and email tests for optional fields --- tests/contact/email.test.ts | 19 ++++++++++++- tests/contact/validation.test.ts | 48 ++++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/tests/contact/email.test.ts b/tests/contact/email.test.ts index 677554c..05bd725 100644 --- a/tests/contact/email.test.ts +++ b/tests/contact/email.test.ts @@ -1,7 +1,6 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import type { Resend } from "resend"; import { getEmailConfig, sendEmail, type EmailConfig } from "@/api/contact/email.js"; -import type { Config } from "@/api/contact/config.js"; import type { ContactBody } from "@/api/contact/types.js"; vi.mock("resend"); @@ -58,6 +57,24 @@ describe("email.ts", () => { }); }); + it("formats fromLine with name when provided", async () => { + const bodyWithName: ContactBody = { ...body, name: "Tester" }; + await sendEmail(mockEmailConfig, bodyWithName); + expect(mockResend.emails.send).toHaveBeenCalledWith( + expect.objectContaining({ + text: `From: Tester \n\n${body.message.trim()}` + }) + ); + }); + + it("uses default subject when not provided", async () => { + const bodyNoSubject: ContactBody = { email: "user@test.com", message: "Hello" }; + await sendEmail(mockEmailConfig, bodyNoSubject); + expect(mockResend.emails.send).toHaveBeenCalledWith( + expect.objectContaining({ subject: "Contact form: New message" }) + ); + }); + it("throws Resend errors", async () => { (mockResend.emails.send as any).mockRejectedValue(new Error("API fail")); await expect(sendEmail(mockEmailConfig, body)).rejects.toThrow("API fail"); diff --git a/tests/contact/validation.test.ts b/tests/contact/validation.test.ts index 6430448..9175c19 100644 --- a/tests/contact/validation.test.ts +++ b/tests/contact/validation.test.ts @@ -51,13 +51,17 @@ describe("EMAIL_REGEX", () => { }); describe("isValidBody",() => { - it("should accept a valid body", () => { - const valid = { - subject: "Hello", + it("should accept a valid body with all fields", () => { + expect(isValidBody({ email: "test@example.com", - message: "Valid test message." - }; - expect(isValidBody(valid)).toBe(true); + message: "Valid test message.", + subject: "Hello", + name: "Test" + })).toBe(true); + }); + + it("should accept a valid body with only required fields", () => { + expect(isValidBody({ email: "test@example.com", message: "hello" })).toBe(true); }); it("should reject non-objects", () => { @@ -66,25 +70,29 @@ describe("isValidBody",() => { }); }); - it("should reject missing fields", () => { + it("should reject missing required fields", () => { expect(isValidBody({})).toBe(false); - expect(isValidBody({ subject: "Hello", email: "user@example.com" })).toBe(false); + expect(isValidBody({ email: "user@example.com" })).toBe(false); + expect(isValidBody({ message: "hello" })).toBe(false); }); it("should reject non-string field types", () => { - expect(isValidBody({ subject: 123, email: "user@example.com", message: "hello" })).toBe(false); - expect(isValidBody({ subject: "hi", email: 123, message: "hello" })).toBe(false); - expect(isValidBody({ subject: "hi", email: "user@example.com", message: 123 })).toBe(false); + expect(isValidBody({ email: 123, message: "hello" })).toBe(false); + expect(isValidBody({ email: "user@example.com", message: 123 })).toBe(false); }); - it("should reject whitespace-only subject or message", () => { - expect(isValidBody({ subject: " ", email: "user@example.com", message: "hello" })).toBe(false); + it("should reject whitespace-only message", () => { expect(isValidBody({ subject: "hi", email: "user@example.com", message: " " })).toBe(false); }); + it("should allow whitespace-only or empty subject", () => { + expect(isValidBody({ email: "user@example.com", message: "hello", subject: " " })).toBe(true); + expect(isValidBody({ email: "user@example.com", message: "hello", subject: "" })).toBe(true); + }); + it("should reject subject over 200 chars", () => { - expect(isValidBody({ subject: "x".repeat(201), email: "user@example.com", message: "hello" })).toBe(false); - expect(isValidBody({ subject: "x".repeat(200), email: "user@example.com", message: "hello"})).toBe(true); + expect(isValidBody({ email: "user@example.com", message: "hello", subject: "x".repeat(201) })).toBe(false); + expect(isValidBody({ email: "user@example.com", message: "hello", subject: "x".repeat(200) })).toBe(true); }); it("should reject message over 2000 chars", () => { @@ -95,5 +103,15 @@ describe("isValidBody",() => { it("should reject invalid emails", () => { expect(isValidBody({ subject: "hello", email: "invalid", message: "nah..." })).toBe(false); }); + + it("should accept valid optional name", () => { + expect(isValidBody({ email: "user@example.com", message: "hello", name: "Test" })).toBe(true); + expect(isValidBody({ email: "user@example.com", message: "hello", name: "" })).toBe(true); + }); + + it("should reject name over 100 chars", () => { + expect(isValidBody({ email: "user@example.com", message: "hello", name: "a".repeat(101) })).toBe(false); + expect(isValidBody({ email: "user@example.com", message: "hello", name: "a".repeat(100) })).toBe(true); + }); }); From 62db72c985192adbcd989822d0fe9c167a44876f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 15:15:31 -0400 Subject: [PATCH 3/4] docs: update README with optional fields and request body table --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a9d5f80..52d9f61 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,14 @@ Deployable Resend contact form API ## Usage ```js -await fetch('https://your-deployment.vercel.app/api/contact', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, +await fetch("https://your-deployment.vercel.app/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - subject: 'Hello', - email: 'sender@example.com', - message: 'Your message here' + email: "sender@example.com", // required + message: "Your message here", // required + subject: "Hello", // optional + name: "Your name" // optional }) }); ``` From 92194e8026a600058df28c49a290894c029d4109 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 15:16:42 -0400 Subject: [PATCH 4/4] chore: bump version to 1.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d88d216..bfb6109 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "contact-api", "type": "module", - "version": "1.0.0", + "version": "1.1.0", "description": "Deployable contact form API", "author": "Mason L'Etoile", "license": "MIT",