added contact form server

This commit is contained in:
Sascha 2025-08-28 13:58:09 +02:00
parent 27f554b389
commit 350fc82533
18 changed files with 2574 additions and 42 deletions

22
backend/.env.example Normal file
View File

@ -0,0 +1,22 @@
# Server Configuration
PORT=3001
NODE_ENV=development
# Email Configuration (Gmail example)
EMAIL_SERVICE=gmail
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_SECURE=false
EMAIL_USER=your-email@gmail.com
EMAIL_PASS=your-app-password
# Recipient Configuration
RECIPIENT_EMAIL=your-email@gmail.com
RECIPIENT_NAME=Your Name
# Frontend URL (for CORS)
FRONTEND_URL=http://localhost:5173
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=5

78
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,78 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# IDE/Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

239
backend/README.md Normal file
View File

@ -0,0 +1,239 @@
# Portfolio Backend API
A secure Node.js backend API for handling contact form submissions from your portfolio website.
## Features
- 🔒 **Security**: Rate limiting, CORS protection, input validation, spam protection
- 📧 **Email Integration**: Nodemailer with Gmail/SMTP support
- ✅ **Validation**: Comprehensive form validation with Joi
- 🚀 **Performance**: Express.js with async error handling
- 🛡️ **Anti-Spam**: Honeypot fields and rate limiting
- 📝 **Logging**: Request/response logging and error tracking
- 🎨 **Email Templates**: Professional HTML email templates
## Setup Instructions
### 1. Install Dependencies
```bash
cd backend
npm install
```
### 2. Configure Environment
Copy the example environment file and configure your settings:
```bash
cp .env.example .env
```
Edit `.env` with your actual configuration:
```env
# Server Configuration
PORT=3001
NODE_ENV=development
# Email Configuration (Gmail example)
EMAIL_SERVICE=gmail
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_SECURE=false
EMAIL_USER=your-email@gmail.com
EMAIL_PASS=your-app-password
# Recipient Configuration
RECIPIENT_EMAIL=your-email@gmail.com
RECIPIENT_NAME=Your Name
# Frontend URL (for CORS)
FRONTEND_URL=http://localhost:5173
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=5
```
### 3. Gmail Setup (Recommended)
1. **Enable 2-Factor Authentication** on your Gmail account
2. **Generate App Password**:
- Go to Google Account settings
- Security → 2-Step Verification → App passwords
- Generate a password for "Mail"
- Use this as `EMAIL_PASS` in your `.env` file
### 4. Alternative Email Providers
**Outlook/Hotmail:**
```env
EMAIL_SERVICE=hotmail
EMAIL_HOST=smtp-mail.outlook.com
EMAIL_PORT=587
EMAIL_SECURE=false
```
**Custom SMTP:**
```env
EMAIL_SERVICE=
EMAIL_HOST=your-smtp-host.com
EMAIL_PORT=587
EMAIL_SECURE=false
```
### 5. Run the Server
**Development:**
```bash
npm run dev
```
**Production:**
```bash
npm start
```
### 6. Test the API
Visit: `http://localhost:3001/health` to verify the server is running.
For development, you can test the contact endpoint: `http://localhost:3001/api/contact/test`
## API Endpoints
### Health Check
- **GET** `/health` - Server health status
### Contact Form
- **POST** `/api/contact` - Send contact email
**Request Body:**
```json
{
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"subject": "Project Inquiry",
"message": "Hello, I'd like to discuss a project..."
}
```
**Response:**
```json
{
"success": true,
"message": "Your message has been sent successfully! I'll get back to you soon."
}
```
## Security Features
- **Rate Limiting**: 5 requests per 15 minutes per IP
- **Input Validation**: Joi schema validation for all fields
- **CORS Protection**: Configurable origin restrictions
- **Helmet Security**: Security headers
- **Honeypot Anti-Spam**: Hidden field detection
- **Request Logging**: IP and user agent tracking
## Deployment Options
### 1. Heroku
```bash
# Install Heroku CLI, then:
heroku create your-portfolio-api
heroku config:set NODE_ENV=production
heroku config:set EMAIL_USER=your-email@gmail.com
# ... set other environment variables
git push heroku main
```
### 2. Railway
```bash
# Install Railway CLI, then:
railway login
railway new
railway add
railway up
```
### 3. DigitalOcean App Platform
- Create new app from GitHub repository
- Set environment variables in the dashboard
- Deploy automatically
### 4. Vercel (Serverless)
- Convert Express app to serverless functions
- Deploy with `vercel` CLI
## Environment Variables Reference
| Variable | Description | Example |
| ------------------------- | --------------------------- | ---------------------- |
| `PORT` | Server port | `3001` |
| `NODE_ENV` | Environment | `production` |
| `EMAIL_SERVICE` | Email service provider | `gmail` |
| `EMAIL_HOST` | SMTP host | `smtp.gmail.com` |
| `EMAIL_PORT` | SMTP port | `587` |
| `EMAIL_SECURE` | Use SSL/TLS | `false` |
| `EMAIL_USER` | Email username | `your@gmail.com` |
| `EMAIL_PASS` | Email password/app password | `app-password` |
| `RECIPIENT_EMAIL` | Where to send emails | `your@gmail.com` |
| `RECIPIENT_NAME` | Recipient display name | `Your Name` |
| `FRONTEND_URL` | Frontend URL for CORS | `https://yoursite.com` |
| `RATE_LIMIT_WINDOW_MS` | Rate limit window | `900000` (15 min) |
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | `5` |
## Troubleshooting
### Email Not Sending
1. Check Gmail app password is correct
2. Verify 2FA is enabled on Gmail
3. Check console logs for detailed errors
4. Test with `/health` endpoint first
### CORS Issues
1. Verify `FRONTEND_URL` matches your frontend domain exactly
2. Include protocol (http/https) in the URL
3. No trailing slashes
### Rate Limiting
- Default: 5 requests per 15 minutes per IP
- Adjust `RATE_LIMIT_*` variables as needed
- Check IP address in logs for debugging
## Production Checklist
- [ ] Environment variables configured
- [ ] Email service tested
- [ ] Rate limiting configured
- [ ] CORS origins restricted
- [ ] SSL/HTTPS enabled
- [ ] Error monitoring setup
- [ ] Logs configured
- [ ] Backup strategy in place
## Support
If you encounter issues:
1. Check the console logs
2. Verify your `.env` configuration
3. Test email configuration with `/health` endpoint
4. Ensure your email provider allows SMTP access

