Integrate dotenv for environment variable management and enhance form error handling
- Added dotenv to load environment variables from a .env file in server.js for better configuration management. - Improved the Form component by refining error handling during form submission, including specific messages for different error scenarios. - Updated the CSRF token handling and ensured proper validation of response types from the contact API. - Enhanced user feedback by providing clearer messages for success and error states in the form submission process.
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
// Load environment variables from .env file first
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import { handler } from './dist/server/entry.mjs';
|
import { handler } from './dist/server/entry.mjs';
|
||||||
|
|||||||
@@ -84,12 +84,12 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '', s
|
|||||||
{
|
{
|
||||||
disclaimer && (
|
disclaimer && (
|
||||||
<div class="mt-3 flex items-start mb-6">
|
<div class="mt-3 flex items-start mb-6">
|
||||||
<div class="flex mt-0.5">
|
<div class="flex items-center h-5">
|
||||||
<input
|
<input
|
||||||
id="disclaimer"
|
id="disclaimer"
|
||||||
name="disclaimer"
|
name="disclaimer"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="cursor-pointer mt-1 py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
|
class="w-4 h-4 cursor-pointer rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-slate-700 dark:checked:bg-blue-600"
|
||||||
required
|
required
|
||||||
aria-describedby="invalid-feedback-disclaimer"
|
aria-describedby="invalid-feedback-disclaimer"
|
||||||
/>
|
/>
|
||||||
@@ -163,13 +163,10 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '', s
|
|||||||
<div id="manual-review-result" class="mt-4 text-center text-green-700 dark:text-green-400 font-medium"></div>
|
<div id="manual-review-result" class="mt-4 text-center text-green-700 dark:text-green-400 font-medium"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script define:vars={{ spamReviewConfig: spamReview }}>
|
||||||
// TypeScript: declare the property on window
|
// Make spamReview accessible in client script
|
||||||
declare global {
|
const spamReview = spamReviewConfig || {};
|
||||||
interface Window {
|
|
||||||
__originalEmail?: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function setCsrfToken() {
|
async function setCsrfToken() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/contact?csrf=true');
|
const res = await fetch('/api/contact?csrf=true');
|
||||||
@@ -177,7 +174,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '', s
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const csrfInput = document.getElementById('csrf_token');
|
const csrfInput = document.getElementById('csrf_token');
|
||||||
if (csrfInput && data.csrfToken) {
|
if (csrfInput && data.csrfToken) {
|
||||||
(csrfInput as HTMLInputElement).value = data.csrfToken;
|
csrfInput.value = data.csrfToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -187,7 +184,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '', s
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', setCsrfToken);
|
document.addEventListener('DOMContentLoaded', setCsrfToken);
|
||||||
|
|
||||||
const form = document.getElementById('contact-form') as HTMLFormElement | null;
|
const form = document.getElementById('contact-form');
|
||||||
|
|
||||||
if (form) {
|
if (form) {
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
@@ -198,14 +195,29 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '', s
|
|||||||
response = await fetch('/api/contact', {
|
response = await fetch('/api/contact', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
result = await response.json();
|
|
||||||
|
// Check if response is JSON before parsing
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
result = await response.json();
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Unexpected response type: ${contentType || 'unknown'}. Response: ${text.substring(0, 100)}`);
|
||||||
|
}
|
||||||
console.log('Contact API response:', result);
|
console.log('Contact API response:', result);
|
||||||
} catch (_err) {
|
} catch (err) {
|
||||||
const errorElement = document.getElementById('form-error');
|
const errorElement = document.getElementById('form-error');
|
||||||
if (errorElement) errorElement.classList.remove('hidden');
|
|
||||||
const successElement = document.getElementById('form-success');
|
const successElement = document.getElementById('form-success');
|
||||||
if (successElement) successElement.classList.add('hidden');
|
if (successElement) successElement.classList.add('hidden');
|
||||||
|
if (errorElement) {
|
||||||
|
errorElement.textContent = 'Network error. Please check your connection and try again.';
|
||||||
|
errorElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
console.error('Form submission error:', err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +226,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '', s
|
|||||||
console.log('Spam detected, showing manual review UI.');
|
console.log('Spam detected, showing manual review UI.');
|
||||||
form.style.display = 'none';
|
form.style.display = 'none';
|
||||||
const spamWarning = document.getElementById('spam-warning');
|
const spamWarning = document.getElementById('spam-warning');
|
||||||
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
|
const manualEmail = document.getElementById('manual-email');
|
||||||
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
|
const manualToken = document.getElementById('manual-token');
|
||||||
// Store the original email in a variable (not in the input)
|
// Store the original email in a variable (not in the input)
|
||||||
window.__originalEmail = String(formData.get('email'));
|
window.__originalEmail = String(formData.get('email'));
|
||||||
if (spamWarning && manualEmail && manualToken) {
|
if (spamWarning && manualEmail && manualToken) {
|
||||||
@@ -228,17 +240,64 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '', s
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorElement = document.getElementById('form-error');
|
const errorElement = document.getElementById('form-error');
|
||||||
if (errorElement) errorElement.classList.remove('hidden');
|
|
||||||
const successElement = document.getElementById('form-success');
|
const successElement = document.getElementById('form-success');
|
||||||
if (successElement) successElement.classList.add('hidden');
|
if (successElement) successElement.classList.add('hidden');
|
||||||
|
|
||||||
|
// Handle specific error cases
|
||||||
|
if (result.message && result.message.includes('Please use POST')) {
|
||||||
|
if (errorElement) {
|
||||||
|
errorElement.textContent = 'There was an error with the form submission. Please refresh the page and try again.';
|
||||||
|
errorElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else if (result.errors) {
|
||||||
|
// Handle field-specific validation errors
|
||||||
|
if (errorElement) {
|
||||||
|
errorElement.textContent = 'Please check all fields and try again.';
|
||||||
|
errorElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show field-specific errors
|
||||||
|
Object.keys(result.errors).forEach((fieldName) => {
|
||||||
|
const field = form.querySelector(`[name="${fieldName}"]`);
|
||||||
|
const feedback = document.getElementById(`invalid-feedback-${fieldName}`);
|
||||||
|
|
||||||
|
if (field && feedback) {
|
||||||
|
field.classList.add('border-red-500');
|
||||||
|
feedback.textContent = result.errors[fieldName];
|
||||||
|
feedback.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (result.error) {
|
||||||
|
if (errorElement) {
|
||||||
|
errorElement.textContent = result.error;
|
||||||
|
errorElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (errorElement) {
|
||||||
|
errorElement.textContent = 'There was an error sending your message. Please try again.';
|
||||||
|
errorElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success
|
// Success
|
||||||
const successElement = document.getElementById('form-success');
|
const successElement = document.getElementById('form-success');
|
||||||
if (successElement) successElement.classList.remove('hidden');
|
if (successElement) {
|
||||||
|
successElement.textContent = result.message || 'Your message has been sent successfully. We will get back to you soon!';
|
||||||
|
successElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
const errorElement = document.getElementById('form-error');
|
const errorElement = document.getElementById('form-error');
|
||||||
if (errorElement) errorElement.classList.add('hidden');
|
if (errorElement) errorElement.classList.add('hidden');
|
||||||
|
|
||||||
|
// Clear any field error states
|
||||||
|
form.querySelectorAll('.border-red-500').forEach((el) => {
|
||||||
|
el.classList.remove('border-red-500');
|
||||||
|
});
|
||||||
|
form.querySelectorAll('.invalid-feedback').forEach((el) => {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
setCsrfToken();
|
setCsrfToken();
|
||||||
});
|
});
|
||||||
@@ -249,11 +308,11 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '', s
|
|||||||
if (manualReviewForm) {
|
if (manualReviewForm) {
|
||||||
manualReviewForm.onsubmit = async function (e) {
|
manualReviewForm.onsubmit = async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
|
const manualEmail = document.getElementById('manual-email');
|
||||||
const manualJustification = document.getElementById('manual-justification') as HTMLTextAreaElement | null;
|
const manualJustification = document.getElementById('manual-justification');
|
||||||
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
|
const manualToken = document.getElementById('manual-token');
|
||||||
const resultDiv = document.getElementById('manual-review-result');
|
const resultDiv = document.getElementById('manual-review-result');
|
||||||
const form = document.getElementById('contact-form') as HTMLFormElement | null;
|
const form = document.getElementById('contact-form');
|
||||||
const spamWarning = document.getElementById('spam-warning');
|
const spamWarning = document.getElementById('spam-warning');
|
||||||
if (!manualEmail || !manualJustification || !manualToken || !resultDiv) return;
|
if (!manualEmail || !manualJustification || !manualToken || !resultDiv) return;
|
||||||
const email = manualEmail.value;
|
const email = manualEmail.value;
|
||||||
|
|||||||
@@ -18,27 +18,61 @@ const {
|
|||||||
// Email configuration
|
// Email configuration
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
// Debug: Log environment variables on load (only in development or if SMTP_HOST is set)
|
||||||
|
if (!isProduction || SMTP_HOST) {
|
||||||
|
console.log('[EMAIL CONFIG] SMTP_HOST:', SMTP_HOST || '(not set)');
|
||||||
|
console.log('[EMAIL CONFIG] SMTP_PORT:', SMTP_PORT || '(using default: 587)');
|
||||||
|
console.log('[EMAIL CONFIG] SMTP_USER:', SMTP_USER ? '***' : '(not set - no auth)');
|
||||||
|
console.log('[EMAIL CONFIG] ADMIN_EMAIL:', ADMIN_EMAIL || '(not set)');
|
||||||
|
}
|
||||||
|
|
||||||
// Create a transporter for sending emails
|
// Create a transporter for sending emails
|
||||||
let transporter: nodemailer.Transporter;
|
let transporter: nodemailer.Transporter;
|
||||||
|
|
||||||
// Initialize the transporter based on environment
|
// Initialize the transporter based on environment
|
||||||
function initializeTransporter() {
|
function initializeTransporter() {
|
||||||
if (isProduction && SMTP_HOST) {
|
if (isProduction && SMTP_HOST) {
|
||||||
// Use local Postfix mail relay (no authentication)
|
const port = parseInt(SMTP_PORT, 10) || 587;
|
||||||
transporter = nodemailer.createTransport({
|
// Determine if secure connection (port 465 typically uses SSL)
|
||||||
host: SMTP_HOST,
|
const secure = port === 465;
|
||||||
port: parseInt(SMTP_PORT, 10) || 25, // default to port 25 if not set
|
|
||||||
secure: false, // No SSL for local relay
|
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: false, // Accept self-signed certificates if present
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
transporter.verify((error, success) => {
|
// Build transporter config
|
||||||
|
const transporterConfig: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
tls: { rejectUnauthorized: boolean };
|
||||||
|
auth?: { user: string; pass: string };
|
||||||
|
} = {
|
||||||
|
host: SMTP_HOST,
|
||||||
|
port: port,
|
||||||
|
secure: secure,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false, // Accept self-signed certificates (useful for Mailcow)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add authentication if credentials are provided
|
||||||
|
if (SMTP_USER && SMTP_PASS) {
|
||||||
|
transporterConfig.auth = {
|
||||||
|
user: SMTP_USER,
|
||||||
|
pass: SMTP_PASS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
transporter = nodemailer.createTransport(transporterConfig);
|
||||||
|
|
||||||
|
transporter.verify((error, _success) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('❌ SMTP connection error:', error);
|
console.error('❌ SMTP connection error:', error.message);
|
||||||
|
console.error(' Host:', SMTP_HOST);
|
||||||
|
console.error(' Port:', port);
|
||||||
|
console.error(' Auth:', SMTP_USER ? 'Yes' : 'No');
|
||||||
} else {
|
} else {
|
||||||
console.log('✅ SMTP server is ready to take messages.');
|
console.log('✅ SMTP server is ready to take messages.');
|
||||||
|
console.log(' Host:', SMTP_HOST);
|
||||||
|
console.log(' Port:', port);
|
||||||
|
console.log(' Auth:', SMTP_USER ? 'Yes' : 'No');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -140,7 +174,7 @@ export function logEmailAttempt(success: boolean, recipient: string, subject: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send an email
|
// Send an email
|
||||||
export async function sendEmail(to: string, subject: string, html: string, text: string, domain?: string): Promise<boolean> {
|
export async function sendEmail(to: string, subject: string, html: string, text: string, _domain?: string): Promise<boolean> {
|
||||||
// Initialize transporter if not already done
|
// Initialize transporter if not already done
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
initializeTransporter();
|
initializeTransporter();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function isSpamWithGemini(message: string): Promise<boolean> {
|
|||||||
console.warn('[Gemini] API key not set; skipping spam check.');
|
console.warn('[Gemini] API key not set; skipping spam check.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
|
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
|
||||||
const prompt = `Is the following message spam? Reply with only 'yes' or 'no'.\n\nMessage:\n${message}`;
|
const prompt = `Is the following message spam? Reply with only 'yes' or 'no'.\n\nMessage:\n${message}`;
|
||||||
const result = await model.generateContent(prompt);
|
const result = await model.generateContent(prompt);
|
||||||
const response = result.response.text().trim().toLowerCase();
|
const response = result.response.text().trim().toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user