Skip to content

Commit db37e6f

Browse files
committed
feat: cut over email to Cloudflare
1 parent 68bd764 commit db37e6f

5 files changed

Lines changed: 166 additions & 33 deletions

File tree

‎apps/web/.env.development.example‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ SMTP_PORT=2500
1919
# Inbucket does not require auth; leave these empty for local testing.
2020
SMTP_USER=
2121
SMTP_PASS=
22-
RESEND_API_KEY=
2322

2423
# Formbase
2524
ALLOW_SIGNIN_SIGNUP=true

‎apps/web/.env.production.example‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ AUTH_GITHUB_SECRET=
1212
AUTH_GOOGLE_ID=
1313
AUTH_GOOGLE_SECRET=
1414

15-
# Email (Resend)
16-
SMTP_TRANSPORT=resend
17-
RESEND_API_KEY=
15+
# Email (Cloudflare Email Service)
16+
SMTP_TRANSPORT=cloudflare
17+
CLOUDFLARE_ACCOUNT_ID=8e642ff58d22bbbe4eda926dda88649e
18+
CLOUDFLARE_API_TOKEN=
19+
# Sender on your verified Cloudflare domain, e.g. Formbase <noreply@mail.yourdomain>
20+
EMAIL_FROM=
1821

1922
# Formbase
2023
ALLOW_SIGNIN_SIGNUP=true

‎packages/email/index.ts‎

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const createSmtpTransport = async (): Promise<MailTransporter> => {
4040
}
4141
: {}),
4242
};
43+
// Dynamic import keeps Node-only nodemailer out of Cloudflare email transport bundles.
4344
const { createTransport } = await import('nodemailer');
4445