View File

@ -0,0 +1,4 @@
// Async error handler wrapper
export const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

View File

@ -0,0 +1,65 @@
// Error handling middleware
export const errorHandler = (err, req, res, next) => {
console.error('Error occurred:', {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
ip: req.ip,
userAgent: req.get('User-Agent'),
timestamp: new Date().toISOString(),
});
// Default error
let error = {
success: false,
message: 'Internal server error',
...(process.env.NODE_ENV === 'development' && {
error: err.message,
stack: err.stack,
}),
};
// Rate limit error
if (err.status === 429) {
error = {
success: false,
message: 'Too many requests. Please try again later.',
retryAfter: err.retryAfter,
};
}
// Validation errors
if (err.name === 'ValidationError') {
error = {
success: false,
message: 'Validation failed',
errors: Object.values(err.errors).map((e) => e.message),
};
}
// JSON parsing errors
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
error = {
success: false,
message: 'Invalid JSON in request body',
};
}
res.status(err.status || 500).json(error);
};
export const notFoundHandler = (req, res) => {
res.status(404).json({
success: false,
message: `Route ${req.method} ${req.url} not found`,
availableRoutes: {
'GET /health': 'Health check endpoint',
'POST /api/contact': 'Send contact form email',
...(process.env.NODE_ENV === 'development' && {
'GET /api/contact/test': 'Test contact API (development only)',
}),
},
});
};

