diff --git a/config/celery.py b/config/celery.py index fa260266f..7216c49ec 100644 --- a/config/celery.py +++ b/config/celery.py @@ -119,6 +119,12 @@ def setup_periodic_tasks(sender, **kwargs): app.signature("asciidoctor_sandbox.tasks.cleanup_old_sandbox_documents"), ) + # Purge expired pending mailing list subscriptions. Executes daily at 3:45 AM. + sender.add_periodic_task( + crontab(hour=3, minute=45), + app.signature("mailing_list.tasks.purge_expired_pending_subscriptions"), + ) + # Sync per-post page views from Plausible. Executes daily at 6:00 AM. sender.add_periodic_task( crontab(hour=6, minute=0), diff --git a/config/settings.py b/config/settings.py index abdcdf82b..88f6bfab4 100755 --- a/config/settings.py +++ b/config/settings.py @@ -10,6 +10,7 @@ from django.core.exceptions import ImproperlyConfigured from pythonjsonlogger import jsonlogger + env = environs.Env() READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) @@ -381,6 +382,7 @@ # Mailman API credentials MAILMAN_REST_API_URL = env("MAILMAN_REST_API_URL", default="http://localhost:8001") +MAILMAN_REST_API_VERSION = env("MAILMAN_REST_API_VERSION", default="3.1") MAILMAN_REST_API_USER = env("MAILMAN_REST_API_USER", default="restadmin") MAILMAN_REST_API_PASS = env("MAILMAN_REST_API_PASS", default="restpass") diff --git a/config/urls.py b/config/urls.py index 1ca01ec70..87699fa2d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -269,6 +269,7 @@ LibraryDetail.as_view(redirect_to_docs=True), name="library-docs-redirect", ), + path("mailing-list/", include("mailing_list.urls")), path("news/", include("news.urls")), path("v3/news/add/", V3AllTypesCreateView.as_view(), name="v3-news-create"), path( diff --git a/core/views.py b/core/views.py index 1c17ee0bb..c83156edc 100644 --- a/core/views.py +++ b/core/views.py @@ -42,10 +42,15 @@ set_selected_boost_version, modernize_boost_slug, ) +from mailing_list import constants from versions.models import Version, docs_path_to_boost_name from . import context_processors from .mixins import V3Mixin, iter_v3_views +from mailing_list.mixins import ( + MailingListCardMixin, + get_subscription_state_count_and_email, +) from .asciidoc import convert_adoc_to_html from .boostrenderer import ( convert_img_paths, @@ -129,7 +134,7 @@ class BoostDevelopmentView(CalendarView): template_name = "boost_development.html" -class CommunityView(V3Mixin, TemplateView): +class CommunityView(MailingListCardMixin, V3Mixin, TemplateView): template_name = "community.html" v3_template_name = "v3/community.html" @@ -498,7 +503,7 @@ def get_v3_context_data(self, **kwargs): return {"last_updated": "2024-02-17"} -class LearnPageView(V3Mixin, TemplateView): +class LearnPageView(MailingListCardMixin, V3Mixin, TemplateView): v3_template_name = "v3/learn_page.html" def get_v3_context_data(self, **kwargs): @@ -2237,6 +2242,38 @@ def _intro_card(library_name, authors): ) context["demo_library_items"] = demo_library_items + # Mailing list subscribe demo + from mailing_list.models import UserMailingListSubscription + + _demo_card_list_id = constants.MAILMAN_LISTS[0] + context["demo_mailman_lists"] = constants.MAILMAN_LISTS + context["demo_subscribe_url"] = reverse("mailing-list-subscribe") + context["demo_quick_subscribe_url"] = reverse("mailing-list-quick-subscribe") + + mailing_list_state = None + + if self.request.user.is_authenticated: + mailing_list_state = get_subscription_state_count_and_email( + self.request.user, constants.MAILMAN_LISTS + ) + context["demo_subscribed_lists"] = set( + UserMailingListSubscription.objects.filter( + user=self.request.user, list_id__in=constants.MAILMAN_LISTS + ).values_list("list_id", flat=True) + ) + else: + context["demo_subscribed_lists"] = set() + + context["demo_mailing_list_card_list_id"] = _demo_card_list_id + context["demo_mailing_list_card_state"] = ( + mailing_list_state.state if mailing_list_state else None + ) + context["demo_subscription_count"] = ( + mailing_list_state.count if mailing_list_state else 0 + ) + context["demo_subscribed_lists_email"] = ( + mailing_list_state.email if mailing_list_state else "" + ) # V3 paths registry v3_paths = [ { diff --git a/docker-compose.yml b/docker-compose.yml index 3d294274d..8af215b21 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,43 +49,43 @@ services: - ../website2022/:/website stop_signal: SIGKILL - # mailman-core: - # image: maxking/mailman-core - # stop_grace_period: 5s - # ports: - # - "8001:8001" # API - # - "8024:8024" # LMTP - incoming emails - # volumes: - # - ./mailman/core:/opt/mailman/ - # networks: - # - backend - # env_file: - # - .env - # depends_on: - # - db + mailman-core: + image: maxking/mailman-core + stop_grace_period: 5s + ports: + - "8001:8001" # API + - "8024:8024" # LMTP - incoming emails + volumes: + - ./mailman/core:/opt/mailman/ + networks: + - backend + env_file: + - .env + depends_on: + - db - # mailman-web: - # image: maxking/mailman-web - # entrypoint: /opt/mailman-docker/compose-start.sh - # env_file: - # - .env - # environment: - # - "DOCKER_DIR=/opt/mailman-docker" - # - "PYTHON=python3" - # - "WEB_PORT=8008" - # depends_on: - # - redis - # - db - # stop_signal: SIGKILL - # ports: - # - "8008:8008" # HTTP - # - "8080:8080" # uwsgi - # volumes: - # - .:/code - # - ./mailman/web:/opt/mailman-web-data - # - ./docker:/opt/mailman-docker - # networks: - # - backend + mailman-web: + image: maxking/mailman-web + entrypoint: /opt/mailman-docker/compose-start.sh + env_file: + - .env + environment: + - "DOCKER_DIR=/opt/mailman-docker" + - "PYTHON=python3" + - "WEB_PORT=8008" + depends_on: + - redis + - db + stop_signal: SIGKILL + ports: + - "8008:8008" # HTTP + - "8080:8080" # uwsgi + volumes: + - .:/code + - ./mailman/web:/opt/mailman-web-data + - ./docker:/opt/mailman-docker + networks: + - backend celery-worker: build: diff --git a/env.template b/env.template index 66f6da731..a489cac07 100644 --- a/env.template +++ b/env.template @@ -86,3 +86,9 @@ ALGOLIA_APP_ID= ALGOLIA_ANALYTICS_API_KEY= OPENROUTER_API_KEY= + +# Used by Django inside Docker — hostname resolves within the container network +MAILMAN_REST_API_URL=http://mailman-core:8001 +# Used by scripts/dev-mailman-helpers on the host — maps to the published port +MAILMAN_DEV_API_URL=http://localhost:8001 +MAILMAN_LISTS=boost-users.lists.boost.org,boost-announce.lists.boost.org,boost.lists.boost.org diff --git a/libraries/views.py b/libraries/views.py index 866ff6a1d..085731ad5 100644 --- a/libraries/views.py +++ b/libraries/views.py @@ -16,6 +16,7 @@ from core.constants import SLACK_URL from core.githubhelper import GithubAPIClient from core.mixins import V3Mixin +from mailing_list.mixins import MailingListCardMixin from core.mock_data import SharedResources from news.models import Entry from versions.exceptions import BoostImportedDataException @@ -454,7 +455,12 @@ def get_results_by_tier(self): @method_decorator(csrf_exempt, name="dispatch") class LibraryDetail( - V3Mixin, VersionAlertMixin, BoostVersionMixin, ContributorMixin, DetailView + MailingListCardMixin, + V3Mixin, + VersionAlertMixin, + BoostVersionMixin, + ContributorMixin, + DetailView, ): """Display a single Library in insolation""" diff --git a/mailing_list/client.py b/mailing_list/client.py new file mode 100644 index 000000000..e0286f505 --- /dev/null +++ b/mailing_list/client.py @@ -0,0 +1,159 @@ +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class MailmanAPIError(Exception): + pass + + +class MailmanClient: + """Thin wrapper around the Mailman REST API. + + Instantiate with defaults (reads from settings) or pass explicit credentials + to talk to a different Mailman instance: + + client = MailmanClient() + client = MailmanClient(base_url="http://other:8001", user="u", password="p") + """ + + def __init__(self, base_url=None, api_version=None, user=None, password=None): + url = (base_url or settings.MAILMAN_REST_API_URL).rstrip("/") + version = api_version or settings.MAILMAN_REST_API_VERSION + self._base = ( + f"{url}/api-proxy/{version}" + if not settings.LOCAL_DEVELOPMENT + else f"{url}/{version}" + ) + self._credentials = ( + user or settings.MAILMAN_REST_API_USER, + password or settings.MAILMAN_REST_API_PASS, + ) + + def subscribe(self, email: str, list_id: str) -> None: + """POST //members — subscribe an email to a list. + + All pre_* flags are True because Django owns the confirmation flow. This is + only called after the user has clicked the Django confirmation link. + """ + url = f"{self._base}/members" + payload = { + "list_id": list_id, + "subscriber": email, + "pre_verified": True, + "pre_confirmed": True, + "pre_approved": True, + } + try: + response = requests.post( + url, data=payload, auth=self._credentials, timeout=10 + ) + except requests.RequestException as exc: + raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc + + if response.status_code == 409: + # Already a member — treat as a no-op. + return + if not response.ok: + raise MailmanAPIError( + f"subscribe failed [{response.status_code}]: {response.text}" + ) + + def is_confirmed(self, email: str, list_id: str) -> bool: + """Return True if the email is a confirmed (active) member of the list.""" + url = f"{self._base}/lists/{list_id}/member/{email}" + try: + response = requests.get(url, auth=self._credentials, timeout=10) + except requests.RequestException as exc: + raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc + + if response.status_code == 404: + return False + if response.ok: + return True + raise MailmanAPIError( + f"member lookup failed [{response.status_code}]: {response.text}" + ) + + def _discard_pending(self, email: str, list_id: str) -> None: + """Discard any pending (unconfirmed) subscription request for email on list_id.""" + url = f"{self._base}/lists/{list_id}/requests" + try: + response = requests.get(url, auth=self._credentials, timeout=10) + except requests.RequestException as exc: + raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc + + if not response.ok: + raise MailmanAPIError( + f"pending requests lookup failed [{response.status_code}]: {response.text}" + ) + + entries = response.json().get("entries", []) + token = next( + ( + e["token"] + for e in entries + if e.get("email") == email and e.get("type") == "subscription" + ), + None, + ) + if token is None: + return + + discard_url = f"{self._base}/lists/{list_id}/requests/{token}" + try: + discard_response = requests.post( + discard_url, + data={"action": "discard"}, + auth=self._credentials, + timeout=10, + ) + except requests.RequestException as exc: + raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc + + if discard_response.status_code == 404: + return + if not discard_response.ok: + raise MailmanAPIError( + f"discard pending failed [{discard_response.status_code}]" + ) + + def unsubscribe(self, email: str, list_id: str) -> None: + """DELETE //members/ — remove a subscription.""" + url = f"{self._base}/lists/{list_id}/member/{email}" + try: + response = requests.get(url, auth=self._credentials, timeout=10) + except requests.RequestException as exc: + raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc + + if response.status_code == 404: + # Not a confirmed member — discard any pending subscription request so + # the user can subscribe again cleanly. + self._discard_pending(email, list_id) + return + if not response.ok: + raise MailmanAPIError( + f"member lookup failed [{response.status_code}]: {response.text}" + ) + + member_id = response.json().get("member_id") + if not member_id: + raise MailmanAPIError("member lookup returned no member_id") + + delete_url = f"{self._base}/members/{member_id}" + try: + del_response = requests.delete( + delete_url, auth=self._credentials, timeout=10 + ) + except requests.RequestException as exc: + raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc + + if del_response.status_code == 404: + return + if not del_response.ok: + raise MailmanAPIError( + f"unsubscribe failed [{del_response.status_code}]: {del_response.text}" + ) diff --git a/mailing_list/constants.py b/mailing_list/constants.py index 7ca5ad7a4..ce3f9ba5f 100644 --- a/mailing_list/constants.py +++ b/mailing_list/constants.py @@ -1,3 +1,75 @@ +from enum import Enum +from urllib.parse import urlparse + +from config.settings import MAILMAN_REST_API_URL + + +def get_domain_with_subdomains(url: str) -> str: + """ + Extracts the full domain (including subdomains) from a given URL. + """ + # If the URL doesn't start with a scheme, urlparse might not parse it correctly. + # We prepend '//' to handle scheme-less URLs properly. + if not url.startswith(("http://", "https://", "//")): + url = "//" + url + + parsed_url = urlparse(url) + + # .netloc extracts the network location (domain + port if present) + # We split by ':' to remove the port number just in case it's included. + domain = parsed_url.netloc.split(":")[0] + + return domain + + +MAILMAN_DOMAIN = get_domain_with_subdomains(MAILMAN_REST_API_URL) + + +def build_mailing_list_address(list_name: str) -> str: + return f"{list_name}@{MAILMAN_DOMAIN}" + + +class MailingLists(Enum): + BOOST = "boost" + BOOST_ANNOUNCE = "boost-announce" + BOOST_USERS = "boost-users" + + +MAILING_LIST_LABELS = { + MailingLists.BOOST.value: { + "name": "Boost Developers", + "address": build_mailing_list_address(MailingLists.BOOST.value), + "description": ( + "The primary discussion list for Boost library developers. Topics cover " + "library submission, development, review, and project-wide decisions. " + "Posts from non-subscribers are automatically rejected, and first-time " + "posts are moderated. Please read the discussion policy before posting: " + "https://www.boost.org/doc/user-guide/discussion-policy.html" + ), + }, + MailingLists.BOOST_ANNOUNCE.value: { + "name": "Boost Announcements", + "address": build_mailing_list_address(MailingLists.BOOST_ANNOUNCE.value), + "description": ( + "A low-volume, announce-only list for upcoming Boost formal reviews and " + "new software releases. A good fit if you want to stay informed without " + "following the high-volume developer discussion." + ), + }, + MailingLists.BOOST_USERS.value: { + "name": "Boost Users", + "address": build_mailing_list_address(MailingLists.BOOST_USERS.value), + "description": ( + "Discussion list for developers using the Boost C++ libraries. The right " + "place to ask questions, share solutions, and get help integrating Boost " + "into your projects. Please read the discussion policy before posting: " + "https://www.boost.org/doc/user-guide/discussion-policy.html" + ), + }, +} + +MAILMAN_LISTS = [f"{_l}.{MAILMAN_DOMAIN}" for _l in MAILING_LIST_LABELS.keys()] + # we only want boost devel for now, leaving the others in case that changes. ML_STATS_URLS = [ "https://lists.boost.org/Archives/boost/{:04}/{:02}/author.php", diff --git a/mailing_list/migrations/0007_usermailinglistsubscription.py b/mailing_list/migrations/0007_usermailinglistsubscription.py new file mode 100644 index 000000000..7694c2e66 --- /dev/null +++ b/mailing_list/migrations/0007_usermailinglistsubscription.py @@ -0,0 +1,52 @@ +# Generated by Django 6.0.2 on 2026-05-08 15:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("mailing_list", "0006_listposting"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserMailingListSubscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("list_id", models.CharField(max_length=64)), + ("email", models.EmailField(max_length=254)), + ( + "status", + models.CharField( + choices=[("pending", "Pending"), ("active", "Active")], + default="pending", + max_length=16, + ), + ), + ("subscribed_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="mailing_list_subscriptions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "list_id"), ("list_id", "email")}, + }, + ), + ] diff --git a/mailing_list/mixins.py b/mailing_list/mixins.py new file mode 100644 index 000000000..36bb0cf16 --- /dev/null +++ b/mailing_list/mixins.py @@ -0,0 +1,89 @@ +from typing import NamedTuple, Optional + +from django.urls import reverse + +from mailing_list import constants +from mailing_list.models import SubscriptionStatus +from mailing_list.models import UserMailingListSubscription + +_DEFAULT_LIST_ID = constants.MAILMAN_LISTS[0] if constants.MAILMAN_LISTS else "" + + +class SubscriptionState(NamedTuple): + state: Optional[str] + count: int + email: Optional[str] + + +def get_subscription_state_count_and_email(user, list_ids) -> SubscriptionState: + if not user.is_authenticated: + return SubscriptionState(None, 0, None) + + subscriptions = UserMailingListSubscription.objects.filter( + user=user, list_id__in=list_ids + ) + pending_count = subscriptions.filter(status=SubscriptionStatus.PENDING).count() + active_count = subscriptions.filter(status=SubscriptionStatus.ACTIVE).count() + + if pending_count > 0: + email = ( + subscriptions.filter(status=SubscriptionStatus.PENDING) + .values_list("email", flat=True) + .first() + ) + return SubscriptionState(SubscriptionStatus.PENDING, pending_count, email) + elif active_count > 0: + email = ( + subscriptions.filter(status=SubscriptionStatus.ACTIVE) + .values_list("email", flat=True) + .first() + ) + return SubscriptionState(SubscriptionStatus.ACTIVE, active_count, email) + else: + return SubscriptionState(None, 0, None) + + +class MailingListCardMixin: + """Injects mailing-list card context into any class-based view. + + Adds the variables needed by v3/includes/_mailing_list_card.html: + mailing_list_card_subscribe_url + mailing_list_card_list_id + mailing_list_card_state ("pending", "active", "error", or None) + mailing_list_card_error_message (set on error state, used by no-JS PRG flow) + mailing_list_card_user_email (authenticated users only, or from PRG params) + mailing_list_card_manage_url (authenticated users only) + mailing_list_card_subscription_count (authenticated users only — ACTIVE count only) + """ + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + request = self.request + + context["mailing_list_card_subscribe_url"] = reverse( + "mailing-list-quick-subscribe" + ) + context["mailing_list_card_list_id"] = _DEFAULT_LIST_ID + + if request.user.is_authenticated: + managed_lists = set(constants.MAILMAN_LISTS) + state = get_subscription_state_count_and_email(request.user, managed_lists) + + context["mailing_list_card_state"] = state.state + context["mailing_list_card_subscription_count"] = state.count + context["mailing_list_card_user_email"] = state.email + context["mailing_list_card_manage_url"] = reverse("profile-account") + + # URL-param overrides for the no-JS PRG flow. + # Error state always wins (DB record was rolled back on failure). + # Anonymous pending has no DB record so the URL param is the only source. + ml_state_param = request.GET.get("ml_state") + if ml_state_param == "error": + context["mailing_list_card_state"] = "error" + context["mailing_list_card_error_message"] = request.GET.get("ml_error", "") + context["mailing_list_card_user_email"] = request.GET.get("ml_email", "") + elif ml_state_param == "pending" and not request.user.is_authenticated: + context["mailing_list_card_state"] = "pending" + context["mailing_list_card_user_email"] = request.GET.get("ml_email", "") + + return context diff --git a/mailing_list/models.py b/mailing_list/models.py index 2fa459990..1ed01fd8d 100644 --- a/mailing_list/models.py +++ b/mailing_list/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.auth import get_user_model from django.db import models @@ -59,6 +60,33 @@ class Meta: unique_together = ["subscription_dt", "email", "list"] +class SubscriptionStatus(models.TextChoices): + PENDING = "pending", "Pending" + ACTIVE = "active", "Active" + + +class UserMailingListSubscription(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="mailing_list_subscriptions", + ) + list_id = models.CharField(max_length=64) + email = models.EmailField(max_length=254) + status = models.CharField( + max_length=16, + choices=SubscriptionStatus, + default=SubscriptionStatus.PENDING, + ) + subscribed_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = [("user", "list_id"), ("list_id", "email")] + + def __str__(self): + return f"{self.user} → {self.list_id}" + + class ListPostingManager(models.Manager): def get_queryset(self): return super().get_queryset().using("hyperkitty") diff --git a/mailing_list/tasks.py b/mailing_list/tasks.py index d0beb223f..4f4f211f0 100644 --- a/mailing_list/tasks.py +++ b/mailing_list/tasks.py @@ -1,9 +1,13 @@ +import datetime + import structlog from django.core.management import call_command from django.conf import settings +from django.utils import timezone from config.celery import app +from mailing_list.models import SubscriptionStatus, UserMailingListSubscription logger = structlog.getLogger(__name__) @@ -16,3 +20,15 @@ def sync_mailinglist_stats(): logger.warning("HYPERKITTY_DATABASE_NAME not set.") return call_command("sync_mailinglist_stats") + + +@app.task +def purge_expired_pending_subscriptions(): + """Delete pending subscription records older than the 7-day confirmation window.""" + cutoff = timezone.now() - datetime.timedelta(days=7) + deleted, _ = UserMailingListSubscription.objects.filter( + status=SubscriptionStatus.PENDING, + subscribed_at__lt=cutoff, + ).delete() + if deleted: + logger.info("purged_pending_subscriptions", count=deleted) diff --git a/mailing_list/tests/test_tasks.py b/mailing_list/tests/test_tasks.py new file mode 100644 index 000000000..f790dc1b0 --- /dev/null +++ b/mailing_list/tests/test_tasks.py @@ -0,0 +1,64 @@ +import datetime + +import pytest +from django.utils import timezone +from model_bakery import baker + +from mailing_list.models import SubscriptionStatus, UserMailingListSubscription +from mailing_list.tasks import purge_expired_pending_subscriptions + + +@pytest.fixture +def user(db): + return baker.make("users.User") + + +@pytest.mark.django_db +def test_purge_deletes_expired_pending(user): + """purge_expired_pending_subscriptions: removes PENDING subscriptions older than 7 days.""" + old_sub = baker.make( + UserMailingListSubscription, + user=user, + list_id="boost.lists.boost.org", + status=SubscriptionStatus.PENDING, + ) + UserMailingListSubscription.objects.filter(pk=old_sub.pk).update( + subscribed_at=timezone.now() - datetime.timedelta(days=8) + ) + + purge_expired_pending_subscriptions() + + assert not UserMailingListSubscription.objects.filter(pk=old_sub.pk).exists() + + +@pytest.mark.django_db +def test_purge_keeps_recent_pending(user): + """purge_expired_pending_subscriptions: retains PENDING subscriptions created within the last 7 days.""" + recent_sub = baker.make( + UserMailingListSubscription, + user=user, + list_id="boost.lists.boost.org", + status=SubscriptionStatus.PENDING, + ) + + purge_expired_pending_subscriptions() + + assert UserMailingListSubscription.objects.filter(pk=recent_sub.pk).exists() + + +@pytest.mark.django_db +def test_purge_keeps_active_records(user): + """purge_expired_pending_subscriptions: does not touch ACTIVE subscriptions regardless of age.""" + active_sub = baker.make( + UserMailingListSubscription, + user=user, + list_id="boost.lists.boost.org", + status=SubscriptionStatus.ACTIVE, + ) + UserMailingListSubscription.objects.filter(pk=active_sub.pk).update( + subscribed_at=timezone.now() - datetime.timedelta(days=30) + ) + + purge_expired_pending_subscriptions() + + assert UserMailingListSubscription.objects.filter(pk=active_sub.pk).exists() diff --git a/mailing_list/tests/test_views.py b/mailing_list/tests/test_views.py new file mode 100644 index 000000000..b20c5d32d --- /dev/null +++ b/mailing_list/tests/test_views.py @@ -0,0 +1,296 @@ +import pytest +from django.core import signing +from django.urls import reverse +from model_bakery import baker +from unittest.mock import patch + +from mailing_list.constants import MAILMAN_LISTS +from mailing_list.models import SubscriptionStatus, UserMailingListSubscription +from mailing_list.views import _CONFIRM_SALT + + +LIST_ID = MAILMAN_LISTS[0] +EMAIL = "subscriber@example.com" + + +@pytest.fixture +def user(db): + u = baker.make("users.User", email="user@example.com") + u.set_password("password") + u.save() + return u + + +@pytest.fixture +def other_user(db): + u = baker.make("users.User", email="other@example.com") + u.set_password("password") + u.save() + return u + + +@pytest.fixture(autouse=True) +def locmem_cache(settings): + settings.CACHES = { + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"} + } + yield + from django.core.cache import cache + + cache.clear() + + +def _make_token(email, list_ids, user_id=None): + payload = {"email": email, "list_ids": list_ids} + if user_id is not None: + payload["user_id"] = user_id + return signing.dumps(payload, salt=_CONFIRM_SALT) + + +# --------------------------------------------------------------------------- +# QuickSubscribeView — anonymous flow +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_anon_quick_subscribe_sends_email_and_returns_pending(client): + """POST /mailing-list/quick-subscribe/ — anon: sends confirmation email, returns pending card.""" + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email") as mock_send, patch( + "mailing_list.views.MailmanClient" + ) as MockClient: + MockClient.return_value.is_confirmed.return_value = False + response = client.post( + url, + {"email": EMAIL, "list_id": LIST_ID}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + mock_send.assert_called_once() + assert b"pending" in response.content.lower() or b"sent" in response.content.lower() + + +@pytest.mark.django_db +def test_anon_quick_subscribe_already_subscribed_returns_error(client): + """POST /mailing-list/quick-subscribe/ — anon: email already confirmed in Mailman returns error.""" + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views.MailmanClient") as MockClient: + MockClient.return_value.is_confirmed.return_value = True + response = client.post( + url, + {"email": EMAIL, "list_id": LIST_ID}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + assert b"already subscribed" in response.content.lower() + + +@pytest.mark.django_db +def test_anon_quick_subscribe_rate_limited(client): + """POST /mailing-list/quick-subscribe/ — anon: 6th request within the window is rate-limited.""" + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email"), patch( + "mailing_list.views.MailmanClient" + ) as MockClient: + MockClient.return_value.is_confirmed.return_value = False + for _ in range(5): + client.post( + url, {"email": EMAIL, "list_id": LIST_ID}, HTTP_HX_REQUEST="true" + ) + response = client.post( + url, + {"email": EMAIL, "list_id": LIST_ID}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + assert b"too many" in response.content.lower() + + +@pytest.mark.django_db +def test_auth_quick_subscribe_rate_limited(client, user): + """POST /mailing-list/quick-subscribe/ — auth: 6th request within the window is rate-limited.""" + client.force_login(user) + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email"), patch( + "mailing_list.views.MailmanClient" + ) as MockClient: + MockClient.return_value.is_confirmed.return_value = False + for _ in range(5): + client.post( + url, {"email": EMAIL, "list_id": LIST_ID}, HTTP_HX_REQUEST="true" + ) + response = client.post( + url, + {"email": EMAIL, "list_id": LIST_ID}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + assert b"too many" in response.content.lower() + + +@pytest.mark.django_db +def test_staff_quick_subscribe_bypasses_rate_limit(client, db): + """POST /mailing-list/quick-subscribe/ — staff: never rate-limited regardless of request count.""" + staff = baker.make("users.User", is_staff=True) + client.force_login(staff) + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email"), patch( + "mailing_list.views.MailmanClient" + ) as MockClient: + MockClient.return_value.is_confirmed.return_value = False + for _ in range(10): + response = client.post( + url, {"email": EMAIL, "list_id": LIST_ID}, HTTP_HX_REQUEST="true" + ) + assert b"too many" not in response.content.lower() + + +# --------------------------------------------------------------------------- +# QuickSubscribeView — authenticated flow +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_auth_quick_subscribe_creates_pending_record(client, user): + """POST /mailing-list/quick-subscribe/ — auth: creates a PENDING subscription record.""" + client.force_login(user) + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email"): + response = client.post( + url, + {"email": EMAIL, "list_id": LIST_ID}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + sub = UserMailingListSubscription.objects.get(user=user, list_id=LIST_ID) + assert sub.status == SubscriptionStatus.PENDING + assert sub.email == EMAIL + + +@pytest.mark.django_db +def test_auth_quick_subscribe_already_pending_returns_pending_card(client, user): + """POST /mailing-list/quick-subscribe/ — auth: existing PENDING record returns pending card without re-sending email.""" + baker.make( + UserMailingListSubscription, + user=user, + list_id=LIST_ID, + email=EMAIL, + status=SubscriptionStatus.PENDING, + ) + client.force_login(user) + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email") as mock_send: + response = client.post( + url, + {"email": EMAIL, "list_id": LIST_ID}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + mock_send.assert_not_called() + assert b"pending" in response.content.lower() + + +@pytest.mark.django_db +def test_auth_quick_subscribe_already_active_returns_success_card(client, user): + """POST /mailing-list/quick-subscribe/ — auth: existing ACTIVE record returns success card without re-sending email.""" + baker.make( + UserMailingListSubscription, + user=user, + list_id=LIST_ID, + email=EMAIL, + status=SubscriptionStatus.ACTIVE, + ) + client.force_login(user) + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email") as mock_send: + response = client.post( + url, + {"email": EMAIL, "list_id": LIST_ID}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + mock_send.assert_not_called() + + +@pytest.mark.django_db +def test_auth_quick_subscribe_duplicate_email_returns_error(client, user, other_user): + """POST /mailing-list/quick-subscribe/ — auth: email already registered by another account returns error.""" + baker.make( + UserMailingListSubscription, + user=other_user, + list_id=LIST_ID, + email=EMAIL, + status=SubscriptionStatus.ACTIVE, + ) + client.force_login(user) + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email"): + response = client.post( + url, + {"email": EMAIL, "list_id": LIST_ID}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + assert b"already registered" in response.content.lower() + assert not UserMailingListSubscription.objects.filter(user=user).exists() + + +# --------------------------------------------------------------------------- +# ConfirmSubscriptionView +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_confirm_valid_token_subscribes_and_shows_success(client, user): + """GET /mailing-list/confirm// — valid token: calls Mailman, marks subscription ACTIVE.""" + baker.make( + UserMailingListSubscription, + user=user, + list_id=LIST_ID, + email=EMAIL, + status=SubscriptionStatus.PENDING, + ) + token = _make_token(EMAIL, [LIST_ID], user_id=user.pk) + url = reverse("mailing-list-confirm", args=[token]) + with patch("mailing_list.views.MailmanClient"): + response = client.get(url) + assert response.status_code == 200 + assert ( + UserMailingListSubscription.objects.get(user=user, list_id=LIST_ID).status + == SubscriptionStatus.ACTIVE + ) + + +@pytest.mark.django_db +def test_confirm_bad_token_shows_invalid_page(client): + """GET /mailing-list/confirm// — bad signature: returns 400 with invalid/expired message.""" + url = reverse("mailing-list-confirm", args=["not-a-real-token"]) + response = client.get(url) + assert response.status_code == 400 + assert ( + b"invalid" in response.content.lower() or b"expired" in response.content.lower() + ) + + +@pytest.mark.django_db +def test_confirm_unknown_user_shows_invalid_page_with_expiry_label(client): + """GET /mailing-list/confirm// — user_id not found: returns 400 with expiry duration in body.""" + token = _make_token(EMAIL, [LIST_ID], user_id=99999999) + url = reverse("mailing-list-confirm", args=[token]) + response = client.get(url) + assert response.status_code == 400 + assert ( + b"invalid" in response.content.lower() or b"expired" in response.content.lower() + ) + assert b"7" in response.content + + +@pytest.mark.django_db +def test_confirm_anonymous_token_subscribes_without_db_record(client): + """GET /mailing-list/confirm// — anonymous token: calls Mailman subscribe without touching DB.""" + token = _make_token(EMAIL, [LIST_ID]) + url = reverse("mailing-list-confirm", args=[token]) + with patch("mailing_list.views.MailmanClient") as MockClient: + response = client.get(url) + assert response.status_code == 200 + MockClient.return_value.subscribe.assert_called_once_with(EMAIL, LIST_ID) diff --git a/mailing_list/urls.py b/mailing_list/urls.py new file mode 100644 index 000000000..86e7a9311 --- /dev/null +++ b/mailing_list/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from mailing_list.views import ConfirmSubscriptionView +from mailing_list.views import QuickSubscribeView +from mailing_list.views import SubscribeView + +urlpatterns = [ + path("subscribe/", SubscribeView.as_view(), name="mailing-list-subscribe"), + path( + "quick-subscribe/", + QuickSubscribeView.as_view(), + name="mailing-list-quick-subscribe", + ), + path( + "confirm//", + ConfirmSubscriptionView.as_view(), + name="mailing-list-confirm", + ), +] diff --git a/mailing_list/views.py b/mailing_list/views.py index e69de29bb..f424b6fd1 100644 --- a/mailing_list/views.py +++ b/mailing_list/views.py @@ -0,0 +1,500 @@ +import datetime +import logging +from urllib.parse import urlencode, urlparse + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core import signing +from django.core.cache import cache +from django.db import IntegrityError, transaction +from django.core.mail import send_mail +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils import timezone +from django.utils.timesince import timesince +from django.views import View + +from mailing_list import constants +from mailing_list.client import MailmanAPIError +from mailing_list.client import MailmanClient +from mailing_list.constants import MAILING_LIST_LABELS +from mailing_list.models import SubscriptionStatus +from mailing_list.models import UserMailingListSubscription + +logger = logging.getLogger(__name__) + +_CONFIRM_SALT = "mailing-list-confirm-a40b24dc-a26d-49ca-81d1-5b2fccb5fd7b" +_CONFIRM_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds + + +def _get_list_prefix(list_id: str) -> str: + return list_id.split(".")[0] + + +def _is_htmx(request) -> bool: + return request.headers.get("HX-Request") == "true" + + +def _prg_redirect(request, **params) -> HttpResponseRedirect: + """Redirect back to the referring page, with optional query params for card state. + + Strips the referer to path-only to prevent open-redirect issues. + """ + referer = request.headers.get("referer", "/") + path = urlparse(referer).path or "/" + qs = urlencode({k: v for k, v in params.items() if v}) + return HttpResponseRedirect(f"{path}?{qs}" if qs else path) + + +def _format_duration(seconds: int) -> str: + now = timezone.now() + return timesince(now - datetime.timedelta(seconds=seconds), now, depth=1) + + +_SUBSCRIBE_RATE_LIMIT = 5 +_SUBSCRIBE_RATE_WINDOW = 3600 # seconds + + +def _get_client_ip(request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.META.get("REMOTE_ADDR", "") + + +def _is_privileged(user) -> bool: + return user.is_authenticated and (user.is_staff or user.is_superuser) + + +def _rate_limit_key(request) -> str: + if request.user.is_authenticated: + return f"ml_rate:user:{request.user.pk}" + return f"ml_rate:ip:{_get_client_ip(request)}" + + +def _is_rate_limited(request) -> bool: + if _is_privileged(request.user): + return False + key = _rate_limit_key(request) + cache.add(key, 0, timeout=_SUBSCRIBE_RATE_WINDOW) + return cache.incr(key) > _SUBSCRIBE_RATE_LIMIT + + +def _send_confirmation_email( + request, email: str, user_id: int | None, list_ids: list[str] +) -> None: + payload = {"email": email, "list_ids": list_ids} + if user_id is not None: + payload["user_id"] = user_id + + token = signing.dumps(payload, salt=_CONFIRM_SALT) + confirm_url = request.build_absolute_uri( + reverse("mailing-list-confirm", args=[token]) + ) + lists = [] + for lid in list_ids: + entry = MAILING_LIST_LABELS.get(_get_list_prefix(lid)) + if entry: + lists.append(entry) + else: + lists.append({"name": lid, "address": lid, "description": None}) + message = render_to_string( + "v3/mailing_list/email/confirm_subscription.txt", + { + "email": email, + "lists": lists, + "confirm_url": confirm_url, + "expiry_label": _format_duration(_CONFIRM_MAX_AGE), + }, + ) + send_mail( + subject="Confirm your Boost mailing list subscription", + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[email], + fail_silently=False, + ) + + +""" +This view is currently only being used in the demo page. This will later have to be adapted to be reused +in the multi-selection modals. +""" + + +class SubscribeView(LoginRequiredMixin, View): + def post(self, request): + email = request.POST.get("email", "").strip() + if not email: + return render( + request, + "v3/mailing_list/_subscribe_result.html", + {"error": "Email is required."}, + ) + + managed_lists = set(constants.MAILMAN_LISTS) + requested = set(request.POST.getlist("list_id")) & managed_lists + current = set( + UserMailingListSubscription.objects.filter( + user=request.user, list_id__in=managed_lists + ).values_list("list_id", flat=True) + ) + + to_subscribe = requested - current + to_unsubscribe = current - requested + + pending = [] + unsubscribed = [] + errors = [] + + for list_id in to_subscribe: + try: + with transaction.atomic(): + UserMailingListSubscription.objects.update_or_create( + user=request.user, + list_id=list_id, + defaults={"email": email, "status": SubscriptionStatus.PENDING}, + ) + except IntegrityError: + errors.append(list_id) + continue + pending.append(list_id) + + if pending: + try: + _send_confirmation_email(request, email, request.user.pk, pending) + except Exception as exc: + logger.error("Failed to send confirmation email to %s: %s", email, exc) + UserMailingListSubscription.objects.filter( + user=request.user, list_id__in=pending + ).delete() + return render( + request, + "v3/mailing_list/_subscribe_result.html", + {"error": "Could not send confirmation email. Please try again."}, + ) + + for list_id in to_unsubscribe: + sub = UserMailingListSubscription.objects.filter( + user=request.user, list_id=list_id + ).first() + if sub and sub.status == SubscriptionStatus.PENDING: + sub.delete() + unsubscribed.append(list_id) + continue + try: + MailmanClient().unsubscribe(email, list_id) + UserMailingListSubscription.objects.filter( + user=request.user, list_id=list_id + ).delete() + unsubscribed.append(list_id) + except MailmanAPIError as exc: + logger.error( + "Mailman unsubscribe error for %s/%s: %s", email, list_id, exc + ) + errors.append(list_id) + + return render( + request, + "v3/mailing_list/_subscribe_result.html", + { + "pending": pending, + "unsubscribed": unsubscribed, + "errors": errors, + "email": email, + }, + ) + + +""" +This will be partially deprecated once multi-selection modals are implemented. +Although multi-selection modals will take care of the sub/unsub logic, this view still shows how you'd +want to set the _mailing_list_card.html state to show the 'pending' or 'subscribed' status. +""" + + +class QuickSubscribeView(View): + """Subscribe to a single list. Works for both authenticated and anonymous users. + + Authenticated flow: tracks subscription state in UserMailingListSubscription. + Anonymous flow: stateless — sends a confirmation email and calls Mailman on confirm. + """ + + def _card(self, request, **ctx): + subscribe_url = reverse("mailing-list-quick-subscribe") + login_url = reverse("account_login") + return render( + request, + "v3/includes/_mailing_list_card.html", + { + "subscribe_url": subscribe_url, + "login_url": login_url, + "list_id": constants.MAILMAN_LISTS[0], + **ctx, + }, + ) + + def post(self, request): + email = request.POST.get("email", "").strip() + managed_lists = set(constants.MAILMAN_LISTS) + list_id = request.POST.get("list_id", "").strip() + + if not email: + if _is_htmx(request): + return self._card( + request, + state="error", + error_message="Email is required.", + list_id=list_id, + ) + return _prg_redirect( + request, ml_state="error", ml_error="Email is required." + ) + + if list_id not in managed_lists: + if _is_htmx(request): + return self._card( + request, + state="error", + error_message="Invalid mailing list.", + user_email=email, + list_id=list_id, + ) + return _prg_redirect( + request, + ml_state="error", + ml_error="Invalid mailing list.", + ml_email=email, + ) + + if _is_rate_limited(request): + if _is_htmx(request): + return self._card( + request, + state="error", + error_message="Too many attempts. Please try again later.", + user_email=email, + list_id=list_id, + ) + return _prg_redirect( + request, + ml_state="error", + ml_error="Too many attempts. Please try again later.", + ml_email=email, + ) + + if request.user.is_authenticated: + return self._handle_authenticated(request, email, list_id, managed_lists) + return self._handle_anonymous(request, email, list_id) + + def _handle_authenticated(self, request, email, list_id, managed_lists): + existing = UserMailingListSubscription.objects.filter( + user=request.user, list_id=list_id + ).first() + + manage_url = reverse("profile-account") + + if existing: + if existing.status == SubscriptionStatus.PENDING: + if _is_htmx(request): + return self._card( + request, + state="pending", + user_email=existing.email, + list_id=list_id, + manage_url=manage_url, + ) + return _prg_redirect(request) + subscription_count = UserMailingListSubscription.objects.filter( + user=request.user, list_id__in=managed_lists + ).count() + if _is_htmx(request): + return render( + request, + "v3/mailing_list/_subscribe_success_card.html", + { + "email": existing.email, + "subscription_count": subscription_count, + "manage_url": manage_url, + }, + ) + return _prg_redirect(request) + + try: + with transaction.atomic(): + UserMailingListSubscription.objects.create( + user=request.user, + list_id=list_id, + email=email, + status=SubscriptionStatus.PENDING, + ) + except IntegrityError: + if _is_htmx(request): + return self._card( + request, + state="error", + error_message="This email is already registered for this list by another account.", + user_email=email, + list_id=list_id, + ) + return _prg_redirect( + request, + ml_state="error", + ml_error="This email is already registered for this list by another account.", + ml_email=email, + ) + + try: + _send_confirmation_email(request, email, request.user.pk, [list_id]) + except Exception as exc: + logger.error( + "Failed to send confirmation email to %s...: %s", email[:3], exc + ) + UserMailingListSubscription.objects.filter( + user=request.user, list_id=list_id + ).delete() + if _is_htmx(request): + return self._card( + request, + state="error", + error_message="Could not send confirmation email. Please try again.", + user_email=email, + list_id=list_id, + ) + return _prg_redirect( + request, + ml_state="error", + ml_error="Could not send confirmation email. Please try again.", + ml_email=email, + ) + + if _is_htmx(request): + return self._card( + request, + state="pending", + user_email=email, + list_id=list_id, + manage_url=manage_url, + ) + return _prg_redirect(request) + + def _handle_anonymous(self, request, email, list_id): + try: + if MailmanClient().is_confirmed(email, list_id): + if _is_htmx(request): + return self._card( + request, + state="error", + error_message=f"{email} is already subscribed to this list.", + user_email=email, + list_id=list_id, + ) + return _prg_redirect( + request, + ml_state="error", + ml_error=f"{email} is already subscribed to this list.", + ml_email=email, + ) + except MailmanAPIError: + pass # can't determine — proceed and let Mailman handle it on confirm + + try: + _send_confirmation_email(request, email, None, [list_id]) + except Exception as exc: + logger.error("Failed to send confirmation email to %s: %s", email, exc) + if _is_htmx(request): + return self._card( + request, + state="error", + error_message="Could not send confirmation email. Please try again.", + user_email=email, + list_id=list_id, + ) + return _prg_redirect( + request, + ml_state="error", + ml_error="Could not send confirmation email. Please try again.", + ml_email=email, + ) + + if _is_htmx(request): + return self._card( + request, state="pending", user_email=email, list_id=list_id + ) + return _prg_redirect(request, ml_state="pending", ml_email=email) + + +class ConfirmSubscriptionView(View): + def get(self, request, token): + try: + data = signing.loads(token, salt=_CONFIRM_SALT, max_age=_CONFIRM_MAX_AGE) + except signing.BadSignature: + return render( + request, + "v3/mailing_list/confirm_invalid.html", + { + "home_url": "/", + "expiry_label": _format_duration(_CONFIRM_MAX_AGE), + }, + status=400, + ) + + email = data.get("email") + list_ids = data.get("list_ids", []) + user_id = data.get("user_id") # absent for anonymous subscriptions + + user = None + if user_id is not None: + User = get_user_model() + try: + user = User.objects.get(pk=user_id) + except User.DoesNotExist: + return render( + request, + "v3/mailing_list/confirm_invalid.html", + { + "home_url": "/", + "expiry_label": _format_duration(_CONFIRM_MAX_AGE), + }, + status=400, + ) + + confirmed = [] + errors = [] + + for list_id in list_ids: + try: + MailmanClient().subscribe(email, list_id) + if user is not None: + UserMailingListSubscription.objects.filter( + user=user, list_id=list_id + ).update(status=SubscriptionStatus.ACTIVE) + confirmed.append(list_id) + except MailmanAPIError as exc: + logger.error( + "Mailman subscribe error during confirmation for %s/%s: %s", + email, + list_id, + exc, + ) + errors.append(list_id) + + def _label(list_id): + entry = MAILING_LIST_LABELS.get(_get_list_prefix(list_id)) + if entry: + return {"name": entry["name"], "address": entry["address"]} + return {"name": list_id, "address": None} + + return render( + request, + "v3/mailing_list/confirm_success.html", + { + "email": email, + "confirmed": [_label(lid) for lid in confirmed], + "errors": [_label(lid) for lid in errors], + "home_url": "/", + }, + ) diff --git a/scripts/dev-mailman-helpers b/scripts/dev-mailman-helpers new file mode 100755 index 000000000..4b7d92cde --- /dev/null +++ b/scripts/dev-mailman-helpers @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# dev-mailman-helpers — local development helpers for the Mailman Core REST API. +# +# Run the script and pick an action from the fzf menu: +# +# list lists — print all mailing lists on the server +# list members — pick a mailing list and print its member roster (JSON) +# create lists — create the lists defined in MAILMAN_LISTS (from .env) +# delete lists — pick one or more lists to delete (fzf multi-select) +# +# Environment variables (loaded from ../.env if present): +# MAILMAN_DEV_API_URL Host-side URL of the Mailman Core REST API +# (default: http://localhost:8001 — the mapped container port) +# MAILMAN_REST_API_USER REST API username (default: restadmin) +# MAILMAN_REST_API_PASS REST API password (default: restpass) +# MAILMAN_LISTS Comma-separated list IDs to create +# e.g. boost-users.lists.boost.org,boost.lists.boost.org + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Load .env from the project root (one level up from this script) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi + +# MAILMAN_REST_API_URL is used by Django inside Docker (mailman-core hostname). +# MAILMAN_DEV_API_URL overrides the URL for this host-side script; defaults to +# localhost:8001 which is the port mapped from the mailman-core container. +URL="${MAILMAN_DEV_API_URL:-http://localhost:8001}/3.1" +API_USER="${MAILMAN_REST_API_USER:-restadmin}" +API_PASS="${MAILMAN_REST_API_PASS:-restpass}" + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +_fetch_list_ids() { + curl -s -u "$API_USER:$API_PASS" "$URL/lists" \ + | python3 -c "import sys,json; [print(e['list_id']) for e in json.loads(sys.stdin.read() or '{}').get('entries', [])]" +} + +# Convert a list_id (foo.lists.example.org) to an FQDN address (foo@lists.example.org) +_list_id_to_fqdn() { + echo "$1" | sed 's/\./@/' +} + +# --------------------------------------------------------------------------- +# Actions +# --------------------------------------------------------------------------- + +action_list_lists() { + lists=$(_fetch_list_ids) || true + if [[ -z "$lists" ]]; then + echo "No lists found at $URL" >&2 + exit 1 + fi + echo "$lists" +} + +action_list_members() { + lists=$(_fetch_list_ids) || true + if [[ -z "$lists" ]]; then + echo "No lists found at $URL" >&2 + exit 1 + fi + + list=$(echo "$lists" | fzf --prompt="Select a list: " --height=10 --border) || true + [[ -z "$list" ]] && return + + curl -s -u "$API_USER:$API_PASS" "$URL/lists/$list/roster/member" \ + | python3 -m json.tool +} + +action_create_lists() { + if [[ -z "${MAILMAN_LISTS:-}" ]]; then + echo "MAILMAN_LISTS is not set in .env — nothing to create." >&2 + exit 1 + fi + + IFS=',' read -ra list_ids <<< "$MAILMAN_LISTS" + for list_id in "${list_ids[@]}"; do + fqdn=$(_list_id_to_fqdn "$list_id") + domain="${list_id#*.}" # Extracts the domain (everything after the first dot) + + echo "Ensuring domain $domain exists..." + domain_http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + -u "$API_USER:$API_PASS" \ + -X POST "$URL/domains" \ + -d "mail_host=$domain") + + if [[ "$domain_http_code" == "201" ]]; then + echo " [created] domain $domain" + elif [[ "$domain_http_code" == "400" ]]; then + # Mailman returns 400 if the domain already exists, which is safe to ignore here + echo " [exists] domain $domain" + else + echo " [failed] domain $domain (HTTP $domain_http_code)" >&2 + fi + + echo "Creating list $fqdn ..." + response_file=$(mktemp) + http_code=$(curl -s -o "$response_file" -w "%{http_code}" \ + -u "$API_USER:$API_PASS" \ + -X POST "$URL/lists" \ + -d "fqdn_listname=$fqdn") + response_body=$(cat "$response_file") + rm -f "$response_file" + + case "$http_code" in + 201) echo " [ok] $fqdn" ;; + 409) echo " [exists] $fqdn" ;; + *) + echo " [failed] $fqdn (HTTP $http_code)" >&2 + if [[ -n "$response_body" ]]; then + echo " Response body:" >&2 + echo "$response_body" >&2 + fi + ;; + esac + done +} + +action_confirm_pending() { + all_pending="" + for list_id in $(_fetch_list_ids); do + pending=$(curl -s -u "$API_USER:$API_PASS" "$URL/lists/$list_id/requests" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for e in data.get('entries', []): + if e.get('type') == 'subscription': + print(e['token'] + ' ' + e['email'] + ' (' + e['list_id'] + ')') +" 2>/dev/null) || true + [[ -n "$pending" ]] && all_pending+="$pending"$'\n' + done + + if [[ -z "$all_pending" ]]; then + echo "No pending subscription requests found." >&2 + return + fi + + selected=$(echo "$all_pending" \ + | fzf --prompt="Select requests to confirm (TAB for multi-select): " \ + --height=15 --border --multi) || true + [[ -z "$selected" ]] && return + + while IFS= read -r line; do + token=$(echo "$line" | awk '{print $1}') + list_id=$(echo "$line" | grep -oP '\(\K[^)]+') + echo "Confirming $token on $list_id ..." + http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST -u "$API_USER:$API_PASS" \ + -d "action=accept" \ + "$URL/lists/$list_id/requests/$token") + case "$http_code" in + 204) echo " [confirmed] $token" ;; + 404) echo " [not found] $token" ;; + *) echo " [failed] $token (HTTP $http_code)" ;; + esac + done <<< "$selected" +} + +action_delete_lists() { + lists=$(_fetch_list_ids) || true + if [[ -z "$lists" ]]; then + echo "No lists found at $URL" >&2 + exit 1 + fi + + selected=$(echo "$lists" \ + | fzf --prompt="Select lists to delete (TAB for multi-select): " \ + --height=15 --border --multi) || true + [[ -z "$selected" ]] && return + + while IFS= read -r list_id; do + echo "Deleting $list_id ..." + http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + -u "$API_USER:$API_PASS" \ + -X DELETE "$URL/lists/$list_id") + case "$http_code" in + 204) echo " [deleted] $list_id" ;; + 404) echo " [not found] $list_id" ;; + *) echo " [failed] $list_id (HTTP $http_code)" ;; + esac + done <<< "$selected" +} + +# --------------------------------------------------------------------------- +# Main menu +# --------------------------------------------------------------------------- + +action=$(printf "list lists\nlist members\ncreate lists\ndelete lists\nconfirm pending" \ + | fzf --prompt="Action: " --height=10 --border --no-sort) || true + +case "$action" in + "list lists") action_list_lists ;; + "list members") action_list_members ;; + "create lists") action_create_lists ;; + "delete lists") action_delete_lists ;; + "confirm pending") action_confirm_pending ;; + *) exit 0 ;; +esac diff --git a/static/css/v3/components.css b/static/css/v3/components.css index a826debe3..290733966 100644 --- a/static/css/v3/components.css +++ b/static/css/v3/components.css @@ -59,3 +59,4 @@ @import "./community-card.css"; @import "./mailing-list-activity-card.css"; @import "./user-profile-bio-card.css"; +@import "./mailing-list-card.css"; diff --git a/static/css/v3/mailing-list-card.css b/static/css/v3/mailing-list-card.css new file mode 100644 index 000000000..01dda7414 --- /dev/null +++ b/static/css/v3/mailing-list-card.css @@ -0,0 +1,81 @@ +/* Form flex layout — creates gap between content column and CTA section */ +.mailing-list-card__form { + display: flex; + flex-direction: column; + width: 100%; + gap: var(--space-large); +} + +/* Loading / form-content visibility toggle via HTMX htmx-request class */ +.mailing-list-card__loading { + display: none; +} + +form.htmx-request .mailing-list-card__loading { + display: flex; +} + +form.htmx-request .mailing-list-card__form-content { + display: none; +} + +/* Spinner — 34×34 border animation matching Figma design */ +.mailing-list-card__spinner { + width: 34px; + height: 34px; + border-radius: 50%; + border: 4px solid var(--color-surface-strong); + border-top-color: var(--color-surface-brand-accent-default); + animation: mailing-list-card-spin 0.7s linear infinite; + align-self: center; +} + +@keyframes mailing-list-card-spin { + to { + transform: rotate(360deg); + } +} + +/* Subscribed/Pending card header — title + inline status badge */ +.mailing-list-card__header--subscribed { + align-items: center; + gap: var(--space-s); +} + +.mailing-list-card__badge { + display: inline-flex; + align-items: center; + padding: var(--space-default); + border-radius: var(--border-radius-s); + background-color: var(--color-surface-brand-accent-default); + color: var(--color-text-primary); + font-family: var(--font-sans); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-regular); + line-height: 100%; + letter-spacing: -1%; + white-space: nowrap; + flex-shrink: 0; +} + +.mailing-list-card__badge--pending { + background-color: var(--color-surface-brand-accent-default); +} + +/* "Already subscribed" plain-text state */ +.mailing-list-card__subscribed { + color: var(--color-text-primary); + font-size: var(--font-size-small); + margin: 0; +} + +/* Unauthenticated login prompt */ +.mailing-list-card__login-prompt { + font-size: var(--font-size-small); + color: var(--color-text-secondary); + margin: 0; +} + +.mailing-list-card__login-link { + color: var(--color-text-link-accent); +} diff --git a/static/css/v3/mailing-list-confirm.css b/static/css/v3/mailing-list-confirm.css new file mode 100644 index 000000000..bff2fc558 --- /dev/null +++ b/static/css/v3/mailing-list-confirm.css @@ -0,0 +1,158 @@ +.mailing-list-confirm { + min-height: 80vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-xl) var(--space-large); + background: var(--color-surface-page); +} + +.mailing-list-confirm__inner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--space-xlarge); + max-width: 560px; + width: 100%; +} + +.mailing-list-confirm__icon-wrap { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 50%; + flex-shrink: 0; +} + +.mailing-list-confirm__icon-wrap--success { + background: var(--color-surface-weak-accent-teal); +} + +.mailing-list-confirm__icon-wrap--error { + background: var(--color-surface-error-weak); +} + +.mailing-list-confirm__icon { + width: 28px; + height: 28px; +} + +.mailing-list-confirm__icon--success { + color: var(--color-stroke-brand-accent); +} + +.mailing-list-confirm__icon--error { + color: var(--color-text-error); +} + +.mailing-list-confirm__header { + display: flex; + flex-direction: column; + gap: var(--space-medium); +} + +.mailing-list-confirm__title { + font-family: var(--font-display); + font-size: var(--font-size-xl); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + letter-spacing: var(--letter-spacing-display-regular); + line-height: var(--line-height-default); + margin: 0; +} + +.mailing-list-confirm__body { + font-family: var(--font-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + color: var(--color-text-secondary); + line-height: var(--line-height-loose); + margin: 0; +} + +.mailing-list-confirm__email { + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); +} + +.mailing-list-confirm__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-default); + width: 100%; + text-align: left; +} + +.mailing-list-confirm__list-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-xs); + padding: var(--space-medium) var(--space-large); + border: 1px solid var(--color-stroke-weak); + border-radius: var(--border-radius-l); + background: var(--color-surface-weak); +} + +.mailing-list-confirm__list-name { + font-family: var(--font-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + line-height: var(--line-height-default); +} + +.mailing-list-confirm__list-address { + font-family: var(--font-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + color: var(--color-text-tertiary); + line-height: var(--line-height-default); +} + +.mailing-list-confirm__list-item--error .mailing-list-confirm__list-name { + color: var(--color-text-error); +} + +.mailing-list-confirm__list-item--error .mailing-list-confirm__list-address { + color: var(--color-text-error); + opacity: 0.75; +} + +.mailing-list-confirm__list-item--error { + border-color: var(--color-stroke-error); + background: var(--color-surface-error-weak); + color: var(--color-text-error); +} + +.mailing-list-confirm__section-label { + font-family: var(--font-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-tight); + margin: 0; + text-align: left; + width: 100%; +} + +.mailing-list-confirm__section-label--error { + color: var(--color-text-error); +} + +@media (max-width: 767px) { + .mailing-list-confirm__title { + font-size: var(--font-size-large); + } + + .mailing-list-confirm__inner { + gap: var(--space-xl); + } +} diff --git a/static/css/v3/v3-examples-section.css b/static/css/v3/v3-examples-section.css index 78c5b2b7a..0adabaf5e 100644 --- a/static/css/v3/v3-examples-section.css +++ b/static/css/v3/v3-examples-section.css @@ -384,3 +384,20 @@ html.dark .v3-examples-section__theme-toggle-moon { .code-block-story-overflow-example { height: 300px; } + +/* Subscribe result fragment */ +.subscribe-result__message { + font-size: var(--font-size-base); + padding: var(--space-default); + border-radius: 4px; +} + +.subscribe-result__message--success { + color: var(--color-text-primary); + background-color: var(--color-surface-weak); +} + +.subscribe-result__message--error { + color: var(--color-text-error); + background-color: var(--color-surface-error-weak); +} diff --git a/templates/v3/community.html b/templates/v3/community.html index bcf970495..7925c6d9f 100644 --- a/templates/v3/community.html +++ b/templates/v3/community.html @@ -38,7 +38,7 @@ {% include "v3/includes/_post_card.html" with heading="Posts from the Boost community" items=posts variant="card" theme="teal" primary_cta_label="View all posts" primary_cta_url=news_url %}
- {% include "v3/includes/_mailing_list_card.html" %} + {% include "v3/includes/_mailing_list_card.html" with subscribe_url=mailing_list_card_subscribe_url list_id=mailing_list_card_list_id state=mailing_list_card_state user_email=mailing_list_card_user_email manage_url=mailing_list_card_manage_url subscription_count=mailing_list_card_subscription_count error_message=mailing_list_card_error_message %}
diff --git a/templates/v3/examples/_v3_example_section.html b/templates/v3/examples/_v3_example_section.html index 1f8849957..f8f52ae11 100644 --- a/templates/v3/examples/_v3_example_section.html +++ b/templates/v3/examples/_v3_example_section.html @@ -617,7 +617,7 @@

