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
8 changes: 8 additions & 0 deletions website/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,14 @@ const config = {
loading: process.env.GISCUS_LOADING || 'lazy'
},
}),
// Newsletter email collection configuration (Kit API v4)
// See here for more information: https://developers.kit.com/api-reference/overview
...(process.env.NEWSLETTER_API_KEY && {
newsletter: {
isEnabled: true,
apiUrl: process.env.NEWSLETTER_API_URL || 'https://api.kit.com/v4',
},
}),
}),
plugins: [
process.env.POSTHOG_API_KEY && [
Expand Down
219 changes: 219 additions & 0 deletions website/netlify/functions/newsletter-subscribe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* Netlify Serverless Function to handle newsletter form submissions
*
* This function securely handles Kit API integration by keeping
* the API key on the server side. It validates input, sanitizes data,
* and handles errors gracefully following Netlify best practices.
*
* @see https://docs.netlify.com/functions/overview/
* @see https://developers.kit.com/api-reference/subscribers/create-a-subscriber
*/

/**
* Get the allowed CORS origin based on environment
* @returns {string} The allowed origin (site URL in production, '*' in development)
*/
function getCorsOrigin() {
return process.env.NODE_ENV === 'production'
? process.env.URL || '*'
: '*';
}

/**
* Build headers with CORS for JSON responses
* @param {Object} additionalHeaders - Additional headers to include
* @returns {Object} Headers object with CORS and content type
*/
function buildCorsHeaders(additionalHeaders = {}) {
return {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': getCorsOrigin(),
...additionalHeaders,
};
}

/**
* Build full CORS headers for preflight responses (no Content-Type)
* @returns {Object} Complete CORS headers object
*/
function buildFullCorsHeaders() {
return {
'Access-Control-Allow-Origin': getCorsOrigin(),
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type',
};
}

/**
* Build headers for JSON responses with full CORS support
* @returns {Object} Headers object with Content-Type and full CORS headers
*/
function buildJsonCorsHeaders() {
return {
'Content-Type': 'application/json',
...buildFullCorsHeaders(),
};
}

exports.handler = async (event, context) => {
// Only allow POST requests
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
headers: buildJsonCorsHeaders(),
body: JSON.stringify({ error: 'Method not allowed' }),
};
}

// Handle CORS preflight
if (event.httpMethod === 'OPTIONS') {
return {
statusCode: 200,
headers: buildFullCorsHeaders(),
body: '',
};
}

try {
// Parse request body
let body;
try {
body = JSON.parse(event.body);
} catch (parseError) {
return {
statusCode: 400,
headers: buildCorsHeaders(),
body: JSON.stringify({ error: 'Invalid JSON in request body' }),
};
}

const { email, firstName, lastName, ...customFields } = body;

// Validate email (RFC 5322 compliant regex)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email || !emailRegex.test(email)) {
return {
statusCode: 400,
headers: buildCorsHeaders(),
body: JSON.stringify({ error: 'Valid email is required' }),
};
}

// Get configuration from environment variables
const newsletterApiKey = process.env.NEWSLETTER_API_KEY;
const apiUrl = process.env.NEWSLETTER_API_URL || 'https://api.kit.com/v4';

if (!newsletterApiKey) {
console.error('Newsletter API configuration missing:', {
hasApiKey: !!newsletterApiKey,
});
return {
statusCode: 500,
headers: buildCorsHeaders(),
body: JSON.stringify({ error: 'Server configuration error' }),
};
}

// Prepare subscriber data for Kit API v4
// Kit API expects: email_address, first_name, state, fields
const subscriberData = {
email_address: email.trim().toLowerCase(),
...(firstName && { first_name: firstName.trim() }),
state: 'active', // Default to active state
...(Object.keys(customFields).length > 0 && {
fields: Object.entries(customFields).reduce((acc, [key, value]) => {
// Only include non-empty custom fields
if (value !== null && value !== undefined && value !== '') {
acc[key] = String(value);
}
return acc;
}, {}),
}),
};

// Call Kit API with timeout
// Kit API requires X-Kit-Api-Key header
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout

try {
const kitUrl = `${apiUrl}/subscribers`;
const kitResponse = await fetch(kitUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Kit-Api-Key': newsletterApiKey,
},
body: JSON.stringify(subscriberData),
signal: controller.signal,
});

clearTimeout(timeoutId);

const kitData = await kitResponse.json();

if (!kitResponse.ok) {
console.error('Kit API error:', {
status: kitResponse.status,
statusText: kitResponse.statusText,
data: kitData,
});

// Don't expose internal API errors to client
const errorMessage = kitData.message || 'Failed to subscribe. Please try again.';

return {
statusCode: kitResponse.status >= 400 && kitResponse.status < 500
? kitResponse.status
: 500,
headers: buildCorsHeaders(),
body: JSON.stringify({
error: errorMessage,
}),
};
}

// Success response
return {
statusCode: 200,
headers: buildJsonCorsHeaders(),
body: JSON.stringify({
success: true,
message: 'Successfully subscribed!',
data: kitData,
}),
};

} catch (fetchError) {
clearTimeout(timeoutId);

if (fetchError.name === 'AbortError') {
console.error('Request timeout to Kit API');
return {
statusCode: 504,
headers: buildCorsHeaders(),
body: JSON.stringify({
error: 'Request timeout. Please try again.',
}),
};
}

throw fetchError; // Re-throw to be caught by outer catch
}

} catch (error) {
console.error('Function error:', {
message: error.message,
stack: error.stack,
});

return {
statusCode: 500,
headers: buildCorsHeaders(),
body: JSON.stringify({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? error.message : undefined,
}),
};
}
};
30 changes: 29 additions & 1 deletion website/package-lock.json

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

1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",
"three": "^0.181.2"
},
"devDependencies": {
Expand Down
Loading