Add contact form spam processing and rate limiting
This commit is contained in:
parent
555462c71a
commit
61a67b8d4a
40
docs/contact_form_processing.md
Normal file
40
docs/contact_form_processing.md
Normal file
@ -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.
|
||||||
@ -4,9 +4,10 @@ from .models import ContactFormSubmission
|
|||||||
|
|
||||||
@admin.register(ContactFormSubmission)
|
@admin.register(ContactFormSubmission)
|
||||||
class ContactFormSubmissionAdmin(admin.ModelAdmin):
|
class ContactFormSubmissionAdmin(admin.ModelAdmin):
|
||||||
list_display = ("created_at", "category", "name", "email", "contact")
|
list_display = ("created_at", "processing_status", "spam_category", "category", "name", "email", "ip_address")
|
||||||
list_filter = ("category", "created_at")
|
list_filter = ("processing_status", "spam_category", "category", "created_at")
|
||||||
search_fields = ("name", "email", "contact", "message", "source_page")
|
search_fields = ("name", "email", "contact", "message", "source_page", "ip_address")
|
||||||
|
actions = ("requeue_submissions",)
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
"name",
|
"name",
|
||||||
"email",
|
"email",
|
||||||
@ -16,5 +17,39 @@ class ContactFormSubmissionAdmin(admin.ModelAdmin):
|
|||||||
"source_page",
|
"source_page",
|
||||||
"ip_address",
|
"ip_address",
|
||||||
"user_agent",
|
"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",
|
"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).")
|
||||||
|
|||||||
437
innovedus_cms/base/contact_submission_processor.py
Normal file
437
innovedus_cms/base/contact_submission_processor.py
Normal file
@ -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", "<br>")
|
||||||
|
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"<p><strong>Name:</strong> {escape(submission.name)}</p>"
|
||||||
|
f"<p><strong>Email:</strong> {escape(submission.email)}</p>"
|
||||||
|
f"<p><strong>Contact:</strong> {escape(submission.contact)}</p>"
|
||||||
|
f"<p><strong>Category:</strong> {escape(submission.get_category_display())}</p>"
|
||||||
|
f"<p><strong>Source Page:</strong> {escape(submission.source_page or '')}</p>"
|
||||||
|
f"<p><strong>IP Address:</strong> {escape(str(submission.ip_address or ''))}</p>"
|
||||||
|
f"<p><strong>Message:</strong></p><p>{escaped_message_html}</p>"
|
||||||
|
)
|
||||||
|
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", "<br>"),
|
||||||
|
"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
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -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),
|
||||||
|
]
|
||||||
@ -171,6 +171,39 @@ class MailSmtpSettings(BaseGenericSetting):
|
|||||||
super().save(*args, **kwargs)
|
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
|
@register_setting
|
||||||
class NewsletterSystemSettings(BaseGenericSetting):
|
class NewsletterSystemSettings(BaseGenericSetting):
|
||||||
member_center_base_url = models.URLField(blank=True, verbose_name=_("Member Center Base URL"))
|
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")),
|
(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"))
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
email = models.EmailField(blank=True, verbose_name=_("Email"))
|
email = models.EmailField(blank=True, verbose_name=_("Email"))
|
||||||
contact = models.CharField(max_length=255, verbose_name=_("Contact"))
|
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"))
|
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"))
|
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("IP Address"))
|
||||||
user_agent = models.TextField(blank=True, verbose_name=_("User Agent"))
|
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"))
|
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:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
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 = _("Contact Form Submission")
|
||||||
verbose_name_plural = _("Contact Form Submissions")
|
verbose_name_plural = _("Contact Form Submissions")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.get_category_display()} - {self.name}"
|
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")
|
||||||
|
|||||||
@ -1,11 +1,19 @@
|
|||||||
from datetime import date
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
from unittest.mock import Mock, patch
|
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.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .contact_submission_processor import (
|
||||||
|
SpamDetectionClient,
|
||||||
|
SpamDetectionError,
|
||||||
|
SpamDetectionResult,
|
||||||
|
process_contact_submissions,
|
||||||
|
)
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
APIResult,
|
APIResult,
|
||||||
MemberCenterClient,
|
MemberCenterClient,
|
||||||
@ -17,7 +25,12 @@ from .newsletter import (
|
|||||||
render_newsletter_html,
|
render_newsletter_html,
|
||||||
verify_one_click_token,
|
verify_one_click_token,
|
||||||
)
|
)
|
||||||
from .models import ContactFormSubmission, SystemNotificationMailSettings
|
from .models import (
|
||||||
|
ContactFormSettings,
|
||||||
|
ContactFormSubmission,
|
||||||
|
ContactSpamDetectionState,
|
||||||
|
SystemNotificationMailSettings,
|
||||||
|
)
|
||||||
from .security import decrypt_text, encrypt_text
|
from .security import decrypt_text, encrypt_text
|
||||||
|
|
||||||
|
|
||||||
@ -265,6 +278,7 @@ class NewsletterTemplateTests(TestCase):
|
|||||||
self.assertEqual(submission.name, "Tester")
|
self.assertEqual(submission.name, "Tester")
|
||||||
self.assertEqual(submission.email, "tester@example.com")
|
self.assertEqual(submission.email, "tester@example.com")
|
||||||
self.assertEqual(submission.category, "other")
|
self.assertEqual(submission.category, "other")
|
||||||
|
self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_QUEUED)
|
||||||
|
|
||||||
def test_contact_form_submit_rejects_invalid_email(self):
|
def test_contact_form_submit_rejects_invalid_email(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@ -281,3 +295,182 @@ class NewsletterTemplateTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertEqual(ContactFormSubmission.objects.count(), 0)
|
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")
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import logging
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from ipaddress import ip_address as parse_ip_address
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
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.files.storage import default_storage
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
|
from django.db import transaction
|
||||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
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.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
from .forms import ContactForm, NewsletterSubscribeForm, NewsletterUnsubscribeForm
|
from .forms import ContactForm, NewsletterSubscribeForm, NewsletterUnsubscribeForm
|
||||||
from .models import (
|
from .models import (
|
||||||
|
ContactFormRateLimitBucket,
|
||||||
|
ContactFormSettings,
|
||||||
|
ContactFormSubmission,
|
||||||
MailSmtpSettings,
|
MailSmtpSettings,
|
||||||
NewsletterCampaign,
|
NewsletterCampaign,
|
||||||
NewsletterSystemSettings,
|
NewsletterSystemSettings,
|
||||||
@ -32,8 +38,6 @@ from .newsletter import (
|
|||||||
extract_token,
|
extract_token,
|
||||||
render_placeholders,
|
render_placeholders,
|
||||||
render_newsletter_html_for_send_job,
|
render_newsletter_html_for_send_job,
|
||||||
send_contact_notification_email,
|
|
||||||
send_contact_user_email,
|
|
||||||
send_subscribe_email,
|
send_subscribe_email,
|
||||||
)
|
)
|
||||||
from .newsletter_scheduler import dispatch_campaign
|
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:
|
def _normalize_ip(value: str):
|
||||||
rendered = template or ""
|
candidate = (value or "").strip()
|
||||||
for key, value in values.items():
|
if not candidate:
|
||||||
rendered = rendered.replace(f"{{{{{key}}}}}", value)
|
return None
|
||||||
return rendered
|
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
|
@require_POST
|
||||||
@ -100,88 +158,9 @@ def contact_form_submit(request):
|
|||||||
messages.error(request, "表單欄位未填完整,請確認後再送出。")
|
messages.error(request, "表單欄位未填完整,請確認後再送出。")
|
||||||
return redirect(request.META.get("HTTP_REFERER") or "/")
|
return redirect(request.META.get("HTTP_REFERER") or "/")
|
||||||
|
|
||||||
submission = form.save(commit=False)
|
submission = _save_contact_submission(form=form, request=request)
|
||||||
submission.ip_address = request.META.get("REMOTE_ADDR") or ""
|
if submission.processing_status == ContactFormSubmission.STATUS_RATE_LIMITED:
|
||||||
submission.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
logger.info("contact form submission rate limited submission_id=%s ip=%s", submission.pk, submission.ip_address)
|
||||||
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", "<br>")
|
|
||||||
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 = (
|
|
||||||
"<p><strong>Name:</strong> "
|
|
||||||
f"{escape(submission.name)}</p>"
|
|
||||||
"<p><strong>Email:</strong> "
|
|
||||||
f"{escape(submission.email)}</p>"
|
|
||||||
"<p><strong>Contact:</strong> "
|
|
||||||
f"{escape(submission.contact)}</p>"
|
|
||||||
"<p><strong>Category:</strong> "
|
|
||||||
f"{escape(submission.get_category_display())}</p>"
|
|
||||||
"<p><strong>Source Page:</strong> "
|
|
||||||
f"{escape(submission.source_page or '')}</p>"
|
|
||||||
"<p><strong>Message:</strong></p>"
|
|
||||||
f"<p>{escaped_message_html}</p>"
|
|
||||||
)
|
|
||||||
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", "<br>"),
|
|
||||||
# "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)
|
|
||||||
|
|
||||||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||||
return JsonResponse({"success": True})
|
return JsonResponse({"success": True})
|
||||||
|
|||||||
@ -6,7 +6,9 @@ from django.utils.translation import gettext as _
|
|||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from wagtail import hooks
|
from wagtail import hooks
|
||||||
|
from wagtail.admin.menu import MenuItem
|
||||||
from wagtail.admin.panels import FieldPanel
|
from wagtail.admin.panels import FieldPanel
|
||||||
|
from wagtail.admin.ui.tables import Column
|
||||||
from wagtail.admin.widgets import Button
|
from wagtail.admin.widgets import Button
|
||||||
from wagtail.permission_policies import ModelPermissionPolicy
|
from wagtail.permission_policies import ModelPermissionPolicy
|
||||||
from wagtail.snippets.models import register_snippet
|
from wagtail.snippets.models import register_snippet
|
||||||
@ -18,11 +20,31 @@ from .forms import (
|
|||||||
NewsletterHtmlEditorWidget,
|
NewsletterHtmlEditorWidget,
|
||||||
NewsletterTemplateAdminForm,
|
NewsletterTemplateAdminForm,
|
||||||
)
|
)
|
||||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
from .models import (
|
||||||
|
ContactFormSubmission,
|
||||||
|
NewsletterCampaign,
|
||||||
|
NewsletterDispatchRecord,
|
||||||
|
NewsletterSystemSettings,
|
||||||
|
NewsletterTemplate,
|
||||||
|
)
|
||||||
|
|
||||||
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
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:
|
def _newsletter_campaign_add_url() -> str:
|
||||||
candidates = [
|
candidates = [
|
||||||
"wagtailsnippets_base_newslettercampaign:add",
|
"wagtailsnippets_base_newslettercampaign:add",
|
||||||
@ -188,6 +210,51 @@ class ReadOnlySnippetPermissionPolicy(ModelPermissionPolicy):
|
|||||||
return super().user_has_permission(user, action)
|
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):
|
class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
||||||
model = NewsletterDispatchRecord
|
model = NewsletterDispatchRecord
|
||||||
icon = "tasks"
|
icon = "tasks"
|
||||||
@ -253,6 +320,23 @@ class NewsletterAdminGroup(SnippetViewSetGroup):
|
|||||||
|
|
||||||
|
|
||||||
register_snippet(NewsletterAdminGroup)
|
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")
|
@hooks.register("register_snippet_listing_buttons")
|
||||||
|
|||||||
33
innovedus_cms/entrypoint.sh
Normal file → Executable file
33
innovedus_cms/entrypoint.sh
Normal file → Executable file
@ -7,11 +7,38 @@ APP_ROLE="${APP_ROLE:-web}"
|
|||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
if [ "$APP_ROLE" = "scheduler" ]; then
|
if [ "$APP_ROLE" = "scheduler" ]; then
|
||||||
SCHEDULER_INTERVAL_SECONDS="${SCHEDULER_INTERVAL_SECONDS:-60}"
|
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
|
while true; do
|
||||||
python manage.py run_newsletter_scheduler --limit 20
|
if ! python manage.py run_newsletter_scheduler --limit 20; then
|
||||||
sleep "$SCHEDULER_INTERVAL_SECONDS"
|
echo "newsletter scheduler command failed" >&2
|
||||||
|
fi
|
||||||
|
sleep "$NEWSLETTER_SCHEDULER_INTERVAL_SECONDS"
|
||||||
done
|
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
|
else
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
Binary file not shown.
@ -270,7 +270,7 @@ msgid "Sending"
|
|||||||
msgstr "寄送中"
|
msgstr "寄送中"
|
||||||
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "已送出"
|
msgstr "已寄出"
|
||||||
|
|
||||||
msgid "Failed"
|
msgid "Failed"
|
||||||
msgstr "失敗"
|
msgstr "失敗"
|
||||||
@ -333,10 +333,97 @@ msgid "Created At"
|
|||||||
msgstr "建立時間"
|
msgstr "建立時間"
|
||||||
|
|
||||||
msgid "Contact Form Submission"
|
msgid "Contact Form Submission"
|
||||||
msgstr "聯絡表單提交"
|
msgstr "聯絡表單留言"
|
||||||
|
|
||||||
msgid "Contact Form Submissions"
|
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"
|
msgid "Newsletter campaigns"
|
||||||
msgstr "電子報"
|
msgstr "電子報"
|
||||||
|
|||||||
@ -55,6 +55,16 @@ def env_optional(name, default=None):
|
|||||||
return normalized
|
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():
|
def build_media_storage_options():
|
||||||
options = {
|
options = {
|
||||||
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
|
"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()
|
GA4_MEASUREMENT_ID = os.environ.get("GA4_MEASUREMENT_ID", "").strip()
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "").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
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user