{{ section_title }}

{% with section_title="Mailing List Card" %}

{{ section_title }}

-
{% include "v3/includes/_mailing_list_card.html" %}
+
{% include "v3/includes/_mailing_list_card.html" with subscribe_url=demo_quick_subscribe_url list_id=demo_mailing_list_card_list_id state=demo_mailing_list_card_state user_email=demo_subscribed_lists_email subscription_count=demo_subscription_count %}
{% endwith %} @@ -960,6 +960,41 @@

{{ section_title }}

{% endwith %} + {% with section_title="Mailing List Subscribe (live)" %} +
+

{{ section_title }}

+ This card is intended for testing purposes only. It'll not be used in any part of the production website. +
+ Input an email below and select the checkboxes of the mailing lists you wanna subscribe to. Leaving it unchecked and hitting "save" will unsubscribe that email from the unchecked lists. +
+
+ {% csrf_token %} + {% include "v3/includes/_field_text.html" with name="email" label="Email" type="email" placeholder="you@example.com" value=request.user.email required="true" validate_type="email" validate_error="Enter a valid email address." %} +
+ Lists + {% for list_id in demo_mailman_lists %} + + {% endfor %} +
+ {% include "v3/includes/_button.html" with type="submit" label="Save" style="primary" only %} +
+
+
+
+ {% endwith %} + {% comment %} Position Fixed dark mode toggle for quick theme switching without scrolling to the header. {% endcomment %} diff --git a/templates/v3/includes/_field_text.html b/templates/v3/includes/_field_text.html index 520914c10..bd33419fa 100644 --- a/templates/v3/includes/_field_text.html +++ b/templates/v3/includes/_field_text.html @@ -87,7 +87,7 @@ {% elif icon_right %} diff --git a/templates/v3/includes/_mailing_list_card.html b/templates/v3/includes/_mailing_list_card.html index c2d5904e1..2f072e0af 100644 --- a/templates/v3/includes/_mailing_list_card.html +++ b/templates/v3/includes/_mailing_list_card.html @@ -1,12 +1,68 @@ -{% comment %}Card with an input to sign up for the mailing list{% endcomment %} -
-
- Join the Boost Developers Mailing List +{% comment %} +Card with a subscribe form for a single mailing list. +Variables: + subscribe_url — (required) POST endpoint; build via request.build_absolute_uri(reverse("mailing-list-quick-subscribe")) + list_id — (optional) mailing list ID; defaults to "boost.lists.boost.org" + state — "active" | "pending" | "error" | None + "active" — user is subscribed; shows success card + "pending" — confirmation email sent; shows pending card + "error" — form submission failed; shows form with error_message + None — default; shows subscribe form + user_email — (optional) email used in the subscription + manage_url — (optional) URL for managing subscriptions; shown in success/pending card + subscription_count — (optional) number of lists subscribed to; shown in success card + error_message — error text shown when state="error" +{% endcomment %} +{% if state == "active" %} + {% include "v3/mailing_list/_subscribe_success_card.html" with email=user_email manage_url=manage_url subscription_count=subscription_count only %} +{% elif state == "pending" %} +
+
+ Manage your Mailing list + Pending +
+
+
+ We've sent you an email — please confirm to verify ownership of this address and activate your subscription. +
+ {% if manage_url %} +
+ {% include "v3/includes/_button.html" with url=manage_url label="Manage your lists →" only %} +
+ {% endif %}
-
-
- Get the latest on releases, features, security patches, fixes and all major announcements. - {% include 'v3/includes/_field_text.html' with name='mailing_email' placeholder='Email Address' required='True' type='email' submit_icon='arrow-right' %} +{% else %} +
+
+ Join the Boost Developers Mailing List +
+
+
+ {% csrf_token %} + +
+ Get the latest on releases, features, security patches, fixes and all major announcements. +
+
+
+ Get the latest on releases, features, security patches, fixes and all major announcements. + {% if state == "error" %} + {% include 'v3/includes/_field_text.html' with name='email' placeholder='Email Address' value=user_email required='True' type='email' submit_icon='arrow-right' validate_type='email' validate_error='Please enter a valid email address.' error=error_message only %} + {% else %} + {% include 'v3/includes/_field_text.html' with name='email' placeholder='Email Address' value=user_email required='True' type='email' submit_icon='arrow-right' validate_type='email' validate_error='Please enter a valid email address.' only %} + {% endif %} +
+
+ {% include "v3/includes/_button.html" with type="submit" label="Subscribe" only %} +
+
-
{% include "v3/includes/_button.html" with type="submit" label="Subscribe" only %}
-
+{% endif %} diff --git a/templates/v3/learn_page.html b/templates/v3/learn_page.html index f45b32e66..f3281fb0a 100644 --- a/templates/v3/learn_page.html +++ b/templates/v3/learn_page.html @@ -49,7 +49,7 @@

