Add form mailer
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Email Configuration
|
||||||
|
# Copy this file to .env and fill in your actual values
|
||||||
|
|
||||||
|
# Email server settings
|
||||||
|
MAIL_SERVER=smtppro.zoho.eu
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=True
|
||||||
|
MAIL_USE_SSL=False
|
||||||
|
|
||||||
|
# Email credentials
|
||||||
|
MAIL_USERNAME=contact@kobelly.be
|
||||||
|
MAIL_PASSWORD=Surface125300!?
|
||||||
|
|
||||||
|
# Default sender
|
||||||
|
MAIL_DEFAULT_SENDER=contact@kobelly.be
|
||||||
|
|
||||||
|
# Flask secret key (change this in production)
|
||||||
|
SECRET_KEY=thisisasecretkeylol
|
||||||
|
|
||||||
|
# Flask environment
|
||||||
|
FLASK_ENV=development
|
||||||
|
FLASK_DEBUG=1
|
||||||
55
README.md
55
README.md
@ -205,6 +205,61 @@ make deploy
|
|||||||
|
|
||||||
The build process creates cache-busted versions with MD5 hashes in the filename to ensure browsers always load the latest version.
|
The build process creates cache-busted versions with MD5 hashes in the filename to ensure browsers always load the latest version.
|
||||||
|
|
||||||
|
## Contact Form Setup
|
||||||
|
|
||||||
|
The website includes a fully functional contact form that sends email notifications. To set up email functionality:
|
||||||
|
|
||||||
|
### 1. Email Configuration
|
||||||
|
|
||||||
|
1. **Copy the environment template:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure your email settings in `.env`:**
|
||||||
|
```bash
|
||||||
|
# For Gmail (recommended)
|
||||||
|
MAIL_SERVER=smtp.gmail.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=True
|
||||||
|
MAIL_USERNAME=contact@kobelly.be
|
||||||
|
MAIL_PASSWORD=your-app-password-here
|
||||||
|
MAIL_DEFAULT_SENDER=contact@kobelly.be
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **For Gmail users:**
|
||||||
|
- Enable 2-factor authentication on your Google account
|
||||||
|
- Generate an "App Password" in Google Account settings
|
||||||
|
- Use the app password as `MAIL_PASSWORD`
|
||||||
|
|
||||||
|
### 2. Alternative Email Providers
|
||||||
|
|
||||||
|
**For other SMTP providers, update the settings accordingly:**
|
||||||
|
```bash
|
||||||
|
# Example for Outlook/Hotmail
|
||||||
|
MAIL_SERVER=smtp-mail.outlook.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=True
|
||||||
|
|
||||||
|
# Example for custom SMTP server
|
||||||
|
MAIL_SERVER=your-smtp-server.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Testing the Contact Form
|
||||||
|
|
||||||
|
1. Start the application
|
||||||
|
2. Navigate to the Contact page
|
||||||
|
3. Fill out and submit the form
|
||||||
|
4. Check your email for the notification
|
||||||
|
|
||||||
|
The form includes:
|
||||||
|
- **Form validation** (required fields, email format, phone format)
|
||||||
|
- **Loading states** (button shows spinner during submission)
|
||||||
|
- **Success/error notifications** (toast notifications)
|
||||||
|
- **Email notifications** (HTML and plain text formats)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|||||||
144
app.py
144
app.py
@ -1,13 +1,93 @@
|
|||||||
from flask import Flask, render_template, request, session, redirect, url_for, make_response
|
from flask import Flask, render_template, request, session, redirect, url_for, make_response, jsonify, flash
|
||||||
from flask_assets import Environment, Bundle
|
from flask_assets import Environment, Bundle
|
||||||
|
from flask_mail import Mail, Message
|
||||||
from translation_manager import init_app, translate, get_current_language, create_language_selector, set_language
|
from translation_manager import init_app, translate, get_current_language, create_language_selector, set_language
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.environ.get('SECRET_KEY', 'your-secret-key-change-in-production')
|
app.secret_key = os.environ.get('SECRET_KEY', 'your-secret-key-change-in-production')
|
||||||
|
|
||||||
|
# Email configuration
|
||||||
|
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
|
||||||
|
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587))
|
||||||
|
app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'True').lower() == 'true'
|
||||||
|
app.config['MAIL_USE_SSL'] = os.environ.get('MAIL_USE_SSL', 'False').lower() == 'true'
|
||||||
|
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME', 'contact@kobelly.be')
|
||||||
|
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD', '')
|
||||||
|
app.config['MAIL_DEFAULT_SENDER'] = os.environ.get('MAIL_DEFAULT_SENDER', 'contact@kobelly.be')
|
||||||
|
|
||||||
|
# Initialize Flask-Mail
|
||||||
|
mail = Mail(app)
|
||||||
|
|
||||||
|
def validate_email(email):
|
||||||
|
"""Validate email format"""
|
||||||
|
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||||
|
return re.match(pattern, email) is not None
|
||||||
|
|
||||||
|
def validate_phone(phone):
|
||||||
|
"""Validate phone number format (basic validation)"""
|
||||||
|
if not phone:
|
||||||
|
return True # Phone is optional
|
||||||
|
# Remove all non-digit characters
|
||||||
|
digits_only = re.sub(r'\D', '', phone)
|
||||||
|
return len(digits_only) >= 8
|
||||||
|
|
||||||
|
def send_contact_email(form_data):
|
||||||
|
"""Send contact form email"""
|
||||||
|
try:
|
||||||
|
# Create email content
|
||||||
|
subject = f"New Contact Form Submission from {form_data['firstName']} {form_data['lastName']}"
|
||||||
|
|
||||||
|
# HTML email body
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>New Contact Form Submission</h2>
|
||||||
|
<p><strong>Name:</strong> {form_data['firstName']} {form_data['lastName']}</p>
|
||||||
|
<p><strong>Email:</strong> {form_data['email']}</p>
|
||||||
|
<p><strong>Phone:</strong> {form_data.get('phone', 'Not provided')}</p>
|
||||||
|
<p><strong>Company:</strong> {form_data.get('company', 'Not provided')}</p>
|
||||||
|
<p><strong>Service Interested In:</strong> {form_data['service']}</p>
|
||||||
|
<p><strong>Budget:</strong> {form_data.get('budget', 'Not specified')}</p>
|
||||||
|
<p><strong>Message:</strong></p>
|
||||||
|
<p>{form_data['message'].replace(chr(10), '<br>')}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Plain text email body
|
||||||
|
text_body = f"""
|
||||||
|
New Contact Form Submission
|
||||||
|
|
||||||
|
Name: {form_data['firstName']} {form_data['lastName']}
|
||||||
|
Email: {form_data['email']}
|
||||||
|
Phone: {form_data.get('phone', 'Not provided')}
|
||||||
|
Company: {form_data.get('company', 'Not provided')}
|
||||||
|
Service Interested In: {form_data['service']}
|
||||||
|
Budget: {form_data.get('budget', 'Not specified')}
|
||||||
|
|
||||||
|
Message:
|
||||||
|
{form_data['message']}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create message
|
||||||
|
msg = Message(
|
||||||
|
subject=subject,
|
||||||
|
recipients=[app.config['MAIL_DEFAULT_SENDER']],
|
||||||
|
html=html_body,
|
||||||
|
body=text_body
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
mail.send(msg)
|
||||||
|
return True, "Email sent successfully"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Failed to send email: {str(e)}"
|
||||||
|
|
||||||
# Initialize Flask-Assets
|
# Initialize Flask-Assets
|
||||||
assets = Environment(app)
|
assets = Environment(app)
|
||||||
assets.url = app.static_url_path
|
assets.url = app.static_url_path
|
||||||
@ -109,6 +189,68 @@ def services():
|
|||||||
def contact():
|
def contact():
|
||||||
return render_template('contact.html')
|
return render_template('contact.html')
|
||||||
|
|
||||||
|
@app.route('/contact', methods=['POST'])
|
||||||
|
def submit_contact():
|
||||||
|
"""Handle contact form submission"""
|
||||||
|
try:
|
||||||
|
# Get form data
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ['firstName', 'lastName', 'email', 'service', 'message']
|
||||||
|
for field in required_fields:
|
||||||
|
if not data.get(field) or not data[field].strip():
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'{field.replace("firstName", "First name").replace("lastName", "Last name").title()} is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate email format
|
||||||
|
if not validate_email(data['email']):
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Please enter a valid email address'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate phone if provided
|
||||||
|
if data.get('phone') and not validate_phone(data['phone']):
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Please enter a valid phone number'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Prepare form data
|
||||||
|
form_data = {
|
||||||
|
'firstName': data['firstName'].strip(),
|
||||||
|
'lastName': data['lastName'].strip(),
|
||||||
|
'email': data['email'].strip(),
|
||||||
|
'phone': data.get('phone', '').strip(),
|
||||||
|
'company': data.get('company', '').strip(),
|
||||||
|
'service': data['service'].strip(),
|
||||||
|
'budget': data.get('budget', '').strip(),
|
||||||
|
'message': data['message'].strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
success, message = send_contact_email(form_data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Thank you for your message! I will get back to you within 48 hours.'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Sorry, there was an error sending your message. Please try again or contact me directly.'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'An unexpected error occurred. Please try again.'
|
||||||
|
}), 500
|
||||||
|
|
||||||
@app.route('/about')
|
@app.route('/about')
|
||||||
def about():
|
def about():
|
||||||
return render_template('about.html')
|
return render_template('about.html')
|
||||||
|
|||||||
@ -5,4 +5,6 @@ cssmin==0.2.0
|
|||||||
jsmin==3.0.1
|
jsmin==3.0.1
|
||||||
click==8.1.7
|
click==8.1.7
|
||||||
blinker==1.6.3
|
blinker==1.6.3
|
||||||
# Translation system - JSON-based, no compilation needed
|
# Translation system - JSON-based, no compilation needed
|
||||||
|
# Email functionality
|
||||||
|
Flask-Mail==0.9.1
|
||||||
@ -4633,3 +4633,73 @@ body.theme-ecommerce {
|
|||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
background: rgba(52, 152, 219, 0.15);
|
background: rgba(52, 152, 219, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notification System */
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
max-width: 400px;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
background: linear-gradient(135deg, #28a745, #20c997);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
background: linear-gradient(135deg, #dc3545, #e74c3c);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
background: linear-gradient(135deg, #17a2b8, #3498db);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contact form button states */
|
||||||
|
.contact-submit-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-submit-btn .fa-spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|||||||
@ -361,3 +361,105 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Contact Form Handling
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const contactForm = document.querySelector('.contact-form');
|
||||||
|
const submitBtn = document.querySelector('.contact-submit-btn');
|
||||||
|
|
||||||
|
if (contactForm) {
|
||||||
|
contactForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Get form data
|
||||||
|
const formData = {
|
||||||
|
firstName: document.getElementById('firstName').value,
|
||||||
|
lastName: document.getElementById('lastName').value,
|
||||||
|
email: document.getElementById('email').value,
|
||||||
|
phone: document.getElementById('phone').value,
|
||||||
|
company: document.getElementById('company').value,
|
||||||
|
service: document.getElementById('service').value,
|
||||||
|
budget: document.getElementById('budget').value,
|
||||||
|
message: document.getElementById('message').value
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disable submit button and show loading state
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Sending...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send form data
|
||||||
|
fetch('/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Show success message
|
||||||
|
showNotification(data.message, 'success');
|
||||||
|
// Reset form
|
||||||
|
contactForm.reset();
|
||||||
|
} else {
|
||||||
|
// Show error message
|
||||||
|
showNotification(data.message, 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showNotification('An error occurred. Please try again.', 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Re-enable submit button
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-paper-plane me-2"></i>Send Message';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification system
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
// Remove existing notifications
|
||||||
|
const existingNotifications = document.querySelectorAll('.notification');
|
||||||
|
existingNotifications.forEach(notification => notification.remove());
|
||||||
|
|
||||||
|
// Create notification element
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `notification notification-${type}`;
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="notification-content">
|
||||||
|
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'} me-2"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
<button class="notification-close" onclick="this.parentElement.parentElement.remove()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to page
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('show');
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Auto-hide after 5 seconds (except for errors)
|
||||||
|
if (type !== 'error') {
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentElement) {
|
||||||
|
notification.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user