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
12 changes: 9 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
RESEND_API_KEY=your_key_here
# Shared email settings
FROM_EMAIL=email@yourdomain.com
TO_EMAIL=contact@yourdomain.com
ALLOWED_ORIGINS=https://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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,15 @@ Copy `.env.example` to `.env` and fill Environment Variables. All values are **r

| Variable | Description |
| ----------------- | ----------- |
| `RESEND_API_KEY` | Your Resend API key |
| `FROM_EMAIL` | Sender address (must be a verified Resend domain) |
| `TO_EMAIL` | Delivery address |
| `ALLOWED_ORIGINS` | Comma-separated list of allowed CORS origins— empty blocks all requests. |
| `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. |

### Deploy

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

```bash
vercel deploy
Expand Down
26 changes: 20 additions & 6 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
import { Resend } from "resend";
import type { EmailProvider } from "./types.js";
import { ResendProvider } from "./providers/resend.js";

export interface Config {
resend: Resend | null;
provider: EmailProvider | null;
fromEmail: string | undefined;
toEmails: string[];
allowedOrigins: string[];
}

const apiKey = process.env["RESEND_API_KEY"];
const fromEmail = process.env["FROM_EMAIL"];
const toEmailsRaw = process.env["TO_EMAIL"] ?? "";
const toEmails = toEmailsRaw.split(",").map(o => o.trim()).filter(Boolean);
const allowedOriginsRaw = process.env["ALLOWED_ORIGINS"] ?? "";
const allowedOrigins = allowedOriginsRaw.split(",").map(o => o.trim()).filter(Boolean);

let resend: Resend | null = null;
if (apiKey) resend = new Resend(apiKey);
function createProvider(): EmailProvider | null {
const providerName = process.env["EMAIL_PROVIDER"]?.toLowerCase();
if (!providerName) {
console.error("EMAIL_PROVIDER is not set");
return null;
}

if (providerName === "resend") {
const apiKey = process.env["RESEND_API_KEY"];
if (!apiKey) return null;
return new ResendProvider(apiKey);
}

console.warn(`Unknown EMAIL_PROVIDER: "${providerName}"`);
return null;
}

