Add spam detection and manual review request feature in contact form

- 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.
This commit is contained in:
becarta
2025-06-12 23:43:12 +02:00
parent 1d80c4156c
commit 5ceb3491c7
8 changed files with 406 additions and 40 deletions

View File

@@ -0,0 +1,84 @@
<form id="contact-form">
<input type="email" name="email" id="email" required />
<textarea name="message" id="message" required></textarea>
<button type="submit">Send</button>
</form>
<div id="spam-warning" style="display:none;">
<p>
Your message was detected as spam and was not sent.<br>
If you believe this is a mistake, you can request a manual review.
</p>
<form id="manual-review-form">
<input type="email" id="manual-email" readonly />
<textarea id="manual-justification" placeholder="Why is this not spam? (optional)"></textarea>
<input type="hidden" id="manual-token" />
<button type="submit">Request Manual Review</button>
</form>
<div id="manual-review-result"></div>
</div>
<script>
const contactForm = document.getElementById('contact-form');
if (contactForm) {
contactForm.onsubmit = async function (e) {
e.preventDefault();
const formData = new FormData(this as HTMLFormElement);
let res, data;
try {
res = await fetch('/api/contact', { method: 'POST', body: formData });
data = await res.json();
console.log('Contact API response:', data);
} catch (_err) {
alert('Unexpected server response.');
return;
}
if (data.spam && data.token) {
console.log('Spam detected, showing manual review UI.');
const spamWarning = document.getElementById('spam-warning');
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
if (spamWarning && manualEmail && manualToken) {
spamWarning.style.display = 'block';
manualEmail.value = String(formData.get('email'));
manualToken.value = data.token;
}
return;
}
if (!res.ok) {
alert(data.error || 'There was an error sending your message.');
} else {
alert('Your message was sent successfully!');
}
};
}
const manualReviewForm = document.getElementById('manual-review-form');
if (manualReviewForm) {
manualReviewForm.onsubmit = async function (e) {
e.preventDefault();
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
const manualJustification = document.getElementById('manual-justification') as HTMLTextAreaElement | null;
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
const resultDiv = document.getElementById('manual-review-result');
if (!manualEmail || !manualJustification || !manualToken || !resultDiv) return;
const email = manualEmail.value;
const justification = manualJustification.value;
const token = manualToken.value;
const res = await fetch('/api/contact/manual-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, justification, token }),
});
const data = await res.json();
if (data.success) {
resultDiv.textContent = 'Your request for manual review has been submitted. Thank you!';
} else {
resultDiv.textContent = data.error || 'There was an error submitting your manual review request.';
}
};
}
</script>

View File

@@ -128,6 +128,23 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
}
</form>
<!-- Manual Review UI -->
<div id="spam-warning" style="display:none;" class="max-w-xl mx-auto rounded-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900 border border-gray-200 dark:border-gray-700 shadow-md p-4 sm:p-6 lg:p-8 w-full mb-6">
<p class="mb-4 text-lg font-medium text-gray-800 dark:text-gray-100">
Your message was detected as spam and was not sent.<br>
<span class="text-base font-normal text-gray-600 dark:text-gray-300">If you believe this is a mistake, you can request a manual review.</span>
</p>
<form id="manual-review-form" class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-email">Please re-enter your email address for confirmation</label>
<input type="email" id="manual-email" required placeholder="Enter your email address again" class="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" />
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-justification">Why is this not spam? <span class="text-gray-400">(optional)</span></label>
<textarea id="manual-justification" placeholder="Explain why your message is legitimate..." class="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"></textarea>
<input type="hidden" id="manual-token" />
<button type="submit" class="mt-2 w-full py-3 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors">Request Manual Review</button>
</form>
<div id="manual-review-result" class="mt-4 text-center text-green-700 dark:text-green-400 font-medium"></div>
</div>
<script>
async function setCsrfToken() {
try {
@@ -146,55 +163,97 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
document.addEventListener('DOMContentLoaded', setCsrfToken);
const form = document.getElementById('contact-form');
const form = document.getElementById('contact-form') as HTMLFormElement | null;
if (form) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
let response, result;
try {
const response = await fetch('/api/contact', {
response = await fetch('/api/contact', {
method: 'POST',
body: formData,
});
if (response.ok) {
const result = await response.json();
console.log(result.message); // Log success message
const successElement = document.getElementById('form-success');
if (successElement) {
successElement.classList.remove('hidden');
}
const errorElement = document.getElementById('form-error');
if (errorElement) {
errorElement.classList.add('hidden');
}
form.reset(); // Clear the form
// Re-fetch CSRF token after successful submission
setCsrfToken();
} else {
console.error('Error:', response.status);
const errorElement = document.getElementById('form-error');
if (errorElement) {
errorElement.classList.remove('hidden');
}
const successElement = document.getElementById('form-success');
if (successElement) {
successElement.classList.add('hidden');
}
}
} catch (error) {
console.error('Error:', error);
result = await response.json();
console.log('Contact API response:', result);
} catch (_err) {
const errorElement = document.getElementById('form-error');
if (errorElement) {
errorElement.classList.remove('hidden');
}
if (errorElement) errorElement.classList.remove('hidden');
const successElement = document.getElementById('form-success');
if (successElement) {
successElement.classList.add('hidden');
}
if (successElement) successElement.classList.add('hidden');
return;
}
// Show manual review UI if spam detected, even if status is 422
if (result.spam && result.token) {
console.log('Spam detected, showing manual review UI.');
form.style.display = 'none';
const spamWarning = document.getElementById('spam-warning');
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
if (spamWarning && manualEmail && manualToken) {
spamWarning.style.display = 'block';
manualEmail.value = String(formData.get('email'));
manualToken.value = result.token;
}
return;
}
if (!response.ok) {
const errorElement = document.getElementById('form-error');
if (errorElement) errorElement.classList.remove('hidden');
const successElement = document.getElementById('form-success');
if (successElement) successElement.classList.add('hidden');
return;
}
// Success
const successElement = document.getElementById('form-success');
if (successElement) successElement.classList.remove('hidden');
const errorElement = document.getElementById('form-error');
if (errorElement) errorElement.classList.add('hidden');
form.reset();
setCsrfToken();
});
}
// Manual Review Form Logic
const manualReviewForm = document.getElementById('manual-review-form');
if (manualReviewForm) {
manualReviewForm.onsubmit = async function (e) {
e.preventDefault();
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
const manualJustification = document.getElementById('manual-justification') as HTMLTextAreaElement | null;
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
const resultDiv = document.getElementById('manual-review-result');
const form = document.getElementById('contact-form') as HTMLFormElement | null;
const spamWarning = document.getElementById('spam-warning');
if (!manualEmail || !manualJustification || !manualToken || !resultDiv) return;
const email = manualEmail.value;
const justification = manualJustification.value;
const token = manualToken.value;
const res = await fetch('/api/contact/manual-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, justification, token }),
});
const data = await res.json();
if (data.success) {
resultDiv.textContent = 'Your request for manual review has been submitted. Thank you!';
// Hide spam-warning and show the normal form again after a short delay
setTimeout(() => {
if (spamWarning) spamWarning.style.display = 'none';
if (form) {
form.style.display = '';
form.reset();
}
resultDiv.textContent = '';
}, 2000);
} else {
resultDiv.textContent = data.error || 'There was an error submitting your manual review request.';
}
};
}
</script>