View File

@ -0,0 +1,23 @@
// Request logging middleware
export const requestLogger = (req, res, next) => {
const start = Date.now();
// Log request
console.log(`📥 ${req.method} ${req.url}`, {
ip: req.ip,
userAgent: req.get('User-Agent'),
timestamp: new Date().toISOString(),
});
// Log response when finished
res.on('finish', () => {
const duration = Date.now() - start;
const statusEmoji = res.statusCode >= 400 ? '❌' : '✅';
console.log(
`📤 ${statusEmoji} ${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`
);
});
next();
};

View File

@ -0,0 +1,84 @@
import Joi from 'joi';
// Validation schema for contact form
const contactSchema = Joi.object({
firstName: Joi.string()
.trim()
.min(1)
.max(50)
.pattern(/^[a-zA-Z\s\-']+$/)
.required()
.messages({
'string.empty': 'First name is required',
'string.min': 'First name must be at least 1 character long',
'string.max': 'First name cannot exceed 50 characters',
'string.pattern.base':
'First name can only contain letters, spaces, hyphens, and apostrophes',
}),
lastName: Joi.string()
.trim()
.min(1)
.max(50)
.pattern(/^[a-zA-Z\s\-']+$/)
.required()
.messages({
'string.empty': 'Last name is required',
'string.min': 'Last name must be at least 1 character long',
'string.max': 'Last name cannot exceed 50 characters',
'string.pattern.base':
'Last name can only contain letters, spaces, hyphens, and apostrophes',
}),
email: Joi.string()
.trim()
.email({ minDomainSegments: 2, tlds: { allow: true } })
.max(254)
.required()
.messages({
'string.email': 'Please provide a valid email address',
'string.empty': 'Email address is required',
'string.max': 'Email address is too long',
}),
subject: Joi.string().trim().max(200).allow('').messages({
'string.max': 'Subject cannot exceed 200 characters',
}),
message: Joi.string().trim().min(10).max(5000).required().messages({
'string.empty': 'Message is required',
'string.min': 'Message must be at least 10 characters long',
'string.max': 'Message cannot exceed 5000 characters',
}),
// Honeypot field (should be empty)
website: Joi.string().allow('').max(0).messages({
'string.max': 'Invalid form submission',
}),
});
// Validation middleware
export const validateContactForm = (req, res, next) => {
const { error, value } = contactSchema.validate(req.body, {
abortEarly: false,
stripUnknown: true,
convert: true,
});
if (error) {
const errors = error.details.map((detail) => ({
field: detail.path[0],
message: detail.message,
}));
return res.status(400).json({
success: false,
message: 'Validation failed',
errors,
});
}
// Replace req.body with validated and sanitized data
req.body = value;
next();
};

1333
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "portfolio-backend",
"version": "1.0.0",
"description": "Backend API for portfolio contact form",
"main": "server.js",
"type": "module",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js",
"build": "echo 'No build step needed for Node.js'",
"test": "echo 'No tests yet'"
},
"dependencies": {
"express": "^4.18.2",
"nodemailer": "^6.9.7",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"express-rate-limit": "^7.1.5",
"joi": "^17.11.0",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
}

74
backend/routes/contact.js Normal file
View File

@ -0,0 +1,74 @@
import express from 'express';
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;
// 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,
});
return res.status(400).json({
success: false,
message: 'Invalid form submission detected.',
});
}
try {
// Send the email
await sendContactEmail({
firstName,
lastName,
email,
subject: subject || 'New Portfolio Contact',
message,
senderIP: req.ip,
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({
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({
success: false,
message:
'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 };

111
backend/server.js Normal file
View File

@ -0,0 +1,111 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { contactRouter } from './routes/contact.js';
import { errorHandler, notFoundHandler } from './middleware/errorHandlers.js';
import { requestLogger } from './middleware/logger.js';
import { initializeEmailService } from './services/emailService.js';
// Get the directory of the current module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load environment variables from the backend directory
dotenv.config({ path: join(__dirname, '.env') });
async function startServer() {
// Initialize email service after environment variables are loaded
await initializeEmailService();
const app = express();
const PORT = process.env.PORT || 3001;
// Security middleware
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
})
);
// CORS configuration
app.use(
cors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
})
);
// Rate limiting
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 5, // limit each IP to 5 requests per windowMs
message: {
error: 'Too many requests from this IP, please try again later.',
retryAfter: Math.ceil(
(parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000) / 1000
),
},
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', limiter);
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Request logging
app.use(requestLogger);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development',
});
});
// API routes
app.use('/api/contact', contactRouter);
// Error handling middleware
app.use(notFoundHandler);
app.use(errorHandler);
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server is running on port ${PORT}`);
console.log(
`📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}`
);
console.log(`🌍 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(
`🔗 Frontend URL: ${process.env.FRONTEND_URL || 'http://localhost:5173'}`
);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('SIGINT signal received: closing HTTP server');
process.exit(0);
});
}
// Start the server
startServer().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});

