diff --git a/apps/web/src/app/api/s/[id]/route.ts b/apps/web/src/app/api/s/[id]/route.ts index be897bd..5aafe43 100644 --- a/apps/web/src/app/api/s/[id]/route.ts +++ b/apps/web/src/app/api/s/[id]/route.ts @@ -143,7 +143,7 @@ export async function POST( if (!spamResult.isSpam) { after(() => - handleEmailNotifications(form, cleanedFormData).catch((error) => { + handleEmailNotifications(form, cleanedFormData).catch((error: unknown) => { console.error('Failed to send submission notification email', error); }), ); diff --git a/bun.lock b/bun.lock index b34dc16..cd79079 100644 --- a/bun.lock +++ b/bun.lock @@ -87,6 +87,7 @@ "dependencies": { "@formbase/auth": "workspace:*", "@formbase/db": "workspace:*", + "@formbase/email": "workspace:*", "@formbase/env": "workspace:*", "@formbase/utils": "workspace:*", "@trpc/server": "^11.17.0", @@ -201,12 +202,12 @@ "version": "0.1.0", "dependencies": { "@formbase/env": "workspace:*", - "nodemailer": "^6.9.13", + "nodemailer": "^10.0.15", + "zod": "^3.23.8", }, "devDependencies": { "@formbase/tsconfig": "workspace:*", "@types/node": "^20.19.39", - "@types/nodemailer": "^6.4.23", "eslint-config-formbase": "workspace:*", }, }, @@ -1161,8 +1162,6 @@ "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - "@types/nodemailer": ["@types/nodemailer@6.4.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -2033,7 +2032,7 @@ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], - "nodemailer": ["nodemailer@6.10.1", "", {}, "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA=="], + "nodemailer": ["nodemailer@10.0.15", "", {}, "sha512-EUqp5PhtcsYXs9Fq/lS7s/8zlTrnBqmzZWzFFROrNikMiz+om/YRKMwqN907t+0A1KKyT8jd39CBUbFZmaZmcA=="], "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], diff --git a/packages/api/package.json b/packages/api/package.json index 8bdd075..7389a36 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,6 +10,7 @@ "dependencies": { "@formbase/auth": "workspace:*", "@formbase/db": "workspace:*", + "@formbase/email": "workspace:*", "@formbase/env": "workspace:*", "@formbase/utils": "workspace:*", "@trpc/server": "^11.17.0", diff --git a/packages/api/routers/form.ts b/packages/api/routers/form.ts index d819382..489cb22 100644 --- a/packages/api/routers/form.ts +++ b/packages/api/routers/form.ts @@ -1,5 +1,6 @@ import { drizzlePrimitives } from "@formbase/db"; import { formDatas, forms, onboardingForms } from "@formbase/db/schema"; +import { recipientEmailSchema } from "@formbase/email/recipient"; import { generateId } from "@formbase/utils/generate-id"; import { z } from "zod"; @@ -125,7 +126,7 @@ export const formRouter = createTRPCRouter({ enableSubmissions: z.boolean().optional(), enableEmailNotifications: z.boolean().optional(), returnUrl: z.string().optional(), - defaultSubmissionEmail: z.string().optional(), + defaultSubmissionEmail: recipientEmailSchema.optional(), honeypotField: z.string().optional(), }), ) diff --git a/packages/email/index.ts b/packages/email/index.ts index 5eef5e6..e6f765b 100644 --- a/packages/email/index.ts +++ b/packages/email/index.ts @@ -1,7 +1,7 @@ -import type { TransportOptions } from 'nodemailer'; - import { env } from '@formbase/env'; +import { recipientEmailSchema } from './recipient'; + export type MessageInfo = { to: string; subject: string; @@ -43,7 +43,7 @@ const createSmtpTransport = async (): Promise => { // Dynamic import keeps Node-only nodemailer out of Cloudflare email transport bundles. const { createTransport } = await import('nodemailer'); - return createTransport(smtpConfig as TransportOptions); + return createTransport(smtpConfig); }; let cachedTransporter: MailTransporter | null = null; @@ -139,6 +139,8 @@ export const sendMail = async ({ subject, body, }: MessageInfo): Promise => { + recipientEmailSchema.parse(to); + if (env.SMTP_TRANSPORT === 'cloudflare') { return sendCloudflareMail({ to, subject, body }); } diff --git a/packages/email/package.json b/packages/email/package.json index 2916dd7..6d0c20e 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./index.ts" + ".": "./index.ts", + "./recipient": "./recipient.ts" }, "scripts": { "lint": "eslint . --cache --max-warnings 0", @@ -12,12 +13,12 @@ }, "dependencies": { "@formbase/env": "workspace:*", - "nodemailer": "^6.9.13" + "nodemailer": "^10.0.15", + "zod": "^3.23.8" }, "devDependencies": { "@formbase/tsconfig": "workspace:*", "@types/node": "^20.19.39", - "@types/nodemailer": "^6.4.23", "eslint-config-formbase": "workspace:*" } } diff --git a/packages/email/recipient.ts b/packages/email/recipient.ts new file mode 100644 index 0000000..545e084 --- /dev/null +++ b/packages/email/recipient.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const recipientEmailSchema = z + .string() + .max(254) + .pipe(z.string().email()); diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json index 9fadae2..74e98d1 100644 --- a/packages/email/tsconfig.json +++ b/packages/email/tsconfig.json @@ -1,7 +1,8 @@ { "extends": ["@formbase/tsconfig/base.json"], "compilerOptions": { - "tsBuildInfoFile": ".tsbuildinfo" + "tsBuildInfoFile": ".tsbuildinfo", + "types": ["node"] }, "include": ["**/*.ts", "**/*.mjs", "**/*.js", "**/*.cjs"] } diff --git a/tests/api/auth-email.test.ts b/tests/api/auth-email.test.ts new file mode 100644 index 0000000..06c8782 --- /dev/null +++ b/tests/api/auth-email.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const smtp = vi.hoisted(() => ({ + createTransport: vi.fn(), + sendMail: vi.fn(), +})); + +vi.mock('nodemailer', () => ({ createTransport: smtp.createTransport })); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('SMTP_TRANSPORT', undefined); + smtp.createTransport.mockReturnValue({ sendMail: smtp.sendMail }); + smtp.sendMail.mockResolvedValue({ messageId: 'auth-local-message' }); + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('Auth email delivery through the shared mailer', () => { + it.each([ + ['sendVerificationEmail', 'Verify your email', 'Verify email'], + ['sendResetPasswordEmail', 'Reset your password', 'Reset password'], + ] as const)( + 'sends %s with the expected recipient and link', + async (name, subject, label) => { + const authEmail = await import('../../packages/auth/email'); + const url = 'https://forms.example/auth/callback?token=local-test-token'; + + await expect( + authEmail[name]({ email: 'account+auth@example.com', url }), + ).resolves.toBeUndefined(); + + expect(smtp.createTransport).toHaveBeenCalledWith( + expect.objectContaining({ name: 'noop', version: '1.0.0' }), + ); + expect(smtp.sendMail).toHaveBeenCalledWith({ + from: '"Formbase" ', + to: 'account+auth@example.com', + subject, + html: expect.stringContaining(`${label}`), + }); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + it.each(['sendVerificationEmail', 'sendResetPasswordEmail'] as const)( + 'rejects an invalid auth recipient in %s before transport initialization', + async (name) => { + const authEmail = await import('../../packages/auth/email'); + + await expect( + authEmail[name]({ + email: 'one@example.com,two@example.com', + url: 'https://forms.example/auth/callback', + }), + ).rejects.toThrow(); + + expect(smtp.createTransport).not.toHaveBeenCalled(); + expect(smtp.sendMail).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + it('propagates transport errors to the auth flow', async () => { + smtp.sendMail.mockRejectedValue(new Error('Local transport failure')); + const { sendResetPasswordEmail } = + await import('../../packages/auth/email'); + + await expect( + sendResetPasswordEmail({ + email: 'account@example.com', + url: 'https://forms.example/reset-password', + }), + ).rejects.toThrow('Local transport failure'); + }); +}); diff --git a/tests/api/email-nodemailer.test.ts b/tests/api/email-nodemailer.test.ts new file mode 100644 index 0000000..fba399b --- /dev/null +++ b/tests/api/email-nodemailer.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +beforeEach(() => { + vi.resetModules(); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('SMTP_TRANSPORT', undefined); + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('Installed Nodemailer compatibility without network access', () => { + it('uses the application noop transport with the installed Nodemailer API', async () => { + const { sendMail } = await import('../../packages/email/index'); + + await expect( + sendMail({ + to: 'local+test@example.com', + subject: 'Offline API compatibility', + body: '

Local fixture only

', + }), + ).resolves.toEqual({ + accepted: [], + envelope: { from: '', to: [] }, + messageId: 'test-message-id', + pending: [], + rejected: [], + response: '250 ok', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('parses a bounded nested-comment fixture with a custom local transport', async () => { + const { createTransport } = await import('nodemailer'); + const envelopes: Array<{ from: string | false; to: string[] }> = []; + const transporter = createTransport({ + name: 'local-fixture', + version: '1.0.0', + send: (mail, callback) => { + const envelope = mail.message.getEnvelope(); + envelopes.push(envelope); + callback(null, { + accepted: envelope.to, + rejected: [], + envelope, + messageId: 'bounded-local-fixture', + response: '250 local fixture', + }); + }, + }); + const comments = `${'('.repeat(16)}local comment${')'.repeat(16)}`; + + await expect( + transporter.sendMail({ + from: 'Formbase ', + to: `Recipient ${comments} `, + subject: 'Bounded parser fixture', + html: '

Local fixture only

', + }), + ).resolves.toMatchObject({ + accepted: ['recipient+tag@example.com'], + messageId: 'bounded-local-fixture', + }); + + expect(envelopes).toEqual([ + { from: 'noreply@example.com', to: ['recipient+tag@example.com'] }, + ]); + expect(fetch).not.toHaveBeenCalled(); + transporter.close(); + }); +}); diff --git a/tests/api/email-smtp.test.ts b/tests/api/email-smtp.test.ts new file mode 100644 index 0000000..a26687c --- /dev/null +++ b/tests/api/email-smtp.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const smtp = vi.hoisted(() => ({ + createTransport: vi.fn(), + sendMail: vi.fn(), +})); + +vi.mock('nodemailer', () => ({ createTransport: smtp.createTransport })); + +const message = { + to: 'recipient+notification@example.com', + subject: 'New submission', + body: '

A local test submission

', +}; + +const smtpEnvKeys = [ + 'SMTP_HOST', + 'SMTP_PORT', + 'SMTP_USER', + 'SMTP_PASS', + 'SMTP_TRANSPORT', +] as const; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv('SKIP_ENV_VALIDATION', ''); + vi.stubEnv('NODE_ENV', 'production'); + smtpEnvKeys.forEach((key) => vi.stubEnv(key, undefined)); + vi.stubEnv('SMTP_TRANSPORT', 'smtp'); + vi.stubEnv('SMTP_HOST', 'smtp.example.invalid'); + vi.stubEnv('SMTP_PORT', '465'); + vi.stubEnv('SMTP_USER', 'local-test-user'); + vi.stubEnv('SMTP_PASS', 'local-test-password'); + smtp.createTransport.mockReturnValue({ sendMail: smtp.sendMail }); + smtp.sendMail.mockResolvedValue({ messageId: 'local-test-message' }); + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('SMTP email transport', () => { + it('creates authenticated production SMTP transport and sends the message', async () => { + const { sendMail } = await import('../../packages/email/index'); + + await expect(sendMail(message)).resolves.toEqual({ + messageId: 'local-test-message', + }); + + expect(smtp.createTransport).toHaveBeenCalledWith({ + host: 'smtp.example.invalid', + port: 465, + secure: true, + auth: { user: 'local-test-user', pass: 'local-test-password' }, + }); + expect(smtp.sendMail).toHaveBeenCalledWith({ + from: '"Formbase" ', + to: message.to, + subject: message.subject, + html: message.body, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reuses the SMTP transport for subsequent messages', async () => { + const { sendMail } = await import('../../packages/email/index'); + + await sendMail(message); + await sendMail({ ...message, subject: 'Second submission' }); + + expect(smtp.createTransport).toHaveBeenCalledTimes(1); + expect(smtp.sendMail).toHaveBeenCalledTimes(2); + }); + + it('supports development SMTP without authentication', async () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('SMTP_PORT', '587'); + vi.stubEnv('SMTP_USER', undefined); + vi.stubEnv('SMTP_PASS', undefined); + const { sendMail } = await import('../../packages/email/index'); + + await sendMail(message); + + expect(smtp.createTransport).toHaveBeenCalledWith({ + host: 'smtp.example.invalid', + port: 587, + secure: false, + }); + }); + + it.each(['SMTP_HOST', 'SMTP_PORT'] as const)( + 'rejects a missing %s before creating a transport', + async (key) => { + vi.stubEnv(key, undefined); + const { sendMail } = await import('../../packages/email/index'); + + await expect(sendMail(message)).rejects.toThrow( + 'Missing SMTP_HOST or SMTP_PORT', + ); + expect(smtp.createTransport).not.toHaveBeenCalled(); + expect(smtp.sendMail).not.toHaveBeenCalled(); + }, + ); + + it.each(['SMTP_USER', 'SMTP_PASS'] as const)( + 'rejects partial credentials with missing %s', + async (key) => { + vi.stubEnv(key, undefined); + const { sendMail } = await import('../../packages/email/index'); + + await expect(sendMail(message)).rejects.toThrow( + 'SMTP_USER and SMTP_PASS must both be set', + ); + expect(smtp.createTransport).not.toHaveBeenCalled(); + }, + ); + + it('rejects unconfigured email transport outside test mode', async () => { + smtpEnvKeys.forEach((key) => vi.stubEnv(key, undefined)); + const { sendMail } = await import('../../packages/email/index'); + + await expect(sendMail(message)).rejects.toThrow( + 'Email transport not configured', + ); + expect(smtp.createTransport).not.toHaveBeenCalled(); + }); + + it.each([ + 'First ', + 'first@example.com,second@example.com', + 'recipient@example.com\r\nBcc: another@example.com', + `${'('.repeat(128)}recipient@example.com${')'.repeat(128)}`, + ])('rejects invalid recipient %j before invoking Nodemailer', async (to) => { + const { sendMail } = await import('../../packages/email/index'); + + await expect(sendMail({ ...message, to })).rejects.toThrow(); + + expect(smtp.createTransport).not.toHaveBeenCalled(); + expect(smtp.sendMail).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api/email.test.ts b/tests/api/email.test.ts index 9750b99..306b3e4 100644 --- a/tests/api/email.test.ts +++ b/tests/api/email.test.ts @@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { sendMail } from '../../packages/email/index'; +vi.mock('nodemailer', () => { + throw new Error('Cloudflare transport must not import Nodemailer'); +}); + const accountId = '8e642ff58d22bbbe4eda926dda88649e'; const apiToken = 'test-cloudflare-token'; const emailFrom = 'Formbase '; @@ -19,7 +23,7 @@ afterEach(() => { }); describe('Cloudflare email transport', () => { - it('sends email through the Cloudflare REST API', async () => { + it('sends email through the Cloudflare REST API without importing Nodemailer', async () => { const apiResponse = { success: true, errors: [], @@ -63,6 +67,25 @@ describe('Cloudflare email transport', () => { }); }); + it.each([ + ['empty address', ''], + ['invalid address', 'not-an-email'], + ['display name', 'Name '], + ['recipient list', 'user@example.com,other@example.com'], + ['header injection', 'user@example.com\r\nBcc: other@example.com'], + ['oversized nested comments', `${'('.repeat(4096)}user@example.com`], + ['255-character address', `${'a'.repeat(243)}@example.com`], + ])('rejects a %s before calling Cloudflare', async (_, to) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + sendMail({ to, subject: 'Test', body: '

Test

' }), + ).rejects.toThrow(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('throws the Cloudflare API error message', async () => { const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => Response.json( diff --git a/tests/api/form.test.ts b/tests/api/form.test.ts index cb9fdf9..3210d9f 100644 --- a/tests/api/form.test.ts +++ b/tests/api/form.test.ts @@ -1,11 +1,11 @@ +import type { TestSession, TestUser } from '../helpers'; + import { beforeEach, describe, expect, it } from 'vitest'; import { createAuthenticatedCaller, createTestSession, createTestUser, - type TestSession, - type TestUser, } from '../helpers'; describe('Form API', () => { @@ -67,6 +67,76 @@ describe('Form API', () => { expect(form?.title).toBe('Updated Title'); expect(form?.description).toBe('New description'); }); + + it('updates the notification recipient with a valid single address', async () => { + const created = await caller.form.create({ title: 'Notifications' }); + + await caller.form.update({ + id: created.id, + defaultSubmissionEmail: 'notifications+contact@example.com', + }); + + const form = await caller.form.get({ formId: created.id }); + expect(form?.defaultSubmissionEmail).toBe( + 'notifications+contact@example.com', + ); + }); + + it('preserves the notification recipient when it is omitted', async () => { + const created = await caller.form.create({ title: 'Notifications' }); + await caller.form.update({ + id: created.id, + defaultSubmissionEmail: 'notifications@example.com', + }); + + await caller.form.update({ id: created.id, title: 'Renamed form' }); + + const form = await caller.form.get({ formId: created.id }); + expect(form?.title).toBe('Renamed form'); + expect(form?.defaultSubmissionEmail).toBe('notifications@example.com'); + }); + + it('accepts a notification recipient at the 254-character limit', async () => { + const created = await caller.form.create({ title: 'Notifications' }); + const recipient = `${'a'.repeat(64)}@${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(61)}`; + expect(recipient).toHaveLength(254); + + await caller.form.update({ + id: created.id, + defaultSubmissionEmail: recipient, + }); + + const form = await caller.form.get({ formId: created.id }); + expect(form?.defaultSubmissionEmail).toBe(recipient); + }); + + it.each([ + ['empty address', ''], + ['invalid address', 'not-an-email'], + ['display name', 'Notifications '], + ['recipient list', 'first@example.com,second@example.com'], + ['line feed', 'notifications@example.com\n'], + ['carriage return', 'notifications@example.com\r'], + ['null byte', 'notifications\0@example.com'], + [ + '255-character address', + `${'a'.repeat(64)}@${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(62)}`, + ], + ])('rejects a %s without changing the form', async (_, recipient) => { + const created = await caller.form.create({ title: 'Original title' }); + const before = await caller.form.get({ formId: created.id }); + + await expect( + caller.form.update({ + id: created.id, + title: 'Should not be saved', + defaultSubmissionEmail: recipient, + }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + const after = await caller.form.get({ formId: created.id }); + expect(after).toEqual(before); + }); }); describe('form.delete', () => { diff --git a/tests/routes/submission-email.test.ts b/tests/routes/submission-email.test.ts new file mode 100644 index 0000000..1d04a55 --- /dev/null +++ b/tests/routes/submission-email.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + after: vi.fn(), + deferred: [] as Array<() => Promise>, + getForm: vi.fn(), + getUser: vi.fn(), + setFormData: vi.fn(), + checkForSpam: vi.fn(), + renderEmail: vi.fn(), + createTransport: vi.fn(), + sendMail: vi.fn(), +})); + +vi.mock('next/server', () => ({ + after: mocks.after, + userAgent: () => ({ browser: {} }), +})); +vi.mock('~/lib/trpc/server', () => ({ + api: { + form: { getFormById: mocks.getForm }, + user: { getUserById: mocks.getUser }, + formData: { setFormData: mocks.setFormData }, + }, +})); +vi.mock('~/lib/email/templates/new-submission', () => ({ + renderNewSubmissionEmail: mocks.renderEmail, +})); +vi.mock('~/lib/spam-detection', () => ({ + checkForSpam: mocks.checkForSpam, + stripHoneypotField: (data: Record, field: string) => + Object.fromEntries(Object.entries(data).filter(([key]) => key !== field)), +})); +vi.mock('~/lib/upload-file', () => ({ + assignFileOrImage: vi.fn(), + uploadFileFromBlob: vi.fn(), +})); +vi.mock('nodemailer', () => ({ createTransport: mocks.createTransport })); + +const form = { + id: 'local-form', + userId: 'local-owner', + title: 'Contact us', + keys: ['name'], + honeypotField: '_gotcha', + enableEmailNotifications: true, + defaultSubmissionEmail: 'notifications@example.com' as string | null, +}; + +const submit = async (data = { name: 'Local test', _gotcha: '' }) => { + const { POST } = await import('../../apps/web/src/app/api/s/[id]/route'); + return POST( + new Request('https://forms.example/api/s/local-form', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + { params: Promise.resolve({ id: 'local-form' }) }, + ); +}; + +const runNotifications = async () => { + for (const callback of mocks.deferred) await callback(); +}; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.deferred.length = 0; + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('SMTP_TRANSPORT', undefined); + vi.stubGlobal('fetch', vi.fn()); + mocks.after.mockImplementation((callback: () => Promise) => { + mocks.deferred.push(callback); + }); + mocks.getForm.mockResolvedValue({ ...form }); + mocks.getUser.mockResolvedValue({ email: 'owner@example.com' }); + mocks.setFormData.mockResolvedValue(undefined); + mocks.checkForSpam.mockReturnValue({ isSpam: false, spamReason: undefined }); + mocks.renderEmail.mockResolvedValue('

Submission notification

'); + mocks.createTransport.mockReturnValue({ sendMail: mocks.sendMail }); + mocks.sendMail.mockResolvedValue({ messageId: 'local-submission-email' }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('Submission notification email', () => { + it('sends to the configured recipient after the submission response', async () => { + const response = await submit(); + + expect(response.status).toBe(200); + expect(mocks.setFormData).toHaveBeenCalledWith({ + data: { name: 'Local test' }, + formId: form.id, + keys: ['name'], + isSpam: false, + spamReason: undefined, + }); + expect(mocks.after).toHaveBeenCalledTimes(1); + expect(mocks.sendMail).not.toHaveBeenCalled(); + + await runNotifications(); + + expect(mocks.getUser).toHaveBeenCalledWith({ userId: form.userId }); + expect(mocks.renderEmail).toHaveBeenCalledWith({ + formTitle: form.title, + submissionData: { name: 'Local test' }, + }); + expect(mocks.sendMail).toHaveBeenCalledWith({ + from: '"Formbase" ', + to: 'notifications@example.com', + subject: 'New Submission for "Contact us"', + html: '

Submission notification

', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('falls back to the owner email when no recipient is configured', async () => { + mocks.getForm.mockResolvedValue({ ...form, defaultSubmissionEmail: null }); + + expect((await submit()).status).toBe(200); + await runNotifications(); + + expect(mocks.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'owner@example.com' }), + ); + }); + + it('stores spam without scheduling or sending a notification', async () => { + mocks.checkForSpam.mockReturnValue({ + isSpam: true, + spamReason: 'honeypot', + }); + + expect( + (await submit({ name: 'Local test', _gotcha: 'filled' })).status, + ).toBe(200); + await runNotifications(); + + expect(mocks.setFormData).toHaveBeenCalledWith( + expect.objectContaining({ isSpam: true, spamReason: 'honeypot' }), + ); + expect(mocks.after).not.toHaveBeenCalled(); + expect(mocks.getUser).not.toHaveBeenCalled(); + expect(mocks.createTransport).not.toHaveBeenCalled(); + expect(mocks.sendMail).not.toHaveBeenCalled(); + }); + + it('does not send email when notifications are disabled', async () => { + mocks.getForm.mockResolvedValue({ + ...form, + enableEmailNotifications: false, + }); + + expect((await submit()).status).toBe(200); + await runNotifications(); + + expect(mocks.getUser).not.toHaveBeenCalled(); + expect(mocks.renderEmail).not.toHaveBeenCalled(); + expect(mocks.createTransport).not.toHaveBeenCalled(); + expect(mocks.sendMail).not.toHaveBeenCalled(); + }); + + it('rejects an invalid legacy recipient before transport initialization', async () => { + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + mocks.getForm.mockResolvedValue({ + ...form, + defaultSubmissionEmail: `${'('.repeat(128)}legacy@example.com${')'.repeat(128)}`, + }); + + expect((await submit()).status).toBe(200); + await runNotifications(); + + expect(error).toHaveBeenCalledWith( + 'Failed to send submission notification email', + expect.any(Error), + ); + expect(mocks.createTransport).not.toHaveBeenCalled(); + expect(mocks.sendMail).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index f65700d..90864c8 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -4,6 +4,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", + "jsx": "preserve", "esModuleInterop": true, "strict": true, "skipLibCheck": true, @@ -18,6 +19,7 @@ "@formbase/auth": ["../packages/auth"], "@formbase/auth/*": ["../packages/auth/*"], "@formbase/env": ["../packages/env"], + "nodemailer": ["../packages/email/node_modules/nodemailer"], "@formbase/utils": ["../packages/utils"], "@formbase/utils/*": ["../packages/utils/*"], "~/*": ["../apps/web/src/*"] diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index 3abcfc2..031a13b 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -1,11 +1,20 @@ +import { createRequire } from 'node:module'; import path from 'path'; import { defineConfig } from 'vitest/config'; +const emailRequire = createRequire( + path.resolve(__dirname, '../packages/email/package.json'), +); +const webRequire = createRequire( + path.resolve(__dirname, '../apps/web/package.json'), +); + process.env['SKIP_ENV_VALIDATION'] = 'true'; process.env.NODE_ENV = 'test'; process.env['DATABASE_URL'] = 'file::memory:?cache=shared'; -process.env['BETTER_AUTH_SECRET'] = 'test-secret-minimum-32-characters-long-for-testing'; +process.env['BETTER_AUTH_SECRET'] = + 'test-secret-minimum-32-characters-long-for-testing'; process.env['ALLOW_SIGNIN_SIGNUP'] = 'true'; export default defineConfig({ @@ -32,10 +41,7 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], reportsDirectory: './coverage', - include: [ - '../packages/api/**/*.ts', - '../apps/web/src/app/api/**/*.ts', - ], + include: ['../packages/api/**/*.ts', '../apps/web/src/app/api/**/*.ts'], exclude: [ '**/*.test.ts', '**/*.spec.ts', @@ -56,7 +62,10 @@ export default defineConfig({ '@formbase/db': path.resolve(__dirname, '../packages/db'), '@formbase/auth': path.resolve(__dirname, '../packages/auth'), '@formbase/env': path.resolve(__dirname, '../packages/env'), + '@formbase/email': path.resolve(__dirname, '../packages/email'), '@formbase/utils': path.resolve(__dirname, '../packages/utils'), + nodemailer: emailRequire.resolve('nodemailer'), + 'next/server': webRequire.resolve('next/server'), '~': path.resolve(__dirname, '../apps/web/src'), }, },