From cc70afdde041f179a921784f68b9d8eff2812e56 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 16:13:24 -0400 Subject: [PATCH 1/9] feat: add EmailPayload and EmailProvider interfaces --- src/types.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/types.ts b/src/types.ts index 1dcbbab..3cd00f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +} From 9b22d39237fdf6ddf75d1c2672992d5b1f2bb1df Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 16:18:56 -0400 Subject: [PATCH 2/9] feat: add ResendProvider adapter --- src/providers/resend.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/providers/resend.ts diff --git a/src/providers/resend.ts b/src/providers/resend.ts new file mode 100644 index 0000000..e1ed45b --- /dev/null +++ b/src/providers/resend.ts @@ -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 { + 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}`); + } + } +} From 5ef7472044708d95da843684aef6779daf5cab40 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 16:26:17 -0400 Subject: [PATCH 3/9] feat: add provider factory to config --- src/config.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index 9f61844..1264700 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 From c2933bd656cfb12220bc3abd3f5547efef793ceb Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 16:31:45 -0400 Subject: [PATCH 4/9] feat: migrate email module to provider abstraction --- src/email.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/email.ts b/src/email.ts index cb86e1b..a29f3c9 100644 --- a/src/email.ts +++ b/src/email.ts @@ -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( @@ -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); } From c9b8441df92a54c9d1969445b6eacc0c7d6a46e1 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 16:43:46 -0400 Subject: [PATCH 5/9] test: update email tests for provider abstraction --- tests/contact/email.test.ts | 104 +++++++++++++----------------------- 1 file changed, 38 insertions(+), 66 deletions(-) diff --git a/tests/contact/email.test.ts b/tests/contact/email.test.ts index cd55e02..790a87f 100644 --- a/tests/contact/email.test.ts +++ b/tests/contact/email.test.ts @@ -1,97 +1,69 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; -import type { Resend } from "resend"; -import { getEmailConfig, sendEmail, type EmailConfig } from "../../src/email.js"; -import type { ContactBody } from "../../src/types.js"; +import type { EmailProvider, ContactBody } from "@/src/types.js"; +import { getEmailConfig, sendEmail, type EmailConfig } from "@/src/email.js"; -vi.mock("resend"); +const mockProvider: EmailProvider = { + id: "mock", + send: vi.fn() +}; -const mockResend = { - emails: { - send: vi.fn() - } -} satisfies Resend; +const mockEmailConfig: EmailConfig = { + provider: mockProvider, + from: "from@test.com", + to: ["to@test.com"] +}; -describe("email.ts", () => { - beforeEach(() => { - vi.clearAllMocks(); - (vi.mocked(mockResend.emails.send) as any).mockResolvedValue({ data: [], error: null }); - }); +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({ resend: null as any, fromEmail: "a@b.com", toEmails: ["c@d.com"] })).toBeNull(); - expect(getEmailConfig({ resend: {} as any, fromEmail: "", toEmails: ["c@d.com"] })).toBeNull(); - expect(getEmailConfig({ resend: {} as any, fromEmail: "a@b.com", toEmails: [] })).toBeNull(); + expect(getEmailConfig({ ...base, provider: null })).toBeNull(); + expect(getEmailConfig({ ...base, fromEmail: "" })).toBeNull(); + expect(getEmailConfig({ ...base, toEmails: [] })).toBeNull(); }); it("returns EmailConfig when valid", () => { - const result = getEmailConfig({ resend: mockResend, fromEmail: "from@test.com", toEmails: ["to@test.com"] }); - expect(result).toMatchObject({ - client: mockResend, - from: 'from@test.com', - to: ['to@test.com'] - }); + expect(getEmailConfig({ ...base })).toMatchObject(mockEmailConfig); }); }); describe("sendEmail", () => { - const mockEmailConfig: EmailConfig = { - client: mockResend as any, - from: "from@test.com", - to: ["to@test.com"] - }; - const body: ContactBody = { - email: "user@test.com", - subject: "Test\nSubject", - message: "Hello " - }; - it("calls resend with sanitized payload", async () => { - await sendEmail(mockEmailConfig, body); - expect(mockResend.emails.send).toHaveBeenCalledWith({ - from: "from@test.com", - to: ["to@test.com"], + 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.trim()}` + text: `From: ${body.email}\n\n${body.message}` }); }); 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()}` - }) + 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 () => { - const bodyNoSubject: ContactBody = { email: "user@test.com", message: "Hello" }; - await sendEmail(mockEmailConfig, bodyNoSubject); - expect(mockResend.emails.send).toHaveBeenCalledWith( + await sendEmail(mockEmailConfig, body); + expect(mockProvider.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"); - }); - - it("throws on Resend success-with-error response", async () => { - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - (mockResend.emails.send as any).mockResolvedValue({ - data: null, - error: { message: "Invalid recipient domain" } - }); - - await expect(sendEmail(mockEmailConfig, body)).rejects.toThrow(/Invalid/); - - expect(consoleSpy).toHaveBeenCalledWith("Resend API error:", { message: "Invalid recipient domain" }); - consoleSpy.mockRestore(); - }); + 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"); + }); }); }); From 417d48c6953f4478023bc6bedfa20bdcb9d55d3e Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 16:48:08 -0400 Subject: [PATCH 6/9] refactor: mirror src structure in tests directory --- tests/{ => api}/contact/index.test.ts | 0 tests/{contact => src}/cors.test.ts | 0 tests/{contact => src}/email.test.ts | 0 tests/{contact => src}/validation.test.ts | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => api}/contact/index.test.ts (100%) rename tests/{contact => src}/cors.test.ts (100%) rename tests/{contact => src}/email.test.ts (100%) rename tests/{contact => src}/validation.test.ts (100%) diff --git a/tests/contact/index.test.ts b/tests/api/contact/index.test.ts similarity index 100% rename from tests/contact/index.test.ts rename to tests/api/contact/index.test.ts diff --git a/tests/contact/cors.test.ts b/tests/src/cors.test.ts similarity index 100% rename from tests/contact/cors.test.ts rename to tests/src/cors.test.ts diff --git a/tests/contact/email.test.ts b/tests/src/email.test.ts similarity index 100% rename from tests/contact/email.test.ts rename to tests/src/email.test.ts diff --git a/tests/contact/validation.test.ts b/tests/src/validation.test.ts similarity index 100% rename from tests/contact/validation.test.ts rename to tests/src/validation.test.ts From cf457561a98c0bc6fc64b15b30494cad4b4b720d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 17:16:15 -0400 Subject: [PATCH 7/9] test: add resend provider tests --- tests/src/providers/resend.test.ts | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/src/providers/resend.test.ts diff --git a/tests/src/providers/resend.test.ts b/tests/src/providers/resend.test.ts new file mode 100644 index 0000000..79d94a1 --- /dev/null +++ b/tests/src/providers/resend.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Resend } from "resend"; +import { ResendProvider } from "@/src/providers/resend.js"; +import type { EmailPayload } from "@/src/types.js"; + +vi.mock("resend", () => { + const mockSend = vi.fn(); + const MockResend = vi.fn().mockImplementation(function(this: any) { + this.emails = { send: mockSend }; + }); + + return { Resend: MockResend }; +}); + +describe("ResendProvider", () => { + const apiKey = "re_123456789"; + + const getMockSend = () => { + const resendInstance = new Resend(apiKey); + return resendInstance.emails.send as any; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sets id to resend", () => { + const provider = new ResendProvider(apiKey); + expect(provider.id).toBe("resend"); + expect(Resend).toHaveBeenCalledWith(apiKey); + }); + + it("sends email payload", async () => { + const provider = new ResendProvider(apiKey); + const mockSend = getMockSend(); + mockSend.mockResolvedValue({ data: { id: "msg_123" }, error: null }); + + const payload: EmailPayload = { + from: "from@test.com", + to: ["to@test.com"], + replyTo: "reply@test.com", + subject: "Test", + text: "Hello" + }; + + await expect(provider.send(payload)).resolves.toBeUndefined(); + expect(mockSend).toHaveBeenCalledWith(payload); + }); + + it("throws on resend error response", async () => { + const provider = new ResendProvider(apiKey); + const mockSend = getMockSend(); + const mockError = { message: "Invalid recipient domain" }; + mockSend.mockResolvedValue({ data: null, error: mockError }); + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const payload: EmailPayload = { + from: "from@test.com", + to: ["to@test.com"], + replyTo: "reply@test.com", + subject: "Test", + text: "Hello" + }; + + await expect(provider.send(payload)).rejects.toThrow(`Email send failed: ${mockError.message}`); + expect(consoleSpy).toHaveBeenCalledWith("Resend API error:", mockError); + + consoleSpy.mockRestore(); + }); +}); From b2b8a1aaa1b75ac2c7d9c696a77488e50210fb7c Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 23 Mar 2026 17:51:14 -0400 Subject: [PATCH 8/9] docs: update README + .env.example for multi-provider --- .env.example | 12 +++++++++--- README.md | 11 ++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 7843dad..7fe9660 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 52d9f61..d0b1b2d 100644 --- a/README.md +++ b/README.md @@ -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 From f2d39e29e16b1f609e723fa16b0031832612eeef Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 8 Apr 2026 22:58:13 -0400 Subject: [PATCH 9/9] fix: relative paths --- tests/api/contact/index.test.ts | 16 ++++++++-------- tests/src/email.test.ts | 4 ++-- tests/src/providers/resend.test.ts | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/api/contact/index.test.ts b/tests/api/contact/index.test.ts index 46db005..13e58c1 100644 --- a/tests/api/contact/index.test.ts +++ b/tests/api/contact/index.test.ts @@ -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 => ({ headers: { origin: "https://example.com", "content-type": "application/json" }, diff --git a/tests/src/email.test.ts b/tests/src/email.test.ts index 790a87f..e359a54 100644 --- a/tests/src/email.test.ts +++ b/tests/src/email.test.ts @@ -1,6 +1,6 @@ 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"; +import type { EmailProvider, ContactBody } from "../../src/types.js"; +import { getEmailConfig, sendEmail, type EmailConfig } from "../../src/email.js"; const mockProvider: EmailProvider = { id: "mock", diff --git a/tests/src/providers/resend.test.ts b/tests/src/providers/resend.test.ts index 79d94a1..77c4d1b 100644 --- a/tests/src/providers/resend.test.ts +++ b/tests/src/providers/resend.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Resend } from "resend"; -import { ResendProvider } from "@/src/providers/resend.js"; -import type { EmailPayload } from "@/src/types.js"; +import { ResendProvider } from "../../../src/providers/resend.js"; +import type { EmailPayload } from "../../../src/types.js"; vi.mock("resend", () => { const mockSend = vi.fn();