View File

@ -0,0 +1,120 @@
import nodemailer from 'nodemailer';
import { createContactEmailTemplate } from '../templates/emailTemplates.js';
// Create email transporter
const createTransporter = () => {
const config = {
service: process.env.EMAIL_SERVICE,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
};
// 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';
}
return nodemailer.createTransport(config);
};
// 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(
'EMAIL_PASS length:',
process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined'
);
const transporter = createTransporter();
await transporter.verify();
console.log('✅ Email configuration verified successfully');
return true;
} catch (error) {
console.error('❌ Email configuration verification failed:', error.message);
console.error('Full error:', error);
return false;
}
};
// Send contact form email
export const sendContactEmail = async ({
firstName,
lastName,
email,
subject,
message,
senderIP,
userAgent,
}) => {
const transporter = createTransporter();
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 result = await transporter.sendMail(mailOptions);
console.log('Email sent successfully:', result.messageId);
return result;
} catch (error) {
console.error('Failed to send email:', error);
throw new Error(`Email sending failed: ${error.message}`);
}
};
// Initialize email service (verify configuration on startup)
export const initializeEmailService = async () => {
console.log('🔧 Initializing email service...');
const isValid = await verifyEmailConfig();
if (!isValid) {
console.warn(
'⚠️ Email service not properly configured. Check your .env file.'
);
}
return isValid;
};

View File

