diff --git a/docs/contact_form_processing.md b/docs/contact_form_processing.md
new file mode 100644
index 0000000..8316dbc
--- /dev/null
+++ b/docs/contact_form_processing.md
@@ -0,0 +1,40 @@
+# Contact form processing
+
+Contact form submissions are stored first and processed asynchronously by:
+
+```bash
+python manage.py run_contact_submission_worker
+```
+
+The `scheduler` application role runs this worker and the newsletter scheduler in independent loops. A database lease allows only one spam-detection request at a time across scheduler replicas, while newsletter and SMTP work continue independently.
+
+## Environment variables
+
+Required in production:
+
+```text
+CONTACT_SPAM_DETECTION_ENABLED=true
+CONTACT_SPAM_DETECTION_URL=https://spamd.innovedus.com/classify
+CONTACT_SPAM_DETECTION_API_KEY=replace-me
+```
+
+Optional tuning:
+
+```text
+CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS=180
+CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS=300
+CONTACT_SPAM_DETECTION_MAX_ATTEMPTS=3
+CONTACT_FORM_WORKER_BATCH_SIZE=20
+CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS=600
+CONTACT_FORM_WORKER_LEASE_SECONDS=600
+CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS=300
+CONTACT_FORM_MAIL_MAX_ATTEMPTS=3
+CONTACT_SCHEDULER_INTERVAL_SECONDS=60
+NEWSLETTER_SCHEDULER_INTERVAL_SECONDS=60
+```
+
+When spam detection is disabled or its URL/key is missing, queued submissions stay in the database and no contact email is sent. A service error opens a database-backed cooldown until the configured retry time, so later scheduler runs do not repeatedly call an AI box that is restarting.
+
+Rate limit count/window and the submitter-copy switch are managed in Wagtail under Contact Form Settings. The default is three submissions per ten minutes per IP, and submitter copies are disabled by default.
+
+The application must only be publicly reachable through an Application Load Balancer whose `routing.http.xff_header_processing.mode` is `append` (the AWS default). The client IP is read from the rightmost valid `X-Forwarded-For` entry, with `REMOTE_ADDR` used only when that header is absent.
diff --git a/innovedus_cms/base/admin.py b/innovedus_cms/base/admin.py
index a71db95..e498483 100644
--- a/innovedus_cms/base/admin.py
+++ b/innovedus_cms/base/admin.py
@@ -4,9 +4,10 @@ from .models import ContactFormSubmission
@admin.register(ContactFormSubmission)
class ContactFormSubmissionAdmin(admin.ModelAdmin):
- list_display = ("created_at", "category", "name", "email", "contact")
- list_filter = ("category", "created_at")
- search_fields = ("name", "email", "contact", "message", "source_page")
+ list_display = ("created_at", "processing_status", "spam_category", "category", "name", "email", "ip_address")
+ list_filter = ("processing_status", "spam_category", "category", "created_at")
+ search_fields = ("name", "email", "contact", "message", "source_page", "ip_address")
+ actions = ("requeue_submissions",)
readonly_fields = (
"name",
"email",
@@ -16,5 +17,39 @@ class ContactFormSubmissionAdmin(admin.ModelAdmin):
"source_page",
"ip_address",
"user_agent",
+ "processing_status",
+ "spam_category",
+ "spam_source",
+ "spam_checked_at",
+ "scan_attempt_count",
+ "send_attempt_count",
+ "next_attempt_at",
+ "processing_started_at",
+ "notification_sent_at",
+ "user_copy_sent_at",
+ "last_error",
"created_at",
+ "updated_at",
)
+
+ @admin.action(description="Requeue selected contact submissions")
+ def requeue_submissions(self, request, queryset):
+ eligible = queryset.filter(
+ processing_status__in=[
+ ContactFormSubmission.STATUS_BLOCKED,
+ ContactFormSubmission.STATUS_FAILED,
+ ContactFormSubmission.STATUS_RATE_LIMITED,
+ ]
+ )
+ updated = eligible.update(
+ processing_status=ContactFormSubmission.STATUS_QUEUED,
+ spam_category=None,
+ spam_source="",
+ spam_checked_at=None,
+ scan_attempt_count=0,
+ send_attempt_count=0,
+ next_attempt_at=None,
+ processing_started_at=None,
+ last_error="",
+ )
+ self.message_user(request, f"Requeued {updated} contact submission(s).")
diff --git a/innovedus_cms/base/contact_submission_processor.py b/innovedus_cms/base/contact_submission_processor.py
new file mode 100644
index 0000000..bcff3fc
--- /dev/null
+++ b/innovedus_cms/base/contact_submission_processor.py
@@ -0,0 +1,437 @@
+import json
+import logging
+import time
+import uuid
+from dataclasses import dataclass
+from datetime import timedelta
+from urllib.error import HTTPError, URLError
+from urllib.request import Request, urlopen
+
+from django.conf import settings
+from django.db import transaction
+from django.db.models import Q
+from django.utils import timezone
+from django.utils.html import escape
+
+from .models import (
+ ContactFormSettings,
+ ContactFormSubmission,
+ ContactSpamDetectionState,
+ MailSmtpSettings,
+ SystemNotificationMailSettings,
+)
+from .newsletter import send_contact_notification_email, send_contact_user_email
+
+logger = logging.getLogger(__name__)
+
+
+class SpamDetectionError(Exception):
+ pass
+
+
+@dataclass(frozen=True)
+class SpamDetectionResult:
+ category: int
+ source: str
+
+
+class SpamDetectionClient:
+ def __init__(self):
+ self.url = settings.CONTACT_SPAM_DETECTION_URL
+ self.api_key = settings.CONTACT_SPAM_DETECTION_API_KEY
+ self.timeout_seconds = max(1, int(settings.CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS))
+
+ def validate_config(self):
+ if not self.url:
+ raise SpamDetectionError("CONTACT_SPAM_DETECTION_URL is not configured")
+ if not self.api_key:
+ raise SpamDetectionError("CONTACT_SPAM_DETECTION_API_KEY is not configured")
+
+ def classify(self, message: str) -> SpamDetectionResult:
+ self.validate_config()
+ request = Request(
+ self.url,
+ data=json.dumps({"message": message}).encode("utf-8"),
+ headers={
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ },
+ method="POST",
+ )
+ try:
+ with urlopen(request, timeout=self.timeout_seconds) as response:
+ response_data = json.loads(response.read().decode("utf-8"))
+ except HTTPError as exc:
+ try:
+ detail = exc.read().decode("utf-8")
+ except Exception:
+ detail = ""
+ raise SpamDetectionError(f"spam detection HTTP {exc.code}: {detail[:500]}") from exc
+ except (URLError, TimeoutError, OSError) as exc:
+ raise SpamDetectionError(f"spam detection request failed: {exc}") from exc
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise SpamDetectionError("spam detection returned invalid JSON") from exc
+
+ if not isinstance(response_data, dict):
+ raise SpamDetectionError("spam detection response must be an object")
+ if response_data.get("error"):
+ raise SpamDetectionError(f"spam detection service error: {response_data['error']}")
+
+ category = response_data.get("category")
+ if type(category) is not int or category not in {0, 1, 2, 3}:
+ raise SpamDetectionError(f"spam detection returned invalid category: {category!r}")
+ return SpamDetectionResult(
+ category=category,
+ source=str(response_data.get("source") or "")[:32],
+ )
+
+
+def _render_contact_template(template: str, values: dict[str, str]) -> str:
+ rendered = template or ""
+ for key, value in values.items():
+ rendered = rendered.replace(f"{{{{{key}}}}}", value)
+ return rendered
+
+
+def _claim_mail_submission(now):
+ stale_before = now - timedelta(seconds=max(1, int(settings.CONTACT_FORM_WORKER_LEASE_SECONDS)))
+ eligible = Q(
+ processing_status=ContactFormSubmission.STATUS_SEND_RETRY,
+ next_attempt_at__lte=now,
+ ) | Q(
+ processing_status=ContactFormSubmission.STATUS_SEND_RETRY,
+ next_attempt_at__isnull=True,
+ ) | Q(
+ processing_status=ContactFormSubmission.STATUS_SENDING,
+ processing_started_at__lte=stale_before,
+ ) | Q(
+ processing_status=ContactFormSubmission.STATUS_SENDING,
+ processing_started_at__isnull=True,
+ )
+ with transaction.atomic():
+ submission = (
+ ContactFormSubmission.objects.select_for_update(skip_locked=True)
+ .filter(eligible)
+ .order_by("created_at", "id")
+ .first()
+ )
+ if submission is None:
+ return None
+ submission.processing_status = ContactFormSubmission.STATUS_SENDING
+ submission.processing_started_at = now
+ submission.next_attempt_at = None
+ submission.save(update_fields=["processing_status", "processing_started_at", "next_attempt_at", "updated_at"])
+ return submission
+
+
+def _claim_scan_submission(now):
+ stale_before = now - timedelta(seconds=max(1, int(settings.CONTACT_FORM_WORKER_LEASE_SECONDS)))
+ eligible = Q(processing_status=ContactFormSubmission.STATUS_QUEUED) | Q(
+ processing_status=ContactFormSubmission.STATUS_SCAN_RETRY,
+ next_attempt_at__lte=now,
+ ) | Q(
+ processing_status=ContactFormSubmission.STATUS_SCAN_RETRY,
+ next_attempt_at__isnull=True,
+ ) | Q(
+ processing_status=ContactFormSubmission.STATUS_SCANNING,
+ processing_started_at__lte=stale_before,
+ )
+ with transaction.atomic():
+ submission = (
+ ContactFormSubmission.objects.select_for_update(skip_locked=True)
+ .filter(eligible)
+ .order_by("created_at", "id")
+ .first()
+ )
+ if submission is None:
+ return None
+ submission.processing_status = ContactFormSubmission.STATUS_SCANNING
+ submission.processing_started_at = now
+ submission.next_attempt_at = None
+ submission.scan_attempt_count += 1
+ submission.save(
+ update_fields=[
+ "processing_status",
+ "processing_started_at",
+ "next_attempt_at",
+ "scan_attempt_count",
+ "updated_at",
+ ]
+ )
+ return submission
+
+
+def _acquire_detection_lease(now):
+ with transaction.atomic():
+ state, _ = ContactSpamDetectionState.objects.get_or_create(pk=1)
+ state = ContactSpamDetectionState.objects.select_for_update().get(pk=state.pk)
+ if state.cooldown_until and state.cooldown_until > now:
+ return "", state.cooldown_until, False
+ if state.locked_until and state.locked_until > now:
+ return "", None, True
+
+ lease_seconds = max(
+ int(settings.CONTACT_FORM_WORKER_LEASE_SECONDS),
+ int(settings.CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS) + 60,
+ )
+ token = uuid.uuid4().hex
+ state.lock_token = token
+ state.locked_until = now + timedelta(seconds=max(1, lease_seconds))
+ state.cooldown_until = None
+ state.save(update_fields=["lock_token", "locked_until", "cooldown_until", "updated_at"])
+ return token, None, False
+
+
+def _release_detection_lease(token: str, *, cooldown_until=None, error=""):
+ ContactSpamDetectionState.objects.filter(pk=1, lock_token=token).update(
+ lock_token="",
+ locked_until=None,
+ cooldown_until=cooldown_until,
+ last_error=error[:4000],
+ updated_at=timezone.now(),
+ )
+
+
+def _record_scan_failure(submission: ContactFormSubmission, error: str):
+ now = timezone.now()
+ retry_at = now + timedelta(seconds=max(1, int(settings.CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS)))
+ if submission.scan_attempt_count >= max(1, int(settings.CONTACT_SPAM_DETECTION_MAX_ATTEMPTS)):
+ submission.processing_status = ContactFormSubmission.STATUS_FAILED
+ else:
+ submission.processing_status = ContactFormSubmission.STATUS_SCAN_RETRY
+ submission.processing_started_at = None
+ submission.next_attempt_at = retry_at
+ submission.last_error = error[:4000]
+ submission.save(
+ update_fields=[
+ "processing_status",
+ "processing_started_at",
+ "next_attempt_at",
+ "last_error",
+ "updated_at",
+ ]
+ )
+ return retry_at
+
+
+def _scan_submission(submission: ContactFormSubmission, client: SpamDetectionClient):
+ result = client.classify(submission.message)
+ now = timezone.now()
+ submission.spam_category = result.category
+ submission.spam_source = result.source
+ submission.spam_checked_at = now
+ submission.next_attempt_at = None
+ submission.last_error = ""
+ if result.category == 0:
+ submission.processing_status = ContactFormSubmission.STATUS_SENDING
+ submission.processing_started_at = now
+ else:
+ submission.processing_status = ContactFormSubmission.STATUS_BLOCKED
+ submission.processing_started_at = None
+ submission.save(
+ update_fields=[
+ "spam_category",
+ "spam_source",
+ "spam_checked_at",
+ "processing_status",
+ "processing_started_at",
+ "next_attempt_at",
+ "last_error",
+ "updated_at",
+ ]
+ )
+ return result
+
+
+def _notification_content(submission: ContactFormSubmission, notification_settings):
+ subject_prefix = (notification_settings.contact_form_subject_prefix or "").strip()
+ subject = f"{subject_prefix} {submission.get_category_display()}".strip()
+ escaped_message_html = escape(submission.message).replace("\n", "
")
+ text_body = (
+ f"Name: {submission.name}\n"
+ f"Email: {submission.email}\n"
+ f"Contact: {submission.contact}\n"
+ f"Category: {submission.get_category_display()}\n"
+ f"Source Page: {submission.source_page}\n"
+ f"IP Address: {submission.ip_address or ''}\n\n"
+ f"Message:\n{submission.message}\n"
+ )
+ html_body = (
+ f"
Name: {escape(submission.name)}
"
+ f"Email: {escape(submission.email)}
"
+ f"Contact: {escape(submission.contact)}
"
+ f"Category: {escape(submission.get_category_display())}
"
+ f"Source Page: {escape(submission.source_page or '')}
"
+ f"IP Address: {escape(str(submission.ip_address or ''))}
"
+ f"Message:
{escaped_message_html}
"
+ )
+ return subject, text_body, html_body
+
+
+def _user_copy_content(submission: ContactFormSubmission, notification_settings):
+ values_text = {
+ "name": submission.name,
+ "email": submission.email,
+ "contact": submission.contact,
+ "category": submission.get_category_display(),
+ "message": submission.message,
+ "source_page": submission.source_page or "",
+ }
+ values_html = {
+ "name": escape(submission.name),
+ "email": escape(submission.email),
+ "contact": escape(submission.contact),
+ "category": escape(submission.get_category_display()),
+ "message": escape(submission.message).replace("\n", "
"),
+ "source_page": escape(submission.source_page or ""),
+ }
+ return (
+ _render_contact_template(notification_settings.contact_form_user_subject_template, values_text),
+ _render_contact_template(notification_settings.contact_form_user_text_template, values_text),
+ _render_contact_template(notification_settings.contact_form_user_html_template, values_html),
+ )
+
+
+def _send_submission(submission: ContactFormSubmission):
+ now = timezone.now()
+ submission.send_attempt_count += 1
+ submission.processing_status = ContactFormSubmission.STATUS_SENDING
+ submission.processing_started_at = now
+ submission.save(
+ update_fields=["send_attempt_count", "processing_status", "processing_started_at", "updated_at"]
+ )
+
+ notification_settings = SystemNotificationMailSettings.load()
+ smtp_settings = MailSmtpSettings.load()
+ contact_settings = ContactFormSettings.load()
+ try:
+ if submission.notification_sent_at is None:
+ subject, text_body, html_body = _notification_content(submission, notification_settings)
+ send_contact_notification_email(
+ subject=subject,
+ text_body=text_body,
+ html_body=html_body,
+ notification_config=notification_settings,
+ smtp_config=smtp_settings,
+ )
+ submission.notification_sent_at = timezone.now()
+ submission.save(update_fields=["notification_sent_at", "updated_at"])
+
+ if contact_settings.send_user_copy and submission.email and submission.user_copy_sent_at is None:
+ user_subject, user_text, user_html = _user_copy_content(submission, notification_settings)
+ send_contact_user_email(
+ to_email=submission.email,
+ subject=user_subject,
+ text_body=user_text,
+ html_body=user_html,
+ notification_config=notification_settings,
+ smtp_config=smtp_settings,
+ )
+ submission.user_copy_sent_at = timezone.now()
+ submission.save(update_fields=["user_copy_sent_at", "updated_at"])
+ except Exception as exc:
+ logger.warning("contact submission email failed submission_id=%s: %s", submission.pk, exc)
+ submission.last_error = str(exc)[:4000]
+ submission.processing_started_at = None
+ delay = max(1, int(settings.CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS))
+ submission.next_attempt_at = timezone.now() + timedelta(seconds=delay)
+ if submission.send_attempt_count >= max(1, int(settings.CONTACT_FORM_MAIL_MAX_ATTEMPTS)):
+ submission.processing_status = ContactFormSubmission.STATUS_FAILED
+ else:
+ submission.processing_status = ContactFormSubmission.STATUS_SEND_RETRY
+ submission.save(
+ update_fields=[
+ "processing_status",
+ "processing_started_at",
+ "next_attempt_at",
+ "last_error",
+ "updated_at",
+ ]
+ )
+ return False
+
+ submission.processing_status = ContactFormSubmission.STATUS_SENT
+ submission.processing_started_at = None
+ submission.next_attempt_at = None
+ submission.last_error = ""
+ submission.save(
+ update_fields=[
+ "processing_status",
+ "processing_started_at",
+ "next_attempt_at",
+ "last_error",
+ "updated_at",
+ ]
+ )
+ return True
+
+
+def process_contact_submissions(*, limit=None, max_runtime_seconds=None) -> dict:
+ limit = max(1, int(limit or settings.CONTACT_FORM_WORKER_BATCH_SIZE))
+ max_runtime_seconds = max(
+ 1,
+ int(max_runtime_seconds or settings.CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS),
+ )
+ result = {
+ "processed": 0,
+ "allowed": 0,
+ "blocked": 0,
+ "sent": 0,
+ "failed": 0,
+ "cooldown_until": None,
+ "detector_busy": False,
+ "disabled": not settings.CONTACT_SPAM_DETECTION_ENABLED,
+ }
+ started_at = time.monotonic()
+ client = SpamDetectionClient()
+
+ while result["processed"] < limit and time.monotonic() - started_at < max_runtime_seconds:
+ now = timezone.now()
+ submission = _claim_mail_submission(now)
+ if submission is not None:
+ result["processed"] += 1
+ if _send_submission(submission):
+ result["sent"] += 1
+ else:
+ result["failed"] += 1
+ continue
+
+ if not settings.CONTACT_SPAM_DETECTION_ENABLED:
+ break
+
+ lease_token, cooldown_until, detector_busy = _acquire_detection_lease(now)
+ if not lease_token:
+ result["cooldown_until"] = cooldown_until
+ result["detector_busy"] = detector_busy
+ break
+
+ submission = _claim_scan_submission(now)
+ if submission is None:
+ _release_detection_lease(lease_token)
+ break
+
+ result["processed"] += 1
+ try:
+ scan_result = _scan_submission(submission, client)
+ except SpamDetectionError as exc:
+ logger.warning("contact spam detection failed submission_id=%s: %s", submission.pk, exc)
+ retry_at = _record_scan_failure(submission, str(exc))
+ _release_detection_lease(lease_token, cooldown_until=retry_at, error=str(exc))
+ result["failed"] += 1
+ result["cooldown_until"] = retry_at
+ break
+
+ _release_detection_lease(lease_token)
+
+ if scan_result.category != 0:
+ result["blocked"] += 1
+ continue
+
+ result["allowed"] += 1
+ if _send_submission(submission):
+ result["sent"] += 1
+ else:
+ result["failed"] += 1
+
+ return result
diff --git a/innovedus_cms/base/management/commands/run_contact_submission_worker.py b/innovedus_cms/base/management/commands/run_contact_submission_worker.py
new file mode 100644
index 0000000..749f990
--- /dev/null
+++ b/innovedus_cms/base/management/commands/run_contact_submission_worker.py
@@ -0,0 +1,31 @@
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+from base.contact_submission_processor import process_contact_submissions
+
+
+class Command(BaseCommand):
+ help = "Scan and send queued contact form submissions"
+
+ def add_arguments(self, parser):
+ parser.add_argument("--limit", type=int, default=settings.CONTACT_FORM_WORKER_BATCH_SIZE)
+ parser.add_argument(
+ "--max-runtime-seconds",
+ type=int,
+ default=settings.CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS,
+ )
+
+ def handle(self, *args, **options):
+ result = process_contact_submissions(
+ limit=options["limit"],
+ max_runtime_seconds=options["max_runtime_seconds"],
+ )
+ cooldown_until = result["cooldown_until"].isoformat() if result["cooldown_until"] else ""
+ self.stdout.write(
+ "processed={processed} allowed={allowed} blocked={blocked} sent={sent} "
+ "failed={failed} disabled={disabled} detector_busy={detector_busy} "
+ "cooldown_until={cooldown}".format(
+ **result,
+ cooldown=cooldown_until,
+ )
+ )
diff --git a/innovedus_cms/base/migrations/0014_contactformratelimitbucket_contactformsettings_and_more.py b/innovedus_cms/base/migrations/0014_contactformratelimitbucket_contactformsettings_and_more.py
new file mode 100644
index 0000000..a974d29
--- /dev/null
+++ b/innovedus_cms/base/migrations/0014_contactformratelimitbucket_contactformsettings_and_more.py
@@ -0,0 +1,127 @@
+# Generated by Django 5.2.15 on 2026-08-05 05:22
+
+from django.db import migrations, models
+
+
+def mark_existing_submissions_as_legacy(apps, schema_editor):
+ ContactFormSubmission = apps.get_model("base", "ContactFormSubmission")
+ ContactFormSubmission.objects.update(processing_status="legacy")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('base', '0013_send_engine_oauth_client_credentials'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContactFormRateLimitBucket',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('ip_address', models.GenericIPAddressField(unique=True, verbose_name='IP Address')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
+ ],
+ options={
+ 'verbose_name': 'Contact Form Rate Limit Bucket',
+ 'verbose_name_plural': 'Contact Form Rate Limit Buckets',
+ },
+ ),
+ migrations.CreateModel(
+ name='ContactFormSettings',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('rate_limit_enabled', models.BooleanField(default=True, verbose_name='Enable IP Rate Limit')),
+ ('rate_limit_max_submissions', models.PositiveIntegerField(default=3, verbose_name='Maximum Submissions Per Window')),
+ ('rate_limit_window_minutes', models.PositiveIntegerField(default=10, verbose_name='Rate Limit Window Minutes')),
+ ('send_user_copy', models.BooleanField(default=False, help_text='Send a copy to the submitter only after spam detection allows the message.', verbose_name='Send User Copy')),
+ ],
+ options={
+ 'verbose_name': 'Contact Form Settings',
+ },
+ ),
+ migrations.CreateModel(
+ name='ContactSpamDetectionState',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('lock_token', models.CharField(blank=True, max_length=64, verbose_name='Lock Token')),
+ ('locked_until', models.DateTimeField(blank=True, null=True, verbose_name='Locked Until')),
+ ('cooldown_until', models.DateTimeField(blank=True, null=True, verbose_name='Cooldown Until')),
+ ('last_error', models.TextField(blank=True, verbose_name='Last Error')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
+ ],
+ options={
+ 'verbose_name': 'Contact Spam Detection State',
+ 'verbose_name_plural': 'Contact Spam Detection State',
+ },
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='last_error',
+ field=models.TextField(blank=True, verbose_name='Last Error'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='next_attempt_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Next Attempt At'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='notification_sent_at',
+ field=models.DateTimeField(blank=True, null=True, verbose_name='Notification Sent At'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='processing_started_at',
+ field=models.DateTimeField(blank=True, null=True, verbose_name='Processing Started At'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='processing_status',
+ field=models.CharField(choices=[('queued', 'Queued'), ('scanning', 'Scanning'), ('scan_retry', 'Scan Retry'), ('blocked', 'Blocked as Spam'), ('sending', 'Sending'), ('send_retry', 'Send Retry'), ('sent', 'Sent'), ('failed', 'Failed'), ('rate_limited', 'Rate Limited'), ('legacy', 'Legacy Submission')], db_index=True, default='queued', max_length=20, verbose_name='Processing Status'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='scan_attempt_count',
+ field=models.PositiveIntegerField(default=0, verbose_name='Scan Attempt Count'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='send_attempt_count',
+ field=models.PositiveIntegerField(default=0, verbose_name='Send Attempt Count'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='spam_category',
+ field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'Allowed'), (1, 'Unsolicited Advertising'), (2, 'Malicious'), (3, 'Meaningless')], null=True, verbose_name='Spam Category'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='spam_checked_at',
+ field=models.DateTimeField(blank=True, null=True, verbose_name='Spam Checked At'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='spam_source',
+ field=models.CharField(blank=True, max_length=32, verbose_name='Spam Detection Source'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='updated_at',
+ field=models.DateTimeField(auto_now=True, verbose_name='Updated At'),
+ ),
+ migrations.AddField(
+ model_name='contactformsubmission',
+ name='user_copy_sent_at',
+ field=models.DateTimeField(blank=True, null=True, verbose_name='User Copy Sent At'),
+ ),
+ migrations.AddIndex(
+ model_name='contactformsubmission',
+ index=models.Index(fields=['ip_address', 'created_at'], name='contact_ip_created_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='contactformsubmission',
+ index=models.Index(fields=['processing_status', 'next_attempt_at', 'created_at'], name='contact_process_due_idx'),
+ ),
+ migrations.RunPython(mark_existing_submissions_as_legacy, migrations.RunPython.noop),
+ ]
diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py
index ebb1398..4632e01 100644
--- a/innovedus_cms/base/models.py
+++ b/innovedus_cms/base/models.py
@@ -171,6 +171,39 @@ class MailSmtpSettings(BaseGenericSetting):
super().save(*args, **kwargs)
+@register_setting
+class ContactFormSettings(BaseGenericSetting):
+ rate_limit_enabled = models.BooleanField(default=True, verbose_name=_("Enable IP Rate Limit"))
+ rate_limit_max_submissions = models.PositiveIntegerField(
+ default=3,
+ verbose_name=_("Maximum Submissions Per Window"),
+ )
+ rate_limit_window_minutes = models.PositiveIntegerField(
+ default=10,
+ verbose_name=_("Rate Limit Window Minutes"),
+ )
+ send_user_copy = models.BooleanField(
+ default=False,
+ help_text=_("Send a copy to the submitter only after spam detection allows the message."),
+ verbose_name=_("Send User Copy"),
+ )
+
+ panels = [
+ MultiFieldPanel(
+ [
+ FieldPanel("rate_limit_enabled"),
+ FieldPanel("rate_limit_max_submissions"),
+ FieldPanel("rate_limit_window_minutes"),
+ ],
+ heading=_("Contact Form Rate Limit"),
+ ),
+ FieldPanel("send_user_copy"),
+ ]
+
+ class Meta:
+ verbose_name = _("Contact Form Settings")
+
+
@register_setting
class NewsletterSystemSettings(BaseGenericSetting):
member_center_base_url = models.URLField(blank=True, verbose_name=_("Member Center Base URL"))
@@ -804,6 +837,36 @@ class ContactFormSubmission(models.Model):
(CATEGORY_OTHER, _("Other")),
]
+ STATUS_QUEUED = "queued"
+ STATUS_SCANNING = "scanning"
+ STATUS_SCAN_RETRY = "scan_retry"
+ STATUS_BLOCKED = "blocked"
+ STATUS_SENDING = "sending"
+ STATUS_SEND_RETRY = "send_retry"
+ STATUS_SENT = "sent"
+ STATUS_FAILED = "failed"
+ STATUS_RATE_LIMITED = "rate_limited"
+ STATUS_LEGACY = "legacy"
+ STATUS_CHOICES = [
+ (STATUS_QUEUED, _("Queued")),
+ (STATUS_SCANNING, _("Scanning")),
+ (STATUS_SCAN_RETRY, _("Scan Retry")),
+ (STATUS_BLOCKED, _("Blocked as Spam")),
+ (STATUS_SENDING, _("Sending")),
+ (STATUS_SEND_RETRY, _("Send Retry")),
+ (STATUS_SENT, _("Sent")),
+ (STATUS_FAILED, _("Failed")),
+ (STATUS_RATE_LIMITED, _("Rate Limited")),
+ (STATUS_LEGACY, _("Legacy Submission")),
+ ]
+
+ SPAM_CATEGORY_CHOICES = [
+ (0, _("Allowed")),
+ (1, _("Unsolicited Advertising")),
+ (2, _("Malicious")),
+ (3, _("Meaningless")),
+ ]
+
name = models.CharField(max_length=100, verbose_name=_("Name"))
email = models.EmailField(blank=True, verbose_name=_("Email"))
contact = models.CharField(max_length=255, verbose_name=_("Contact"))
@@ -812,12 +875,63 @@ class ContactFormSubmission(models.Model):
source_page = models.CharField(max_length=512, blank=True, verbose_name=_("Source Page"))
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("IP Address"))
user_agent = models.TextField(blank=True, verbose_name=_("User Agent"))
+ processing_status = models.CharField(
+ max_length=20,
+ choices=STATUS_CHOICES,
+ default=STATUS_QUEUED,
+ db_index=True,
+ verbose_name=_("Processing Status"),
+ )
+ spam_category = models.PositiveSmallIntegerField(
+ choices=SPAM_CATEGORY_CHOICES,
+ null=True,
+ blank=True,
+ verbose_name=_("Spam Category"),
+ )
+ spam_source = models.CharField(max_length=32, blank=True, verbose_name=_("Spam Detection Source"))
+ spam_checked_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Spam Checked At"))
+ scan_attempt_count = models.PositiveIntegerField(default=0, verbose_name=_("Scan Attempt Count"))
+ send_attempt_count = models.PositiveIntegerField(default=0, verbose_name=_("Send Attempt Count"))
+ next_attempt_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Next Attempt At"))
+ processing_started_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Processing Started At"))
+ notification_sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Notification Sent At"))
+ user_copy_sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("User Copy Sent At"))
+ last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
class Meta:
ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["ip_address", "created_at"], name="contact_ip_created_idx"),
+ models.Index(
+ fields=["processing_status", "next_attempt_at", "created_at"],
+ name="contact_process_due_idx",
+ ),
+ ]
verbose_name = _("Contact Form Submission")
verbose_name_plural = _("Contact Form Submissions")
def __str__(self):
return f"{self.get_category_display()} - {self.name}"
+
+
+class ContactFormRateLimitBucket(models.Model):
+ ip_address = models.GenericIPAddressField(unique=True, verbose_name=_("IP Address"))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
+
+ class Meta:
+ verbose_name = _("Contact Form Rate Limit Bucket")
+ verbose_name_plural = _("Contact Form Rate Limit Buckets")
+
+
+class ContactSpamDetectionState(models.Model):
+ lock_token = models.CharField(max_length=64, blank=True, verbose_name=_("Lock Token"))
+ locked_until = models.DateTimeField(null=True, blank=True, verbose_name=_("Locked Until"))
+ cooldown_until = models.DateTimeField(null=True, blank=True, verbose_name=_("Cooldown Until"))
+ last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
+
+ class Meta:
+ verbose_name = _("Contact Spam Detection State")
+ verbose_name_plural = _("Contact Spam Detection State")
diff --git a/innovedus_cms/base/tests.py b/innovedus_cms/base/tests.py
index f03d731..855147e 100644
--- a/innovedus_cms/base/tests.py
+++ b/innovedus_cms/base/tests.py
@@ -1,11 +1,19 @@
-from datetime import date
+import json
+from datetime import date, timedelta
from types import SimpleNamespace
from urllib.parse import parse_qs
from unittest.mock import Mock, patch
-from django.test import TestCase
+from django.test import TestCase, override_settings
from django.urls import reverse
+from django.utils import timezone
+from .contact_submission_processor import (
+ SpamDetectionClient,
+ SpamDetectionError,
+ SpamDetectionResult,
+ process_contact_submissions,
+)
from .newsletter import (
APIResult,
MemberCenterClient,
@@ -17,7 +25,12 @@ from .newsletter import (
render_newsletter_html,
verify_one_click_token,
)
-from .models import ContactFormSubmission, SystemNotificationMailSettings
+from .models import (
+ ContactFormSettings,
+ ContactFormSubmission,
+ ContactSpamDetectionState,
+ SystemNotificationMailSettings,
+)
from .security import decrypt_text, encrypt_text
@@ -265,6 +278,7 @@ class NewsletterTemplateTests(TestCase):
self.assertEqual(submission.name, "Tester")
self.assertEqual(submission.email, "tester@example.com")
self.assertEqual(submission.category, "other")
+ self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_QUEUED)
def test_contact_form_submit_rejects_invalid_email(self):
response = self.client.post(
@@ -281,3 +295,182 @@ class NewsletterTemplateTests(TestCase):
)
self.assertEqual(response.status_code, 400)
self.assertEqual(ContactFormSubmission.objects.count(), 0)
+
+
+class ContactFormSubmissionFlowTests(TestCase):
+ def _post(self, *, remote_addr="10.0.1.10", forwarded_for="198.51.100.20"):
+ return self.client.post(
+ reverse("contact_form_submit"),
+ data={
+ "name": "Tester",
+ "contact": "tester@example.com",
+ "email": "tester@example.com",
+ "category": "other",
+ "message": "hello",
+ "source_page": "/contact/",
+ },
+ REMOTE_ADDR=remote_addr,
+ HTTP_X_FORWARDED_FOR=forwarded_for,
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+
+ def test_records_rightmost_ip_appended_by_alb(self):
+ response = self._post(forwarded_for="203.0.113.99, 198.51.100.20")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(ContactFormSubmission.objects.get().ip_address, "198.51.100.20")
+
+ def test_falls_back_to_remote_address_without_forwarded_header(self):
+ response = self._post(remote_addr="10.0.1.10", forwarded_for="")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(ContactFormSubmission.objects.get().ip_address, "10.0.1.10")
+
+ def test_fourth_submission_in_default_window_is_rate_limited(self):
+ for _ in range(4):
+ response = self._post()
+ self.assertEqual(response.status_code, 200)
+
+ statuses = list(ContactFormSubmission.objects.order_by("created_at", "id").values_list("processing_status", flat=True))
+ self.assertEqual(
+ statuses,
+ [
+ ContactFormSubmission.STATUS_QUEUED,
+ ContactFormSubmission.STATUS_QUEUED,
+ ContactFormSubmission.STATUS_QUEUED,
+ ContactFormSubmission.STATUS_RATE_LIMITED,
+ ],
+ )
+
+
+@override_settings(
+ CONTACT_SPAM_DETECTION_ENABLED=True,
+ CONTACT_SPAM_DETECTION_URL="https://spam.example.com/classify",
+ CONTACT_SPAM_DETECTION_API_KEY="test-key",
+ CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS=300,
+ CONTACT_SPAM_DETECTION_MAX_ATTEMPTS=3,
+ CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS=300,
+ CONTACT_FORM_MAIL_MAX_ATTEMPTS=3,
+)
+class ContactSubmissionProcessorTests(TestCase):
+ def _submission(self, **overrides):
+ values = {
+ "name": "Tester",
+ "contact": "tester@example.com",
+ "email": "tester@example.com",
+ "category": ContactFormSubmission.CATEGORY_OTHER,
+ "message": "hello",
+ "source_page": "/contact/",
+ "ip_address": "198.51.100.20",
+ }
+ values.update(overrides)
+ return ContactFormSubmission.objects.create(**values)
+
+ @patch("base.contact_submission_processor.send_contact_user_email")
+ @patch("base.contact_submission_processor.send_contact_notification_email")
+ @patch("base.contact_submission_processor.SpamDetectionClient.classify")
+ def test_allowed_submission_is_sent_after_scan(self, classify, send_notification, send_user):
+ submission = self._submission()
+ classify.return_value = SpamDetectionResult(category=0, source="kneo300")
+
+ result = process_contact_submissions(limit=1)
+
+ submission.refresh_from_db()
+ self.assertEqual(result["sent"], 1)
+ self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SENT)
+ self.assertEqual(submission.spam_category, 0)
+ self.assertIsNotNone(submission.notification_sent_at)
+ send_notification.assert_called_once()
+ send_user.assert_not_called()
+
+ @patch("base.contact_submission_processor.send_contact_notification_email")
+ @patch("base.contact_submission_processor.SpamDetectionClient.classify")
+ def test_spam_submission_is_recorded_and_not_sent(self, classify, send_notification):
+ submission = self._submission()
+ classify.return_value = SpamDetectionResult(category=2, source="kneo300")
+
+ result = process_contact_submissions(limit=1)
+
+ submission.refresh_from_db()
+ self.assertEqual(result["blocked"], 1)
+ self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_BLOCKED)
+ self.assertEqual(submission.spam_category, 2)
+ send_notification.assert_not_called()
+
+ @patch("base.contact_submission_processor.send_contact_notification_email")
+ @patch("base.contact_submission_processor.SpamDetectionClient.classify")
+ def test_detection_error_schedules_cooldown_and_does_not_send(self, classify, send_notification):
+ submission = self._submission()
+ classify.side_effect = SpamDetectionError("AI box unavailable")
+
+ result = process_contact_submissions(limit=1)
+
+ submission.refresh_from_db()
+ self.assertEqual(result["failed"], 1)
+ self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SCAN_RETRY)
+ self.assertGreater(submission.next_attempt_at, timezone.now())
+ send_notification.assert_not_called()
+
+ @patch("base.contact_submission_processor.SpamDetectionClient.classify")
+ def test_active_detection_cooldown_prevents_calls_for_other_rows(self, classify):
+ ContactSpamDetectionState.objects.create(
+ pk=1,
+ cooldown_until=timezone.now() + timedelta(minutes=5),
+ last_error="AI box unavailable",
+ )
+ self._submission(email="second@example.com")
+
+ result = process_contact_submissions(limit=2)
+
+ self.assertEqual(result["processed"], 0)
+ self.assertIsNotNone(result["cooldown_until"])
+ classify.assert_not_called()
+
+ @patch("base.contact_submission_processor.SpamDetectionClient.classify")
+ def test_active_detection_lease_prevents_parallel_ai_calls(self, classify):
+ ContactSpamDetectionState.objects.create(
+ pk=1,
+ lock_token="another-worker",
+ locked_until=timezone.now() + timedelta(minutes=5),
+ )
+ self._submission()
+
+ result = process_contact_submissions(limit=1)
+
+ self.assertEqual(result["processed"], 0)
+ self.assertTrue(result["detector_busy"])
+ classify.assert_not_called()
+
+ @patch("base.contact_submission_processor.send_contact_user_email")
+ @patch("base.contact_submission_processor.send_contact_notification_email")
+ @patch("base.contact_submission_processor.SpamDetectionClient.classify")
+ def test_mail_retry_does_not_repeat_successful_notification(self, classify, send_notification, send_user):
+ ContactFormSettings.objects.create(send_user_copy=True)
+ submission = self._submission()
+ classify.return_value = SpamDetectionResult(category=0, source="kneo300")
+ send_user.side_effect = [OSError("SMTP unavailable"), 1]
+
+ process_contact_submissions(limit=1)
+ submission.refresh_from_db()
+ self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SEND_RETRY)
+ self.assertIsNotNone(submission.notification_sent_at)
+
+ submission.next_attempt_at = timezone.now() - timedelta(seconds=1)
+ submission.save(update_fields=["next_attempt_at", "updated_at"])
+ process_contact_submissions(limit=1)
+
+ submission.refresh_from_db()
+ self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SENT)
+ send_notification.assert_called_once()
+ self.assertEqual(send_user.call_count, 2)
+
+ @patch("base.contact_submission_processor.urlopen")
+ def test_category_zero_with_service_error_is_not_accepted(self, urlopen_mock):
+ response = Mock()
+ response.read.return_value = json.dumps(
+ {"category": 0, "source": "kneo300", "error": "timeout"}
+ ).encode("utf-8")
+ urlopen_mock.return_value.__enter__.return_value = response
+
+ with self.assertRaises(SpamDetectionError):
+ SpamDetectionClient().classify("hello")
diff --git a/innovedus_cms/base/views.py b/innovedus_cms/base/views.py
index a699bdb..97cf824 100644
--- a/innovedus_cms/base/views.py
+++ b/innovedus_cms/base/views.py
@@ -2,6 +2,8 @@ import logging
from urllib.parse import urlencode
import hashlib
import json
+from datetime import timedelta
+from ipaddress import ip_address as parse_ip_address
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
@@ -9,14 +11,18 @@ from django.contrib.auth.decorators import login_required
from django.core.files.storage import default_storage
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.validators import validate_email
+from django.db import transaction
from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
-from django.utils.html import escape
+from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from .forms import ContactForm, NewsletterSubscribeForm, NewsletterUnsubscribeForm
from .models import (
+ ContactFormRateLimitBucket,
+ ContactFormSettings,
+ ContactFormSubmission,
MailSmtpSettings,
NewsletterCampaign,
NewsletterSystemSettings,
@@ -32,8 +38,6 @@ from .newsletter import (
extract_token,
render_placeholders,
render_newsletter_html_for_send_job,
- send_contact_notification_email,
- send_contact_user_email,
send_subscribe_email,
)
from .newsletter_scheduler import dispatch_campaign
@@ -84,11 +88,65 @@ def _build_context(*, title: str, message: str, success: bool):
}
-def _render_contact_template(template: str, values: dict[str, str]) -> str:
- rendered = template or ""
- for key, value in values.items():
- rendered = rendered.replace(f"{{{{{key}}}}}", value)
- return rendered
+def _normalize_ip(value: str):
+ candidate = (value or "").strip()
+ if not candidate:
+ return None
+ try:
+ return parse_ip_address(candidate)
+ except ValueError:
+ pass
+
+ if candidate.startswith("[") and "]:" in candidate:
+ candidate = candidate[1 : candidate.rfind("]")]
+ elif candidate.count(":") == 1:
+ candidate = candidate.rsplit(":", 1)[0]
+
+ try:
+ return parse_ip_address(candidate)
+ except ValueError:
+ return None
+
+
+def _get_client_ip(request) -> str:
+ forwarded_ips = [
+ parsed
+ for item in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")
+ if (parsed := _normalize_ip(item)) is not None
+ ]
+ if forwarded_ips:
+ return str(forwarded_ips[-1])
+
+ remote_ip = _normalize_ip(request.META.get("REMOTE_ADDR", ""))
+ return str(remote_ip) if remote_ip is not None else ""
+
+
+def _save_contact_submission(*, form: ContactForm, request) -> ContactFormSubmission:
+ contact_settings = ContactFormSettings.load(request_or_site=request)
+ client_ip = _get_client_ip(request)
+ now = timezone.now()
+ rate_limited = False
+
+ with transaction.atomic():
+ if contact_settings.rate_limit_enabled and client_ip:
+ bucket, _ = ContactFormRateLimitBucket.objects.get_or_create(ip_address=client_ip)
+ ContactFormRateLimitBucket.objects.select_for_update().get(pk=bucket.pk)
+ window_minutes = max(1, int(contact_settings.rate_limit_window_minutes or 10))
+ max_submissions = max(0, int(contact_settings.rate_limit_max_submissions or 0))
+ recent_count = ContactFormSubmission.objects.filter(
+ ip_address=client_ip,
+ created_at__gte=now - timedelta(minutes=window_minutes),
+ ).count()
+ rate_limited = recent_count >= max_submissions
+
+ submission = form.save(commit=False)
+ submission.ip_address = client_ip or None
+ submission.user_agent = request.META.get("HTTP_USER_AGENT", "")
+ if rate_limited:
+ submission.processing_status = ContactFormSubmission.STATUS_RATE_LIMITED
+ submission.save()
+
+ return submission
@require_POST
@@ -100,88 +158,9 @@ def contact_form_submit(request):
messages.error(request, "表單欄位未填完整,請確認後再送出。")
return redirect(request.META.get("HTTP_REFERER") or "/")
- submission = form.save(commit=False)
- submission.ip_address = request.META.get("REMOTE_ADDR") or ""
- submission.user_agent = request.META.get("HTTP_USER_AGENT", "")
- submission.save()
- notification_settings = SystemNotificationMailSettings.load(request_or_site=request)
- smtp_settings = MailSmtpSettings.load(request_or_site=request)
- subject_prefix = (notification_settings.contact_form_subject_prefix or "").strip()
- subject = f"{subject_prefix} {submission.get_category_display()}".strip()
- escaped_message_html = escape(submission.message).replace("\n", "
")
- text_body = (
- f"Name: {submission.name}\n"
- f"Email: {submission.email}\n"
- f"Contact: {submission.contact}\n"
- f"Category: {submission.get_category_display()}\n"
- f"Source Page: {submission.source_page}\n\n"
- f"Message:\n{submission.message}\n"
- )
- html_body = (
- "Name: "
- f"{escape(submission.name)}
"
- "Email: "
- f"{escape(submission.email)}
"
- "Contact: "
- f"{escape(submission.contact)}
"
- "Category: "
- f"{escape(submission.get_category_display())}
"
- "Source Page: "
- f"{escape(submission.source_page or '')}
"
- "Message:
"
- f"{escaped_message_html}
"
- )
- try:
- send_contact_notification_email(
- subject=subject,
- text_body=text_body,
- html_body=html_body,
- notification_config=notification_settings,
- smtp_config=smtp_settings,
- )
- except Exception as exc:
- logger.warning("contact form admin notification email failed: %s", exc)
-
- # if submission.email:
- # values_text = {
- # "name": submission.name,
- # "email": submission.email,
- # "contact": submission.contact,
- # "category": submission.get_category_display(),
- # "message": submission.message,
- # "source_page": submission.source_page or "",
- # }
- # values_html = {
- # "name": escape(submission.name),
- # "email": escape(submission.email),
- # "contact": escape(submission.contact),
- # "category": escape(submission.get_category_display()),
- # "message": escape(submission.message).replace("\n", "
"),
- # "source_page": escape(submission.source_page or ""),
- # }
- # user_subject = _render_contact_template(
- # notification_settings.contact_form_user_subject_template,
- # values_text,
- # )
- # user_text = _render_contact_template(
- # notification_settings.contact_form_user_text_template,
- # values_text,
- # )
- # user_html = _render_contact_template(
- # notification_settings.contact_form_user_html_template,
- # values_html,
- # )
- # try:
- # send_contact_user_email(
- # to_email=submission.email,
- # subject=user_subject,
- # text_body=user_text,
- # html_body=user_html,
- # notification_config=notification_settings,
- # smtp_config=smtp_settings,
- # )
- # except Exception as exc:
- # logger.warning("contact form user copy email failed: %s", exc)
+ submission = _save_contact_submission(form=form, request=request)
+ if submission.processing_status == ContactFormSubmission.STATUS_RATE_LIMITED:
+ logger.info("contact form submission rate limited submission_id=%s ip=%s", submission.pk, submission.ip_address)
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JsonResponse({"success": True})
diff --git a/innovedus_cms/base/wagtail_hooks.py b/innovedus_cms/base/wagtail_hooks.py
index 5770f37..83e71e8 100644
--- a/innovedus_cms/base/wagtail_hooks.py
+++ b/innovedus_cms/base/wagtail_hooks.py
@@ -6,7 +6,9 @@ from django.utils.translation import gettext as _
from django.shortcuts import redirect
from django.shortcuts import render
from wagtail import hooks
+from wagtail.admin.menu import MenuItem
from wagtail.admin.panels import FieldPanel
+from wagtail.admin.ui.tables import Column
from wagtail.admin.widgets import Button
from wagtail.permission_policies import ModelPermissionPolicy
from wagtail.snippets.models import register_snippet
@@ -18,11 +20,31 @@ from .forms import (
NewsletterHtmlEditorWidget,
NewsletterTemplateAdminForm,
)
-from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
+from .models import (
+ ContactFormSubmission,
+ NewsletterCampaign,
+ NewsletterDispatchRecord,
+ NewsletterSystemSettings,
+ NewsletterTemplate,
+)
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
+def _contact_status_value(submission):
+ return submission.get_processing_status_display()
+
+
+def _contact_spam_category_value(submission):
+ if submission.spam_category is None:
+ return "尚未檢查"
+ return submission.get_spam_category_display()
+
+
+def _contact_category_value(submission):
+ return submission.get_category_display()
+
+
def _newsletter_campaign_add_url() -> str:
candidates = [
"wagtailsnippets_base_newslettercampaign:add",
@@ -188,6 +210,51 @@ class ReadOnlySnippetPermissionPolicy(ModelPermissionPolicy):
return super().user_has_permission(user, action)
+class ContactFormSubmissionViewSet(SnippetViewSet):
+ model = ContactFormSubmission
+ icon = "form"
+ menu_label = "聯絡表單留言"
+ menu_order = 240
+ add_to_admin_menu = False
+ inspect_view_enabled = True
+ copy_view_enabled = False
+ permission_policy = ReadOnlySnippetPermissionPolicy(ContactFormSubmission)
+ list_display = [
+ "created_at",
+ Column("processing_status", label="處理狀態", accessor=_contact_status_value, sort_key="processing_status"),
+ Column("spam_category", label="垃圾訊息判定", accessor=_contact_spam_category_value, sort_key="spam_category"),
+ Column("category", label="留言類型", accessor=_contact_category_value, sort_key="category"),
+ "name",
+ "email",
+ "ip_address",
+ ]
+ list_filter = ["processing_status", "spam_category", "category", "created_at"]
+ search_fields = ["name", "email", "contact", "message", "source_page", "ip_address"]
+ inspect_view_fields = [
+ "name",
+ "email",
+ "contact",
+ "category",
+ "message",
+ "source_page",
+ "ip_address",
+ "user_agent",
+ "processing_status",
+ "spam_category",
+ "spam_source",
+ "spam_checked_at",
+ "scan_attempt_count",
+ "send_attempt_count",
+ "next_attempt_at",
+ "processing_started_at",
+ "notification_sent_at",
+ "user_copy_sent_at",
+ "last_error",
+ "created_at",
+ "updated_at",
+ ]
+
+
class NewsletterDispatchRecordViewSet(SnippetViewSet):
model = NewsletterDispatchRecord
icon = "tasks"
@@ -253,6 +320,23 @@ class NewsletterAdminGroup(SnippetViewSetGroup):
register_snippet(NewsletterAdminGroup)
+register_snippet(ContactFormSubmissionViewSet)
+
+
+class ContactFormSubmissionReportMenuItem(MenuItem):
+ def is_shown(self, request):
+ return request.user.has_perm("base.view_contactformsubmission")
+
+
+@hooks.register("register_reports_menu_item")
+def register_contact_form_submission_report_menu_item():
+ return ContactFormSubmissionReportMenuItem(
+ "聯絡表單留言",
+ reverse("wagtailsnippets_base_contactformsubmission:list"),
+ name="contact-form-submissions",
+ icon_name="form",
+ order=600,
+ )
@hooks.register("register_snippet_listing_buttons")
diff --git a/innovedus_cms/entrypoint.sh b/innovedus_cms/entrypoint.sh
old mode 100644
new mode 100755
index 4e3f22c..0f829d4
--- a/innovedus_cms/entrypoint.sh
+++ b/innovedus_cms/entrypoint.sh
@@ -7,11 +7,38 @@ APP_ROLE="${APP_ROLE:-web}"
python manage.py migrate --noinput
if [ "$APP_ROLE" = "scheduler" ]; then
- SCHEDULER_INTERVAL_SECONDS="${SCHEDULER_INTERVAL_SECONDS:-60}"
- while true; do
- python manage.py run_newsletter_scheduler --limit 20
- sleep "$SCHEDULER_INTERVAL_SECONDS"
- done
+ NEWSLETTER_SCHEDULER_INTERVAL_SECONDS="${NEWSLETTER_SCHEDULER_INTERVAL_SECONDS:-${SCHEDULER_INTERVAL_SECONDS:-60}}"
+ CONTACT_SCHEDULER_INTERVAL_SECONDS="${CONTACT_SCHEDULER_INTERVAL_SECONDS:-60}"
+
+ run_newsletter_loop() {
+ while true; do
+ if ! python manage.py run_newsletter_scheduler --limit 20; then
+ echo "newsletter scheduler command failed" >&2
+ fi
+ sleep "$NEWSLETTER_SCHEDULER_INTERVAL_SECONDS"
+ done
+ }
+
+ run_contact_loop() {
+ while true; do
+ if ! python manage.py run_contact_submission_worker; then
+ echo "contact submission worker command failed" >&2
+ fi
+ sleep "$CONTACT_SCHEDULER_INTERVAL_SECONDS"
+ done
+ }
+
+ run_newsletter_loop &
+ newsletter_pid=$!
+ run_contact_loop &
+ contact_pid=$!
+
+ stop_scheduler_loops() {
+ kill "$newsletter_pid" "$contact_pid" 2>/dev/null || true
+ wait "$newsletter_pid" "$contact_pid" 2>/dev/null || true
+ }
+ trap stop_scheduler_loops EXIT TERM INT
+ wait
else
python manage.py collectstatic --noinput
exec "$@"
diff --git a/innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.mo b/innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.mo
index 5002f67..57fa201 100644
Binary files a/innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.mo and b/innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.mo differ
diff --git a/innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.po b/innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.po
index 7a41125..fb745de 100644
--- a/innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.po
+++ b/innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.po
@@ -270,7 +270,7 @@ msgid "Sending"
msgstr "寄送中"
msgid "Sent"
-msgstr "已送出"
+msgstr "已寄出"
msgid "Failed"
msgstr "失敗"
@@ -333,10 +333,97 @@ msgid "Created At"
msgstr "建立時間"
msgid "Contact Form Submission"
-msgstr "聯絡表單提交"
+msgstr "聯絡表單留言"
msgid "Contact Form Submissions"
-msgstr "聯絡表單提交"
+msgstr "聯絡表單留言"
+
+msgid "Contact form submissions"
+msgstr "聯絡表單留言"
+
+msgid "Contact Form Settings"
+msgstr "聯絡表單設定"
+
+msgid "Contact Form Rate Limit"
+msgstr "聯絡表單頻率限制"
+
+msgid "Enable IP Rate Limit"
+msgstr "啟用 IP 留言頻率限制"
+
+msgid "Maximum Submissions Per Window"
+msgstr "限制區間內最多留言數"
+
+msgid "Rate Limit Window Minutes"
+msgstr "限制區間(分鐘)"
+
+msgid "Send User Copy"
+msgstr "寄送留言副本給填表人"
+
+msgid "Send a copy to the submitter only after spam detection allows the message."
+msgstr "僅在垃圾訊息檢測通過後,寄送留言副本給填表人。"
+
+msgid "Processing Status"
+msgstr "處理狀態"
+
+msgid "Queued"
+msgstr "等待垃圾訊息檢查"
+
+msgid "Scanning"
+msgstr "檢查垃圾訊息中"
+
+msgid "Scan Retry"
+msgstr "等待重新檢查"
+
+msgid "Blocked as Spam"
+msgstr "已阻擋垃圾訊息"
+
+msgid "Send Retry"
+msgstr "等待重新寄送"
+
+msgid "Rate Limited"
+msgstr "超過留言頻率限制"
+
+msgid "Legacy Submission"
+msgstr "舊資料(未重新檢查)"
+
+msgid "Spam Category"
+msgstr "垃圾訊息分類"
+
+msgid "Allowed"
+msgstr "正常留言(允許寄送)"
+
+msgid "Unsolicited Advertising"
+msgstr "廣告/推銷訊息"
+
+msgid "Malicious"
+msgstr "惡意/釣魚訊息"
+
+msgid "Meaningless"
+msgstr "無意義/測試內容"
+
+msgid "Spam Detection Source"
+msgstr "垃圾訊息判定來源"
+
+msgid "Spam Checked At"
+msgstr "垃圾訊息檢測時間"
+
+msgid "Scan Attempt Count"
+msgstr "檢測嘗試次數"
+
+msgid "Send Attempt Count"
+msgstr "寄送嘗試次數"
+
+msgid "Next Attempt At"
+msgstr "下次嘗試時間"
+
+msgid "Processing Started At"
+msgstr "開始處理時間"
+
+msgid "Notification Sent At"
+msgstr "內部通知寄送時間"
+
+msgid "User Copy Sent At"
+msgstr "填表人副本寄送時間"
msgid "Newsletter campaigns"
msgstr "電子報"
diff --git a/innovedus_cms/mysite/settings/base.py b/innovedus_cms/mysite/settings/base.py
index 6b8a9db..6ae8099 100644
--- a/innovedus_cms/mysite/settings/base.py
+++ b/innovedus_cms/mysite/settings/base.py
@@ -55,6 +55,16 @@ def env_optional(name, default=None):
return normalized
+def env_int(name, default):
+ value = os.environ.get(name)
+ if value is None:
+ return default
+ try:
+ return int(value.strip())
+ except (TypeError, ValueError):
+ return default
+
+
def build_media_storage_options():
options = {
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
@@ -111,6 +121,18 @@ def build_allowed_hosts():
GA4_MEASUREMENT_ID = os.environ.get("GA4_MEASUREMENT_ID", "").strip()
SECRET_KEY = os.environ.get("SECRET_KEY", "").strip()
+CONTACT_SPAM_DETECTION_ENABLED = env_bool("CONTACT_SPAM_DETECTION_ENABLED", default=False)
+CONTACT_SPAM_DETECTION_URL = os.environ.get("CONTACT_SPAM_DETECTION_URL", "").strip()
+CONTACT_SPAM_DETECTION_API_KEY = os.environ.get("CONTACT_SPAM_DETECTION_API_KEY", "").strip()
+CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS = env_int("CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS", 180)
+CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS = env_int("CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS", 300)
+CONTACT_SPAM_DETECTION_MAX_ATTEMPTS = env_int("CONTACT_SPAM_DETECTION_MAX_ATTEMPTS", 3)
+CONTACT_FORM_WORKER_BATCH_SIZE = env_int("CONTACT_FORM_WORKER_BATCH_SIZE", 20)
+CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS = env_int("CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS", 600)
+CONTACT_FORM_WORKER_LEASE_SECONDS = env_int("CONTACT_FORM_WORKER_LEASE_SECONDS", 600)
+CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS = env_int("CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS", 300)
+CONTACT_FORM_MAIL_MAX_ATTEMPTS = env_int("CONTACT_FORM_MAIL_MAX_ATTEMPTS", 3)
+
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/