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
1 change: 1 addition & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ PORT=3000
NODE_ENV=development
ALLOWED_ORIGIN=http://localhost:4200
BREVO_API_KEY=replace-with-your-brevo-api-key
BREVO_REQUEST_TIMEOUT_MS=15000
CONTACT_TO_EMAIL=recipient@example.com
CONTACT_FROM_EMAIL=verified-sender@example.com
CONTACT_FROM_NAME=Portfolio Contact Form
8 changes: 7 additions & 1 deletion server/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@ const envSchema = z.object({

BREVO_API_KEY: z.string().min(1, 'BREVO_API_KEY is required.'),

BREVO_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(15_000),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject timeouts above Node's timer limit

When BREVO_REQUEST_TIMEOUT_MS is 2147483648 or larger, this schema accepts it, but Node 24 clamps an overflowing setTimeout delay to 1 ms. In the Docker runtime targeted here, such a configured value therefore aborts essentially every Brevo request immediately instead of providing a longer timeout. Add an upper bound of 2147483647 or explicitly cap the parsed value.

Useful? React with 👍 / 👎.


Comment on lines 10 to +13
CONTACT_TO_EMAIL: z.email(),

CONTACT_FROM_EMAIL: z.email(),

CONTACT_FROM_NAME: z.string().trim().min(1, 'CONTACT_FROM_NAME is required.').max(100),
});

const parsedEnv = envSchema.safeParse(process.env);
export function validateEnvironment(environment: NodeJS.ProcessEnv) {
return envSchema.safeParse(environment);
}

const parsedEnv = validateEnvironment(process.env);

if (!parsedEnv.success) {
console.error('Invalid environment variables:', parsedEnv.error.flatten().fieldErrors);
Expand Down
3 changes: 1 addition & 2 deletions server/src/services/email.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { env } from '../config/env.js';

const BREVO_API_URL = 'https://api.brevo.com/v3/smtp/email';
const BREVO_REQUEST_TIMEOUT_MS = 8_000;

export type EmailDeliveryErrorCategory = 'network' | 'provider-response' | 'timeout';

Expand All @@ -27,7 +26,7 @@ export type ContactEmailSender = (input: SendContactEmailInput) => Promise<void>

export async function sendContactEmail(
input: SendContactEmailInput,
timeoutMs = BREVO_REQUEST_TIMEOUT_MS,
timeoutMs = env.BREVO_REQUEST_TIMEOUT_MS,
): Promise<void> {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), timeoutMs);
Expand Down
32 changes: 32 additions & 0 deletions server/test/contact.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ process.env.PORT = '3000';
process.env.NODE_ENV = 'test';
process.env.ALLOWED_ORIGIN = 'http://localhost:4200';
process.env.BREVO_API_KEY = 'test-api-key';
process.env.BREVO_REQUEST_TIMEOUT_MS = '17500';
process.env.CONTACT_TO_EMAIL = 'recipient@example.com';
process.env.CONTACT_FROM_EMAIL = 'verified-sender@example.com';
process.env.CONTACT_FROM_NAME = 'Portfolio Contact Form';

const { createApp } = await import('../src/app.js');
const { env, validateEnvironment } = await import('../src/config/env.js');
const { EmailDeliveryError, sendContactEmail } = await import('../src/services/email.service.js');

const validContactRequest = {
Expand All @@ -25,6 +27,36 @@ const validContactRequest = {
company: '',
};

test('accepts a configured Brevo request timeout', () => {
assert.equal(env.BREVO_REQUEST_TIMEOUT_MS, 17_500);
});

test('rejects an invalid Brevo request timeout', () => {
const result = validateEnvironment({
...process.env,
BREVO_REQUEST_TIMEOUT_MS: 'not-a-number',
});

assert.equal(result.success, false);

if (!result.success) {
assert.ok(result.error.flatten().fieldErrors.BREVO_REQUEST_TIMEOUT_MS?.length);
}
});

test('uses the safe default when the Brevo request timeout is omitted', () => {
const environment = { ...process.env };
delete environment.BREVO_REQUEST_TIMEOUT_MS;

const result = validateEnvironment(environment);

assert.equal(result.success, true);

if (result.success) {
assert.equal(result.data.BREVO_REQUEST_TIMEOUT_MS, 15_000);
}
});

test('delivers a valid contact request with the validated Reply-To details', async () => {
let deliveredMessage: SendContactEmailInput | undefined;
const emailSender: ContactEmailSender = async (input) => {
Expand Down