From 3d966f4d26e83e44d3797d56fe4c1956bf17a3e9 Mon Sep 17 00:00:00 2001 From: kamycoding Date: Tue, 11 Aug 2026 21:14:00 +0200 Subject: [PATCH] chore: add contact network diagnostics --- server/src/routes/contact.route.ts | 1 + server/src/services/email.service.ts | 56 +++++++++++++++++- server/test/contact.integration.test.ts | 75 ++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/server/src/routes/contact.route.ts b/server/src/routes/contact.route.ts index c9f4f59..047fb50 100644 --- a/server/src/routes/contact.route.ts +++ b/server/src/routes/contact.route.ts @@ -44,6 +44,7 @@ export function createContactRouter(emailSender: ContactEmailSender = sendContac ...(error.providerStatus === undefined ? {} : { providerStatus: error.providerStatus }), + ...error.networkDiagnostics, } : { category: 'unexpected' }; diff --git a/server/src/services/email.service.ts b/server/src/services/email.service.ts index c42c7a2..755b8e6 100644 --- a/server/src/services/email.service.ts +++ b/server/src/services/email.service.ts @@ -4,18 +4,70 @@ const BREVO_API_URL = 'https://api.brevo.com/v3/smtp/email'; export type EmailDeliveryErrorCategory = 'network' | 'provider-response' | 'timeout'; +export interface EmailNetworkDiagnostics { + readonly errorName?: string; + readonly code?: string; + readonly causeCode?: string; + readonly syscall?: string; + readonly hostname?: string; +} + export class EmailDeliveryError extends Error { readonly category: EmailDeliveryErrorCategory; readonly providerStatus: number | undefined; + readonly networkDiagnostics: EmailNetworkDiagnostics; - constructor(category: EmailDeliveryErrorCategory, providerStatus?: number) { + constructor( + category: EmailDeliveryErrorCategory, + providerStatus?: number, + networkDiagnostics: EmailNetworkDiagnostics = {}, + ) { super('Email delivery failed.'); this.name = 'EmailDeliveryError'; this.category = category; this.providerStatus = providerStatus; + this.networkDiagnostics = networkDiagnostics; + } +} + +function readProperty(value: unknown, property: string): unknown { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return undefined; + } + + try { + return Reflect.get(value, property); + } catch { + return undefined; } } +function readDiagnosticString(value: unknown, property: string): string | undefined { + const candidate = readProperty(value, property); + + return typeof candidate === 'string' && candidate.length > 0 && candidate.length <= 200 + ? candidate + : undefined; +} + +function sanitizeNetworkDiagnostics(error: unknown): EmailNetworkDiagnostics { + const cause = readProperty(error, 'cause'); + const errorName = readDiagnosticString(error, 'name'); + const code = readDiagnosticString(error, 'code'); + const causeCode = readDiagnosticString(cause, 'code'); + const syscall = readDiagnosticString(error, 'syscall') ?? readDiagnosticString(cause, 'syscall'); + const hostname = + readDiagnosticString(error, 'hostname') ?? readDiagnosticString(cause, 'hostname'); + + return { + ...(errorName === undefined ? {} : { errorName }), + ...(code === undefined ? {} : { code }), + ...(causeCode === undefined ? {} : { causeCode }), + ...(syscall === undefined ? {} : { syscall }), + ...(hostname === undefined ? {} : { hostname }), + }; +} + export interface SendContactEmailInput { readonly name: string; readonly email: string; @@ -71,7 +123,7 @@ export async function sendContactEmail( throw new EmailDeliveryError('timeout'); } - throw new EmailDeliveryError('network'); + throw new EmailDeliveryError('network', undefined, sanitizeNetworkDiagnostics(error)); } finally { clearTimeout(timeout); } diff --git a/server/test/contact.integration.test.ts b/server/test/contact.integration.test.ts index d9d99c0..c8544ae 100644 --- a/server/test/contact.integration.test.ts +++ b/server/test/contact.integration.test.ts @@ -138,7 +138,13 @@ test( throw new EmailDeliveryError('timeout'); } - throw new EmailDeliveryError('network'); + throw new EmailDeliveryError('network', undefined, { + errorName: 'TypeError', + code: 'FETCH_FAILED', + causeCode: 'ENOTFOUND', + syscall: 'getaddrinfo', + hostname: 'api.brevo.com', + }); }; const originalConsoleError = console.error; const diagnostics: unknown[] = []; @@ -187,8 +193,20 @@ test( assert.deepEqual(diagnostics, [ { category: 'provider-response', providerStatus: 500 }, { category: 'timeout' }, - { category: 'network' }, + { + category: 'network', + errorName: 'TypeError', + code: 'FETCH_FAILED', + causeCode: 'ENOTFOUND', + syscall: 'getaddrinfo', + hostname: 'api.brevo.com', + }, ]); + const loggedDiagnostics = JSON.stringify(diagnostics); + assert.equal(loggedDiagnostics.includes(process.env.BREVO_API_KEY ?? ''), false); + assert.equal(loggedDiagnostics.includes(validContactRequest.name), false); + assert.equal(loggedDiagnostics.includes(validContactRequest.email), false); + assert.equal(loggedDiagnostics.includes(validContactRequest.message), false); }, ); @@ -256,6 +274,59 @@ test('keeps the health endpoint independent from contact email delivery', async }); }); +test( + 'preserves only allow-listed network diagnostics from a failed Brevo fetch', + { concurrency: false }, + async () => { + const originalFetch = globalThis.fetch; + + try { + globalThis.fetch = (async () => { + const cause = Object.assign(new Error('DNS lookup included unsafe details.'), { + code: 'ENOTFOUND', + syscall: 'getaddrinfo', + hostname: 'api.brevo.com', + apiKey: process.env.BREVO_API_KEY, + requestBody: validContactRequest.message, + }); + const error = Object.assign(new TypeError('fetch failed', { cause }), { + code: 'FETCH_FAILED', + requestBody: validContactRequest, + }); + + throw error; + }) as typeof fetch; + + await assert.rejects( + sendContactEmail({ + name: validContactRequest.name, + email: validContactRequest.email, + message: validContactRequest.message, + }), + (error: unknown) => { + assert.ok(error instanceof EmailDeliveryError); + assert.equal(error.category, 'network'); + assert.deepEqual(error.networkDiagnostics, { + errorName: 'TypeError', + code: 'FETCH_FAILED', + causeCode: 'ENOTFOUND', + syscall: 'getaddrinfo', + hostname: 'api.brevo.com', + }); + assert.equal(JSON.stringify(error.networkDiagnostics).includes('test-api-key'), false); + assert.equal( + JSON.stringify(error.networkDiagnostics).includes(validContactRequest.message), + false, + ); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } + }, +); + test( 'aborts a stalled Brevo request after the configured timeout', { concurrency: false },