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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
});
```
Expand Down
12 changes: 9 additions & 3 deletions api/contact/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 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) {
Expand Down
3 changes: 2 additions & 1 deletion api/contact/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface ContactBody {
subject: string;
email: string;
message: string;
subject?: string;
name?: string;
}
7 changes: 4 additions & 3 deletions api/contact/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const { subject, email, message } = record as Record<string, unknown>;
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))
);
}

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 18 additions & 1 deletion tests/contact/email.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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 <user@test.com>\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");
Expand Down
48 changes: 33 additions & 15 deletions tests/contact/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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);
});
});

Loading