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

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');
}
});
}