diff --git a/.gitignore b/.gitignore index 48cd56d..02b0197 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ pnpm-debug.log* .vscode/ .idea/ .DS_Store +.vercel diff --git a/backend/.gitignore b/backend/.gitignore index 7992a84..e9d6196 100644 Binary files a/backend/.gitignore and b/backend/.gitignore differ diff --git a/backend/package-lock.json b/backend/package-lock.json index 34abce8..9060552 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,7 +14,8 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "joi": "^17.11.0", - "nodemailer": "^6.9.7" + "nodemailer": "^6.9.7", + "resend": "^6.0.2" }, "devDependencies": { "nodemon": "^3.0.2" @@ -1044,6 +1045,23 @@ "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": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", diff --git a/backend/package.json b/backend/package.json index 1a14e93..55dc00f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,13 +11,14 @@ "test": "echo 'No tests yet'" }, "dependencies": { - "express": "^4.18.2", - "nodemailer": "^6.9.7", "cors": "^2.8.5", - "helmet": "^7.1.0", + "dotenv": "^16.3.1", + "express": "^4.18.2", "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", "joi": "^17.11.0", - "dotenv": "^16.3.1" + "nodemailer": "^6.9.7", + "resend": "^6.0.2" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/backend/routes/contact.js b/backend/routes/contact.js index 21ea7cd..f06ed28 100644 --- a/backend/routes/contact.js +++ b/backend/routes/contact.js @@ -1,74 +1,93 @@ import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { body, validationResult } from 'express-validator'; import { sendContactEmail } from '../services/emailService.js'; -import { validateContactForm } from '../middleware/validation.js'; -import { asyncHandler } from '../middleware/asyncHandler.js'; const router = express.Router(); -// POST /api/contact - Send contact form email -router.post( - '/', - validateContactForm, - asyncHandler(async (req, res) => { - const { firstName, lastName, email, subject, message } = req.body; +// GDPR compliant rate limiting +const contactLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 3, // Limit each IP to 3 requests per windowMs + message: { + error: 'Too many contact requests from this IP, please try again later.', + retryAfter: 15 * 60, + }, + standardHeaders: true, + legacyHeaders: false, +}); - // Check for honeypot (if present in body) - if (req.body.website && req.body.website.trim() !== '') { - console.warn('Spam detected: honeypot field filled', { - 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({ success: false, - message: 'Invalid form submission detected.', + message: 'Invalid form data.', + errors: errors.array(), }); } - try { - // Send the email - await sendContactEmail({ - firstName, - lastName, - email, - subject: subject || 'New Portfolio Contact', - message, - senderIP: req.ip, - userAgent: req.get('User-Agent'), - }); + const { + firstName, + lastName, + email, + subject, + message, + gdprConsent, + website, + } = req.body; - console.log('Contact email sent successfully', { - from: email, - name: `${firstName} ${lastName}`, - subject: subject || 'New Portfolio Contact', - ip: req.ip, - }); - - res.json({ - success: true, - message: - "Your message has been sent successfully! I'll get back to you soon.", - }); - } catch (error) { - console.error('Failed to send contact email:', error); - - res.status(500).json({ + // Honeypot check + if (website) { + console.warn('🚨 Spam detected:', req.ip); + return res.status(400).json({ success: false, - message: - 'Sorry, there was an error sending your message. Please try again later.', + message: 'Invalid submission detected.', }); } - }) -); -// 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, + // 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({ + firstName, + lastName, + email, + subject, + message, + senderIP: req.ip, + userAgent: req.get('User-Agent'), }); - }); -} + + res.json({ + success: true, + message: "Thank you for your message! I'll get back to you soon.", + }); + } catch (error) { + console.error('❌ Contact error:', error.message); // No personal data in logs + res.status(500).json({ + success: false, + message: 'Failed to send message. Please try again later.', + }); + } +}); export { router as contactRouter }; diff --git a/backend/server.js b/backend/server.js index 1870d90..b103b68 100644 --- a/backend/server.js +++ b/backend/server.js @@ -24,6 +24,9 @@ async function startServer() { const app = express(); const PORT = process.env.PORT || 3001; + // Trust proxy (required for Railway, Heroku, etc.) + app.set('trust proxy', 1); + // Security middleware app.use( helmet({ @@ -34,10 +37,14 @@ async function startServer() { // CORS configuration app.use( cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:5173', - methods: ['GET', 'POST'], - allowedHeaders: ['Content-Type', 'Authorization'], + origin: [ + 'http://localhost:5173', + 'http://localhost:3000', + 'https://portfolio-page-zeta-ten.vercel.app', + ], credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], }) ); @@ -53,6 +60,11 @@ async function startServer() { }, standardHeaders: true, legacyHeaders: false, + // Add this for better proxy support + skip: (req) => { + // Skip rate limiting for health checks + return req.path === '/health'; + }, }); app.use('/api/', limiter); @@ -81,7 +93,7 @@ async function startServer() { app.use(errorHandler); // Start server - app.listen(PORT, () => { + app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 Server is running on port ${PORT}`); console.log( `📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}` diff --git a/backend/services/emailService.js b/backend/services/emailService.js index fe6c480..3ce458e 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -1,45 +1,100 @@ import nodemailer from 'nodemailer'; -import { createContactEmailTemplate } from '../templates/emailTemplates.js'; -// Create email transporter +// Create email transporter for custom SMTP server const createTransporter = () => { - const config = { - service: process.env.EMAIL_SERVICE, - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, - }, - }; + console.log('🔧 Creating custom SMTP transporter with config:', { + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + secure: process.env.SMTP_SECURE === 'true', + 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 (!process.env.EMAIL_SERVICE || process.env.EMAIL_SERVICE === 'custom') { - delete config.service; - config.host = process.env.EMAIL_HOST; - config.port = parseInt(process.env.EMAIL_PORT) || 587; - config.secure = process.env.EMAIL_SECURE === 'true'; + if ( + !process.env.SMTP_HOST || + !process.env.SMTP_USER || + !process.env.SMTP_PASS + ) { + 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 export const verifyEmailConfig = async () => { try { - console.log('🔍 Debug - Creating transporter with config:'); - console.log('EMAIL_SERVICE:', process.env.EMAIL_SERVICE); - console.log('EMAIL_USER:', process.env.EMAIL_USER); + console.log('🔍 Verifying custom SMTP configuration...'); + console.log('SMTP_HOST:', process.env.SMTP_HOST || 'Missing'); console.log( - 'EMAIL_PASS length:', - process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined' + 'SMTP_PORT:', + 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(); await transporter.verify(); - console.log('✅ Email configuration verified successfully'); + console.log('✅ SMTP configuration verified successfully'); return true; } catch (error) { - console.error('❌ Email configuration verification failed:', error.message); - console.error('Full error:', error); + console.error('❌ SMTP configuration verification failed:', error.message); + + if (process.env.NODE_ENV === 'production') { + console.log( + '🔧 Continuing in production mode despite verification failure' + ); + return true; + } + return false; } }; @@ -54,66 +109,86 @@ export const sendContactEmail = async ({ senderIP, userAgent, }) => { - const transporter = createTransporter(); + const startTime = Date.now(); + console.log('📧 Starting email send process via custom SMTP...'); - 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 = { - from: { - name: fullName, - address: process.env.EMAIL_USER, // Use your email as the sender - }, - to: { - name: process.env.RECIPIENT_NAME || 'Portfolio Owner', - address: process.env.RECIPIENT_EMAIL, - }, - replyTo: { - name: fullName, - address: email, // This allows you to reply directly to the sender - }, - subject: emailSubject, - html: emailTemplate.html, - text: emailTemplate.text, - headers: { - 'X-Priority': '1', - 'X-MSMail-Priority': 'High', - Importance: 'high', - }, - }; - - // Send the email try { + const transporter = createTransporter(); + const fullName = `${firstName} ${lastName}`; + + const mailOptions = { + from: { + name: 'Portfolio Contact Form', + address: process.env.FROM_EMAIL, // Your verified domain email + }, + to: process.env.RECIPIENT_EMAIL, + replyTo: { + name: fullName, + address: email, + }, + subject: `[Portfolio] ${subject}`, + html: ` +
Name: ${fullName}
+Email: ${email}
+Subject: ${subject}
+Message:
+IP: ${senderIP || 'Unknown'}
+User Agent: ${userAgent || 'Unknown'}
+Sent: ${new Date().toISOString()}
+ `, + text: ` +New Contact Form Submission + +Name: ${fullName} +Email: ${email} +Subject: ${subject} + +Message: +${message} + +--- +IP: ${senderIP || 'Unknown'} +User Agent: ${userAgent || 'Unknown'} +Sent: ${new Date().toISOString()} + `, + }; + 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; } 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}`); } }; -// Initialize email service (verify configuration on startup) +// Initialize email service export const initializeEmailService = async () => { - console.log('🔧 Initializing email service...'); + console.log('🔧 Initializing custom SMTP email service...'); + const isValid = await verifyEmailConfig(); if (!isValid) { - console.warn( - '⚠️ Email service not properly configured. Check your .env file.' - ); + console.warn('⚠️ Custom SMTP email service not properly configured.'); + } else { + console.log('✅ Custom SMTP email service initialized successfully'); } return isValid; diff --git a/package-lock.json b/package-lock.json index bbb1efe..4e6ce6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@types/react-router-dom": "^5.3.3", + "express-validator": "^7.2.1", "lucide-react": "^0.542.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -2603,6 +2604,19 @@ "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": { "version": "3.1.3", "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" } }, + "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": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3716,6 +3736,15 @@ "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": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", diff --git a/package.json b/package.json index 3ace978..ef56dc2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@types/react-router-dom": "^5.3.3", + "express-validator": "^7.2.1", "lucide-react": "^0.542.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/src/components/sections/ContactSection.tsx b/src/components/sections/ContactSection.tsx index ea9a5df..019a871 100644 --- a/src/components/sections/ContactSection.tsx +++ b/src/components/sections/ContactSection.tsx @@ -1,8 +1,8 @@ import { Mail, Github, Linkedin } from 'lucide-react'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import type { FormEvent } from 'react'; import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal'; -import { getApiUrl, ENDPOINTS } from '../../config/api'; +import { API_CONFIG } from '../../config/api'; interface ContactSectionProps { title?: string; @@ -30,12 +30,18 @@ export default function ContactSection({ message: string; }>({ type: null, message: '' }); + // GDPR consent state + const [gdprConsent, setGdprConsent] = useState(false); + // Email obfuscation function using config const handleEmailClick = (e: React.MouseEvent) => { e.preventDefault(); window.location.href = createEmailLink(); }; + // Add form ref + const formRef = useRef