View File

@@ -5,7 +5,13 @@ import {
checkRateLimit,
sendAdminNotification,
sendUserConfirmation,
sendEmail,
} from '../../utils/email-handler';
import { isSpamWithGemini } from "../../utils/gemini-spam-check";
import jwt from 'jsonwebtoken';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret';
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
// Enhanced email validation with more comprehensive regex
const isValidEmail = (email: string): boolean => {
@@ -210,6 +216,23 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
errors.spam = 'Your message was flagged as potential spam. Please revise your message and try again.';
}
// Gemini AI spam detection
if (await isSpamWithGemini(message)) {
const token = jwt.sign({ email, message }, MANUAL_REVIEW_SECRET, { expiresIn: '1h' });
console.warn(
`[SPAM DETECTED by Gemini]`,
{ name, email, message, ip: request.headers.get('x-forwarded-for') }
);
return new Response(
JSON.stringify({
error: "Your message was detected as spam and was not sent. If this is a mistake, you can request a manual review.",
spam: true,
token
}),
{ status: 422 }
);
}
// If there are validation errors, return them
if (Object.keys(errors).length > 0) {
return new Response(
@@ -283,3 +306,23 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
);
}
};
export const manualReviewPOST: APIRoute = async ({ request }) => {
const { token, email: submittedEmail, justification } = await request.json();
try {
const payload = jwt.verify(token, MANUAL_REVIEW_SECRET) as { email: string, message: string };
if (payload.email !== submittedEmail) {
return new Response(JSON.stringify({ error: 'Email does not match original submission.' }), { status: 403 });
}
// Send to manual review mailbox
await sendEmail(
MANUAL_REVIEW_EMAIL,
'Manual Review Requested: Contact Form Submission',
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`,
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
);
return new Response(JSON.stringify({ success: true }));
} catch (_err) {
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
}
};

View File

@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import jwt from 'jsonwebtoken';
import { sendEmail } from '../../../utils/email-handler';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret';
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
export const POST: APIRoute = async ({ request }) => {
const { token, email: submittedEmail, justification } = await request.json();
try {
const payload = jwt.verify(token, MANUAL_REVIEW_SECRET) as { email: string, message: string };
if (payload.email !== submittedEmail) {
return new Response(JSON.stringify({ error: 'Email does not match original submission.' }), { status: 403 });
}
// Send to manual review mailbox
await sendEmail(
MANUAL_REVIEW_EMAIL,
'Manual Review Requested: Contact Form Submission',
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`,
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
);
return new Response(JSON.stringify({ success: true }));
} catch (_err) {
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
}
};

View File

@@ -54,8 +54,8 @@ function initializeTransporter() {
// Rate limiter configuration
const rateLimiter = new RateLimiterMemory({
points: 5, // 5 attempts
duration: 3600, // per hour
points: 20, // 20 attempts
duration: 300, // per 5 minutes
});
// CSRF protection

View File

@@ -0,0 +1,15 @@
import { GoogleGenerativeAI } from "@google/generative-ai";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
if (!GEMINI_API_KEY) {
throw new Error("GEMINI_API_KEY environment variable is not set.");
}
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
export async function isSpamWithGemini(message: string): Promise<boolean> {
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
const prompt = `Is the following message spam? Reply with only 'yes' or 'no'.\n\nMessage:\n${message}`;
const result = await model.generateContent(prompt);
const response = result.response.text().trim().toLowerCase();
return response.startsWith("yes");
}