Start anywhere. Learn everything. Build anything.

{% endwith %}
- {% include 'v3/includes/_mailing_list_card.html' %} + {% include 'v3/includes/_mailing_list_card.html' with subscribe_url=mailing_list_card_subscribe_url list_id=mailing_list_card_list_id state=mailing_list_card_state user_email=mailing_list_card_user_email manage_url=mailing_list_card_manage_url subscription_count=mailing_list_card_subscription_count error_message=mailing_list_card_error_message %}
diff --git a/templates/v3/libraries/library-subpage.html b/templates/v3/libraries/library-subpage.html index 4ab0b69e7..0538e9b89 100644 --- a/templates/v3/libraries/library-subpage.html +++ b/templates/v3/libraries/library-subpage.html @@ -53,7 +53,7 @@ {% include "v3/includes/_post_card.html" with heading="Latest "|add:object.display_name|add:" posts" items=library_posts variant="card" theme="teal" layout="horizontal" primary_cta_label="View all posts" primary_cta_url="/news/" %}
- {% include "v3/includes/_mailing_list_card.html" %} + {% include "v3/includes/_mailing_list_card.html" with subscribe_url=mailing_list_card_subscribe_url list_id=mailing_list_card_list_id state=mailing_list_card_state user_email=mailing_list_card_user_email manage_url=mailing_list_card_manage_url subscription_count=mailing_list_card_subscription_count error_message=mailing_list_card_error_message %}
diff --git a/templates/v3/mailing_list/_subscribe_result.html b/templates/v3/mailing_list/_subscribe_result.html new file mode 100644 index 000000000..f355e8adb --- /dev/null +++ b/templates/v3/mailing_list/_subscribe_result.html @@ -0,0 +1,29 @@ +{% comment %}HTMX response fragment for SubscribeView{% endcomment %} + diff --git a/templates/v3/mailing_list/_subscribe_success_card.html b/templates/v3/mailing_list/_subscribe_success_card.html new file mode 100644 index 000000000..023fabeee --- /dev/null +++ b/templates/v3/mailing_list/_subscribe_success_card.html @@ -0,0 +1,16 @@ +{% comment %}HTMX outerHTML swap response rendered by QuickSubscribeView on success.{% endcomment %} +
+
+ Manage your Mailing list + Subscribed +
+
+
+ + You're subscribed with {{ email }} to {{ subscription_count }} list{{ subscription_count|pluralize }}. + +
+
+ {% include "v3/includes/_button.html" with url=manage_url label="Manage your lists →" only %} +
+
diff --git a/templates/v3/mailing_list/confirm_invalid.html b/templates/v3/mailing_list/confirm_invalid.html new file mode 100644 index 000000000..b691315d3 --- /dev/null +++ b/templates/v3/mailing_list/confirm_invalid.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% load static %} + +{% block extra_head %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+ + + +
+

Invalid or expired link

+

+ This confirmation link is invalid or has expired. + Links expire after {{ expiry_label }} — please subscribe again to receive a new confirmation email. +

+
+ + {% include "v3/includes/_button.html" with url=home_url label="Go to homepage" only %} + +
+
+{% endblock %} diff --git a/templates/v3/mailing_list/confirm_success.html b/templates/v3/mailing_list/confirm_success.html new file mode 100644 index 000000000..3786353ce --- /dev/null +++ b/templates/v3/mailing_list/confirm_success.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% load static %} + +{% block extra_head %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+ + + + {% if confirmed %} +
+

Subscription confirmed

+

+ + has been subscribed to the following list{{ confirmed|length|pluralize }}. +

+
+ +
    + {% for item in confirmed %} +
  • + {{ item.name }} + {% if item.address %}{{ item.address }}{% endif %} +
  • + {% endfor %} +
+ {% endif %} + + {% if errors %} + +
    + {% for item in errors %} +
  • + {{ item.name }} + {% if item.address %}{{ item.address }}{% endif %} +
  • + {% endfor %} +
+ {% endif %} + + {% include "v3/includes/_button.html" with url=home_url label="Go to homepage" only %} + +
+
+{% endblock %} diff --git a/templates/v3/mailing_list/email/confirm_subscription.txt b/templates/v3/mailing_list/email/confirm_subscription.txt new file mode 100644 index 000000000..cae0e591f --- /dev/null +++ b/templates/v3/mailing_list/email/confirm_subscription.txt @@ -0,0 +1,17 @@ +Hi, + +You requested to subscribe {{ email }} to the following Boost mailing list{{ lists|length|pluralize }}: +{% for item in lists %} +------------------------------------------------------------ +{{ item.name }} — {{ item.address }} +{% if item.description %} +{{ item.description }} +{% endif %}{% endfor %}------------------------------------------------------------ + +To confirm your subscription, click the link below: + + {{ confirm_url }} + +This link expires in {{ expiry_label }}. + +If you did not request this, you can safely ignore this email. diff --git a/templates/v3/release_detail.html b/templates/v3/release_detail.html index 4614a5d3c..0aef23697 100644 --- a/templates/v3/release_detail.html +++ b/templates/v3/release_detail.html @@ -34,7 +34,7 @@ {% endif %}
- {% include "v3/includes/_mailing_list_card.html" %} + {% include "v3/includes/_mailing_list_card.html" with subscribe_url=mailing_list_card_subscribe_url list_id=mailing_list_card_list_id state=mailing_list_card_state user_email=mailing_list_card_user_email manage_url=mailing_list_card_manage_url subscription_count=mailing_list_card_subscription_count error_message=mailing_list_card_error_message %} {% include "v3/includes/_basic_card.html" with title="Want to fix bugs, review code, maintain libraries, and propose new features?" text="Read our contributors guide and join the Boost community." primary_button_url="/contribute" primary_button_label="Start here" primary_button_style="green" theme="green" only %} {% large_static 'img/v3/home-page/heros.png' as image_url %} diff --git a/versions/views.py b/versions/views.py index 43c57aee4..40b5839d9 100755 --- a/versions/views.py +++ b/versions/views.py @@ -21,6 +21,7 @@ from waffle import flag_is_active from core.mixins import V3Mixin +from mailing_list.mixins import MailingListCardMixin from core.models import RenderedContent from libraries.constants import LATEST_RELEASE_URL_PATH_STR from libraries.mixins import VersionAlertMixin, BoostVersionMixin @@ -61,7 +62,9 @@ def set_version(request): @method_decorator(csrf_exempt, name="dispatch") -class VersionDetail(V3Mixin, BoostVersionMixin, VersionAlertMixin, DetailView): +class VersionDetail( + MailingListCardMixin, V3Mixin, BoostVersionMixin, VersionAlertMixin, DetailView +): """Web display of list of Versions""" model = Version