4546
return createTransport(smtpConfig as TransportOptions);
@@ -51,6 +52,7 @@ const getTransporter = async () => {
5152
if (cachedTransporter) return cachedTransporter;
5253

5354
if (env.NODE_ENV === 'test') {
55+
// Dynamic import keeps Node-only nodemailer out of Cloudflare email transport bundles.
5456
const { createTransport } = await import('nodemailer');
5557
cachedTransporter = createTransport({
5658
name: 'noop',
@@ -78,59 +80,67 @@ const getTransporter = async () => {
7880

7981
if (!hasSmtpConfig) {
8082
throw new Error(
81-
'Email transport not configured. Set SMTP_TRANSPORT to resend or smtp.',
83+
'Email transport not configured. Set SMTP_TRANSPORT to cloudflare or smtp.',
8284
);
8385
}
8486

8587
cachedTransporter = await createSmtpTransport();
8688
return cachedTransporter;
8789
};
8890

89-
const sendResendMail = async ({
91+
const sendCloudflareMail = async ({
9092
to,
9193
subject,
9294
body,
9395
}: MessageInfo): Promise<unknown> => {
94-
if (!env.RESEND_API_KEY) {
95-
throw new Error('Missing RESEND_API_KEY');
96+
const apiToken = env.CLOUDFLARE_API_TOKEN;
97+
const accountId = env.CLOUDFLARE_ACCOUNT_ID;
98+
99+
if (!apiToken || !accountId) {
100+
throw new Error('Missing CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID');
96101
}
97102

98-
const response = await fetch('https://api.resend.com/emails', {
99-
method: 'POST',
100-
headers: {
101-
Authorization: `Bearer ${env.RESEND_API_KEY}`,
102-
'Content-Type': 'application/json',
103+
const response = await fetch(
104+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/email/sending/send`,
105+
{
106+
method: 'POST',
107+
headers: {
108+
Authorization: `Bearer ${apiToken}`,
109+
'Content-Type': 'application/json',
110+
},
111+
body: JSON.stringify({
112+
from: env.EMAIL_FROM ?? from,
113+
to,
114+
subject,
115+
html: body,
116+
}),
103117
},
104-
body: JSON.stringify({
105-
from,
106-
to,
107-
subject,
108-
html: body,
109-
}),
110-
});
111-
112-
if (!response.ok) {
113-
const errorBody = (await response.json().catch(() => null)) as {
114-
message?: unknown;
115-
} | null;
118+
);
119+
const result = (await response.json().catch(() => null)) as {
120+
success?: boolean;
121+
errors?: { message?: unknown }[];
122+
} | null;
123+
124+
if (!response.ok || result?.success !== true) {
125+
const errorMessage = result?.errors?.[0]?.message;
116126
const message =
117-
typeof errorBody?.message === 'string'
118-
? errorBody.message
119-
: `Resend email request failed with status ${response.status}`;
127+
typeof errorMessage === 'string'
128+
? errorMessage
129+
: `Cloudflare email request failed with status ${response.status}`;
120130

121131
throw new Error(message);
122132
}
123133

124-
return response.json();
134+
return result;
125135
};
126136

127137
export const sendMail = async ({
128138
to,
129139
subject,
130140
body,
131141
}: MessageInfo): Promise<unknown> => {
132-
if (env.SMTP_TRANSPORT === 'resend') {
133-
return sendResendMail({ to, subject, body });
142+
if (env.SMTP_TRANSPORT === 'cloudflare') {
143+
return sendCloudflareMail({ to, subject, body });
134144
}
135145

136146
const transporter = await getTransporter();

‎packages/env/index.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ export const env = createEnv({
5858
STORAGE_BUCKET: z.string().trim().min(1).optional(),
5959
STORAGE_REGION: z.string().trim().min(1).optional(),
6060

61-
RESEND_API_KEY: z.string().trim().optional(),
62-
SMTP_TRANSPORT: z.enum(['smtp', 'resend']).optional(),
61+
SMTP_TRANSPORT: z.enum(['smtp', 'cloudflare']).optional(),
62+
CLOUDFLARE_API_TOKEN: z.string().trim().optional(),
63+
CLOUDFLARE_ACCOUNT_ID: z.string().trim().optional(),
64+
EMAIL_FROM: z.string().trim().min(1).optional(),
6365

6466
CRON_SECRET: z.string().trim().min(1).optional(),
6567
VERCEL_URL: z.string().optional(),

‎tests/api/email.test.ts‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { sendMail } from '../../packages/email/index';
4+
5+
const accountId = '8e642ff58d22bbbe4eda926dda88649e';
6+
const apiToken = 'test-cloudflare-token';
7+
const emailFrom = 'Formbase <noreply@mail.example.com>';
8+
9+
beforeEach(() => {
10+
vi.stubEnv('SMTP_TRANSPORT', 'cloudflare');
11+
vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', accountId);
12+
vi.stubEnv('CLOUDFLARE_API_TOKEN', apiToken);
13+
vi.stubEnv('EMAIL_FROM', emailFrom);
14+
});
15+
16+
afterEach(() => {
17+
vi.unstubAllEnvs();
18+
vi.unstubAllGlobals();
19+
});
20+
21+
describe('Cloudflare email transport', () => {
22+
it('sends email through the Cloudflare REST API', async () => {
23+
const apiResponse = {
24+
success: true,
25+
errors: [],
26+
result: {
27+
delivered: ['user@example.com'],
28+
permanent_bounces: [],
29+
queued: [],
30+
},
31+
};
32+
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) =>
33+
Response.json(apiResponse, { status: 200 }),
34+
);
35+
vi.stubGlobal('fetch', fetchMock);
36+
37+
await expect(
38+
sendMail({
39+
to: 'user@example.com',
40+
subject: 'Welcome',
41+
body: '<p>Hello</p>',
42+
}),
43+
).resolves.toEqual(apiResponse);
44+
45+
expect(fetchMock).toHaveBeenCalledTimes(1);
46+
const call = fetchMock.mock.calls[0];
47+
if (!call) throw new Error('Expected Cloudflare fetch call');
48+
49+
const [url, init] = call;
50+
expect(url).toBe(
51+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/email/sending/send`,
52+
);
53+
expect(init.method).toBe('POST');
54+
expect(init.headers).toEqual({
55+
Authorization: `Bearer ${apiToken}`,
56+
'Content-Type': 'application/json',
57+
});
58+
expect(JSON.parse(init.body as string)).toEqual({
59+
from: emailFrom,
60+
to: 'user@example.com',
61+
subject: 'Welcome',
62+
html: '<p>Hello</p>',
63+
});
64+
});
65+
66+
it('throws the Cloudflare API error message', async () => {
67+
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) =>
68+
Response.json(
69+
{
70+
success: false,
71+
errors: [
72+
{
73+
code: 10001,
74+
message: 'email.sending.error.invalid_request_schema',
75+
},
76+
],
77+
result: null,
78+
},
79+
{ status: 200 },
80+
),
81+
);
82+
vi.stubGlobal('fetch', fetchMock);
83+
84+
await expect(
85+
sendMail({
86+
to: 'user@example.com',
87+
subject: 'Welcome',
88+
body: '<p>Hello</p>',
89+
}),
90+
).rejects.toThrow('email.sending.error.invalid_request_schema');
91+
});
92+
93+
it('throws the Cloudflare API error message for non-ok responses', async () => {
94+
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) =>
95+
Response.json(
96+
{
97+
success: false,
98+
errors: [
99+
{
100+
code: 10001,
101+
message: 'email.sending.error.invalid_request_schema',
102+
},
103+
],
104+
result: null,
105+
},
106+
{ status: 400 },
107+
),
108+
);
109+
vi.stubGlobal('fetch', fetchMock);
110+
111+
await expect(
112+
sendMail({
113+
to: 'user@example.com',
114+
subject: 'Welcome',
115+
body: '<p>Hello</p>',
116+
}),
117+
).rejects.toThrow('email.sending.error.invalid_request_schema');
118+
});
119+
});

0 commit comments

Comments
 (0)