Arbeiten an Funktionalität Kontaktformular, DSGVO
This commit is contained in:
parent
146a9494f5
commit
cf609fce1c
|
|
@ -20,3 +20,4 @@ pnpm-debug.log*
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.vercel
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -14,7 +14,8 @@
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"nodemailer": "^6.9.7"
|
"nodemailer": "^6.9.7",
|
||||||
|
"resend": "^6.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|
@ -1044,6 +1045,23 @@
|
||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-um08qWpSVvEVqAePEy/bsa7pqtnJK+qTCZ0Et7YE7xuqM46J0C9gnSbIJKR3LIcRVMgO9jUeot8rH0UI84eqMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "^1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,14 @@
|
||||||
"test": "echo 'No tests yet'"
|
"test": "echo 'No tests yet'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
|
||||||
"nodemailer": "^6.9.7",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"helmet": "^7.1.0",
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"dotenv": "^16.3.1"
|
"nodemailer": "^6.9.7",
|
||||||
|
"resend": "^6.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,93 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { body, validationResult } from 'express-validator';
|
||||||
import { sendContactEmail } from '../services/emailService.js';
|
import { sendContactEmail } from '../services/emailService.js';
|
||||||
import { validateContactForm } from '../middleware/validation.js';
|
|
||||||
import { asyncHandler } from '../middleware/asyncHandler.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// POST /api/contact - Send contact form email
|
// GDPR compliant rate limiting
|
||||||
router.post(
|
const contactLimiter = rateLimit({
|
||||||
'/',
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
validateContactForm,
|
max: 3, // Limit each IP to 3 requests per windowMs
|
||||||
asyncHandler(async (req, res) => {
|
message: {
|
||||||
const { firstName, lastName, email, subject, message } = req.body;
|
error: 'Too many contact requests from this IP, please try again later.',
|
||||||
|
retryAfter: 15 * 60,
|
||||||
// Check for honeypot (if present in body)
|
},
|
||||||
if (req.body.website && req.body.website.trim() !== '') {
|
standardHeaders: true,
|
||||||
console.warn('Spam detected: honeypot field filled', {
|
legacyHeaders: false,
|
||||||
ip: req.ip,
|
|
||||||
email,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GDPR compliant validation
|
||||||
|
const validateContactForm = [
|
||||||
|
body('firstName').trim().isLength({ min: 1, max: 50 }).escape(),
|
||||||
|
body('lastName').trim().isLength({ min: 1, max: 50 }).escape(),
|
||||||
|
body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }),
|
||||||
|
body('subject').trim().isLength({ min: 1, max: 100 }).escape(),
|
||||||
|
body('message').trim().isLength({ min: 10, max: 2000 }).escape(),
|
||||||
|
body('gdprConsent').equals('true').withMessage('GDPR consent required'),
|
||||||
|
body('website').optional().isEmpty(), // Honeypot
|
||||||
|
];
|
||||||
|
|
||||||
|
router.post('/', contactLimiter, validateContactForm, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Invalid form submission detected.',
|
message: 'Invalid form data.',
|
||||||
|
errors: errors.array(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const {
|
||||||
// Send the email
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
gdprConsent,
|
||||||
|
website,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Honeypot check
|
||||||
|
if (website) {
|
||||||
|
console.warn('🚨 Spam detected:', req.ip);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid submission detected.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GDPR consent check
|
||||||
|
if (gdprConsent !== 'true') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'GDPR consent required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email with minimal logging for GDPR
|
||||||
await sendContactEmail({
|
await sendContactEmail({
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
subject: subject || 'New Portfolio Contact',
|
subject,
|
||||||
message,
|
message,
|
||||||
senderIP: req.ip,
|
senderIP: req.ip,
|
||||||
userAgent: req.get('User-Agent'),
|
userAgent: req.get('User-Agent'),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Contact email sent successfully', {
|
|
||||||
from: email,
|
|
||||||
name: `${firstName} ${lastName}`,
|
|
||||||
subject: subject || 'New Portfolio Contact',
|
|
||||||
ip: req.ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message:
|
message: "Thank you for your message! I'll get back to you soon.",
|
||||||
"Your message has been sent successfully! I'll get back to you soon.",
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send contact email:', error);
|
console.error('❌ Contact error:', error.message); // No personal data in logs
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
message: 'Failed to send message. Please try again later.',
|
||||||
'Sorry, there was an error sending your message. Please try again later.',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/contact/test - Test endpoint (development only)
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
router.get('/test', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
message: 'Contact API is working!',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
environment: process.env.NODE_ENV,
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { router as contactRouter };
|
export { router as contactRouter };
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ async function startServer() {
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Trust proxy (required for Railway, Heroku, etc.)
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Security middleware
|
// Security middleware
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
|
|
@ -34,10 +37,14 @@ async function startServer() {
|
||||||
// CORS configuration
|
// CORS configuration
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
origin: [
|
||||||
methods: ['GET', 'POST'],
|
'http://localhost:5173',
|
||||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
'http://localhost:3000',
|
||||||
|
'https://portfolio-page-zeta-ten.vercel.app',
|
||||||
|
],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -53,6 +60,11 @@ async function startServer() {
|
||||||
},
|
},
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
// Add this for better proxy support
|
||||||
|
skip: (req) => {
|
||||||
|
// Skip rate limiting for health checks
|
||||||
|
return req.path === '/health';
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/', limiter);
|
app.use('/api/', limiter);
|
||||||
|
|
@ -81,7 +93,7 @@ async function startServer() {
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`🚀 Server is running on port ${PORT}`);
|
console.log(`🚀 Server is running on port ${PORT}`);
|
||||||
console.log(
|
console.log(
|
||||||
`📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}`
|
`📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}`
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,100 @@
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { createContactEmailTemplate } from '../templates/emailTemplates.js';
|
|
||||||
|
|
||||||
// Create email transporter
|
// Create email transporter for custom SMTP server
|
||||||
const createTransporter = () => {
|
const createTransporter = () => {
|
||||||
const config = {
|
console.log('🔧 Creating custom SMTP transporter with config:', {
|
||||||
service: process.env.EMAIL_SERVICE,
|
host: process.env.SMTP_HOST,
|
||||||
auth: {
|
port: process.env.SMTP_PORT,
|
||||||
user: process.env.EMAIL_USER,
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
pass: process.env.EMAIL_PASS,
|
user: process.env.SMTP_USER ? 'Set' : 'Missing',
|
||||||
},
|
pass: process.env.SMTP_PASS ? 'Set' : 'Missing',
|
||||||
};
|
});
|
||||||
|
|
||||||
// If not using a predefined service, use custom SMTP settings
|
if (
|
||||||
if (!process.env.EMAIL_SERVICE || process.env.EMAIL_SERVICE === 'custom') {
|
!process.env.SMTP_HOST ||
|
||||||
delete config.service;
|
!process.env.SMTP_USER ||
|
||||||
config.host = process.env.EMAIL_HOST;
|
!process.env.SMTP_PASS
|
||||||
config.port = parseInt(process.env.EMAIL_PORT) || 587;
|
) {
|
||||||
config.secure = process.env.EMAIL_SECURE === 'true';
|
throw new Error('Custom SMTP credentials not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodemailer.createTransport(config);
|
return nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||||
|
secure: process.env.SMTP_SECURE === 'true', // true for 465 (SSL), false for 587 (TLS)
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false, // Accept self-signed certificates if needed
|
||||||
|
ciphers: 'SSLv3', // For older servers if needed
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
connectionTimeout: 30000,
|
||||||
|
greetingTimeout: 10000,
|
||||||
|
socketTimeout: 30000,
|
||||||
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
logger: process.env.NODE_ENV === 'development',
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify email configuration
|
// Verify email configuration
|
||||||
export const verifyEmailConfig = async () => {
|
export const verifyEmailConfig = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Debug - Creating transporter with config:');
|
console.log('🔍 Verifying custom SMTP configuration...');
|
||||||
console.log('EMAIL_SERVICE:', process.env.EMAIL_SERVICE);
|
console.log('SMTP_HOST:', process.env.SMTP_HOST || 'Missing');
|
||||||
console.log('EMAIL_USER:', process.env.EMAIL_USER);
|
|
||||||
console.log(
|
console.log(
|
||||||
'EMAIL_PASS length:',
|
'SMTP_PORT:',
|
||||||
process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined'
|
process.env.SMTP_PORT || 'Missing (default: 587)'
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'SMTP_SECURE:',
|
||||||
|
process.env.SMTP_SECURE || 'Missing (default: false)'
|
||||||
|
);
|
||||||
|
console.log('SMTP_USER:', process.env.SMTP_USER ? 'Set' : 'Missing');
|
||||||
|
console.log('SMTP_PASS:', process.env.SMTP_PASS ? 'Set' : 'Missing');
|
||||||
|
console.log('FROM_EMAIL:', process.env.FROM_EMAIL ? 'Set' : 'Missing');
|
||||||
|
console.log(
|
||||||
|
'RECIPIENT_EMAIL:',
|
||||||
|
process.env.RECIPIENT_EMAIL ? 'Set' : 'Missing'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!process.env.SMTP_HOST ||
|
||||||
|
!process.env.SMTP_USER ||
|
||||||
|
!process.env.SMTP_PASS
|
||||||
|
) {
|
||||||
|
console.error('❌ Missing required SMTP credentials');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.FROM_EMAIL || !process.env.RECIPIENT_EMAIL) {
|
||||||
|
console.error('❌ Missing sender or recipient email');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip verification in production to avoid Railway timeout issues
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.log('🔧 Skipping SMTP verification in production environment');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection in development
|
||||||
const transporter = createTransporter();
|
const transporter = createTransporter();
|
||||||
await transporter.verify();
|
await transporter.verify();
|
||||||
console.log('✅ Email configuration verified successfully');
|
console.log('✅ SMTP configuration verified successfully');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Email configuration verification failed:', error.message);
|
console.error('❌ SMTP configuration verification failed:', error.message);
|
||||||
console.error('Full error:', error);
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.log(
|
||||||
|
'🔧 Continuing in production mode despite verification failure'
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -54,66 +109,86 @@ export const sendContactEmail = async ({
|
||||||
senderIP,
|
senderIP,
|
||||||
userAgent,
|
userAgent,
|
||||||
}) => {
|
}) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log('📧 Starting email send process via custom SMTP...');
|
||||||
|
|
||||||
|
try {
|
||||||
const transporter = createTransporter();
|
const transporter = createTransporter();
|
||||||
|
|
||||||
const fullName = `${firstName} ${lastName}`;
|
const fullName = `${firstName} ${lastName}`;
|
||||||
const emailSubject = `[Portfolio Contact] ${subject}`;
|
|
||||||
|
|
||||||
// Create email content
|
|
||||||
const emailTemplate = createContactEmailTemplate({
|
|
||||||
fullName,
|
|
||||||
email,
|
|
||||||
subject,
|
|
||||||
message,
|
|
||||||
senderIP,
|
|
||||||
userAgent,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Email options
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: {
|
from: {
|
||||||
name: fullName,
|
name: 'Portfolio Contact Form',
|
||||||
address: process.env.EMAIL_USER, // Use your email as the sender
|
address: process.env.FROM_EMAIL, // Your verified domain email
|
||||||
},
|
|
||||||
to: {
|
|
||||||
name: process.env.RECIPIENT_NAME || 'Portfolio Owner',
|
|
||||||
address: process.env.RECIPIENT_EMAIL,
|
|
||||||
},
|
},
|
||||||
|
to: process.env.RECIPIENT_EMAIL,
|
||||||
replyTo: {
|
replyTo: {
|
||||||
name: fullName,
|
name: fullName,
|
||||||
address: email, // This allows you to reply directly to the sender
|
address: email,
|
||||||
},
|
|
||||||
subject: emailSubject,
|
|
||||||
html: emailTemplate.html,
|
|
||||||
text: emailTemplate.text,
|
|
||||||
headers: {
|
|
||||||
'X-Priority': '1',
|
|
||||||
'X-MSMail-Priority': 'High',
|
|
||||||
Importance: 'high',
|
|
||||||
},
|
},
|
||||||
|
subject: `[Portfolio] ${subject}`,
|
||||||
|
html: `
|
||||||
|
<h2>New Contact Form Submission</h2>
|
||||||
|
<p><strong>Name:</strong> ${fullName}</p>
|
||||||
|
<p><strong>Email:</strong> ${email}</p>
|
||||||
|
<p><strong>Subject:</strong> ${subject}</p>
|
||||||
|
<p><strong>Message:</strong></p>
|
||||||
|
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px;">
|
||||||
|
${message.replace(/\n/g, '<br>')}
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<p><small>IP: ${senderIP || 'Unknown'}</small></p>
|
||||||
|
<p><small>User Agent: ${userAgent || 'Unknown'}</small></p>
|
||||||
|
<p><small>Sent: ${new Date().toISOString()}</small></p>
|
||||||
|
`,
|
||||||
|
text: `
|
||||||
|
New Contact Form Submission
|
||||||
|
|
||||||
|
Name: ${fullName}
|
||||||
|
Email: ${email}
|
||||||
|
Subject: ${subject}
|
||||||
|
|
||||||
|
Message:
|
||||||
|
${message}
|
||||||
|
|
||||||
|
---
|
||||||
|
IP: ${senderIP || 'Unknown'}
|
||||||
|
User Agent: ${userAgent || 'Unknown'}
|
||||||
|
Sent: ${new Date().toISOString()}
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send the email
|
|
||||||
try {
|
|
||||||
const result = await transporter.sendMail(mailOptions);
|
const result = await transporter.sendMail(mailOptions);
|
||||||
console.log('Email sent successfully:', result.messageId);
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.log(
|
||||||
|
`✅ Email sent successfully via custom SMTP in ${duration}ms:`,
|
||||||
|
result.messageId
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send email:', error);
|
const duration = Date.now() - startTime;
|
||||||
|
console.error(`❌ Custom SMTP email send failed after ${duration}ms:`, {
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
command: error.command,
|
||||||
|
});
|
||||||
|
|
||||||
throw new Error(`Email sending failed: ${error.message}`);
|
throw new Error(`Email sending failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize email service (verify configuration on startup)
|
// Initialize email service
|
||||||
export const initializeEmailService = async () => {
|
export const initializeEmailService = async () => {
|
||||||
console.log('🔧 Initializing email service...');
|
console.log('🔧 Initializing custom SMTP email service...');
|
||||||
|
|
||||||
const isValid = await verifyEmailConfig();
|
const isValid = await verifyEmailConfig();
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
console.warn(
|
console.warn('⚠️ Custom SMTP email service not properly configured.');
|
||||||
'⚠️ Email service not properly configured. Check your .env file.'
|
} else {
|
||||||
);
|
console.log('✅ Custom SMTP email service initialized successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isValid;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"express-validator": "^7.2.1",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
|
@ -2603,6 +2604,19 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-validator": {
|
||||||
|
"version": "7.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
|
||||||
|
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"validator": "~13.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -2987,6 +3001,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|
@ -3716,6 +3736,15 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
||||||
|
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"express-validator": "^7.2.1",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Mail, Github, Linkedin } from 'lucide-react';
|
import { Mail, Github, Linkedin } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal';
|
import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal';
|
||||||
import { getApiUrl, ENDPOINTS } from '../../config/api';
|
import { API_CONFIG } from '../../config/api';
|
||||||
|
|
||||||
interface ContactSectionProps {
|
interface ContactSectionProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -30,12 +30,18 @@ export default function ContactSection({
|
||||||
message: string;
|
message: string;
|
||||||
}>({ type: null, message: '' });
|
}>({ type: null, message: '' });
|
||||||
|
|
||||||
|
// GDPR consent state
|
||||||
|
const [gdprConsent, setGdprConsent] = useState(false);
|
||||||
|
|
||||||
// Email obfuscation function using config
|
// Email obfuscation function using config
|
||||||
const handleEmailClick = (e: React.MouseEvent) => {
|
const handleEmailClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.location.href = createEmailLink();
|
window.location.href = createEmailLink();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add form ref
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -50,6 +56,15 @@ export default function ContactSection({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GDPR consent validation
|
||||||
|
if (!gdprConsent) {
|
||||||
|
setFormStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Please accept the privacy policy to continue.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent rapid submissions
|
// Prevent rapid submissions
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -66,6 +81,8 @@ export default function ContactSection({
|
||||||
email: formData.get('email') as string,
|
email: formData.get('email') as string,
|
||||||
subject: formData.get('subject') as string,
|
subject: formData.get('subject') as string,
|
||||||
message: formData.get('message') as string,
|
message: formData.get('message') as string,
|
||||||
|
gdprConsent: gdprConsent.toString(), // Add this
|
||||||
|
website: honeypot, // Anti-spam honeypot
|
||||||
};
|
};
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
|
|
@ -91,8 +108,7 @@ export default function ContactSection({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Submit to your own backend API
|
// Submit to your own backend API
|
||||||
const apiUrl = `${getApiUrl()}${ENDPOINTS.contact}`;
|
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTACT}`, {
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -107,14 +123,29 @@ export default function ContactSection({
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
// Check if response is ok before trying to parse JSON
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if response has content before parsing JSON
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Empty response from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(text);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setFormStatus({
|
setFormStatus({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: result.message
|
message: result.message
|
||||||
});
|
});
|
||||||
e.currentTarget.reset();
|
|
||||||
|
// Reset form using ref
|
||||||
|
if (formRef.current) {
|
||||||
|
formRef.current.reset();
|
||||||
|
}
|
||||||
setHoneypot(''); // Reset honeypot
|
setHoneypot(''); // Reset honeypot
|
||||||
|
|
||||||
// Clear success message after 5 seconds
|
// Clear success message after 5 seconds
|
||||||
|
|
@ -266,7 +297,11 @@ export default function ContactSection({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className="contact-section__form" onSubmit={handleSubmit}>
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
className="contact-section__form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
{/* Honeypot field - hidden from users but visible to bots */}
|
{/* Honeypot field - hidden from users but visible to bots */}
|
||||||
<div className="contact-section__honeypot">
|
<div className="contact-section__honeypot">
|
||||||
<label htmlFor="website" className="contact-section__honeypot-label">
|
<label htmlFor="website" className="contact-section__honeypot-label">
|
||||||
|
|
@ -354,10 +389,30 @@ export default function ContactSection({
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* GDPR Consent Checkbox */}
|
||||||
|
<div className="contact-section__form-group contact-section__form-group--checkbox">
|
||||||
|
<label className="contact-section__checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={gdprConsent}
|
||||||
|
onChange={(e) => setGdprConsent(e.target.checked)}
|
||||||
|
className="contact-section__checkbox"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className="contact-section__checkbox-text">
|
||||||
|
I consent to the processing of my personal data according to the{' '}
|
||||||
|
<a href="/privacy-policy" target="_blank" rel="noopener noreferrer">
|
||||||
|
Privacy Policy
|
||||||
|
</a>{' '}
|
||||||
|
(GDPR/DSGVO compliant) *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="contact-section__form-button"
|
className="contact-section__form-button"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || !gdprConsent} // Disable if no consent
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Sending...' : 'Send Message'}
|
{isSubmitting ? 'Sending...' : 'Send Message'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
// API configuration for different environments
|
const isDevelopment = import.meta.env.DEV;
|
||||||
type Environment = 'development' | 'production';
|
|
||||||
|
|
||||||
const API_CONFIG: Record<Environment, { baseURL: string }> = {
|
export const API_CONFIG = {
|
||||||
development: {
|
BASE_URL: isDevelopment
|
||||||
baseURL: 'http://localhost:3002',
|
? 'http://localhost:3002'
|
||||||
},
|
: 'https://portfolio-backend-production-39b3.up.railway.app',
|
||||||
production: {
|
ENDPOINTS: {
|
||||||
baseURL: 'https://your-backend-api.herokuapp.com', // Update with your actual backend URL
|
CONTACT: '/api/contact',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getApiUrl = (): string => {
|
// Export individual items for backward compatibility
|
||||||
const isDev = import.meta.env.DEV;
|
export const ENDPOINTS = API_CONFIG.ENDPOINTS;
|
||||||
const environment: Environment = isDev ? 'development' : 'production';
|
|
||||||
return API_CONFIG[environment].baseURL;
|
// Helper function to get full API URL
|
||||||
|
export const getApiUrl = (endpoint: string): string => {
|
||||||
|
return `${API_CONFIG.BASE_URL}${endpoint}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ENDPOINTS = {
|
// Default export
|
||||||
contact: '/api/contact',
|
export default API_CONFIG;
|
||||||
health: '/health',
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
export default function PrivacyPolicy() {
|
||||||
|
return (
|
||||||
|
<div className="privacy-policy">
|
||||||
|
<h1>Privacy Policy (GDPR/DSGVO)</h1>
|
||||||
|
<p>Last updated: {new Date().toLocaleDateString()}</p>
|
||||||
|
|
||||||
|
<h2>Data Collection</h2>
|
||||||
|
<p>We collect only the information you provide through our contact form:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name (first and last)</li>
|
||||||
|
<li>Email address</li>
|
||||||
|
<li>Message content</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Purpose</h2>
|
||||||
|
<p>Your data is used solely to respond to your inquiry.</p>
|
||||||
|
|
||||||
|
<h2>Data Retention</h2>
|
||||||
|
<p>Your data is not stored permanently. Email content is processed and forwarded immediately.</p>
|
||||||
|
|
||||||
|
<h2>Your Rights</h2>
|
||||||
|
<p>You have the right to access, rectify, or delete your personal data. Contact: info@sascha-bach.de</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"buildCommand": "npm run build",
|
||||||
|
"outputDirectory": "dist",
|
||||||
|
"framework": "vite",
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/privacy-policy",
|
||||||
|
"destination": "/privacy-policy.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue