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
2 changes: 1 addition & 1 deletion apps/web/src/app/api/s/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
);
Expand Down
9 changes: 4 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/api/routers/form.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(),
}),
)
Expand Down
8 changes: 5 additions & 3 deletions packages/email/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -43,7 +43,7 @@ const createSmtpTransport = async (): Promise<MailTransporter> => {
// 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;
Expand Down Expand Up @@ -139,6 +139,8 @@ export const sendMail = async ({
subject,
body,
}: MessageInfo): Promise<unknown> => {
recipientEmailSchema.parse(to);

if (env.SMTP_TRANSPORT === 'cloudflare') {
return sendCloudflareMail({ to, subject, body });
}
Expand Down
7 changes: 4 additions & 3 deletions packages/email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@
"private": true,
"type": "module",
"exports": {
".": "./index.ts"
".": "./index.ts",
"./recipient": "./recipient.ts"
},
"scripts": {
"lint": "eslint . --cache --max-warnings 0",
"typecheck": "tsc --noEmit --tsBuildInfoFile .tsbuildinfo"
},
"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:*"
}
}
6 changes: 6 additions & 0 deletions packages/email/recipient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { z } from 'zod';

export const recipientEmailSchema = z
.string()
.max(254)
.pipe(z.string().email());
3 changes: 2 additions & 1 deletion packages/email/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"extends": ["@formbase/tsconfig/base.json"],
"compilerOptions": {
"tsBuildInfoFile": ".tsbuildinfo"
"tsBuildInfoFile": ".tsbuildinfo",
"types": ["node"]
},
"include": ["**/*.ts", "**/*.mjs", "**/*.js", "**/*.cjs"]
}
82 changes: 82 additions & 0 deletions tests/api/auth-email.test.ts
Original file line number Diff line number Diff line change
@@ -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" <noreply@formbase.dev>',
to: 'account+auth@example.com',
subject,
html: expect.stringContaining(`<a href="${url}">${label}</a>`),
});
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');
});
});
74 changes: 74 additions & 0 deletions tests/api/email-nodemailer.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>Local fixture only</p>',
}),
).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 <noreply@example.com>',
to: `Recipient ${comments} <recipient+tag@example.com>`,
subject: 'Bounded parser fixture',
html: '<p>Local fixture only</p>',
}),
).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();
});
});
Loading
Loading