From 2e512df57a934deff587a4bfed0e98655663aaed Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 12 May 2026 12:31:06 -0300 Subject: [PATCH 01/18] docker: enable mailman-core and mailman-web services feat: add Mailman REST API client and config settings feat: add UserMailingListSubscription model feat: add mailing list subscribe/unsubscribe view and route feat: add subscribe result HTMX fragment and styles chore: add mailing list subscribe demo to component demo view chore: add mailman-members dev helper script fix: mailing list name chore: rename script feat: improve test script fix: hostname feat: wire up mailing list card feat: show success susbcribe card chore: request email verification feat: discard pending subscriptions when user unsub before verification feat: add email field to subscription model feat: add status field to subscription model feat: add pending logs to demo component feat: improve dev mm script feat: add boost owned email validation flow feat: Allow anonymous subscriptions and enhance pending state UI fix: csrf token injection fix: styles chore: improve subscription confirmation pages styles feat: improve subscription confirmation styles fix: centralize _CONFIRM_MAX_AGE value feat: improve email templates fix: font size fix: change doc chore: remove unused login url chore: add dev note to mailing list example card chore: update salt and add dev notes chore: centralize and improve subscription state logic fix: typography styles feat: no-js fix: guard mailing_list_state access and remove debug print fix: correct MAILMAN_LISTS defaults to use full list IDs fix: remove redundant buttons.css import and use _button.html in confirm templates fix: pass expiry_label to confirm_invalid when user not found fix: narrow exception handling in email sending to SMTPException and OSError fix: add novalidate and client-side email validation to mailing list card fix: replace build_absolute_uri with reverse for internal URLs revert: keep broad Exception catch in email sending feat: purge expired pending subscriptions via Celery beat feat: rate-limit anonymous subscription attempts per IP fix: add (list_id, email) uniqueness constraint to prevent duplicate subscriptions test: add view and task tests for mailing list subscription flows refactor: apply subscribe rate limit to all users, exempt staff and superusers fix: wrap IntegrityError catches in transaction.atomic savepoints --- config/celery.py | 6 + config/settings.py | 9 + config/urls.py | 1 + core/views.py | 45 +- docker-compose.yml | 72 +-- env.template | 6 + libraries/views.py | 8 +- mailing_list/client.py | 131 +++++ mailing_list/constants.py | 33 ++ .../0007_usermailinglistsubscription.py | 52 ++ mailing_list/mixins.py | 90 ++++ mailing_list/models.py | 28 + mailing_list/tasks.py | 16 + mailing_list/tests/test_tasks.py | 61 +++ mailing_list/tests/test_views.py | 273 ++++++++++ mailing_list/urls.py | 19 + mailing_list/views.py | 490 ++++++++++++++++++ scripts/dev-mailman-helpers | 182 +++++++ static/css/v3/components.css | 1 + static/css/v3/mailing-list-card.css | 81 +++ static/css/v3/mailing-list-confirm.css | 158 ++++++ static/css/v3/v3-examples-section.css | 17 + templates/v3/community.html | 2 +- .../v3/examples/_v3_example_section.html | 37 +- templates/v3/includes/_field_text.html | 2 +- templates/v3/includes/_mailing_list_card.html | 76 ++- templates/v3/learn_page.html | 2 +- templates/v3/libraries/library-subpage.html | 2 +- .../v3/mailing_list/_subscribe_result.html | 29 ++ .../mailing_list/_subscribe_success_card.html | 16 + .../v3/mailing_list/confirm_invalid.html | 33 ++ .../v3/mailing_list/confirm_success.html | 54 ++ .../email/confirm_subscription.txt | 17 + templates/v3/release_detail.html | 2 +- versions/views.py | 5 +- 35 files changed, 2000 insertions(+), 56 deletions(-) create mode 100644 mailing_list/client.py create mode 100644 mailing_list/migrations/0007_usermailinglistsubscription.py create mode 100644 mailing_list/mixins.py create mode 100644 mailing_list/tests/test_tasks.py create mode 100644 mailing_list/tests/test_views.py create mode 100644 mailing_list/urls.py create mode 100755 scripts/dev-mailman-helpers create mode 100644 static/css/v3/mailing-list-card.css create mode 100644 static/css/v3/mailing-list-confirm.css create mode 100644 templates/v3/mailing_list/_subscribe_result.html create mode 100644 templates/v3/mailing_list/_subscribe_success_card.html create mode 100644 templates/v3/mailing_list/confirm_invalid.html create mode 100644 templates/v3/mailing_list/confirm_success.html create mode 100644 templates/v3/mailing_list/email/confirm_subscription.txt 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..e70bccd65 100755 --- a/config/settings.py +++ b/config/settings.py @@ -381,8 +381,17 @@ # Mailman API credentials MAILMAN_REST_API_URL = env("MAILMAN_REST_API_URL", default="http://localhost:8001") +MAILMAN_REST_API_PATH = env("MAILMAN_REST_API_PATH", 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") +MAILMAN_LISTS = env.list( + "MAILMAN_LISTS", + default=[ + "boost-users.lists.boost.org", + "boost-announce.lists.boost.org", + "boost.lists.boost.org", + ], +) # Fastly API credentials FASTLY_SERVICE = env("FASTLY_SERVICE", default="empty") 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..4aaf7d9f0 100644 --- a/core/views.py +++ b/core/views.py @@ -46,6 +46,10 @@ 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 +133,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 +502,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): @@ -1463,6 +1467,7 @@ def get_context_data(self, **kwargs): from libraries.utils import ( patch_commit_authors, ) + from django.conf import settings as _settings CODE_DEMO_BEAST = """int main() { @@ -2237,6 +2242,42 @@ 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 = "boost.lists.boost.org" + context["demo_mailman_lists"] = _settings.MAILMAN_LISTS + context["demo_subscribe_url"] = self.request.build_absolute_uri( + reverse("mailing-list-subscribe") + ) + context["demo_quick_subscribe_url"] = self.request.build_absolute_uri( + 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, _settings.MAILMAN_LISTS + ) + context["demo_subscribed_lists"] = set( + UserMailingListSubscription.objects.filter( + user=self.request.user, list_id__in=_settings.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[0] if mailing_list_state else None + ) + context["demo_subscription_count"] = ( + mailing_list_state[1] if mailing_list_state else 0 + ) + context["demo_subscribed_lists_email"] = ( + mailing_list_state[2] 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..43d8d63d9 --- /dev/null +++ b/mailing_list/client.py @@ -0,0 +1,131 @@ +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class MailmanAPIError(Exception): + pass + + +def _base_url() -> str: + return settings.MAILMAN_REST_API_URL.rstrip("/") + settings.MAILMAN_REST_API_PATH + + +def _auth() -> tuple[str, str]: + return (settings.MAILMAN_REST_API_USER, settings.MAILMAN_REST_API_PASS) + + +def subscribe(email: str, list_id: str) -> None: + """POST /3.1/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"{_base_url()}/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=_auth(), 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(email: str, list_id: str) -> bool: + """Return True if the email is a confirmed (active) member of the list.""" + url = f"{_base_url()}/lists/{list_id}/member/{email}" + try: + response = requests.get(url, auth=_auth(), 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(email: str, list_id: str) -> None: + """Discard any pending (unconfirmed) subscription request for email on list_id.""" + url = f"{_base_url()}/lists/{list_id}/requests" + try: + response = requests.get(url, auth=_auth(), 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"{_base_url()}/lists/{list_id}/requests/{token}" + try: + requests.post(discard_url, data={"action": "discard"}, auth=_auth(), timeout=10) + except requests.RequestException as exc: + raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc + + +def unsubscribe(email: str, list_id: str) -> None: + """DELETE /3.1/members/ — remove a subscription.""" + url = f"{_base_url()}/lists/{list_id}/member/{email}" + try: + response = requests.get(url, auth=_auth(), 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. + _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"{_base_url()}/members/{member_id}" + try: + del_response = requests.delete(delete_url, auth=_auth(), 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..37de3f9cc 100644 --- a/mailing_list/constants.py +++ b/mailing_list/constants.py @@ -1,3 +1,36 @@ +MAILING_LIST_LABELS = { + "boost.lists.boost.org": { + "name": "Boost Developers", + "address": "boost@lists.boost.org", + "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" + ), + }, + "boost-announce.lists.boost.org": { + "name": "Boost Announcements", + "address": "boost-announce@lists.boost.org", + "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." + ), + }, + "boost-users.lists.boost.org": { + "name": "Boost Users", + "address": "boost-users@lists.boost.org", + "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" + ), + }, +} + # 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..dbb03dea5 --- /dev/null +++ b/mailing_list/mixins.py @@ -0,0 +1,90 @@ +from typing import Union, Tuple + +from django.conf import settings +from django.urls import reverse + +from mailing_list.models import SubscriptionStatus +from mailing_list.models import UserMailingListSubscription + +_DEFAULT_LIST_ID = "boost.lists.boost.org" + + +def get_subscription_state_count_and_email( + user, list_ids +) -> Tuple[Union[str, None], int, Union[str, None]]: + """Return the subscription state for a single user/list pair. + + Returns: + "pending" — a subscription record exists with PENDING status + "active" — a subscription record exists with ACTIVE status + None — user is not authenticated or has no subscription for list_id + """ + if not user.is_authenticated: + return None, 0, None + + subscriptions = list( + UserMailingListSubscription.objects.filter(user=user, list_id__in=list_ids) + ) + + pending_subs = [ + sub for sub in subscriptions if sub.status == SubscriptionStatus.PENDING + ] + pending_count = len(pending_subs) + + active_subs = [ + sub for sub in subscriptions if sub.status == SubscriptionStatus.ACTIVE + ] + active_count = len(active_subs) + + if pending_count > 0: + return SubscriptionStatus.PENDING, pending_count, subscriptions[0].email + elif active_count > 0: + return SubscriptionStatus.ACTIVE, active_count, subscriptions[0].email + else: + return 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(settings.MAILMAN_LISTS) + state = get_subscription_state_count_and_email(request.user, managed_lists) + + context["mailing_list_card_state"] = state[0] + context["mailing_list_card_subscription_count"] = state[1] + context["mailing_list_card_user_email"] = state[2] + 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..115890c1b --- /dev/null +++ b/mailing_list/tests/test_tasks.py @@ -0,0 +1,61 @@ +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): + 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): + 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): + 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..6a22ff27f --- /dev/null +++ b/mailing_list/tests/test_views.py @@ -0,0 +1,273 @@ +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.models import SubscriptionStatus, UserMailingListSubscription +from mailing_list.views import _CONFIRM_SALT + + +LIST_ID = "boost.lists.boost.org" +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"} + } + + +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): + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email") as mock_send, patch( + "mailing_list.views.mailman_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): + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views.mailman_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): + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email"), patch( + "mailing_list.views.mailman_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): + client.force_login(user) + url = reverse("mailing-list-quick-subscribe") + with patch("mailing_list.views._send_confirmation_email"), patch( + "mailing_list.views.mailman_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): + 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.mailman_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): + 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): + 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): + 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): + 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): + 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.mailman_subscribe"): + 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): + 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): + 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): + token = _make_token(EMAIL, [LIST_ID]) + url = reverse("mailing-list-confirm", args=[token]) + with patch("mailing_list.views.mailman_subscribe") as mock_sub: + response = client.get(url) + assert response.status_code == 200 + mock_sub.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..aa387de26 100644 --- a/mailing_list/views.py +++ b/mailing_list/views.py @@ -0,0 +1,490 @@ +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.client import MailmanAPIError +from mailing_list.client import is_confirmed as mailman_is_confirmed +from mailing_list.client import subscribe as mailman_subscribe +from mailing_list.client import unsubscribe as mailman_unsubscribe +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 _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(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(settings.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: + mailman_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, **ctx}, + ) + + def post(self, request): + email = request.POST.get("email", "").strip() + managed_lists = set(settings.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 or "boost.lists.boost.org", + ) + 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 or "boost.lists.boost.org", + ) + 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, 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 mailman_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: + mailman_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(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..2115cf99a --- /dev/null +++ b/scripts/dev-mailman-helpers @@ -0,0 +1,182 @@ +#!/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") + echo "Creating $fqdn ..." + http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + -u "$API_USER:$API_PASS" \ + -X POST "$URL/lists" \ + -d "fqdn_listname=$fqdn") + case "$http_code" in + 201) echo " [ok] $fqdn" ;; + 409) echo " [exists] $fqdn" ;; + *) echo " [failed] $fqdn (HTTP $http_code)" ;; + 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..374b4a37a 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..9c9a3a7f0 --- /dev/null +++ b/templates/v3/mailing_list/confirm_success.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% load static %} + +{% block extra_head %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+ + + +
+

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 %} +
+ + {% 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 From 869ae05d2c6c8e5ad9bc11b35776122f2189a786 Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 2 Jun 2026 12:34:51 -0300 Subject: [PATCH 02/18] chore: rename MAILMAN_REST_API_PATH to MAILMAN_REST_API_VERSION --- config/settings.py | 2 +- mailing_list/client.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/config/settings.py b/config/settings.py index e70bccd65..9950b4b0f 100755 --- a/config/settings.py +++ b/config/settings.py @@ -381,7 +381,7 @@ # Mailman API credentials MAILMAN_REST_API_URL = env("MAILMAN_REST_API_URL", default="http://localhost:8001") -MAILMAN_REST_API_PATH = env("MAILMAN_REST_API_PATH", default="/3.1") +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") MAILMAN_LISTS = env.list( diff --git a/mailing_list/client.py b/mailing_list/client.py index 43d8d63d9..b264a62b1 100644 --- a/mailing_list/client.py +++ b/mailing_list/client.py @@ -11,7 +11,11 @@ class MailmanAPIError(Exception): def _base_url() -> str: - return settings.MAILMAN_REST_API_URL.rstrip("/") + settings.MAILMAN_REST_API_PATH + return ( + settings.MAILMAN_REST_API_URL.rstrip("/") + + "/" + + settings.MAILMAN_REST_API_VERSION + ) def _auth() -> tuple[str, str]: From 660c3b8fdb30a246fce39850d2ac01a99885ddcf Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 2 Jun 2026 12:35:21 -0300 Subject: [PATCH 03/18] refactor: use SubscriptionState NamedTuple and queryset counts in subscription helper --- core/views.py | 6 ++--- mailing_list/mixins.py | 57 +++++++++++++++++++++--------------------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/core/views.py b/core/views.py index 4aaf7d9f0..8115aa19b 100644 --- a/core/views.py +++ b/core/views.py @@ -2270,13 +2270,13 @@ def _intro_card(library_name, authors): context["demo_mailing_list_card_list_id"] = _demo_card_list_id context["demo_mailing_list_card_state"] = ( - mailing_list_state[0] if mailing_list_state else None + mailing_list_state.state if mailing_list_state else None ) context["demo_subscription_count"] = ( - mailing_list_state[1] if mailing_list_state else 0 + mailing_list_state.count if mailing_list_state else 0 ) context["demo_subscribed_lists_email"] = ( - mailing_list_state[2] if mailing_list_state else "" + mailing_list_state.email if mailing_list_state else "" ) # V3 paths registry v3_paths = [ diff --git a/mailing_list/mixins.py b/mailing_list/mixins.py index dbb03dea5..5a6dacbc9 100644 --- a/mailing_list/mixins.py +++ b/mailing_list/mixins.py @@ -1,4 +1,4 @@ -from typing import Union, Tuple +from typing import NamedTuple, Optional from django.conf import settings from django.urls import reverse @@ -9,39 +9,38 @@ _DEFAULT_LIST_ID = "boost.lists.boost.org" -def get_subscription_state_count_and_email( - user, list_ids -) -> Tuple[Union[str, None], int, Union[str, None]]: - """Return the subscription state for a single user/list pair. +class SubscriptionState(NamedTuple): + state: Optional[str] + count: int + email: Optional[str] - Returns: - "pending" — a subscription record exists with PENDING status - "active" — a subscription record exists with ACTIVE status - None — user is not authenticated or has no subscription for list_id - """ + +def get_subscription_state_count_and_email(user, list_ids) -> SubscriptionState: if not user.is_authenticated: - return None, 0, None + return SubscriptionState(None, 0, None) - subscriptions = list( - UserMailingListSubscription.objects.filter(user=user, list_id__in=list_ids) + subscriptions = UserMailingListSubscription.objects.filter( + user=user, list_id__in=list_ids ) - - pending_subs = [ - sub for sub in subscriptions if sub.status == SubscriptionStatus.PENDING - ] - pending_count = len(pending_subs) - - active_subs = [ - sub for sub in subscriptions if sub.status == SubscriptionStatus.ACTIVE - ] - active_count = len(active_subs) + pending_count = subscriptions.filter(status=SubscriptionStatus.PENDING).count() + active_count = subscriptions.filter(status=SubscriptionStatus.ACTIVE).count() if pending_count > 0: - return SubscriptionStatus.PENDING, pending_count, subscriptions[0].email + email = ( + subscriptions.filter(status=SubscriptionStatus.PENDING) + .values_list("email", flat=True) + .first() + ) + return SubscriptionState(SubscriptionStatus.PENDING, pending_count, email) elif active_count > 0: - return SubscriptionStatus.ACTIVE, active_count, subscriptions[0].email + email = ( + subscriptions.filter(status=SubscriptionStatus.ACTIVE) + .values_list("email", flat=True) + .first() + ) + return SubscriptionState(SubscriptionStatus.ACTIVE, active_count, email) else: - return None, 0, None + return SubscriptionState(None, 0, None) class MailingListCardMixin: @@ -70,9 +69,9 @@ def get_context_data(self, **kwargs): managed_lists = set(settings.MAILMAN_LISTS) state = get_subscription_state_count_and_email(request.user, managed_lists) - context["mailing_list_card_state"] = state[0] - context["mailing_list_card_subscription_count"] = state[1] - context["mailing_list_card_user_email"] = state[2] + 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. From d528dcf137861bacddbf1f835cc24471d795240e Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 2 Jun 2026 12:50:35 -0300 Subject: [PATCH 04/18] refactor: convert Mailman client to a class --- mailing_list/client.py | 237 ++++++++++++++++++++++------------------- mailing_list/views.py | 11 +- 2 files changed, 130 insertions(+), 118 deletions(-) diff --git a/mailing_list/client.py b/mailing_list/client.py index b264a62b1..33406f506 100644 --- a/mailing_list/client.py +++ b/mailing_list/client.py @@ -10,126 +10,139 @@ class MailmanAPIError(Exception): pass -def _base_url() -> str: - return ( - settings.MAILMAN_REST_API_URL.rstrip("/") - + "/" - + settings.MAILMAN_REST_API_VERSION - ) +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: -def _auth() -> tuple[str, str]: - return (settings.MAILMAN_REST_API_USER, settings.MAILMAN_REST_API_PASS) - - -def subscribe(email: str, list_id: str) -> None: - """POST /3.1/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. + client = MailmanClient() + client = MailmanClient(base_url="http://other:8001", user="u", password="p") """ - url = f"{_base_url()}/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=_auth(), 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(email: str, list_id: str) -> bool: - """Return True if the email is a confirmed (active) member of the list.""" - url = f"{_base_url()}/lists/{list_id}/member/{email}" - try: - response = requests.get(url, auth=_auth(), 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(email: str, list_id: str) -> None: - """Discard any pending (unconfirmed) subscription request for email on list_id.""" - url = f"{_base_url()}/lists/{list_id}/requests" - try: - response = requests.get(url, auth=_auth(), 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}" + 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}/{version}" + self._credentials = ( + user or settings.MAILMAN_REST_API_USER, + password or settings.MAILMAN_REST_API_PASS, ) - 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"{_base_url()}/lists/{list_id}/requests/{token}" - try: - requests.post(discard_url, data={"action": "discard"}, auth=_auth(), timeout=10) - except requests.RequestException as exc: - raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc - - -def unsubscribe(email: str, list_id: str) -> None: - """DELETE /3.1/members/ — remove a subscription.""" - url = f"{_base_url()}/lists/{list_id}/member/{email}" - try: - response = requests.get(url, auth=_auth(), 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. - _discard_pending(email, list_id) - return - if not response.ok: + 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}" ) - member_id = response.json().get("member_id") - if not member_id: - raise MailmanAPIError("member lookup returned no member_id") - - delete_url = f"{_base_url()}/members/{member_id}" - try: - del_response = requests.delete(delete_url, auth=_auth(), 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}" + 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: + 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 + + 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/views.py b/mailing_list/views.py index aa387de26..cea7fa735 100644 --- a/mailing_list/views.py +++ b/mailing_list/views.py @@ -18,14 +18,13 @@ from django.views import View from mailing_list.client import MailmanAPIError -from mailing_list.client import is_confirmed as mailman_is_confirmed -from mailing_list.client import subscribe as mailman_subscribe -from mailing_list.client import unsubscribe as mailman_unsubscribe +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__) +_mailman = MailmanClient() _CONFIRM_SALT = "mailing-list-confirm-a40b24dc-a26d-49ca-81d1-5b2fccb5fd7b" _CONFIRM_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds @@ -183,7 +182,7 @@ def post(self, request): unsubscribed.append(list_id) continue try: - mailman_unsubscribe(email, list_id) + _mailman.unsubscribe(email, list_id) UserMailingListSubscription.objects.filter( user=request.user, list_id=list_id ).delete() @@ -373,7 +372,7 @@ def _handle_authenticated(self, request, email, list_id, managed_lists): def _handle_anonymous(self, request, email, list_id): try: - if mailman_is_confirmed(email, list_id): + if _mailman.is_confirmed(email, list_id): if _is_htmx(request): return self._card( request, @@ -457,7 +456,7 @@ def get(self, request, token): for list_id in list_ids: try: - mailman_subscribe(email, list_id) + _mailman.subscribe(email, list_id) if user is not None: UserMailingListSubscription.objects.filter( user=user, list_id=list_id From 54e77d12351f823de80f4a0bc3bc2873e0511bc0 Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 2 Jun 2026 12:54:57 -0300 Subject: [PATCH 05/18] refactor: instantiate MailmanClient locally instead of module-level --- mailing_list/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mailing_list/views.py b/mailing_list/views.py index cea7fa735..6a9ff032e 100644 --- a/mailing_list/views.py +++ b/mailing_list/views.py @@ -24,7 +24,6 @@ from mailing_list.models import UserMailingListSubscription logger = logging.getLogger(__name__) -_mailman = MailmanClient() _CONFIRM_SALT = "mailing-list-confirm-a40b24dc-a26d-49ca-81d1-5b2fccb5fd7b" _CONFIRM_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds @@ -182,7 +181,7 @@ def post(self, request): unsubscribed.append(list_id) continue try: - _mailman.unsubscribe(email, list_id) + MailmanClient().unsubscribe(email, list_id) UserMailingListSubscription.objects.filter( user=request.user, list_id=list_id ).delete() @@ -372,7 +371,7 @@ def _handle_authenticated(self, request, email, list_id, managed_lists): def _handle_anonymous(self, request, email, list_id): try: - if _mailman.is_confirmed(email, list_id): + if MailmanClient().is_confirmed(email, list_id): if _is_htmx(request): return self._card( request, @@ -456,7 +455,7 @@ def get(self, request, token): for list_id in list_ids: try: - _mailman.subscribe(email, list_id) + MailmanClient().subscribe(email, list_id) if user is not None: UserMailingListSubscription.objects.filter( user=user, list_id=list_id From 6c5482c375de277c52c0f2296d4151fa61be8497 Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 2 Jun 2026 14:25:02 -0300 Subject: [PATCH 06/18] fix: update test mocks to patch MailmanClient instead of removed module functions --- mailing_list/tests/test_views.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/mailing_list/tests/test_views.py b/mailing_list/tests/test_views.py index 6a22ff27f..466623115 100644 --- a/mailing_list/tests/test_views.py +++ b/mailing_list/tests/test_views.py @@ -51,8 +51,9 @@ def _make_token(email, list_ids, user_id=None): def test_anon_quick_subscribe_sends_email_and_returns_pending(client): url = reverse("mailing-list-quick-subscribe") with patch("mailing_list.views._send_confirmation_email") as mock_send, patch( - "mailing_list.views.mailman_is_confirmed", return_value=False - ): + "mailing_list.views.MailmanClient" + ) as MockClient: + MockClient.return_value.is_confirmed.return_value = False response = client.post( url, {"email": EMAIL, "list_id": LIST_ID}, @@ -66,7 +67,8 @@ def test_anon_quick_subscribe_sends_email_and_returns_pending(client): @pytest.mark.django_db def test_anon_quick_subscribe_already_subscribed_returns_error(client): url = reverse("mailing-list-quick-subscribe") - with patch("mailing_list.views.mailman_is_confirmed", return_value=True): + 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}, @@ -80,8 +82,9 @@ def test_anon_quick_subscribe_already_subscribed_returns_error(client): def test_anon_quick_subscribe_rate_limited(client): url = reverse("mailing-list-quick-subscribe") with patch("mailing_list.views._send_confirmation_email"), patch( - "mailing_list.views.mailman_is_confirmed", return_value=False - ): + "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" @@ -100,8 +103,9 @@ def test_auth_quick_subscribe_rate_limited(client, user): client.force_login(user) url = reverse("mailing-list-quick-subscribe") with patch("mailing_list.views._send_confirmation_email"), patch( - "mailing_list.views.mailman_is_confirmed", return_value=False - ): + "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" @@ -121,8 +125,9 @@ def test_staff_quick_subscribe_bypasses_rate_limit(client, db): client.force_login(staff) url = reverse("mailing-list-quick-subscribe") with patch("mailing_list.views._send_confirmation_email"), patch( - "mailing_list.views.mailman_is_confirmed", return_value=False - ): + "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" @@ -232,7 +237,7 @@ def test_confirm_valid_token_subscribes_and_shows_success(client, user): ) token = _make_token(EMAIL, [LIST_ID], user_id=user.pk) url = reverse("mailing-list-confirm", args=[token]) - with patch("mailing_list.views.mailman_subscribe"): + with patch("mailing_list.views.MailmanClient"): response = client.get(url) assert response.status_code == 200 assert ( @@ -267,7 +272,7 @@ def test_confirm_unknown_user_shows_invalid_page_with_expiry_label(client): def test_confirm_anonymous_token_subscribes_without_db_record(client): token = _make_token(EMAIL, [LIST_ID]) url = reverse("mailing-list-confirm", args=[token]) - with patch("mailing_list.views.mailman_subscribe") as mock_sub: + with patch("mailing_list.views.MailmanClient") as MockClient: response = client.get(url) assert response.status_code == 200 - mock_sub.assert_called_once_with(EMAIL, LIST_ID) + MockClient.return_value.subscribe.assert_called_once_with(EMAIL, LIST_ID) From 0d0b0f6f10824df7dfc8d027241e0f9592260c78 Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 16 Jun 2026 12:45:22 -0300 Subject: [PATCH 07/18] fix: build mailman list ids dynamically --- config/settings.py | 9 +-------- core/views.py | 8 ++++---- mailing_list/constants.py | 40 ++++++++++++++++++++++++++++++++++++--- mailing_list/mixins.py | 4 ++-- mailing_list/views.py | 13 +++++++++---- 5 files changed, 53 insertions(+), 21 deletions(-) diff --git a/config/settings.py b/config/settings.py index 9950b4b0f..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) @@ -384,14 +385,6 @@ 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") -MAILMAN_LISTS = env.list( - "MAILMAN_LISTS", - default=[ - "boost-users.lists.boost.org", - "boost-announce.lists.boost.org", - "boost.lists.boost.org", - ], -) # Fastly API credentials FASTLY_SERVICE = env("FASTLY_SERVICE", default="empty") diff --git a/core/views.py b/core/views.py index 8115aa19b..097c3b025 100644 --- a/core/views.py +++ b/core/views.py @@ -42,6 +42,7 @@ 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 @@ -1467,7 +1468,6 @@ def get_context_data(self, **kwargs): from libraries.utils import ( patch_commit_authors, ) - from django.conf import settings as _settings CODE_DEMO_BEAST = """int main() { @@ -2246,7 +2246,7 @@ def _intro_card(library_name, authors): from mailing_list.models import UserMailingListSubscription _demo_card_list_id = "boost.lists.boost.org" - context["demo_mailman_lists"] = _settings.MAILMAN_LISTS + context["demo_mailman_lists"] = constants.MAILMAN_LISTS context["demo_subscribe_url"] = self.request.build_absolute_uri( reverse("mailing-list-subscribe") ) @@ -2258,11 +2258,11 @@ def _intro_card(library_name, authors): if self.request.user.is_authenticated: mailing_list_state = get_subscription_state_count_and_email( - self.request.user, _settings.MAILMAN_LISTS + self.request.user, constants.MAILMAN_LISTS ) context["demo_subscribed_lists"] = set( UserMailingListSubscription.objects.filter( - user=self.request.user, list_id__in=_settings.MAILMAN_LISTS + user=self.request.user, list_id__in=constants.MAILMAN_LISTS ).values_list("list_id", flat=True) ) else: diff --git a/mailing_list/constants.py b/mailing_list/constants.py index 37de3f9cc..7075c8657 100644 --- a/mailing_list/constants.py +++ b/mailing_list/constants.py @@ -1,5 +1,35 @@ +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 + + +class MailingLists(Enum): + BOOST = "boost" + BOOST_ANNOUNCE = "boost-announce" + BOOST_USERS = "boost-users" + + MAILING_LIST_LABELS = { - "boost.lists.boost.org": { + [MailingLists.BOOST.value]: { "name": "Boost Developers", "address": "boost@lists.boost.org", "description": ( @@ -10,7 +40,7 @@ "https://www.boost.org/doc/user-guide/discussion-policy.html" ), }, - "boost-announce.lists.boost.org": { + [MailingLists.BOOST_ANNOUNCE.value]: { "name": "Boost Announcements", "address": "boost-announce@lists.boost.org", "description": ( @@ -19,7 +49,7 @@ "following the high-volume developer discussion." ), }, - "boost-users.lists.boost.org": { + [MailingLists.BOOST_USERS.value]: { "name": "Boost Users", "address": "boost-users@lists.boost.org", "description": ( @@ -31,6 +61,10 @@ }, } +MAILMAN_DOMAIN = get_domain_with_subdomains(MAILMAN_REST_API_URL) + +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/mixins.py b/mailing_list/mixins.py index 5a6dacbc9..1648d148d 100644 --- a/mailing_list/mixins.py +++ b/mailing_list/mixins.py @@ -1,8 +1,8 @@ from typing import NamedTuple, Optional -from django.conf import settings from django.urls import reverse +from mailing_list import constants from mailing_list.models import SubscriptionStatus from mailing_list.models import UserMailingListSubscription @@ -66,7 +66,7 @@ def get_context_data(self, **kwargs): context["mailing_list_card_list_id"] = _DEFAULT_LIST_ID if request.user.is_authenticated: - managed_lists = set(settings.MAILMAN_LISTS) + managed_lists = set(constants.MAILMAN_LISTS) state = get_subscription_state_count_and_email(request.user, managed_lists) context["mailing_list_card_state"] = state.state diff --git a/mailing_list/views.py b/mailing_list/views.py index 6a9ff032e..7b72acd5e 100644 --- a/mailing_list/views.py +++ b/mailing_list/views.py @@ -17,6 +17,7 @@ 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 @@ -29,6 +30,10 @@ _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" @@ -91,7 +96,7 @@ def _send_confirmation_email( ) lists = [] for lid in list_ids: - entry = MAILING_LIST_LABELS.get(lid) + entry = MAILING_LIST_LABELS.get(_get_list_prefix(lid)) if entry: lists.append(entry) else: @@ -130,7 +135,7 @@ def post(self, request): {"error": "Email is required."}, ) - managed_lists = set(settings.MAILMAN_LISTS) + managed_lists = set(constants.MAILMAN_LISTS) requested = set(request.POST.getlist("list_id")) & managed_lists current = set( UserMailingListSubscription.objects.filter( @@ -229,7 +234,7 @@ def _card(self, request, **ctx): def post(self, request): email = request.POST.get("email", "").strip() - managed_lists = set(settings.MAILMAN_LISTS) + managed_lists = set(constants.MAILMAN_LISTS) list_id = request.POST.get("list_id", "").strip() if not email: @@ -471,7 +476,7 @@ def get(self, request, token): errors.append(list_id) def _label(list_id): - entry = MAILING_LIST_LABELS.get(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} From 68b05727ba35f7cb50bd1b7ec7e4b4c758d2791f Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 16 Jun 2026 13:25:58 -0300 Subject: [PATCH 08/18] fix: mailman lists with local domain --- core/views.py | 2 +- mailing_list/client.py | 8 ++++++++ mailing_list/constants.py | 21 +++++++++++++-------- mailing_list/mixins.py | 2 +- mailing_list/views.py | 13 ++++++++++--- scripts/dev-mailman-helpers | 33 ++++++++++++++++++++++++++++++--- 6 files changed, 63 insertions(+), 16 deletions(-) diff --git a/core/views.py b/core/views.py index 097c3b025..8f63728c8 100644 --- a/core/views.py +++ b/core/views.py @@ -2245,7 +2245,7 @@ def _intro_card(library_name, authors): # Mailing list subscribe demo from mailing_list.models import UserMailingListSubscription - _demo_card_list_id = "boost.lists.boost.org" + _demo_card_list_id = constants.MAILMAN_LISTS[0] context["demo_mailman_lists"] = constants.MAILMAN_LISTS context["demo_subscribe_url"] = self.request.build_absolute_uri( reverse("mailing-list-subscribe") diff --git a/mailing_list/client.py b/mailing_list/client.py index 33406f506..d4f1a9114 100644 --- a/mailing_list/client.py +++ b/mailing_list/client.py @@ -48,12 +48,20 @@ def subscribe(self, email: str, list_id: str) -> None: url, data=payload, auth=self._credentials, timeout=10 ) except requests.RequestException as exc: + logger.info("Mailman API unreachable", exc_info=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: + logger.info( + "subscribe failed", + extra={ + "status_code": response.status_code, + "response_text": response.text, + }, + ) raise MailmanAPIError( f"subscribe failed [{response.status_code}]: {response.text}" ) diff --git a/mailing_list/constants.py b/mailing_list/constants.py index 7075c8657..ce3f9ba5f 100644 --- a/mailing_list/constants.py +++ b/mailing_list/constants.py @@ -22,6 +22,13 @@ def get_domain_with_subdomains(url: str) -> str: 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" @@ -29,9 +36,9 @@ class MailingLists(Enum): MAILING_LIST_LABELS = { - [MailingLists.BOOST.value]: { + MailingLists.BOOST.value: { "name": "Boost Developers", - "address": "boost@lists.boost.org", + "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. " @@ -40,18 +47,18 @@ class MailingLists(Enum): "https://www.boost.org/doc/user-guide/discussion-policy.html" ), }, - [MailingLists.BOOST_ANNOUNCE.value]: { + MailingLists.BOOST_ANNOUNCE.value: { "name": "Boost Announcements", - "address": "boost-announce@lists.boost.org", + "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]: { + MailingLists.BOOST_USERS.value: { "name": "Boost Users", - "address": "boost-users@lists.boost.org", + "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 " @@ -61,8 +68,6 @@ class MailingLists(Enum): }, } -MAILMAN_DOMAIN = get_domain_with_subdomains(MAILMAN_REST_API_URL) - 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. diff --git a/mailing_list/mixins.py b/mailing_list/mixins.py index 1648d148d..95f13bb6e 100644 --- a/mailing_list/mixins.py +++ b/mailing_list/mixins.py @@ -6,7 +6,7 @@ from mailing_list.models import SubscriptionStatus from mailing_list.models import UserMailingListSubscription -_DEFAULT_LIST_ID = "boost.lists.boost.org" +_DEFAULT_LIST_ID = constants.MAILMAN_LISTS[0] class SubscriptionState(NamedTuple): diff --git a/mailing_list/views.py b/mailing_list/views.py index 7b72acd5e..7a527049c 100644 --- a/mailing_list/views.py +++ b/mailing_list/views.py @@ -229,7 +229,12 @@ def _card(self, request, **ctx): return render( request, "v3/includes/_mailing_list_card.html", - {"subscribe_url": subscribe_url, "login_url": login_url, **ctx}, + { + "subscribe_url": subscribe_url, + "login_url": login_url, + "list_id": constants.MAILMAN_LISTS[0], + **ctx, + }, ) def post(self, request): @@ -237,13 +242,15 @@ def post(self, request): managed_lists = set(constants.MAILMAN_LISTS) list_id = request.POST.get("list_id", "").strip() + logger.info(f"Quick subscribe request: email={email}, list_id={list_id}") + if not email: if _is_htmx(request): return self._card( request, state="error", error_message="Email is required.", - list_id=list_id or "boost.lists.boost.org", + list_id=list_id, ) return _prg_redirect( request, ml_state="error", ml_error="Email is required." @@ -256,7 +263,7 @@ def post(self, request): state="error", error_message="Invalid mailing list.", user_email=email, - list_id=list_id or "boost.lists.boost.org", + list_id=list_id, ) return _prg_redirect( request, diff --git a/scripts/dev-mailman-helpers b/scripts/dev-mailman-helpers index 2115cf99a..4b7d92cde 100755 --- a/scripts/dev-mailman-helpers +++ b/scripts/dev-mailman-helpers @@ -87,15 +87,42 @@ action_create_lists() { IFS=',' read -ra list_ids <<< "$MAILMAN_LISTS" for list_id in "${list_ids[@]}"; do fqdn=$(_list_id_to_fqdn "$list_id") - echo "Creating $fqdn ..." - http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + 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)" ;; + *) + echo " [failed] $fqdn (HTTP $http_code)" >&2 + if [[ -n "$response_body" ]]; then + echo " Response body:" >&2 + echo "$response_body" >&2 + fi + ;; esac done } From 1711b282baefbeace16bf6e5f4589f94ac5a348e Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 16 Jun 2026 13:58:12 -0300 Subject: [PATCH 09/18] fix: tests --- mailing_list/tests/test_tasks.py | 3 +++ mailing_list/tests/test_views.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/mailing_list/tests/test_tasks.py b/mailing_list/tests/test_tasks.py index 115890c1b..f790dc1b0 100644 --- a/mailing_list/tests/test_tasks.py +++ b/mailing_list/tests/test_tasks.py @@ -15,6 +15,7 @@ def user(db): @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, @@ -32,6 +33,7 @@ def test_purge_deletes_expired_pending(user): @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, @@ -46,6 +48,7 @@ def test_purge_keeps_recent_pending(user): @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, diff --git a/mailing_list/tests/test_views.py b/mailing_list/tests/test_views.py index 466623115..3257000a6 100644 --- a/mailing_list/tests/test_views.py +++ b/mailing_list/tests/test_views.py @@ -4,11 +4,12 @@ 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 = "boost.lists.boost.org" +LIST_ID = MAILMAN_LISTS[0] EMAIL = "subscriber@example.com" @@ -49,6 +50,7 @@ def _make_token(email, list_ids, user_id=None): @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" @@ -66,6 +68,7 @@ def test_anon_quick_subscribe_sends_email_and_returns_pending(client): @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 @@ -80,6 +83,7 @@ def test_anon_quick_subscribe_already_subscribed_returns_error(client): @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" @@ -100,6 +104,7 @@ def test_anon_quick_subscribe_rate_limited(client): @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( @@ -121,6 +126,7 @@ def test_auth_quick_subscribe_rate_limited(client, user): @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") @@ -142,6 +148,7 @@ def test_staff_quick_subscribe_bypasses_rate_limit(client, db): @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"): @@ -158,6 +165,7 @@ def test_auth_quick_subscribe_creates_pending_record(client, user): @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, @@ -180,6 +188,7 @@ def test_auth_quick_subscribe_already_pending_returns_pending_card(client, user) @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, @@ -201,6 +210,7 @@ def test_auth_quick_subscribe_already_active_returns_success_card(client, user): @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, @@ -228,6 +238,7 @@ def test_auth_quick_subscribe_duplicate_email_returns_error(client, user, other_ @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, @@ -248,6 +259,7 @@ def test_confirm_valid_token_subscribes_and_shows_success(client, user): @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 @@ -258,6 +270,7 @@ def test_confirm_bad_token_shows_invalid_page(client): @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) @@ -270,6 +283,7 @@ def test_confirm_unknown_user_shows_invalid_page_with_expiry_label(client): @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: From b130bb5a7b02960378c8ffb0533586542af37759 Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 16 Jun 2026 14:10:38 -0300 Subject: [PATCH 10/18] fix: remove logs --- mailing_list/client.py | 8 -------- mailing_list/views.py | 2 -- 2 files changed, 10 deletions(-) diff --git a/mailing_list/client.py b/mailing_list/client.py index d4f1a9114..33406f506 100644 --- a/mailing_list/client.py +++ b/mailing_list/client.py @@ -48,20 +48,12 @@ def subscribe(self, email: str, list_id: str) -> None: url, data=payload, auth=self._credentials, timeout=10 ) except requests.RequestException as exc: - logger.info("Mailman API unreachable", exc_info=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: - logger.info( - "subscribe failed", - extra={ - "status_code": response.status_code, - "response_text": response.text, - }, - ) raise MailmanAPIError( f"subscribe failed [{response.status_code}]: {response.text}" ) diff --git a/mailing_list/views.py b/mailing_list/views.py index 7a527049c..5bcddb3dd 100644 --- a/mailing_list/views.py +++ b/mailing_list/views.py @@ -242,8 +242,6 @@ def post(self, request): managed_lists = set(constants.MAILMAN_LISTS) list_id = request.POST.get("list_id", "").strip() - logger.info(f"Quick subscribe request: email={email}, list_id={list_id}") - if not email: if _is_htmx(request): return self._card( From 89d3ae08f46e90e35844439a74933a43f4acd8b7 Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 16 Jun 2026 14:17:20 -0300 Subject: [PATCH 11/18] fix: don't ignore discard pending requests --- mailing_list/client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mailing_list/client.py b/mailing_list/client.py index 33406f506..3d503d225 100644 --- a/mailing_list/client.py +++ b/mailing_list/client.py @@ -101,7 +101,7 @@ def _discard_pending(self, email: str, list_id: str) -> None: discard_url = f"{self._base}/lists/{list_id}/requests/{token}" try: - requests.post( + discard_response = requests.post( discard_url, data={"action": "discard"}, auth=self._credentials, @@ -110,6 +110,13 @@ def _discard_pending(self, email: str, list_id: str) -> None: 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}" From 6165ea5849a96640d3532e8832021a1df0992daa Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 16 Jun 2026 14:24:05 -0300 Subject: [PATCH 12/18] fix: narrow field type validation --- templates/v3/includes/_field_text.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/v3/includes/_field_text.html b/templates/v3/includes/_field_text.html index 374b4a37a..bd33419fa 100644 --- a/templates/v3/includes/_field_text.html +++ b/templates/v3/includes/_field_text.html @@ -87,7 +87,7 @@ {% elif icon_right %} From 15b37ccea2da0294d7694ce0d2a1b95ee5a815eb Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Tue, 16 Jun 2026 14:30:14 -0300 Subject: [PATCH 13/18] fix: conditionally render success message --- .../v3/mailing_list/confirm_success.html | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/templates/v3/mailing_list/confirm_success.html b/templates/v3/mailing_list/confirm_success.html index 9c9a3a7f0..3786353ce 100644 --- a/templates/v3/mailing_list/confirm_success.html +++ b/templates/v3/mailing_list/confirm_success.html @@ -16,22 +16,24 @@ -
-

Subscription confirmed

-

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

-
+ {% 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 %} -
+
    + {% for item in confirmed %} +
  • + {{ item.name }} + {% if item.address %}{{ item.address }}{% endif %} +
  • + {% endfor %} +
+ {% endif %} {% if errors %}