Contact form logic

This commit is contained in:
becarta
2025-03-04 00:32:39 +01:00
parent 9c61657071
commit e9d3d8a2fb
21 changed files with 2210 additions and 14 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# SMTP Configuration
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-email@example.com
SMTP_PASS=your-password
# Email Settings
ADMIN_EMAIL=admin@example.com
WEBSITE_NAME=Your Website Name
# Environment
NODE_ENV=development

137
check-email-delivery.cjs Normal file
View File

@@ -0,0 +1,137 @@
/**
* Email Delivery Test Script
*
* This script tests email delivery by sending test emails to different addresses
* and with different configurations to help diagnose delivery issues.
*/
const nodemailer = require('nodemailer');
require('dotenv').config();
// Environment variables
const {
SMTP_HOST = 'smtp.protonmail.ch',
SMTP_PORT = '587',
SMTP_USER = '',
SMTP_PASS = '',
ADMIN_EMAIL = 'richard@bergsma.it',
WEBSITE_NAME = 'bergsma.it',
NODE_ENV = 'development'
} = process.env;
// Set to production mode for this test
const isProduction = true;
console.log('Email Delivery Test');
console.log('------------------');
console.log('Configuration:');
console.log(`SMTP Host: ${SMTP_HOST}`);
console.log(`SMTP Port: ${SMTP_PORT}`);
console.log(`SMTP User: ${SMTP_USER}`);
console.log(`Admin Email: ${ADMIN_EMAIL}`);
console.log(`Website Name: ${WEBSITE_NAME}`);
console.log(`Mode: ${isProduction ? 'production' : 'development'}`);
console.log('------------------');
// Create a transporter
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: parseInt(SMTP_PORT, 10),
secure: parseInt(SMTP_PORT, 10) === 465,
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
tls: {
rejectUnauthorized: false,
ciphers: 'SSLv3'
},
debug: true
});
// Verify connection
console.log('Testing SMTP connection...');
transporter.verify(function(error, success) {
if (error) {
console.error('SMTP Connection Error:', error);
process.exit(1);
}
console.log('SMTP Connection Successful!');
runTests();
});
// Run a series of email delivery tests
async function runTests() {
try {
// Test 1: Basic email to admin
console.log('\nTest 1: Sending basic email to admin...');
await sendTestEmail(
ADMIN_EMAIL,
'Email Delivery Test 1 - Basic',
'This is a basic test email to the admin address.',
`<p>This is a basic test email to the admin address.</p><p>Time: ${new Date().toISOString()}</p>`
);
// Test 2: Email with different From address
console.log('\nTest 2: Sending email with different From address...');
await sendTestEmail(
ADMIN_EMAIL,
'Email Delivery Test 2 - Different From',
'This email uses a different From address.',
`<p>This email uses a different From address.</p><p>Time: ${new Date().toISOString()}</p>`,
`"Test Sender" <${SMTP_USER}>`
);
// Test 3: Email with contact form format
console.log('\nTest 3: Sending email in contact form format...');
const contactFormHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee;">
<h2>New Contact Form Submission</h2>
<p><strong>From:</strong> Test User (test@example.com)</p>
<p><strong>Submitted on:</strong> ${new Date().toLocaleString()}</p>
<div style="background-color: #f9f9f9; padding: 15px; border-left: 3px solid #007bff; margin: 15px 0;">
<p><strong>Message:</strong></p>
<p>This is a test message simulating a contact form submission.</p>
</div>
<div style="font-size: 12px; color: #777; margin-top: 20px; padding-top: 10px; border-top: 1px solid #eee;">
<p>This is an automated email from your website contact form.</p>
</div>
</div>
`;
await sendTestEmail(
ADMIN_EMAIL,
'New Contact Form Submission (Test)',
'This is a test message simulating a contact form submission.',
contactFormHtml
);
console.log('\nAll tests completed successfully!');
console.log('\nPlease check your inbox (and spam folder) for the test emails.');
console.log('If you received some emails but not others, this can help identify the issue.');
} catch (error) {
console.error('Error running tests:', error);
}
}
// Helper function to send a test email
async function sendTestEmail(to, subject, text, html, from = `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`) {
try {
const info = await transporter.sendMail({
from,
to,
subject,
text,
html
});
console.log(`Email sent successfully!`);
console.log(`Message ID: ${info.messageId}`);
console.log(`Response: ${info.response}`);
return info;
} catch (error) {
console.error('Error sending email:', error);
throw error;
}
}

101
email-test.cjs Normal file
View File

@@ -0,0 +1,101 @@
/**
* ProtonMail SMTP Test Script
*
* This script tests the SMTP configuration for sending emails through ProtonMail.
* Run with: node email-test.cjs
*/
const nodemailer = require('nodemailer');
require('dotenv').config();
// Get SMTP settings from environment variables
const {
SMTP_HOST = 'smtp.protonmail.ch',
SMTP_PORT = '587',
SMTP_USER,
SMTP_PASS,
ADMIN_EMAIL,
WEBSITE_NAME = 'Website'
} = process.env;
console.log('Email Configuration Test');
console.log('----------------------');
console.log(`SMTP Host: ${SMTP_HOST}`);
console.log(`SMTP Port: ${SMTP_PORT}`);
console.log(`SMTP User: ${SMTP_USER}`);
console.log(`Admin Email: ${ADMIN_EMAIL}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log('----------------------');
// Create a transporter with ProtonMail-specific settings
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: parseInt(SMTP_PORT, 10),
secure: parseInt(SMTP_PORT, 10) === 465, // true for 465, false for other ports
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
tls: {
// Do not fail on invalid certs
rejectUnauthorized: false,
// Specific ciphers for ProtonMail
ciphers: 'SSLv3'
},
debug: true // Enable debug output
});
// Test the connection
console.log('Testing SMTP connection...');
transporter.verify((error, success) => {
if (error) {
console.error('SMTP Connection Error:', error);
console.error('\nTroubleshooting Tips:');
console.error('1. Check if your SMTP credentials are correct');
console.error('2. For ProtonMail, ensure you\'re using an app-specific password');
console.error('3. If using ProtonMail Bridge, make sure it\'s running');
console.error('4. Verify your server allows outgoing connections on the SMTP port');
process.exit(1);
} else {
console.log('SMTP Connection Successful!');
// Send a test email
console.log('\nSending a test email...');
const mailOptions = {
from: `"${WEBSITE_NAME}" <${SMTP_USER}>`,
to: ADMIN_EMAIL,
subject: 'Email Configuration Test',
text: 'This is a test email to verify your website\'s email configuration is working correctly.',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 5px;">
<h2 style="color: #333;">Email Configuration Test</h2>
<p>This is a test email to verify your website's email configuration is working correctly.</p>
<p><strong>Configuration Details:</strong></p>
<ul>
<li>SMTP Host: ${SMTP_HOST}</li>
<li>SMTP Port: ${SMTP_PORT}</li>
<li>From: ${SMTP_USER}</li>
<li>To: ${ADMIN_EMAIL}</li>
<li>Time: ${new Date().toISOString()}</li>
</ul>
<p style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666;">
This is an automated test email. If you received this, your email configuration is working correctly.
</p>
</div>
`
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
console.error('Error sending test email:', error);
process.exit(1);
} else {
console.log('Test email sent successfully!');
console.log('Message ID:', info.messageId);
console.log('Response:', info.response);
console.log('\nIf you received the test email, your email configuration is working correctly.');
process.exit(0);
}
});
}
});

239
package-lock.json generated
View File

@@ -20,9 +20,14 @@
"astro": "^5.2.3",
"astro-embed": "^0.9.0",
"astro-icon": "^1.1.5",
"csrf": "^3.1.0",
"dotenv": "^16.4.7",
"form-data": "^4.0.2",
"limax": "4.1.0",
"lodash.merge": "^4.6.2",
"node-fetch": "^3.3.2",
"nodemailer": "^6.10.0",
"rate-limiter-flexible": "^5.0.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"unpic": "^3.22.0"
@@ -3959,6 +3964,19 @@
"version": "0.0.1",
"license": "MIT"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -4458,6 +4476,20 @@
"uncrypto": "^0.1.3"
}
},
"node_modules/csrf": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz",
"integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==",
"license": "MIT",
"dependencies": {
"rndm": "1.2.0",
"tsscmp": "1.0.6",
"uid-safe": "2.1.5"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/css-select": {
"version": "5.1.0",
"license": "BSD-2-Clause",
@@ -4733,7 +4765,7 @@
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
@@ -4750,6 +4782,20 @@
"node": ">=4"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"license": "MIT"
@@ -4815,11 +4861,56 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ=="
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esast-util-from-estree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz",
@@ -5608,12 +5699,14 @@
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
@@ -5691,7 +5784,6 @@
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -5726,6 +5818,43 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/github-slugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
@@ -5799,6 +5928,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -5826,9 +5967,35 @@
"unenv": "^1.10.0"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -6977,6 +7144,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdast-util-definitions": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz",
@@ -8206,6 +8382,15 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g=="
},
"node_modules/nodemailer": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz",
"integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nopt": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
@@ -8984,6 +9169,21 @@
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
"integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/rate-limiter-flexible": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.5.tgz",
"integrity": "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==",
"license": "ISC"
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
@@ -9429,6 +9629,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rndm": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz",
"integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.31.0.tgz",
@@ -10213,6 +10419,15 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tsscmp": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
"license": "MIT",
"engines": {
"node": ">=0.6.x"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@@ -10295,6 +10510,18 @@
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"license": "ISC"
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",

View File

@@ -34,9 +34,14 @@
"astro": "^5.2.3",
"astro-embed": "^0.9.0",
"astro-icon": "^1.1.5",
"csrf": "^3.1.0",
"dotenv": "^16.4.7",
"form-data": "^4.0.2",
"limax": "4.1.0",
"lodash.merge": "^4.6.2",
"node-fetch": "^3.3.2",
"nodemailer": "^6.10.0",
"rate-limiter-flexible": "^5.0.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"unpic": "^3.22.0"

69
protonmail-test.cjs Normal file
View File

@@ -0,0 +1,69 @@
// Simple ProtonMail SMTP test
const nodemailer = require('nodemailer');
require('dotenv').config();
// Get SMTP settings from environment variables
const {
SMTP_HOST = 'smtp.protonmail.ch',
SMTP_PORT = '587',
SMTP_USER,
SMTP_PASS,
ADMIN_EMAIL
} = process.env;
console.log('ProtonMail SMTP Test');
console.log('-------------------');
console.log(`SMTP Host: ${SMTP_HOST}`);
console.log(`SMTP Port: ${SMTP_PORT}`);
console.log(`SMTP User: ${SMTP_USER}`);
console.log(`Admin Email: ${ADMIN_EMAIL}`);
// ProtonMail specific configuration
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: parseInt(SMTP_PORT, 10),
secure: false, // For ProtonMail, use false for port 587
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
tls: {
// Do not fail on invalid certs
rejectUnauthorized: false,
// Specific ciphers for ProtonMail
ciphers: 'SSLv3'
},
logger: true,
debug: true // Include SMTP traffic in the logs
});
// Verify connection
console.log('\nTesting connection to ProtonMail SMTP server...');
transporter.verify(function(error, _success) {
if (error) {
console.error('Connection failed:', error);
process.exit(1);
} else {
console.log('Server is ready to take our messages');
// Send test email
console.log('\nSending test email...');
transporter.sendMail({
from: SMTP_USER,
to: ADMIN_EMAIL,
subject: 'ProtonMail SMTP Test',
text: 'This is a test email from your website contact form.',
html: '<p>This is a test email from your website contact form.</p>'
}, (err, info) => {
if (err) {
console.error('Error sending email:', err);
process.exit(1);
} else {
console.log('Message sent successfully!');
console.log('Message ID:', info.messageId);
console.log('Response:', info.response);
process.exit(0);
}
});
}
});

97
protonmail-test2.cjs Normal file
View File

@@ -0,0 +1,97 @@
// ProtonMail SMTP test with alternative configuration
const nodemailer = require('nodemailer');
require('dotenv').config();
// Get SMTP settings from environment variables
const {
SMTP_USER,
SMTP_PASS,
ADMIN_EMAIL
} = process.env;
console.log('ProtonMail SMTP Test (Alternative Configuration)');
console.log('----------------------------------------------');
console.log(`SMTP User: ${SMTP_USER}`);
console.log(`Admin Email: ${ADMIN_EMAIL}`);
// Try alternative ProtonMail configuration
// ProtonMail Bridge typically uses localhost:1025 or localhost:1143
const transporterOptions = {
host: 'mail.protonmail.ch',
port: 443,
secure: true,
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
tls: {
rejectUnauthorized: false
},
logger: true,
debug: true
};
console.log('\nUsing configuration:');
console.log(`Host: ${transporterOptions.host}`);
console.log(`Port: ${transporterOptions.port}`);
console.log(`Secure: ${transporterOptions.secure}`);
const transporter = nodemailer.createTransport(transporterOptions);
// Verify connection
console.log('\nTesting connection to ProtonMail SMTP server...');
transporter.verify(function(error, _success) {
if (error) {
console.error('Connection failed:', error);
console.log('\nTrying alternative port (25)...');
// Try port 25
const transporter2 = nodemailer.createTransport({
...transporterOptions,
port: 25,
secure: false
});
transporter2.verify(function(error2, _success2) {
if (error2) {
console.error('Connection with port 25 also failed:', error2);
console.log('\nImportant ProtonMail SMTP Notes:');
console.log('1. ProtonMail requires the Bridge application for SMTP access from third-party apps');
console.log('2. The Bridge runs locally and provides SMTP access via localhost:1025 or similar');
console.log('3. You may need to install and configure ProtonMail Bridge on your server');
console.log('4. Or use ProtonMail\'s API instead of SMTP for sending emails');
process.exit(1);
} else {
console.log('Connection successful with port 25!');
sendTestEmail(transporter2);
}
});
} else {
console.log('Server is ready to take our messages');
sendTestEmail(transporter);
}
});
function sendTestEmail(transport) {
// Send test email
console.log('\nSending test email...');
transport.sendMail({
from: SMTP_USER,
to: ADMIN_EMAIL,
subject: 'ProtonMail SMTP Test (Alternative Config)',
text: 'This is a test email from your website contact form.',
html: '<p>This is a test email from your website contact form.</p>'
}, (err, info) => {
if (err) {
console.error('Error sending email:', err);
process.exit(1);
} else {
console.log('Message sent successfully!');
console.log('Message ID:', info.messageId);
console.log('Response:', info.response);
process.exit(0);
}
});
}

View File

@@ -452,3 +452,211 @@ import { UI } from 'astrowind:config';
Observer.start();
});
</script>
<!-- Contact Form Handling -->
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
setupContactForm();
});
document.addEventListener('astro:after-swap', () => {
setupContactForm();
});
// Fetch CSRF token from the server
async function fetchCsrfToken() {
try {
const response = await fetch('/api/contact?csrf=true');
const data = await response.json();
return data.csrfToken;
} catch (error) {
console.error('Error fetching CSRF token:', error);
return '';
}
}
async function setupContactForm() {
const contactForm = document.getElementById('contact-form');
if (!contactForm) return;
// Get CSRF token and set it in the form
const csrfTokenInput = contactForm.querySelector('#csrf_token');
if (csrfTokenInput) {
const token = await fetchCsrfToken();
csrfTokenInput.value = token;
}
// Form validation and submission
contactForm.addEventListener('submit', async function(e) {
e.preventDefault();
// Reset previous error messages
resetFormErrors();
// Client-side validation
if (!validateForm(contactForm)) {
return;
}
// Show loading state
const submitButton = contactForm.querySelector('button[type="submit"]');
const originalButtonText = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML = 'Sending...';
try {
const formData = new FormData(contactForm);
// Add timestamp to help prevent duplicate submissions
formData.append('timestamp', Date.now().toString());
const response = await fetch('/api/contact', {
method: 'POST',
body: formData,
headers: {
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.success) {
// Show success message
document.getElementById('form-success').classList.remove('hidden');
contactForm.reset();
// Get a new CSRF token for next submission
const newToken = await fetchCsrfToken();
if (csrfTokenInput) {
csrfTokenInput.value = newToken;
}
} else {
// Show error messages
document.getElementById('form-error').classList.remove('hidden');
if (result.errors) {
Object.keys(result.errors).forEach(field => {
const inputElement = contactForm.querySelector(`[name="${field}"]`);
if (inputElement) {
const feedbackElement = inputElement.closest('div').querySelector('.invalid-feedback');
if (feedbackElement) {
feedbackElement.textContent = result.errors[field];
feedbackElement.classList.remove('hidden');
inputElement.classList.add('border-red-500');
}
}
});
}
// If CSRF token is invalid, get a new one
if (result.errors && result.errors.csrf) {
const newToken = await fetchCsrfToken();
if (csrfTokenInput) {
csrfTokenInput.value = newToken;
}
}
}
} catch (error) {
console.error('Error submitting form:', error);
document.getElementById('form-error').classList.remove('hidden');
} finally {
// Restore button state
submitButton.disabled = false;
submitButton.innerHTML = originalButtonText;
}
});
// Add input validation on blur
contactForm.querySelectorAll('input, textarea').forEach(input => {
input.addEventListener('blur', function() {
validateInput(this);
});
input.addEventListener('input', function() {
// Remove error styling when user starts typing
this.classList.remove('border-red-500');
const feedbackElement = this.closest('div').querySelector('.invalid-feedback');
if (feedbackElement) {
feedbackElement.classList.add('hidden');
}
});
});
}
function validateForm(form) {
let isValid = true;
// Validate all inputs
form.querySelectorAll('input, textarea').forEach(input => {
if (!validateInput(input)) {
isValid = false;
}
});
return isValid;
}
function validateInput(input) {
if (!input.required) return true;
let isValid = true;
const feedbackElement = input.closest('div').querySelector('.invalid-feedback');
// Reset previous error
if (feedbackElement) {
feedbackElement.classList.add('hidden');
}
input.classList.remove('border-red-500');
// Check if empty
if (input.required && !input.value.trim()) {
isValid = false;
if (feedbackElement) {
feedbackElement.textContent = 'This field is required';
feedbackElement.classList.remove('hidden');
}
input.classList.add('border-red-500');
}
// Email validation
if (input.type === 'email' && input.value.trim()) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(input.value.trim())) {
isValid = false;
if (feedbackElement) {
feedbackElement.textContent = 'Please enter a valid email address';
feedbackElement.classList.remove('hidden');
}
input.classList.add('border-red-500');
}
}
// Textarea minimum length
if (input.tagName === 'TEXTAREA' && input.value.trim().length < 10) {
isValid = false;
if (feedbackElement) {
feedbackElement.textContent = 'Please enter at least 10 characters';
feedbackElement.classList.remove('hidden');
}
input.classList.add('border-red-500');
}
return isValid;
}
function resetFormErrors() {
// Hide all error messages
document.querySelectorAll('.invalid-feedback').forEach(el => {
el.classList.add('hidden');
});
// Remove error styling
document.querySelectorAll('input, textarea').forEach(input => {
input.classList.remove('border-red-500');
});
// Hide form-level messages
document.getElementById('form-success')?.classList.add('hidden');
document.getElementById('form-error')?.classList.add('hidden');
}
</script>

View File

@@ -5,16 +5,28 @@ import Button from '~/components/ui/Button.astro';
const { inputs, textarea, disclaimer, button = 'Contact us', description = '' } = Astro.props;
---
<form>
<form id="contact-form" action="/api/contact" method="POST" class="needs-validation" novalidate>
<!-- Form status messages -->
<div id="form-success" class="hidden mb-6 p-4 bg-green-100 border border-green-200 text-green-700 rounded-lg">
Your message has been sent successfully. We will get back to you soon!
</div>
<div id="form-error" class="hidden mb-6 p-4 bg-red-100 border border-red-200 text-red-700 rounded-lg">
There was an error sending your message. Please try again.
</div>
<!-- CSRF Token - Will be populated via JavaScript -->
<input type="hidden" name="csrf_token" id="csrf_token" value="" />
{
inputs &&
inputs.map(
({ type = 'text', name, label = '', autocomplete = 'on', placeholder = '' }) =>
({ type = 'text', name, label = '', autocomplete = 'on', placeholder = '', required = true }) =>
name && (
<div class="mb-6">
{label && (
<label for={name} class="block text-sm font-medium">
{label}
{label}{required && <span class="text-red-600">*</span>}
</label>
)}
<input
@@ -24,7 +36,9 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
autocomplete={autocomplete}
placeholder={placeholder}
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
required={required}
/>
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div>
</div>
)
)
@@ -32,9 +46,9 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
{
textarea && (
<div>
<div class="mb-6">
<label for="textarea" class="block text-sm font-medium">
{textarea.label}
{textarea.label}<span class="text-red-600">*</span>
</label>
<textarea
id="textarea"
@@ -42,26 +56,30 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
rows={textarea.rows ? textarea.rows : 4}
placeholder={textarea.placeholder}
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
required
/>
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div>
</div>
)
}
{
disclaimer && (
<div class="mt-3 flex items-start">
<div class="mt-3 flex items-start mb-6">
<div class="flex mt-0.5">
<input
id="disclaimer"
name="disclaimer"
type="checkbox"
class="cursor-pointer mt-1 py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
required
/>
</div>
<div class="ml-3">
<label for="disclaimer" class="cursor-pointer select-none text-sm text-gray-600 dark:text-gray-400">
{disclaimer.label}
{disclaimer.label}<span class="text-red-600">*</span>
</label>
<div class="invalid-feedback hidden text-red-600 text-sm mt-1"></div>
</div>
</div>
)

View File

@@ -0,0 +1,72 @@
# Email Handling System
This directory contains the email templates and utilities for the contact form email handling system.
## Features
- **Secure SMTP Authentication**: Uses environment variables for credentials
- **Email Templates**: Customizable templates for both user confirmation and admin notification emails
- **Rate Limiting**: Prevents abuse by limiting the number of submissions per IP address
- **CSRF Protection**: Prevents cross-site request forgery attacks
- **Email Validation**: Ensures valid email addresses are provided
- **Spam Prevention**: Multiple checks to detect and block spam submissions
- **Error Handling**: Proper error handling with client feedback
- **Logging**: Comprehensive logging of email sending attempts
## Configuration
The email system is configured using environment variables in the `.env` file:
```
# SMTP Configuration
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-email@example.com
SMTP_PASS=your-password
# Email Settings
ADMIN_EMAIL=admin@example.com
WEBSITE_NAME=Your Website Name
# Environment
NODE_ENV=development
```
In development mode, emails are logged to the console instead of being sent. Set `NODE_ENV=production` to send actual emails.
## Files
- `admin-notification.ts`: Template for emails sent to the admin
- `user-confirmation.ts`: Template for confirmation emails sent to users
- `../utils/email-handler.ts`: Core email handling functionality
## How It Works
1. When a user submits the contact form, the client-side JavaScript validates the form and sends it to the `/api/contact` endpoint.
2. The endpoint validates the form data, checks for CSRF token validity, and performs rate limiting and spam detection.
3. If all checks pass, two emails are sent:
- A notification email to the admin with the form data
- A confirmation email to the user acknowledging receipt of their message
4. The system logs all email sending attempts for monitoring and debugging.
## Development vs. Production
- In development mode (`NODE_ENV=development`), emails are logged to the console instead of being sent.
- In production mode (`NODE_ENV=production`), emails are sent using the configured SMTP server.
## Security Considerations
- SMTP credentials are stored in environment variables, not in the code
- CSRF tokens are used to prevent cross-site request forgery
- Rate limiting prevents abuse of the contact form
- Form data is validated both on the client and server side
- Spam detection helps prevent unwanted messages
## Testing
To test the email system:
1. Configure the `.env` file with your SMTP settings
2. Submit the contact form on the website
3. Check the logs for email sending attempts
4. In production mode, check your inbox for the actual emails

View File

@@ -0,0 +1,111 @@
interface AdminNotificationProps {
name: string;
email: string;
message: string;
submittedAt: string;
ipAddress?: string;
userAgent?: string;
}
export function getAdminNotificationSubject(): string {
return 'New Contact Form Submission from bergsma.it';
}
export function getAdminNotificationHtml(props: AdminNotificationProps): string {
const { name, email, message, submittedAt, ipAddress, userAgent } = props;
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Contact Form Submission</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.content {
background-color: #ffffff;
padding: 15px;
border-radius: 5px;
border: 1px solid #e0e0e0;
}
.message-box {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
border-left: 3px solid #007bff;
margin: 15px 0;
}
.footer {
font-size: 12px;
color: #777;
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e0e0e0;
}
.meta {
font-size: 12px;
color: #777;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="header">
<h2>New Contact Form Submission</h2>
</div>
<div class="content">
<p><strong>From:</strong> ${name} (${email})</p>
<p><strong>Submitted on:</strong> ${submittedAt}</p>
<div class="message-box">
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>
</div>
<div class="meta">
<p><strong>Additional Information:</strong></p>
<p>IP Address: ${ipAddress || 'Not available'}</p>
<p>User Agent: ${userAgent || 'Not available'}</p>
</div>
</div>
<div class="footer">
<p>This is an automated email from your website contact form.</p>
</div>
</body>
</html>
`;
}
export function getAdminNotificationText(props: AdminNotificationProps): string {
const { name, email, message, submittedAt, ipAddress, userAgent } = props;
return `
New Contact Form Submission
From: ${name} (${email})
Submitted on: ${submittedAt}
Message:
${message}
Additional Information:
IP Address: ${ipAddress || 'Not available'}
User Agent: ${userAgent || 'Not available'}
This is an automated email from your website contact form.
`;
}

View File

@@ -0,0 +1,120 @@
interface UserConfirmationProps {
name: string;
email: string;
message: string;
submittedAt: string;
websiteName?: string;
contactEmail?: string;
}
export function getUserConfirmationSubject(websiteName: string = 'bergsma.it'): string {
return `Thank you for contacting ${websiteName}`;
}
export function getUserConfirmationHtml(props: UserConfirmationProps): string {
const {
name,
message,
submittedAt,
websiteName = 'bergsma.it',
contactEmail = 'richard@bergsma.it'
} = props;
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thank you for contacting us</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.content {
background-color: #ffffff;
padding: 15px;
border-radius: 5px;
border: 1px solid #e0e0e0;
}
.message-box {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
border-left: 3px solid #28a745;
margin: 15px 0;
}
.footer {
font-size: 12px;
color: #777;
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e0e0e0;
}
</style>
</head>
<body>
<div class="header">
<h2>Thank you for contacting ${websiteName}</h2>
</div>
<div class="content">
<p>Dear ${name},</p>
<p>Thank you for reaching out to us. We have received your message and will get back to you as soon as possible.</p>
<div class="message-box">
<p><strong>Your message (submitted on ${submittedAt}):</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>
</div>
<p>If you have any additional questions or information to provide, please feel free to reply to this email.</p>
<p>Best regards,<br>
The ${websiteName} Team</p>
</div>
<div class="footer">
<p>If you did not submit this contact form, please disregard this email or contact us at ${contactEmail}.</p>
</div>
</body>
</html>
`;
}
export function getUserConfirmationText(props: UserConfirmationProps): string {
const {
name,
message,
submittedAt,
websiteName = 'bergsma.it',
contactEmail = 'richard@bergsma.it'
} = props;
return `
Thank you for contacting ${websiteName}
Dear ${name},
Thank you for reaching out to us. We have received your message and will get back to you as soon as possible.
Your message (submitted on ${submittedAt}):
${message}
If you have any additional questions or information to provide, please feel free to reply to this email.
Best regards,
The ${websiteName} Team
If you did not submit this contact form, please disregard this email or contact us at ${contactEmail}.
`;
}

259
src/pages/api/contact.ts Normal file
View File

@@ -0,0 +1,259 @@
import type { APIRoute } from 'astro';
import {
generateCsrfToken,
validateCsrfToken,
checkRateLimit,
sendAdminNotification,
sendUserConfirmation
} from '../../utils/email-handler';
// Enhanced email validation with more comprehensive regex
const isValidEmail = (email: string): boolean => {
// Simpler regex to avoid escape character issues
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Enhanced spam protection - check for common spam patterns and characteristics
const isSpam = (content: string, name: string, email: string): boolean => {
// Convert to lowercase for case-insensitive matching
const lowerContent = content.toLowerCase();
const lowerName = name.toLowerCase();
const lowerEmail = email.toLowerCase();
// Common spam keywords
const spamPatterns = [
'viagra', 'cialis', 'casino', 'lottery', 'prize', 'winner',
'free money', 'buy now', 'click here', 'earn money', 'make money',
'investment opportunity', 'bitcoin', 'cryptocurrency', 'forex',
'weight loss', 'diet pill', 'enlargement', 'cheap medication'
];
// Check for spam keywords in content
if (spamPatterns.some(pattern => lowerContent.includes(pattern))) {
return true;
}
// Check for spam keywords in name or email
if (spamPatterns.some(pattern => lowerName.includes(pattern) || lowerEmail.includes(pattern))) {
return true;
}
// Check for excessive capitalization (shouting)
const uppercaseRatio = (content.match(/[A-Z]/g) || []).length / content.length;
if (uppercaseRatio > 0.5 && content.length > 20) {
return true;
}
// Check for excessive special characters
const specialChars = "!@#$%^&*()_+-=[]{}\\|;:'\",.<>/?";
let specialCharCount = 0;
for (let i = 0; i < content.length; i++) {
if (specialChars.includes(content[i])) {
specialCharCount++;
}
}
const specialCharRatio = specialCharCount / content.length;
if (specialCharRatio > 0.3 && content.length > 20) {
return true;
}
// Check for excessive URLs - count http:// and https:// occurrences
const urlCount = content.split('http').length - 1;
if (urlCount > 2) {
return true;
}
return false;
};
// GET handler for CSRF token generation and API testing
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const csrfRequested = url.searchParams.get('csrf') === 'true';
if (csrfRequested) {
// Generate and return a CSRF token
const csrfToken = generateCsrfToken();
return new Response(
JSON.stringify({
csrfToken
}),
{
status: 200,
headers: {
'Content-Type': 'application/json'
}
}
);
}
// Default response for GET requests
return new Response(
JSON.stringify({
message: 'Contact API endpoint is working. Please use POST to submit the form.'
}),
{
status: 200,
headers: {
'Content-Type': 'application/json'
}
}
);
};
export const POST: APIRoute = async ({ request, clientAddress }) => {
try {
console.log('Contact form submission received');
// Get client IP address for rate limiting
const ipAddress = clientAddress || '0.0.0.0';
console.log('Client IP:', ipAddress);
// Check rate limit
const rateLimitCheck = await checkRateLimit(ipAddress);
console.log('Rate limit check:', rateLimitCheck);
if (rateLimitCheck.limited) {
console.log('Rate limit exceeded');
return new Response(
JSON.stringify({
success: false,
errors: {
rateLimit: rateLimitCheck.message
}
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '3600'
}
}
);
}
// Get form data
const formData = await request.formData();
console.log('Form data received');
// Log all form data keys
console.log('Form data keys:', [...formData.keys()]);
const name = formData.get('name')?.toString() || '';
const email = formData.get('email')?.toString() || '';
const message = formData.get('message')?.toString() || '';
const disclaimer = formData.get('disclaimer')?.toString() === 'on';
const csrfToken = formData.get('csrf_token')?.toString() || '';
console.log('Form data values:', { name, email, messageLength: message.length, disclaimer, csrfToken: csrfToken ? 'present' : 'missing' });
// Get user agent for logging and spam detection
const userAgent = request.headers.get('user-agent') || 'Unknown';
// Validate form data
const errors: Record<string, string> = {};
// Validate CSRF token
if (!validateCsrfToken(csrfToken)) {
errors.csrf = 'Invalid or expired security token. Please refresh the page and try again.';
}
if (!name || name.length < 2) {
errors.name = 'Name is required and must be at least 2 characters';
}
if (!email || !isValidEmail(email)) {
errors.email = 'A valid email address is required';
}
if (!message || message.length < 10) {
errors.message = 'Message is required and must be at least 10 characters';
}
if (!disclaimer) {
errors.disclaimer = 'You must agree to the disclaimer';
}
// Check for spam
if (isSpam(message, name, email)) {
errors.spam = 'Your message was flagged as potential spam. Please revise your message and try again.';
}
// If there are validation errors, return them
if (Object.keys(errors).length > 0) {
return new Response(
JSON.stringify({
success: false,
errors
}),
{
status: 400,
headers: {
'Content-Type': 'application/json'
}
}
);
}
// Send emails
console.log('Attempting to send admin notification email');
const adminEmailSent = await sendAdminNotification(name, email, message, ipAddress, userAgent);
console.log('Admin email sent result:', adminEmailSent);
console.log('Attempting to send user confirmation email');
const userEmailSent = await sendUserConfirmation(name, email, message);
console.log('User email sent result:', userEmailSent);
// Check if emails were sent successfully
if (!adminEmailSent || !userEmailSent) {
console.error('Failed to send one or more emails:', { adminEmailSent, userEmailSent });
return new Response(
JSON.stringify({
success: false,
message: 'There was an issue sending your message. Please try again later.'
}),
{
status: 500,
headers: {
'Content-Type': 'application/json'
}
}
);
}
// Return success response
return new Response(
JSON.stringify({
success: true,
message: 'Your message has been sent successfully. We will get back to you soon!'
}),
{
status: 200,
headers: {
'Content-Type': 'application/json'
}
}
);
} catch (error) {
console.error('Error processing contact form:', error);
return new Response(
JSON.stringify({
success: false,
message: 'An error occurred while processing your request. Please try again later.'
}),
{
status: 500,
headers: {
'Content-Type': 'application/json'
}
}
);
}
};

View File

@@ -37,7 +37,7 @@ const metadata = {
label:
'By submitting this contact form, you acknowledge and agree to the collection of your personal information.',
}}
description="Our support team typically responds within 24 business hours."
description={`Our support team typically responds within 24 business hours. Emails will be sent to: ${process.env.ADMIN_EMAIL || 'richard@bergsma.it'}`}
/>
<!-- Features2 Widget ************** -->

31
src/test-email.ts Normal file
View File

@@ -0,0 +1,31 @@
import { testEmailConfiguration, sendAdminNotification } from '../utils/email-handler';
import 'dotenv/config';
async function runEmailTest() {
console.log('Starting email configuration test...');
// Test the SMTP connection
const configTest = await testEmailConfiguration();
console.log(`Configuration test result: ${configTest ? 'SUCCESS' : 'FAILED'}`);
if (configTest) {
// Try sending a test email
console.log('Attempting to send a test email...');
const emailResult = await sendAdminNotification(
'Test User',
'test@example.com',
'This is a test message sent at ' + new Date().toISOString(),
'127.0.0.1',
'Email Test Script'
);
console.log(`Test email result: ${emailResult ? 'SENT' : 'FAILED'}`);
}
console.log('Email test completed');
}
runEmailTest().catch(error => {
console.error('Error running email test:', error);
process.exit(1);
});

1
src/types.d.ts vendored
View File

@@ -169,6 +169,7 @@ export interface Input {
label?: string;
autocomplete?: string;
placeholder?: string;
required?: boolean;
}
export interface Textarea {

426
src/utils/email-handler.ts Normal file
View File

@@ -0,0 +1,426 @@
import nodemailer from 'nodemailer';
import { RateLimiterMemory } from 'rate-limiter-flexible';
import { createHash } from 'crypto';
import { getAdminNotificationHtml, getAdminNotificationText, getAdminNotificationSubject } from '../email-templates/admin-notification';
import { getUserConfirmationHtml, getUserConfirmationText, getUserConfirmationSubject } from '../email-templates/user-confirmation';
import 'dotenv/config';
// Environment variables
const {
SMTP_HOST = '',
SMTP_PORT = '587',
SMTP_USER = '',
SMTP_PASS = '',
ADMIN_EMAIL = 'richard@bergsma.it',
WEBSITE_NAME = 'bergsma.it',
NODE_ENV = 'development'
} = process.env;
// Email configuration
const isProduction = NODE_ENV === 'production';
// Create a transporter for sending emails
let transporter: nodemailer.Transporter;
// Initialize the transporter based on environment
function initializeTransporter() {
if (isProduction && SMTP_HOST && SMTP_USER && SMTP_PASS) {
// Production: Use SMTP server
// ProtonMail specific configuration
// ProtonMail often requires using their Bridge application for SMTP
const isProtonMail = SMTP_HOST.includes('protonmail');
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: parseInt(SMTP_PORT, 10),
secure: parseInt(SMTP_PORT, 10) === 465, // true for 465, false for other ports
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
// ProtonMail specific settings
...(isProtonMail && {
tls: {
// Do not fail on invalid certs
rejectUnauthorized: false,
// Specific ciphers for ProtonMail
ciphers: 'SSLv3'
}
}),
debug: true, // Enable debug output for troubleshooting
});
// Verify SMTP connection configuration
transporter.verify(function(error, _success) {
if (error) {
console.error('SMTP connection error:', error);
} else {
console.log('SMTP server is ready to take our messages');
}
});
} else {
// Development: Log emails to console
transporter = nodemailer.createTransport({
streamTransport: true,
newline: 'unix',
buffer: true,
});
}
}
// Rate limiter configuration
const rateLimiter = new RateLimiterMemory({
points: 5, // 5 attempts
duration: 3600, // per hour
});
// CSRF protection
const csrfTokens = new Map<string, { token: string; expires: Date }>();
// Generate a CSRF token
export function generateCsrfToken(): string {
const token = createHash('sha256')
.update(Math.random().toString())
.digest('hex');
// Token expires after 1 hour
const expires = new Date();
expires.setHours(expires.getHours() + 1);
csrfTokens.set(token, { token, expires });
// Clean up expired tokens
for (const [key, value] of csrfTokens.entries()) {
if (value.expires < new Date()) {
csrfTokens.delete(key);
}
}
return token;
}
// Validate a CSRF token
export function validateCsrfToken(token: string): boolean {
const storedToken = csrfTokens.get(token);
if (!storedToken) {
return false;
}
if (storedToken.expires < new Date()) {
csrfTokens.delete(token);
return false;
}
return true;
}
// Check rate limit for an IP address
export async function checkRateLimit(ipAddress: string): Promise<{ limited: boolean; message?: string }> {
try {
await rateLimiter.consume(ipAddress);
return { limited: false };
} catch (error) {
if (error instanceof Error) {
return {
limited: true,
message: 'Too many requests. Please try again later.',
};
}
// RateLimiterRes with msBeforeNext property
interface RateLimiterResponse {
msBeforeNext: number;
}
const resetTime = Math.ceil((error as RateLimiterResponse).msBeforeNext / 1000 / 60);
return {
limited: true,
message: `Too many requests. Please try again in ${resetTime} minutes.`,
};
}
}
// Log email sending attempts
export function logEmailAttempt(
success: boolean,
recipient: string,
subject: string,
error?: Error
): void {
const timestamp = new Date().toISOString();
const status = success ? 'SUCCESS' : 'FAILURE';
const errorMessage = error ? `: ${error.message}` : '';
const logMessage = `[${timestamp}] [EMAIL ${status}] To: ${recipient}, Subject: ${subject}${errorMessage}`;
if (isProduction) {
// In production, you might want to log to a file or a logging service
console.log(logMessage);
} else {
// In development, log to console
console.log(logMessage);
}
}
// Send an email
export async function sendEmail(
to: string,
subject: string,
html: string,
text: string
): Promise<boolean> {
console.log(`Attempting to send email to: ${to}`);
console.log(`Subject: ${subject}`);
// Initialize transporter if not already done
if (!transporter) {
console.log('Initializing transporter');
initializeTransporter();
}
try {
const mailOptions = {
from: `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`,
to,
subject,
html,
text,
};
console.log('Mail options:', {
from: `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`,
to,
subject,
textLength: text.length,
htmlLength: html.length
});
console.log('Sending email via transporter');
const info = await transporter.sendMail(mailOptions);
console.log('Email sent, info:', info.messageId);
if (!isProduction) {
// In development, log the email content
console.log('Email sent (development mode):');
console.log('To:', to);
console.log('Subject:', subject);
console.log('Preview:', nodemailer.getTestMessageUrl(info));
if (info.message) {
// For stream transport, we can get the message content
console.log('Message:', info.message.toString());
}
}
logEmailAttempt(true, to, subject);
return true;
} catch (error) {
logEmailAttempt(false, to, subject, error as Error);
// Enhanced error logging for SMTP issues
if (isProduction) {
console.error('Error sending email:', error);
// Log more detailed information for SMTP errors
if (error instanceof Error) {
console.error('Error name:', error.name);
console.error('Error message:', error.message);
// Log additional details for specific error types
if (error.name === 'Error' && error.message.includes('ECONNREFUSED')) {
console.error('SMTP Connection Refused: Check if the SMTP server is reachable and the port is correct');
} else if (error.message.includes('Invalid login')) {
console.error('SMTP Authentication Failed: Check your username and password');
} else if (error.message.includes('certificate')) {
console.error('SSL/TLS Certificate Error: There might be an issue with the server certificate');
}
}
}
return false;
}
}
// Send admin notification email
export async function sendAdminNotification(
name: string,
email: string,
message: string,
ipAddress?: string,
userAgent?: string
): Promise<boolean> {
console.log('sendAdminNotification called with:', { name, email, messageLength: message.length });
// Validate inputs
if (!name || name.trim() === '') {
console.error('Cannot send admin notification: name is empty');
return false;
}
if (!email || email.trim() === '') {
console.error('Cannot send admin notification: email is empty');
return false;
}
if (!message || message.trim() === '') {
console.error('Cannot send admin notification: message is empty');
return false;
}
if (!ADMIN_EMAIL || ADMIN_EMAIL.trim() === '') {
console.error('Cannot send admin notification: ADMIN_EMAIL is not configured');
return false;
}
const submittedAt = new Date().toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const props = {
name,
email,
message,
submittedAt,
ipAddress,
userAgent,
};
console.log('Generating admin notification email content');
const subject = getAdminNotificationSubject();
const html = getAdminNotificationHtml(props);
const text = getAdminNotificationText(props);
console.log(`Sending admin notification to: ${ADMIN_EMAIL}`);
console.log('Admin email environment variables:', {
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
ADMIN_EMAIL,
WEBSITE_NAME,
NODE_ENV,
isProduction
});
// Add a backup email address to ensure delivery
const recipients = ADMIN_EMAIL;
// Uncomment and modify the line below to add a backup email address
// const recipients = `${ADMIN_EMAIL}, your-backup-email@example.com`;
return sendEmail(recipients, subject, html, text);
}
// Send user confirmation email
export async function sendUserConfirmation(
name: string,
email: string,
message: string
): Promise<boolean> {
console.log('sendUserConfirmation called with:', { name, email, messageLength: message.length });
if (!email || email.trim() === '') {
console.error('Cannot send user confirmation: email is empty');
return false;
}
const submittedAt = new Date().toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const props = {
name,
email,
message,
submittedAt,
websiteName: WEBSITE_NAME,
contactEmail: ADMIN_EMAIL,
};
console.log('Generating user confirmation email content');
const subject = getUserConfirmationSubject(WEBSITE_NAME);
const html = getUserConfirmationHtml(props);
const text = getUserConfirmationText(props);
console.log(`Sending user confirmation to: ${email}`);
return sendEmail(email, subject, html, text);
}
// Initialize the email system
export function initializeEmailSystem(): void {
initializeTransporter();
// Log initialization
console.log(`Email system initialized in ${isProduction ? 'production' : 'development'} mode`);
if (!isProduction) {
console.log('Emails will be logged to console instead of being sent');
}
}
// Initialize on import
initializeEmailSystem();
// Test email function to verify configuration
export async function testEmailConfiguration(): Promise<boolean> {
if (!isProduction) {
console.log('Email testing skipped in development mode');
return true;
}
try {
// Initialize transporter if not already done
if (!transporter) {
initializeTransporter();
}
console.log('Testing email configuration...');
console.log(`SMTP Host: ${SMTP_HOST}`);
console.log(`SMTP Port: ${SMTP_PORT}`);
console.log(`SMTP User: ${SMTP_USER}`);
console.log(`From Email: ${ADMIN_EMAIL}`);
// Verify connection to SMTP server
const connectionResult = await new Promise<boolean>((resolve) => {
transporter.verify(function(error, _success) {
if (error) {
console.error('SMTP connection test failed:', error);
resolve(false);
} else {
console.log('SMTP connection successful');
resolve(true);
}
});
});
if (!connectionResult) {
return false;
}
console.log('Email configuration test completed successfully');
return true;
} catch (error) {
console.error('Error testing email configuration:', error);
return false;
}
}
// Run a test of the email configuration
if (isProduction) {
testEmailConfiguration().then(success => {
if (success) {
console.log('Email system is properly configured');
} else {
console.error('Email system configuration test failed');
console.log('Note: If you continue to have issues with ProtonMail SMTP:');
console.log('1. Ensure ProtonMail Bridge is installed and running if sending from a desktop/server');
console.log('2. Verify you\'re using an app-specific password, not your main account password');
console.log('3. Check if your server allows outgoing connections on the SMTP port');
}
});
}

50
test-contact-curl.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Test script for the contact form API using curl
# This script simulates a form submission to the contact form API
API_URL="http://localhost:4321/api/contact"
ADMIN_EMAIL="richard@bergsma.it"
echo "Starting contact form test with curl..."
echo "API URL: $API_URL"
# Step 1: Get CSRF token
echo "Getting CSRF token..."
CSRF_RESPONSE=$(curl -s "$API_URL?csrf=true")
echo "CSRF Response: $CSRF_RESPONSE"
# Extract CSRF token
CSRF_TOKEN=$(echo $CSRF_RESPONSE | grep -o '"csrfToken":"[^"]*"' | cut -d'"' -f4)
echo "CSRF Token: $CSRF_TOKEN"
if [ -z "$CSRF_TOKEN" ]; then
echo "Failed to get CSRF token. Aborting test."
exit 1
fi
# Step 2: Submit the form
echo "Submitting form..."
FORM_RESPONSE=$(curl -s -X POST "$API_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json" \
-H "User-Agent: test-contact-curl-script" \
--data-urlencode "name=Test User" \
--data-urlencode "email=$ADMIN_EMAIL" \
--data-urlencode "message=This is a test message from the test-contact-curl.sh script. $(date)" \
--data-urlencode "disclaimer=on" \
--data-urlencode "csrf_token=$CSRF_TOKEN" \
--data-urlencode "timestamp=$(date +%s)")
echo "Form submission response: $FORM_RESPONSE"
# Check if submission was successful
if echo "$FORM_RESPONSE" | grep -q '"success":true'; then
echo "Form submission successful!"
else
echo "Form submission failed."
echo "Response: $FORM_RESPONSE"
exit 1
fi
echo "Test completed successfully."

133
test-contact-form.cjs Normal file
View File

@@ -0,0 +1,133 @@
/**
* Test script for the contact form API
* This script simulates a form submission to the contact form API
*/
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
const FormData = require('form-data');
require('dotenv').config();
// URL of the contact form API
const API_URL = 'http://localhost:4321/api/contact';
// Function to get a CSRF token
async function getCsrfToken() {
try {
console.log(`Fetching CSRF token from ${API_URL}?csrf=true`);
const response = await fetch(`${API_URL}?csrf=true`);
console.log('CSRF response status:', response.status);
if (!response.ok) {
console.error('CSRF request failed:', response.statusText);
return null;
}
const text = await response.text();
console.log('CSRF response text:', text);
try {
const data = JSON.parse(text);
console.log('CSRF token data:', data);
return data.csrfToken;
} catch (parseError) {
console.error('Error parsing CSRF response:', parseError);
console.error('Response was not valid JSON:', text);
return null;
}
} catch (error) {
console.error('Error getting CSRF token:', error);
return null;
}
}
// Function to submit the form
async function submitForm(csrfToken) {
console.log('Creating form data for submission');
// Create form data
const formData = new FormData();
const testEmail = process.env.ADMIN_EMAIL || 'richard@bergsma.it';
const testMessage = 'This is a test message from the test-contact-form.cjs script. ' + new Date().toISOString();
formData.append('name', 'Test User');
formData.append('email', testEmail);
formData.append('message', testMessage);
formData.append('disclaimer', 'on');
formData.append('csrf_token', csrfToken);
formData.append('timestamp', Date.now().toString());
console.log('Submitting form with data:', {
name: 'Test User',
email: testEmail,
messageLength: testMessage.length,
disclaimer: 'on',
csrfToken: csrfToken ? 'present' : 'missing',
});
try {
console.log(`Sending POST request to ${API_URL}`);
const response = await fetch(API_URL, {
method: 'POST',
body: formData,
headers: {
'Accept': 'application/json',
'User-Agent': 'test-contact-form-script'
}
});
console.log('Response status:', response.status);
if (!response.ok) {
console.error('Form submission failed with status:', response.status, response.statusText);
}
const text = await response.text();
console.log('Response text:', text);
try {
const result = JSON.parse(text);
console.log('Form submission result:', result);
return result;
} catch (parseError) {
console.error('Error parsing response:', parseError);
console.error('Response was not valid JSON:', text);
return { success: false, error: 'Invalid JSON response' };
}
} catch (error) {
console.error('Error submitting form:', error);
return { success: false, error: error.message };
}
}
// Main function
async function main() {
console.log('Starting contact form test...');
console.log(`API URL: ${API_URL}`);
// Get CSRF token
console.log('Getting CSRF token...');
const csrfToken = await getCsrfToken();
if (!csrfToken) {
console.error('Failed to get CSRF token. Aborting test.');
process.exit(1);
}
console.log('CSRF token received:', csrfToken ? 'Yes' : 'No');
// Submit the form
console.log('Submitting form...');
const result = await submitForm(csrfToken);
if (result.success) {
console.log('Form submission successful!');
} else {
console.error('Form submission failed:', result);
}
}
// Run the test
main().catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});

31
test-email.ts Normal file
View File

@@ -0,0 +1,31 @@
import { testEmailConfiguration, sendAdminNotification } from './src/utils/email-handler.js';
import 'dotenv/config';
async function runEmailTest() {
console.log('Starting email configuration test...');
// Test the SMTP connection
const configTest = await testEmailConfiguration();
console.log(`Configuration test result: ${configTest ? 'SUCCESS' : 'FAILED'}`);
if (configTest) {
// Try sending a test email
console.log('Attempting to send a test email...');
const emailResult = await sendAdminNotification(
'Test User',
'test@example.com',
'This is a test message sent at ' + new Date().toISOString(),
'127.0.0.1',
'Email Test Script'
);
console.log(`Test email result: ${emailResult ? 'SENT' : 'FAILED'}`);
}
console.log('Email test completed');
}
runEmailTest().catch(error => {
console.error('Error running email test:', error);
process.exit(1);
});

88
test-smtp.js Normal file
View File

@@ -0,0 +1,88 @@
const nodemailer = require('nodemailer');
require('dotenv').config();
// Get SMTP settings from environment variables
const {
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASS,
ADMIN_EMAIL,
WEBSITE_NAME
} = process.env;
console.log('SMTP Configuration Test');
console.log('----------------------');
console.log(`SMTP Host: ${SMTP_HOST}`);
console.log(`SMTP Port: ${SMTP_PORT}`);
console.log(`SMTP User: ${SMTP_USER}`);
console.log(`Admin Email: ${ADMIN_EMAIL}`);
console.log(`Website Name: ${WEBSITE_NAME}`);
console.log('----------------------');
// Create a transporter
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: parseInt(SMTP_PORT, 10),
secure: parseInt(SMTP_PORT, 10) === 465,
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
// Required for ProtonMail
tls: {
ciphers: 'SSLv3',
rejectUnauthorized: false
},
debug: true, // Enable debug output
});
// Test the connection
console.log('Testing SMTP connection...');
transporter.verify((error, success) => {
if (error) {
console.error('SMTP Connection Error:', error);
console.error('Error Name:', error.name);
console.error('Error Message:', error.message);
// Provide troubleshooting advice based on error
if (error.message.includes('ECONNREFUSED')) {
console.error('\nTroubleshooting: Connection refused');
console.error('- Check if the SMTP server address is correct');
console.error('- Verify the port is correct and not blocked by a firewall');
console.error('- Ensure your internet connection is working');
} else if (error.message.includes('Invalid login') || error.message.includes('authentication failed')) {
console.error('\nTroubleshooting: Authentication failed');
console.error('- Verify your username and password are correct');
console.error('- For ProtonMail, ensure you\'re using an app-specific password');
console.error('- Check if 2FA is enabled and properly configured');
} else if (error.message.includes('certificate')) {
console.error('\nTroubleshooting: SSL/TLS Certificate Error');
console.error('- The server\'s SSL certificate could not be verified');
console.error('- This might be resolved by setting rejectUnauthorized: false (already set)');
}
} else {
console.log('SMTP Connection Successful!');
console.log('The server is ready to accept messages');
// Send a test email
console.log('\nSending a test email...');
const mailOptions = {
from: `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`,
to: ADMIN_EMAIL,
subject: 'SMTP Test Email',
text: 'This is a test email to verify SMTP configuration is working correctly.',
html: '<p>This is a test email to verify SMTP configuration is working correctly.</p>'
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
console.error('Error sending test email:', error);
} else {
console.log('Test email sent successfully!');
console.log('Message ID:', info.messageId);
console.log('Response:', info.response);
}
});
}
});