- Integrated Gemini AI for spam detection in the contact form API, returning a token for manual review requests if spam is detected. - Implemented a manual review UI in the Form.astro component, allowing users to submit their email and justification for review. - Updated email handler to send manual review requests to a designated email address. - Enhanced rate limiter configuration to allow more attempts in a shorter duration for better user experience. - Added new dependencies: jsonwebtoken and @types/jsonwebtoken for handling JWTs in the spam detection process.
450 lines
12 KiB
TypeScript
450 lines
12 KiB
TypeScript
import nodemailer from 'nodemailer';
|
|
import { RateLimiterMemory } from 'rate-limiter-flexible';
|
|
import { createHash } from 'crypto';
|
|
import 'dotenv/config';
|
|
|
|
// Environment variables
|
|
const {
|
|
SMTP_HOST = '',
|
|
SMTP_PORT = '587',
|
|
SMTP_USER = '',
|
|
SMTP_PASS = '',
|
|
ADMIN_EMAIL = '',
|
|
WEBSITE_NAME = '365DevNet Support',
|
|
} = process.env;
|
|
|
|
// Email configuration
|
|
// Force production mode for testing
|
|
const isProduction = true; // NODE_ENV === 'production';
|
|
|
|
// Create a transporter for sending emails
|
|
let transporter: nodemailer.Transporter;
|
|
|
|
// Initialize the transporter based on environment
|
|
function initializeTransporter() {
|
|
if (isProduction && SMTP_HOST) {
|
|
// Use local Postfix mail relay (no authentication)
|
|
transporter = nodemailer.createTransport({
|
|
host: SMTP_HOST,
|
|
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) => {
|
|
if (error) {
|
|
console.error('❌ SMTP connection error:', error);
|
|
} else {
|
|
console.log('✅ SMTP server is ready to take messages.');
|
|
}
|
|
});
|
|
} else {
|
|
// Fallback for development: log email output to console
|
|
transporter = nodemailer.createTransport({
|
|
streamTransport: true,
|
|
newline: 'unix',
|
|
buffer: true,
|
|
});
|
|
|
|
console.log('⚠️ Email transporter using streamTransport (development mode)');
|
|
}
|
|
}
|
|
|
|
// Rate limiter configuration
|
|
const rateLimiter = new RateLimiterMemory({
|
|
points: 20, // 20 attempts
|
|
duration: 300, // per 5 minutes
|
|
});
|
|
|
|
// CSRF protection
|
|
const csrfTokens = new Map<string, { token: string; expires: Date }>();
|
|
|
|
// Generate a CSRF token
|
|
export function generateCsrfToken(): string {
|
|
const token = createHash('sha256').update(Math.random().toString()).digest('hex');
|
|
|
|
// Token expires after 1 hour
|
|
const expires = new Date();
|
|
expires.setHours(expires.getHours() + 1);
|
|
|
|
csrfTokens.set(token, { token, expires });
|
|
|
|
// Clean up expired tokens
|
|
for (const [key, value] of csrfTokens.entries()) {
|
|
if (value.expires < new Date()) {
|
|
csrfTokens.delete(key);
|
|
}
|
|
}
|
|
|
|
return token;
|
|
}
|
|
|
|
// Validate a CSRF token
|
|
export function validateCsrfToken(token: string): boolean {
|
|
const storedToken = csrfTokens.get(token);
|
|
|
|
if (!storedToken) {
|
|
return false;
|
|
}
|
|
|
|
if (storedToken.expires < new Date()) {
|
|
csrfTokens.delete(token);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Check rate limit for an IP address
|
|
export async function checkRateLimit(ipAddress: string): Promise<{ limited: boolean; message?: string }> {
|
|
try {
|
|
await rateLimiter.consume(ipAddress);
|
|
return { limited: false };
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
return {
|
|
limited: true,
|
|
message: 'Too many requests. Please try again later.',
|
|
};
|
|
}
|
|
// RateLimiterRes with msBeforeNext property
|
|
interface RateLimiterResponse {
|
|
msBeforeNext: number;
|
|
}
|
|
const resetTime = Math.ceil((error as RateLimiterResponse).msBeforeNext / 1000 / 60);
|
|
return {
|
|
limited: true,
|
|
message: `Too many requests. Please try again in ${resetTime} minutes.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Log email sending attempts
|
|
export function logEmailAttempt(success: boolean, recipient: string, subject: string, error?: Error): void {
|
|
const timestamp = new Date().toISOString();
|
|
const status = success ? 'SUCCESS' : 'FAILURE';
|
|
const errorMessage = error ? `: ${error.message}` : '';
|
|
|
|
const logMessage = `[${timestamp}] [EMAIL ${status}] To: ${recipient}, Subject: ${subject}${errorMessage}`;
|
|
|
|
if (isProduction) {
|
|
// In production, you might want to log to a file or a logging service
|
|
console.log(logMessage);
|
|
} else {
|
|
// In development, log to console
|
|
console.log(logMessage);
|
|
}
|
|
}
|
|
|
|
// Send an email
|
|
export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<boolean> {
|
|
// Initialize transporter if not already done
|
|
if (!transporter) {
|
|
initializeTransporter();
|
|
}
|
|
|
|
try {
|
|
const fromAddress = isProduction ? `"${WEBSITE_NAME}" <${SMTP_USER || 'noreply@' + WEBSITE_NAME}>` : `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
|
|
|
|
const mailOptions = {
|
|
from: fromAddress,
|
|
to,
|
|
subject,
|
|
html,
|
|
text,
|
|
};
|
|
|
|
// Log the connection target
|
|
console.log(`[MAILER DEBUG] Sending via: ${SMTP_HOST}:${SMTP_PORT}`);
|
|
console.log(`[MAILER DEBUG] From: ${fromAddress} → To: ${to}`);
|
|
|
|
await transporter.sendMail(mailOptions);
|
|
|
|
logEmailAttempt(true, to, subject);
|
|
return true;
|
|
} catch (error) {
|
|
logEmailAttempt(false, to, subject, error as Error);
|
|
console.error('Full SMTP error stack:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Send admin notification email
|
|
export async function sendAdminNotification(
|
|
name: string,
|
|
email: string,
|
|
message: string,
|
|
ipAddress?: string,
|
|
userAgent?: string
|
|
): Promise<boolean> {
|
|
// Validate inputs
|
|
if (!name || name.trim() === '') {
|
|
console.error('Cannot send admin notification: name is empty');
|
|
return false;
|
|
}
|
|
|
|
if (!email || email.trim() === '') {
|
|
console.error('Cannot send admin notification: email is empty');
|
|
return false;
|
|
}
|
|
|
|
if (!message || message.trim() === '') {
|
|
console.error('Cannot send admin notification: message is empty');
|
|
return false;
|
|
}
|
|
|
|
const subject = `New Contact Form Submission from ${name}`;
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>New Contact Form Submission</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
.header {
|
|
background-color: #2563eb;
|
|
color: white;
|
|
padding: 20px;
|
|
text-align: center;
|
|
border-radius: 8px 8px 0 0;
|
|
}
|
|
.content {
|
|
background-color: #f8fafc;
|
|
padding: 20px;
|
|
border: 1px solid #e2e8f0;
|
|
border-top: none;
|
|
border-radius: 0 0 8px 8px;
|
|
}
|
|
.field {
|
|
margin-bottom: 15px;
|
|
}
|
|
.field-label {
|
|
font-weight: 600;
|
|
color: #4b5563;
|
|
margin-bottom: 5px;
|
|
}
|
|
.field-value {
|
|
background-color: white;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
.message-content {
|
|
white-space: pre-wrap;
|
|
background-color: white;
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
border: 1px solid #e2e8f0;
|
|
margin: 10px 0;
|
|
}
|
|
.footer {
|
|
margin-top: 20px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e2e8f0;
|
|
font-size: 0.9em;
|
|
color: #6b7280;
|
|
}
|
|
.meta-info {
|
|
font-size: 0.85em;
|
|
color: #6b7280;
|
|
margin-top: 20px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid #e2e8f0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>New Contact Form Submission</h1>
|
|
</div>
|
|
<div class="content">
|
|
<div class="field">
|
|
<div class="field-label">Name</div>
|
|
<div class="field-value">${name}</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="field-label">Email</div>
|
|
<div class="field-value">${email}</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="field-label">Message</div>
|
|
<div class="message-content">${message.replace(/\n/g, '<br>')}</div>
|
|
</div>
|
|
<div class="meta-info">
|
|
${ipAddress ? `<div><strong>IP Address:</strong> ${ipAddress}</div>` : ''}
|
|
${userAgent ? `<div><strong>User Agent:</strong> ${userAgent}</div>` : ''}
|
|
<div><strong>Time:</strong> ${new Date().toLocaleString()}</div>
|
|
</div>
|
|
<div class="footer">
|
|
<p>This message was sent from the contact form on ${WEBSITE_NAME}</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
const text = `
|
|
New Contact Form Submission
|
|
|
|
Name: ${name}
|
|
Email: ${email}
|
|
Message:
|
|
${message}
|
|
${ipAddress ? `IP Address: ${ipAddress}` : ''}
|
|
${userAgent ? `User Agent: ${userAgent}` : ''}
|
|
Time: ${new Date().toLocaleString()}
|
|
|
|
This message was sent from the contact form on ${WEBSITE_NAME}
|
|
`;
|
|
|
|
return sendEmail(ADMIN_EMAIL, subject, html, text);
|
|
}
|
|
|
|
// Send user confirmation email
|
|
export async function sendUserConfirmation(name: string, email: string, message: string): Promise<boolean> {
|
|
// Validate inputs
|
|
if (!name || name.trim() === '') {
|
|
console.error('Cannot send user confirmation: name is empty');
|
|
return false;
|
|
}
|
|
|
|
if (!email || email.trim() === '') {
|
|
console.error('Cannot send user confirmation: email is empty');
|
|
return false;
|
|
}
|
|
|
|
const subject = `Thank you for contacting ${WEBSITE_NAME}`;
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Thank you for your message</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
.header {
|
|
background-color: #2563eb;
|
|
color: white;
|
|
padding: 20px;
|
|
text-align: center;
|
|
border-radius: 8px 8px 0 0;
|
|
}
|
|
.content {
|
|
background-color: #f8fafc;
|
|
padding: 20px;
|
|
border: 1px solid #e2e8f0;
|
|
border-top: none;
|
|
border-radius: 0 0 8px 8px;
|
|
}
|
|
.message {
|
|
background-color: white;
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
border: 1px solid #e2e8f0;
|
|
margin: 20px 0;
|
|
}
|
|
.footer {
|
|
margin-top: 20px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e2e8f0;
|
|
font-size: 0.9em;
|
|
color: #6b7280;
|
|
}
|
|
.button {
|
|
display: inline-block;
|
|
background-color: #2563eb;
|
|
color: white;
|
|
padding: 12px 24px;
|
|
text-decoration: none;
|
|
border-radius: 6px;
|
|
margin: 20px 0;
|
|
}
|
|
.button:hover {
|
|
background-color: #1d4ed8;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Thank you for your message</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>Dear ${name},</p>
|
|
<p>Thank you for contacting ${WEBSITE_NAME}. We have received your message and will get back to you as soon as possible.</p>
|
|
|
|
<div class="message">
|
|
<h3>Your Message:</h3>
|
|
<p>${message.replace(/\n/g, '<br>')}</p>
|
|
</div>
|
|
|
|
<p>If you have any additional information to share, please don't hesitate to reply to this email.</p>
|
|
|
|
<a href="https://www.365devnet.eu" class="button">Visit Our Website</a>
|
|
|
|
<div class="footer">
|
|
<p>Best regards,<br>${WEBSITE_NAME} Team</p>
|
|
<p><small>This is an automated message, please do not reply directly to this email.</small></p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
const text = `
|
|
Thank you for your message
|
|
|
|
Dear ${name},
|
|
|
|
Thank you for contacting ${WEBSITE_NAME}. We have received your message and will get back to you as soon as possible.
|
|
|
|
Here's a copy of your message:
|
|
|
|
${message}
|
|
|
|
If you have any additional information to share, please don't hesitate to reply to this email.
|
|
|
|
Best regards,
|
|
${WEBSITE_NAME} Team
|
|
|
|
This is an automated message, please do not reply directly to this email.
|
|
`;
|
|
|
|
return sendEmail(email, subject, html, text);
|
|
}
|
|
|
|
// Initialize the email system
|
|
export function initializeEmailSystem(): void {
|
|
initializeTransporter();
|
|
}
|
|
|
|
// Test email configuration
|
|
export async function testEmailConfiguration(): Promise<boolean> {
|
|
if (!transporter) {
|
|
initializeTransporter();
|
|
}
|
|
|
|
try {
|
|
await transporter.verify();
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Email configuration test failed:', error);
|
|
return false;
|
|
}
|
|
}
|