diff --git a/core/settings/base.py b/core/settings/base.py index 6f38f62..0cb6e15 100644 --- a/core/settings/base.py +++ b/core/settings/base.py @@ -51,6 +51,7 @@ LOCAL_APPS = [ "pages.citation", + "pages.contact", "pages.dashboards", "pages.data_management", "pages.home", diff --git a/core/settings/development.py b/core/settings/development.py index bf9a636..4b7a06f 100644 --- a/core/settings/development.py +++ b/core/settings/development.py @@ -5,6 +5,7 @@ """ from .base import * # noqa: F401,F403 +from .base import env DEBUG = True @@ -56,3 +57,20 @@ }, }, } + + +# EMAIL (Development defaults, override via .env if needed) +# ------------------------------------------------------------------------------ +EMAIL_BACKEND = env( + "EMAIL_BACKEND", + default="django.core.mail.backends.console.EmailBackend", +) +DEFAULT_FROM_EMAIL = env( + "DEFAULT_FROM_EMAIL", + default="Pathogens Portal ", +) +CONTACT_RECIPIENT_EMAIL = env( + "CONTACT_RECIPIENT_EMAIL", + default="dev-null@example.org", +) +EMAIL_TIMEOUT = env.int("EMAIL_TIMEOUT", default=10) diff --git a/core/settings/production.py b/core/settings/production.py index 52092d3..241137f 100644 --- a/core/settings/production.py +++ b/core/settings/production.py @@ -40,3 +40,25 @@ # ------------------------------------------------------------------------------ MEDIA_ROOT = env("MEDIA_ROOT") MEDIA_URL = env("MEDIA_URL", default="media").rstrip("/") + "/" + + +# EMAIL (Production via env; placeholders acceptable) +# ------------------------------------------------------------------------------ +EMAIL_BACKEND = env( + "EMAIL_BACKEND", + default="django.core.mail.backends.smtp.EmailBackend", +) +DEFAULT_FROM_EMAIL = env( + "DEFAULT_FROM_EMAIL", + default="Pathogens Portal ", +) +CONTACT_RECIPIENT_EMAIL = env( + "CONTACT_RECIPIENT_EMAIL", + default="dev-null@example.org", +) +EMAIL_HOST = env("EMAIL_HOST", default="") +EMAIL_PORT = env.int("EMAIL_PORT", default=587) +EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=True) +EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="") +EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="") +EMAIL_TIMEOUT = env.int("EMAIL_TIMEOUT", default=10) diff --git a/core/urls.py b/core/urls.py index 5e9e68b..7a29474 100644 --- a/core/urls.py +++ b/core/urls.py @@ -24,6 +24,7 @@ path(settings.ADMIN_URL, admin.site.urls, name="admin"), path("", include("pages.home.urls")), path("citation/", include("pages.citation.urls")), + path("contact/", include("pages.contact.urls")), path("dashboards/", include("pages.dashboards.urls")), path("data-management/", include("pages.data_management.urls")), path("privacy/", include("pages.privacy.urls")), diff --git a/pages/contact/__init__.py b/pages/contact/__init__.py new file mode 100644 index 0000000..0bee658 --- /dev/null +++ b/pages/contact/__init__.py @@ -0,0 +1 @@ +"""Contact app package.""" diff --git a/pages/contact/apps.py b/pages/contact/apps.py new file mode 100644 index 0000000..2f0ceeb --- /dev/null +++ b/pages/contact/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContactConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pages.contact" diff --git a/pages/contact/forms.py b/pages/contact/forms.py new file mode 100644 index 0000000..0c36a53 --- /dev/null +++ b/pages/contact/forms.py @@ -0,0 +1,172 @@ +"""Contact form definitions and anti-spam validation. + +This module defines the `ContactForm` used by the contact page. It includes +layered, user-transparent anti-spam checks (honeypot, submission timing, and +double-submit cookie) and content validation. + +The form accepts `request` in its constructor to access cookies for the +double-submit verification. +""" + +from __future__ import annotations + +import re +import time +from typing import Any, Optional + +from django import forms +from django.core import signing +from django.core.exceptions import ValidationError + + +URL_REGEX = re.compile(r"(https?://|www\.)", re.IGNORECASE) + +# Validation and anti-spam constants +MIN_SUBMIT_SECONDS = 2 +MAX_URLS_ALLOWED = 3 + + +class ContactForm(forms.Form): + """Contact form with layered anti-spam. + + Fields: + name: Optional full name of the sender (2–100 chars when provided). + email: Optional reply address, validated when provided. + message: Main text body, 20–5000 chars, with URL and HTML limits. + suggestion, dm_support, other: Category checkboxes, at least one must + be selected. + website: Honeypot field, must remain empty. + contact_ts: Signed timestamp token to check submission timing. + contact_dsc: Token that must match the cookie for double-submit check. + + Anti-spam strategy: + - Honeypot rejects naive bots filling hidden fields. + - TimestampSigner token rejects submissions that are too fast (<2s). + - Double-submit cookie reduces scripted posts and replays. + """ + + name = forms.CharField(min_length=2, max_length=100, required=False) + email = forms.EmailField(required=False) + message = forms.CharField(min_length=20, max_length=5000, widget=forms.Textarea) + suggestion = forms.BooleanField(required=False) + dm_support = forms.BooleanField( + required=False, label="Request for data management/sharing support" + ) + other = forms.BooleanField(required=False) + + # Anti-spam fields + website = forms.CharField(required=False, widget=forms.HiddenInput) + contact_ts = forms.CharField(widget=forms.HiddenInput, strip=False) + contact_dsc = forms.CharField(widget=forms.HiddenInput, strip=False) + + # Internal state for logging (not exposed to users) + _blocked_reason: Optional[str] = None + + def __init__(self, *args: Any, request=None, **kwargs: Any) -> None: + """Initialise the form. + + Args: + *args: Positional form args. + request: Optional HttpRequest to access cookies for token checks. + **kwargs: Keyword form args. + """ + super().__init__(*args, **kwargs) + self.request = request + + def clean_email(self) -> str: + """Validate email and prevent header injection. + + Returns: + The sanitised email value. + + Raises: + ValidationError: If CR/LF characters are present. + """ + value = self.cleaned_data.get("email", "") + if "\r" in value or "\n" in value: + raise ValidationError("Enter a valid email address.") + return value + + def clean_message(self) -> str: + """Validate message content against HTML and URL constraints. + + Returns: + The validated message content. + + Raises: + ValidationError: If HTML-like tags are present or URL count exceeds + the allowed threshold. + """ + value = self.cleaned_data.get("message", "") + # Reject HTML-like tags + if re.search(r"]*>", value): + raise ValidationError("Please remove HTML tags.") + # Cap URLs + url_count = len(URL_REGEX.findall(value)) + if url_count > MAX_URLS_ALLOWED: + raise ValidationError("Too many links in the message.") + return value + + def clean(self) -> dict[str, Any]: + """Form-level validation for category, timing, and token checks. + + Returns: + The cleaned data dictionary. + + Raises: + ValidationError: On any anti-spam or category selection failure. + """ + cleaned = super().clean() + + # At least one category must be selected + if not ( + cleaned.get("suggestion") + or cleaned.get("dm_support") + or cleaned.get("other") + ): + raise ValidationError("Please select at least one option.") + + # Honeypot + if cleaned.get("website"): + self._blocked_reason = "HONEYPOT_HIT" + raise ValidationError( + "We couldn't submit the form. Please try again in a moment." + ) + + # Timing token + ts_token = cleaned.get("contact_ts") or "" + try: + # Accept TimestampSigner style token or JSON payload signed via dumps + try: + signer = signing.TimestampSigner(salt="contact-ts") + ts_str = signer.unsign(ts_token) + ts = int(ts_str) + except signing.BadSignature: + payload = signing.loads(ts_token, salt="contact-ts") + ts = int(payload.get("ts", 0)) + except signing.BadSignature: + self._blocked_reason = "TOKEN_BAD_SIGNATURE" + raise ValidationError( + "We couldn't submit the form. Please try again in a moment." + ) + + now = int(time.time()) + if now - ts < MIN_SUBMIT_SECONDS: + self._blocked_reason = "TOO_FAST" + raise ValidationError( + "We couldn't submit the form. Please try again in a moment." + ) + + # Double-submit cookie check + posted_token = cleaned.get("contact_dsc") or "" + cookie_token = None + if hasattr(self, "request") and self.request is not None: + cookie_token = self.request.COOKIES.get("contact_dsc") + + if not cookie_token or cookie_token != posted_token: + self._blocked_reason = "TOKEN_MISMATCH" + raise ValidationError( + "We couldn't submit the form. Please try again in a moment." + ) + + return cleaned diff --git a/pages/contact/templates/contact/contact_form.html b/pages/contact/templates/contact/contact_form.html new file mode 100644 index 0000000..3b6772e --- /dev/null +++ b/pages/contact/templates/contact/contact_form.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ We welcome questions and suggestions regarding the Swedish Pathogens Portal and we can help with your data + management or data sharing question (or at least point you to where your question can be answered). You can fill + out the form below and the the team behind the Portal will be happy to help you find a good + solution. Alternatively, you can send us an email: pathogens@scilifelab.se. +
+ +

Contact and suggestions form

+
+ Please fill out this form to make suggestions for the Swedish Pathogens Portal or to ask a data management or + data sharing question. Please provide your contact information if you would like to receive a response. The data + portal team will send you a response within the next couple of days. +
+ + {# Error summary #} + {% if form.non_field_errors %} + + {% endif %} + + {# Success and info messages #} + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+ {% csrf_token %} + + {# Hidden anti-spam fields #} + {{ form.website }} + {{ form.contact_ts }} + {{ form.contact_dsc }} + +
+ + + {% if form.name.errors %} +

{{ form.name.errors.0 }}

+ {% endif %} +

Please provide your name in case you would like to receive a response from us. In other cases, name is not required.

+
+ +
+ + + {% if form.email.errors %} +

{{ form.email.errors.0 }}

+ {% endif %} +

Please provide your email address in case you would like to receive a response from us. In other cases, the email address is not required.

+
+ +
+
What kind of a question or suggestion do you have? + *
+
+ + + +
+ {% for err in form.non_field_errors %} + {% if "Please select at least one option." in err %} +

Please select at least one alternative.

+ {% endif %} + {% endfor %} +

You can ask a question or suggest one or more items using this form.

+
+ +
+ + + {% if form.message.errors %} +

{{ form.message.errors.0 }}

+ {% endif %} +

Please describe what you need help with (e.g., sharing your own dataset) or + provide link(s) and/or DOI(s) to your suggested dataset, project, publication, preprint, etc. and anything else + that we should know.

+
+ +
+ +
+
+ +
+

Personal data policy

+

+ The personal data you provide in this form, your name and email address, will be used to process your suggestion of + added resource to the Swedish Pathogens Portal. The Data Portal is a + service run by the SciLifeLab Data Centre on assignment from the Swedish Research Council (Vetenskapsrådet). It + serves to address + the government assignment to the Swedish Research Council + to participate in the European open data platform for COVID-19 research. +

+

+ The information you provide will be processed for research purposes, i.e. using the lawful basis of public interest + and in accordance with Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016, the + General Data Protection Regulation. +

+

+ The following parties will have access to processing your personal data: SciLifeLab Data Centre, Uppsala + University. Your personal data will be deleted when no longer needed, or when stipulated by the archival rules for + the university as a government authority. If you want to update or remove your personal data please contact the + controller SciLifeLab Data Centre at Uppsala University using datacentre@scilifelab.se. +

+
+
+{% endblock %} diff --git a/pages/contact/tests.py b/pages/contact/tests.py new file mode 100644 index 0000000..880f0d1 --- /dev/null +++ b/pages/contact/tests.py @@ -0,0 +1,285 @@ +"""Tests for the contact form and view. + +Covers anti-spam (honeypot, timing, token), validation, and email sending. +Uses Django's locmem email backend for assertions. +""" + +from __future__ import annotations + +import re +import time +from django.test import TestCase, Client, override_settings +from django.urls import reverse +from django.core import mail +from unittest.mock import patch + + +@override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + DEFAULT_FROM_EMAIL="Pathogens Portal ", +) +class ContactFormTests(TestCase): + """Integration tests for the contact view and form.""" + + def setUp(self) -> None: + """Initialise test client for each test.""" + self.client = Client() + + def _get_tokens_from_response(self, response): + """Extract hidden anti-spam token values from rendered HTML. + + Args: + response: The GET response containing the form. + + Returns: + Tuple of (timestamp_token, double_submit_token). + """ + content = response.content.decode() + dsc_match = re.search(r'name="contact_dsc" value="([^"]+)"', content) + ts_match = re.search(r'name="contact_ts" value="([^"]+)"', content) + dsc = dsc_match.group(1) if dsc_match else None + ts = ts_match.group(1) if ts_match else None + return ts, dsc + + def test_get_renders_form_and_sets_cookie(self): + """GET should render the form and set the double-submit cookie.""" + url = reverse("contact:contact") + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + # Cookie set + self.assertIn("contact_dsc", resp.cookies) + # Hidden fields present + ts, dsc = self._get_tokens_from_response(resp) + self.assertIsNotNone(ts) + self.assertIsNotNone(dsc) + + def test_csrf_missing_returns_403(self): + """POST without CSRF token should be rejected when checks are enforced.""" + client = Client(enforce_csrf_checks=True) + url = reverse("contact:contact") + # No CSRF token provided on POST + resp = client.post(url, data={}) + self.assertEqual(resp.status_code, 403) + + def test_happy_path_sends_email(self): + """Valid submission sends a single email with expected subject.""" + url = reverse("contact:contact") + resp = self.client.get(url) + ts, dsc = self._get_tokens_from_response(resp) + # Wait a bit to pass min timing of 2s + time.sleep(2) + post = { + "name": "Alice", + "email": "alice@example.org", + "message": "This is a valid message with minimal content.", + "suggestion": "on", + "website": "", + "contact_ts": ts, + "contact_dsc": dsc, + } + resp2 = self.client.post(url, data=post, follow=True) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + "[Contact] Contact and suggestions form", mail.outbox[0].subject + ) + # Success message present after redirect + self.assertIn(b"Thanks! Your message was sent", resp2.content) + + def test_honeypot_blocks(self): + """Filling the honeypot field must block submission.""" + url = reverse("contact:contact") + resp = self.client.get(url) + ts, dsc = self._get_tokens_from_response(resp) + post = { + "name": "Alice", + "email": "alice@example.org", + "message": "Valid message body that is long enough.", + "website": "spam", + "contact_ts": ts, + "contact_dsc": dsc, + } + resp2 = self.client.post(url, data=post) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + def test_timing_too_fast_blocks(self): + """Posting faster than 2s after GET should be blocked.""" + url = reverse("contact:contact") + resp = self.client.get(url) + ts, dsc = self._get_tokens_from_response(resp) + post = { + "name": "Alice", + "email": "alice@example.org", + "message": "Valid message body that is long enough.", + "suggestion": "on", + "website": "", + "contact_ts": ts, + "contact_dsc": dsc, + } + resp2 = self.client.post(url, data=post) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + @patch("pages.contact.views.EmailMessage.send", side_effect=Exception("SMTP down")) + def test_smtp_failure_path(self, _mock_send): + """If SMTP fails, display generic error and do not send email.""" + url = reverse("contact:contact") + resp = self.client.get(url) + ts, dsc = self._get_tokens_from_response(resp) + time.sleep(2) + post = { + "name": "Alice", + "email": "alice@example.org", + "message": "Valid message body that is long enough.", + "suggestion": "on", + "website": "", + "contact_ts": ts, + "contact_dsc": dsc, + } + resp2 = self.client.post(url, data=post) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + def test_double_submit_mismatch_blocks(self): + """Mismatch between cookie and hidden token should block.""" + url = reverse("contact:contact") + resp = self.client.get(url) + ts, _dsc = self._get_tokens_from_response(resp) + # Intentionally wrong token + post = { + "name": "Alice", + "email": "alice@example.org", + "message": "Valid message body that is long enough.", + "suggestion": "on", + "website": "", + "contact_ts": ts, + "contact_dsc": "wrong", + } + resp2 = self.client.post(url, data=post) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + # Category hint should not appear for non-category errors + self.assertNotIn(b"Please select at least one alternative.", resp2.content) + + def test_html_rejected_and_url_cap(self): + """HTML content or too many URLs should be rejected.""" + url = reverse("contact:contact") + resp = self.client.get(url) + ts, dsc = self._get_tokens_from_response(resp) + time.sleep(2) + # HTML rejected + post_html = { + "name": "Alice", + "email": "alice@example.org", + "message": "no html", + "suggestion": "on", + "website": "", + "contact_ts": ts, + "contact_dsc": dsc, + } + resp_html = self.client.post(url, data=post_html) + self.assertEqual(resp_html.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + # URL cap + content_many_urls = " ".join( + [ + "https://a.example", + "https://b.example", + "https://c.example", + "https://d.example", + ] + ) + post_urls = { + "name": "Alice", + "email": "alice@example.org", + "message": content_many_urls, + "suggestion": "on", + "website": "", + "contact_ts": ts, + "contact_dsc": dsc, + } + resp_urls = self.client.post(url, data=post_urls) + self.assertEqual(resp_urls.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + def test_header_injection_prevention(self): + """CR/LF in email should be rejected by form validator.""" + url = reverse("contact:contact") + resp = self.client.get(url) + ts, dsc = self._get_tokens_from_response(resp) + time.sleep(2) + post = { + "name": "Alice", + "email": "evil@example.org\nBcc: attacker@example.org", + "message": "Valid message body that is long enough.", + "suggestion": "on", + "website": "", + "contact_ts": ts, + "contact_dsc": dsc, + } + resp2 = self.client.post(url, data=post) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + def test_requires_at_least_one_category(self): + """At least one category checkbox must be selected.""" + url = reverse("contact:contact") + resp = self.client.get(url) + ts, dsc = self._get_tokens_from_response(resp) + time.sleep(2) + post = { + "name": "Alice", + "email": "alice@example.org", + "message": "Valid message body that is long enough.", + # No category checked + "website": "", + "contact_ts": ts, + "contact_dsc": dsc, + } + resp2 = self.client.post(url, data=post) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + resp2 = self.client.post(url, data=post) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + def test_resubmission_after_error_works_without_reload(self): + """After validation error, new tokens/cookie allow retry without page reload.""" + url = reverse("contact:contact") + # Initial GET + resp = self.client.get(url) + ts1, dsc1 = self._get_tokens_from_response(resp) + # Trigger a validation error (e.g., message too short) + post_invalid = { + "name": "Alice", + "email": "alice@example.org", + "message": "short", + "suggestion": "on", + "website": "", + "contact_ts": ts1, + "contact_dsc": dsc1, + } + resp_invalid = self.client.post(url, data=post_invalid) + self.assertEqual(resp_invalid.status_code, 200) + # Extract refreshed tokens from the error page + ts2, dsc2 = self._get_tokens_from_response(resp_invalid) + # Cookie must match hidden token + cookie_dsc = self.client.cookies.get("contact_dsc").value + self.assertEqual(dsc2, cookie_dsc) + # Wait to satisfy min timing before retry + time.sleep(2) + # Retry with corrected message and refreshed tokens + post_valid = { + "name": "Alice", + "email": "alice@example.org", + "message": "This is a valid message body that is long enough.", + "suggestion": "on", + "website": "", + "contact_ts": ts2, + "contact_dsc": dsc2, + } + resp_valid = self.client.post(url, data=post_valid, follow=True) + self.assertEqual(resp_valid.status_code, 200) + self.assertEqual(len(mail.outbox), 1) diff --git a/pages/contact/urls.py b/pages/contact/urls.py new file mode 100644 index 0000000..b8289c4 --- /dev/null +++ b/pages/contact/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import Contact + +app_name = "contact" + +urlpatterns = [ + path("", Contact.as_view(), name="contact"), +] diff --git a/pages/contact/views.py b/pages/contact/views.py new file mode 100644 index 0000000..1b82760 --- /dev/null +++ b/pages/contact/views.py @@ -0,0 +1,195 @@ +"""Views for the contact page. + +This module contains a `FormView`-based implementation that issues anti-spam +tokens on GET and validates user input on POST before sending an email via +Django's email backend. +""" + +from __future__ import annotations + +import logging +import os +import secrets +import time +from typing import Any + +from django.conf import settings +from django.core import signing +from django.core.mail import EmailMessage +from django.http import HttpRequest, HttpResponse +from django.urls import reverse_lazy +from django.views.generic.edit import FormView +from django.contrib import messages + +from .forms import ContactForm + + +class Contact(FormView): + template_name = "contact/contact_form.html" + form_class = ContactForm + success_url = reverse_lazy("contact:contact") + title = "Contact and suggestions form" + logger = logging.getLogger(__name__) + + def get_form_kwargs(self) -> dict[str, Any]: + """Inject request into the form for cookie access. + + Returns: + Keyword arguments for form construction including `request`. + """ + kwargs = super().get_form_kwargs() + kwargs["request"] = self.request + return kwargs + + def _generate_tokens(self) -> tuple[str, str]: + """Create a signed timestamp token and a double-submit cookie value. + + Returns: + A tuple ``(signed_ts, dsc_token)`` where ``signed_ts`` is produced + by `TimestampSigner` and ``dsc_token`` is a random string. + """ + signer = signing.TimestampSigner(salt="contact-ts") + signed_ts = signer.sign(str(int(time.time()))) + dsc_token = secrets.token_urlsafe(16) + return signed_ts, dsc_token + + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + """Render the form and set anti-spam tokens/cookie. + + Sets the signed timestamp and double-submit token as hidden fields and + sets a HttpOnly cookie (`contact_dsc`). + """ + form = self.get_form() + signed_ts, dsc_token = self._generate_tokens() + form.initial.update( + { + "contact_ts": signed_ts, + "contact_dsc": dsc_token, + } + ) + + response = self.render_to_response(self.get_context_data(form=form)) + # HttpOnly cookie, secure flag will be added by Django when using HTTPS in prod + secure_flag = ( + self.request.is_secure() + if hasattr(self.request, "is_secure") + else getattr(settings, "SESSION_COOKIE_SECURE", False) + ) + + response.set_cookie( + key="contact_dsc", + value=dsc_token, + max_age=30 * 60, + httponly=True, + secure=secure_flag, + samesite=getattr(settings, "SESSION_COOKIE_SAMESITE", "Lax"), + ) + return response + + def form_valid(self, form: ContactForm) -> HttpResponse: + """Compose and send the email, then redirect. + + Builds a plain-text body including name, email, selected categories and + origin URL. Adds `Reply-To` only if the user provided an email. + """ + from_email = getattr( + settings, + "DEFAULT_FROM_EMAIL", + os.environ.get("DEFAULT_FROM_EMAIL", "no-reply@example.org"), + ) + recipient = getattr( + settings, + "CONTACT_RECIPIENT_EMAIL", + os.environ.get("CONTACT_RECIPIENT_EMAIL", "dev-null@example.org"), + ) + + name = form.cleaned_data.get("name", "") + user_email = form.cleaned_data.get("email", "") + message = form.cleaned_data["message"] + + # Build category summary mirroring old portal + categories = [] + if form.cleaned_data.get("suggestion"): + categories.append("Suggestion for the Portal") + if form.cleaned_data.get("dm_support"): + categories.append("Request for data management or data sharing support") + if form.cleaned_data.get("other"): + categories.append("Other") + origin_url = self.request.build_absolute_uri(self.request.path) + + body = ( + f"From: {name}\n" + f"Email: {user_email}\n" + f"Categories: {', '.join(categories) if categories else '—'}\n" + f"Origin URL: {origin_url}\n\n" + f"{message}" + ) + + start = time.time() + try: + headers = {"Reply-To": user_email} if user_email else None + email = EmailMessage( + subject="[Contact] Contact and suggestions form", + body=body, + from_email=from_email, + to=[recipient], + headers=headers, + ) + email.send(fail_silently=False) + duration_ms = int((time.time() - start) * 1000) + self.logger.info( + "event=contact_submit outcome=success duration_ms=%s", duration_ms + ) + messages.success( + self.request, + "Thanks! Your message was sent, we’ll get back to you soon.", + ) + except Exception: # noqa: BLE001 + duration_ms = int((time.time() - start) * 1000) + self.logger.error( + "event=contact_submit outcome=error reason=EMAIL_SEND_ERROR duration_ms=%s", + duration_ms, + exc_info=True, + ) + # Re-render the form with a generic error + form._blocked_reason = "EMAIL_SEND_ERROR" + form.add_error( + None, "We couldn't submit the form. Please try again in a moment." + ) + return self.form_invalid(form) + + # Redirect to clear POST and show success state + return super().form_valid(form) + + def form_invalid(self, form: ContactForm) -> HttpResponse: + """Log a reason code without personal information and re-render the form.""" + reason = getattr(form, "_blocked_reason", None) or "VALIDATION_ERROR" + self.logger.warning("event=contact_submit outcome=blocked reason=%s", reason) + # Re-issue tokens and cookie so user can retry without reload + signed_ts, dsc_token = self._generate_tokens() + # Update both initial and bound data so rendered hidden inputs match the new cookie + form.fields["contact_ts"].initial = signed_ts + form.fields["contact_dsc"].initial = dsc_token + try: + data = form.data.copy() + data["contact_ts"] = signed_ts + data["contact_dsc"] = dsc_token + form.data = data + except Exception: # noqa: BLE001 + # If form.data is not a QueryDict (unlikely), continue with initial values only + pass + response = super().form_invalid(form) + secure_flag = ( + self.request.is_secure() + if hasattr(self.request, "is_secure") + else getattr(settings, "SESSION_COOKIE_SECURE", False) + ) + response.set_cookie( + key="contact_dsc", + value=dsc_token, + max_age=30 * 60, + httponly=True, + secure=secure_flag, + samesite=getattr(settings, "SESSION_COOKIE_SAMESITE", "Lax"), + ) + return response