@ -0,0 +1,158 @@
// Email templates for contact form submissions
export const createContactEmailTemplate = ({
fullName,
email,
subject,
message,
senderIP,
userAgent,
timestamp,
}) => {
// HTML template
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Portfolio Contact</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f8f9fa;
}
.container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
border-bottom: 3px solid #007bff;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
margin: 0;
color: #007bff;
font-size: 24px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #495057;
margin-bottom: 5px;
display: block;
}
.field-value {
background-color: #f8f9fa;
padding: 10px 15px;
border-left: 4px solid #007bff;
border-radius: 4px;
word-break: break-word;
}
.message-content {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
padding: 20px;
border-radius: 6px;
white-space: pre-wrap;
font-family: Georgia, serif;
line-height: 1.8;
}
.metadata {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
font-size: 14px;
color: #6c757d;
}
.reply-info {
background-color: #e7f3ff;
border: 1px solid #b3d9ff;
padding: 15px;
border-radius: 6px;
margin-top: 20px;
}
.reply-info strong {
color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 New Portfolio Contact</h1>
<p>You've received a new message through your portfolio contact form!</p>
</div>
<div class="field">
<span class="field-label">👤 From:</span>
<div class="field-value"><strong>${fullName}</strong></div>
</div>
<div class="field">
<span class="field-label">📧 Email:</span>
<div class="field-value"><a href="mailto:${email}" style="color: #007bff; text-decoration: none;">${email}</a></div>
</div>
<div class="field">
<span class="field-label">📋 Subject:</span>
<div class="field-value">${subject}</div>
</div>
<div class="field">
<span class="field-label">💬 Message:</span>
<div class="message-content">${message}</div>
</div>
<div class="reply-info">
<strong>💡 Quick Reply:</strong> You can reply directly to this email to respond to ${fullName}.
</div>
<div class="metadata">
<p><strong>📅 Received:</strong> ${new Date(
timestamp
).toLocaleString()}</p>
<p><strong>🌐 IP Address:</strong> ${senderIP}</p>
<p><strong>🔧 User Agent:</strong> ${
userAgent || 'Not provided'
}</p>
</div>
</div>
</body>
</html>
`;
// Plain text version
const text = `
NEW PORTFOLIO CONTACT
=====================
From: ${fullName}
Email: ${email}
Subject: ${subject}
Message:
--------
${message}
Metadata:
---------
Received: ${new Date(timestamp).toLocaleString()}
IP Address: ${senderIP}
User Agent: ${userAgent || 'Not provided'}
You can reply directly to this email to respond to the sender.
`;
return { html, text };
};

40
backend/test-email.js Normal file
View File

@ -0,0 +1,40 @@
import nodemailer from 'nodemailer';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
// Get the directory of the current module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load environment variables
dotenv.config({ path: join(__dirname, '.env') });
console.log('Testing Gmail configuration...');
console.log('EMAIL_USER:', process.env.EMAIL_USER);
console.log(
'EMAIL_PASS length:',
process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined'
);
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
transporter
.verify()
.then(() => {
console.log('✅ Gmail connection successful!');
process.exit(0);
})
.catch((error) => {
console.error('❌ Gmail connection failed:');
console.error('Error code:', error.code);
console.error('Error message:', error.message);
console.error('Response:', error.response);
process.exit(1);
});

View File

@ -2,6 +2,7 @@ import { Mail, Github, Linkedin } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal';
import { getApiUrl, ENDPOINTS } from '../../config/api';
interface ContactSectionProps {
title?: string;
@ -24,6 +25,10 @@ export default function ContactSection({
// Anti-spam honeypot state
const [honeypot, setHoneypot] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [formStatus, setFormStatus] = useState<{
type: 'success' | 'error' | null;
message: string;
}>({ type: null, message: '' });
// Email obfuscation function using config
const handleEmailClick = (e: React.MouseEvent) => {
@ -33,10 +38,15 @@ export default function ContactSection({
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Check honeypot - if filled, it's likely spam
if (honeypot) {
console.warn('Spam detected - honeypot field was filled');
setFormStatus({
type: 'error',
message: 'Invalid form submission detected.'
});
setIsSubmitting(false);
return;
}
@ -46,7 +56,8 @@ export default function ContactSection({
}
setIsSubmitting(true);
setFormStatus({ type: null, message: '' }); // Clear previous status
// Get form data
const formData = new FormData(e.currentTarget);
const data = {
@ -59,7 +70,10 @@ export default function ContactSection({
// Basic validation
if (!data.firstName || !data.lastName || !data.email || !data.message) {
alert('Please fill in all required fields.');
setFormStatus({
type: 'error',
message: 'Please fill in all required fields.'
});
setIsSubmitting(false);
return;
}
@ -67,38 +81,106 @@ export default function ContactSection({
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
alert('Please enter a valid email address.');
setFormStatus({
type: 'error',
message: 'Please enter a valid email address.'
});
setIsSubmitting(false);
return;
}
try {
// Submit to Formspree (replace YOUR_FORM_ID with actual ID from Formspree)
const response = await fetch('https://formspree.io/f/YOUR_FORM_ID', {
// Submit to your own backend API
const apiUrl = `${getApiUrl()}${ENDPOINTS.contact}`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: `${data.firstName} ${data.lastName}`,
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
subject: data.subject || 'Contact from Portfolio',
message: data.message,
_replyto: data.email, // This tells Formspree what email to reply to
website: honeypot, // Include honeypot for spam detection
}),
});
if (response.ok) {
alert('Thank you for your message! I\'ll get back to you soon.');
const result = await response.json();
if (result.success) {
setFormStatus({
type: 'success',
message: result.message
});
e.currentTarget.reset();
setHoneypot(''); // Reset honeypot
// Clear success message after 5 seconds
setTimeout(() => {
setFormStatus({ type: null, message: '' });
}, 5000);
} else {
throw new Error('Form submission failed');
// Handle different types of server errors
let errorMessage = 'Form submission failed';
if (response.status === 400) {
if (result.errors && Array.isArray(result.errors)) {
// Validation errors from server
const validationErrors = result.errors.map((err: any) =>
typeof err === 'object' ? err.message : err
).join(', ');
errorMessage = `Validation errors: ${validationErrors}`;
} else if (result.message) {
errorMessage = result.message;
} else {
errorMessage = 'Please check your form data and try again.';
}
} else if (response.status === 429) {
errorMessage = 'Too many requests. Please wait a moment and try again.';
} else if (response.status === 500) {
errorMessage = 'Server error. Our team has been notified. Please try again later.';
} else if (result.message) {
errorMessage = result.message;
}
throw new Error(errorMessage);
}
} catch (error) {
console.error('Form submission error:', error);
alert('Sorry, there was an error sending your message. Please try again.');
let userMessage = 'Sorry, there was an error sending your message.';
if (error instanceof Error) {
// Network errors
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
userMessage = 'Network error: Please check your internet connection and try again.';
}
// Timeout errors
else if (error.message.includes('timeout')) {
userMessage = 'Request timeout: The server is taking too long to respond. Please try again.';
}
// CORS errors
else if (error.message.includes('CORS')) {
userMessage = 'Connection error: Please refresh the page and try again.';
}
// Server validation errors or custom error messages
else if (error.message.length > 0 && !error.message.includes('Form submission failed')) {
userMessage = error.message;
}
// Generic fallback
else {
userMessage = 'Unexpected error occurred. Please try again or contact us directly.';
}
}
setFormStatus({
type: 'error',
message: userMessage
});
}
setIsSubmitting(false);
};
@ -142,21 +224,21 @@ export default function ContactSection({
</div>
<div className="contact-section__social">
<button
<button
className="contact-section__social-button"
onClick={() => window.open(personalConfig.social.github.url, '_blank')}
aria-label="GitHub Profile"
>
<Github className="contact-section__social-icon" />
</button>
<button
<button
className="contact-section__social-button"
onClick={() => window.open(personalConfig.social.linkedin.url, '_blank')}
aria-label="LinkedIn Profile"
>
<Linkedin className="contact-section__social-icon" />
</button>
<button
<button
className="contact-section__social-button"
onClick={handleEmailClick}
aria-label="Send Email"
@ -174,14 +256,23 @@ export default function ContactSection({
I'll get back to you as soon as possible
</p>
</div>
{/* Status Message Display */}
{formStatus.type && (
<div className={`contact-section__status contact-section__status--${formStatus.type}`}>
<p className="contact-section__status-message">
{formStatus.message}
</p>
</div>
)}
<form className="contact-section__form" onSubmit={handleSubmit}>
{/* Honeypot field - hidden from users but visible to bots */}
<div className="contact-section__honeypot">
<label htmlFor="website" className="contact-section__honeypot-label">
Website (leave blank)
</label>
<input
<input
id="website"
name="website"
type="text"
@ -198,11 +289,11 @@ export default function ContactSection({
<label htmlFor="firstName" className="contact-section__form-label">
First Name *
</label>
<input
id="firstName"
<input
id="firstName"
name="firstName"
type="text"
placeholder="John"
placeholder="John"
className="contact-section__form-input"
required
/>
@ -211,60 +302,60 @@ export default function ContactSection({
<label htmlFor="lastName" className="contact-section__form-label">
Last Name *
</label>
<input
id="lastName"
<input
id="lastName"
name="lastName"
type="text"
placeholder="Doe"
placeholder="Doe"
className="contact-section__form-input"
required
/>
</div>
</div>
<div className="contact-section__form-group">
<label htmlFor="email" className="contact-section__form-label">
Email *
</label>
<input
id="email"
<input
id="email"
name="email"
type="email"
placeholder="john@example.com"
type="email"
placeholder="john@example.com"
className="contact-section__form-input"
required
/>
</div>
<div className="contact-section__form-group">
<label htmlFor="subject" className="contact-section__form-label">
Subject
</label>
<input
id="subject"
<input
id="subject"
name="subject"
type="text"
placeholder="Project Inquiry"
placeholder="Project Inquiry"
className="contact-section__form-input"
/>
</div>
<div className="contact-section__form-group">
<label htmlFor="message" className="contact-section__form-label">
Message *
</label>
<textarea
id="message"
<textarea
id="message"
name="message"
placeholder="Tell me about your project..."
placeholder="Tell me about your project..."
rows={5}
className="contact-section__form-textarea"
required
></textarea>
</div>
<button
type="submit"
<button
type="submit"
className="contact-section__form-button"
disabled={isSubmitting}
>

22
src/config/api.ts Normal file
View File

@ -0,0 +1,22 @@
// API configuration for different environments
type Environment = 'development' | 'production';
const API_CONFIG: Record<Environment, { baseURL: string }> = {
development: {
baseURL: 'http://localhost:3002',
},
production: {
baseURL: 'https://your-backend-api.herokuapp.com', // Update with your actual backend URL
},
};
export const getApiUrl = (): string => {
const isDev = import.meta.env.DEV;
const environment: Environment = isDev ? 'development' : 'production';
return API_CONFIG[environment].baseURL;
};
export const ENDPOINTS = {
contact: '/api/contact',
health: '/health',
};

View File

@ -151,6 +151,32 @@
font-size: 0.875rem;
}
// Status Message Styles
&__status {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
font-size: 0.875rem;
font-weight: 500;
&--success {
background-color: var(--contact-status-success-bg);
border: 1px solid var(--contact-status-success-border);
color: var(--contact-status-success-text);
}
&--error {
background-color: var(--contact-status-error-bg);
border: 1px solid var(--contact-status-error-border);
color: var(--contact-status-error-text);
}
}
&__status-message {
margin: 0;
line-height: 1.4;
}
&__form {
padding: 0 1.5rem 1.5rem;
display: flex;

View File

@ -25,7 +25,14 @@ $contact-light-theme: (
'contact-social-text': #4b5563,
'contact-social-border': rgba(79, 70, 229, 0.2),
'contact-social-hover-bg': #ffffff,
'contact-social-hover-border': rgba(79, 70, 229, 0.3)
'contact-social-hover-border': rgba(79, 70, 229, 0.3),
// Status message styling (light theme)
'contact-status-success-bg': #dcfce7,
'contact-status-success-border': #16a34a,
'contact-status-success-text': #15803d,
'contact-status-error-bg': #fef2f2,
'contact-status-error-border': #dc2626,
'contact-status-error-text': #dc2626
);
$contact-dark-theme: (
@ -58,5 +65,12 @@ $contact-dark-theme: (
'contact-social-text': #e5e7eb,
'contact-social-border': rgba(99, 102, 241, 0.3),
'contact-social-hover-bg': #374151,
'contact-social-hover-border': rgba(99, 102, 241, 0.4)
'contact-social-hover-border': rgba(99, 102, 241, 0.4),
// Status message styling (dark theme)
'contact-status-success-bg': rgba(34, 197, 94, 0.1),
'contact-status-success-border': #22c55e,
'contact-status-success-text': #4ade80,
'contact-status-error-bg': rgba(239, 68, 68, 0.1),
'contact-status-error-border': #ef4444,
'contact-status-error-text': #f87171
);