export const config: Config = {
resend,
provider: createProvider(),
fromEmail,
toEmails,
allowedOrigins
Expand Down
18 changes: 7 additions & 11 deletions src/email.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import type { Resend } from "resend";
import type { ContactBody } from "./types.js";
import type { EmailProvider, EmailPayload, ContactBody } from "./types.js";
import type { Config } from "./config.js";

export interface EmailConfig {
client: Resend;
provider: EmailProvider;
from: string;
to: string[];
}

export function getEmailConfig(config: Config): EmailConfig | null {
if (
!config.resend ||
!config.provider ||
!config.fromEmail?.trim() ||
!config.toEmails?.length
) return null;
return { client: config.resend, from: config.fromEmail, to: config.toEmails };
return { provider: config.provider, from: config.fromEmail, to: config.toEmails };
}

export async function sendEmail(
Expand All @@ -24,16 +23,13 @@ export async function sendEmail(
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({
const payload: EmailPayload = {
from: config.from,
to: config.to,
replyTo: body.email,
subject: `Contact form: ${subjectLine}`,
text: `From: ${fromLine}\n\n${body.message.trim()}`
});

if (result.error) {
console.error("Resend API error:", result.error);
throw new Error(`Email send failed: ${result.error.message}`);
}

await config.provider.send(payload);
}
19 changes: 19 additions & 0 deletions src/providers/resend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Resend } from "resend";
import type { EmailProvider, EmailPayload } from "../types.js";

export class ResendProvider implements EmailProvider {
readonly id = "resend";
private client: Resend;

constructor(apiKey: string) {
this.client = new Resend(apiKey);
}

async send(payload: EmailPayload): Promise<void> {
const result = await this.client.emails.send(payload);
if (result.error) {
console.error("Resend API error:", result.error);
throw new Error(`Email send failed: ${result.error.message}`);
}
}
}
13 changes: 13 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,16 @@ export interface ContactBody {
subject?: string;
name?: string;
}

export interface EmailPayload {
from: string;
to: string[];
replyTo: string;
subject: string;
text: string;
}

export interface EmailProvider {
readonly id: string;
send(body: EmailPayload): Promise<void>;
}
16 changes: 8 additions & 8 deletions tests/contact/index.test.ts → tests/api/contact/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { vi, describe, it, expect, beforeEach } from "vitest";
import type { VercelRequest, VercelResponse } from "@vercel/node";

vi.mock("@vercel/firewall", () => ({ checkRateLimit: vi.fn() }));
vi.mock("../../src/cors.js", () => ({ setCorsHeaders: vi.fn() }));
vi.mock("../../src/validation.js", () => ({ isValidBody: vi.fn() }));
vi.mock("../../src/email.js", () => ({ getEmailConfig: vi.fn(), sendEmail: vi.fn() }));
vi.mock("../../src/config.js", () => ({ config: { allowedOrigins: ["https://example.com"] } }));
vi.mock("../../../src/cors.js", () => ({ setCorsHeaders: vi.fn() }));
vi.mock("../../../src/validation.js", () => ({ isValidBody: vi.fn() }));
vi.mock("../../../src/email.js", () => ({ getEmailConfig: vi.fn(), sendEmail: vi.fn() }));
vi.mock("../../../src/config.js", () => ({ config: { allowedOrigins: ["https://example.com"] } }));

import { checkRateLimit } from "@vercel/firewall";
import { setCorsHeaders } from "../../src/cors.js";
import { isValidBody } from "../../src/validation.js";
import { getEmailConfig, sendEmail } from "../../src/email.js";
import handler from "../../api/contact/index.js";
import { setCorsHeaders } from "../../../src/cors.js";
import { isValidBody } from "../../../src/validation.js";
import { getEmailConfig, sendEmail } from "../../../src/email.js";
import handler from "../../../api/contact/index.js";

const makeReq = (overrides: Partial<VercelRequest> = {}): VercelRequest => ({
headers: { origin: "https://example.com", "content-type": "application/json" },
Expand Down
97 changes: 0 additions & 97 deletions tests/contact/email.test.ts

This file was deleted.

File renamed without changes.
69 changes: 69 additions & 0 deletions tests/src/email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
import type { EmailProvider, ContactBody } from "../../src/types.js";
import { getEmailConfig, sendEmail, type EmailConfig } from "../../src/email.js";

const mockProvider: EmailProvider = {
id: "mock",
send: vi.fn()
};

const mockEmailConfig: EmailConfig = {
provider: mockProvider,
from: "from@test.com",
to: ["to@test.com"]
};

const body: ContactBody = {
email: "user@test.com",
message: "Hello"
};

beforeEach(() => vi.clearAllMocks());

describe("email.ts", () => {
describe("getEmailConfig", () => {
const base = { provider: mockProvider, fromEmail: mockEmailConfig.from, toEmails: mockEmailConfig.to, allowedOrigins: [] };

it("returns null if config missing or empty props", () => {
expect(getEmailConfig({ ...base, provider: null })).toBeNull();
expect(getEmailConfig({ ...base, fromEmail: "" })).toBeNull();
expect(getEmailConfig({ ...base, toEmails: [] })).toBeNull();
});

it("returns EmailConfig when valid", () => {
expect(getEmailConfig({ ...base })).toMatchObject(mockEmailConfig);
});
});

describe("sendEmail", () => {
it("calls resend with sanitized payload", async () => {
await sendEmail(mockEmailConfig, { ...body, subject: "Test\nSubject" });
expect(mockProvider.send).toHaveBeenCalledWith({
from: mockEmailConfig.from,
to: mockEmailConfig.to,
replyTo: body.email,
subject: "Contact form: Test Subject",
text: `From: ${body.email}\n\n${body.message}`
});
});

it("formats fromLine with name when provided", async () => {
await sendEmail(mockEmailConfig, { ...body, name: "Tester" });
expect(mockProvider.send).toHaveBeenCalledWith(
expect.objectContaining({ text: `From: Tester <${body.email}>\n\n${body.message}` })
);
});

it("uses default subject when not provided", async () => {
await sendEmail(mockEmailConfig, body);
expect(mockProvider.send).toHaveBeenCalledWith(
expect.objectContaining({ subject: "Contact form: New message" })
);
});

it("throws when provider.send rejects", async () => {
(mockProvider.send as any).mockRejectedValue(new Error("Send failed"));
await expect(sendEmail(mockEmailConfig, body)).rejects.toThrow("Send failed");
});
});
});
Loading
Loading