Skip to content

Commit 32786c7

Browse files
authored
Story #2282: Implements Mailman mailing list subscription (#2444)
1 parent bc99736 commit 32786c7

35 files changed

Lines changed: 2120 additions & 56 deletions

config/celery.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ def setup_periodic_tasks(sender, **kwargs):
119119
app.signature("asciidoctor_sandbox.tasks.cleanup_old_sandbox_documents"),
120120
)
121121

122+
# Purge expired pending mailing list subscriptions. Executes daily at 3:45 AM.
123+
sender.add_periodic_task(
124+
crontab(hour=3, minute=45),
125+
app.signature("mailing_list.tasks.purge_expired_pending_subscriptions"),
126+
)
127+
122128
# Sync per-post page views from Plausible. Executes daily at 6:00 AM.
123129
sender.add_periodic_task(
124130
crontab(hour=6, minute=0),

config/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.core.exceptions import ImproperlyConfigured
1111
from pythonjsonlogger import jsonlogger
1212

13+
1314
env = environs.Env()
1415

1516
READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False)
@@ -381,6 +382,7 @@
381382

382383
# Mailman API credentials
383384
MAILMAN_REST_API_URL = env("MAILMAN_REST_API_URL", default="http://localhost:8001")
385+
MAILMAN_REST_API_VERSION = env("MAILMAN_REST_API_VERSION", default="3.1")
384386
MAILMAN_REST_API_USER = env("MAILMAN_REST_API_USER", default="restadmin")
385387
MAILMAN_REST_API_PASS = env("MAILMAN_REST_API_PASS", default="restpass")
386388

config/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@
271271
LibraryDetail.as_view(redirect_to_docs=True),
272272
name="library-docs-redirect",
273273
),
274+
path("mailing-list/", include("mailing_list.urls")),
274275
path("news/", include("news.urls")),
275276
path("v3/news/add/", V3AllTypesCreateView.as_view(), name="v3-news-create"),
276277
path(

core/views.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,15 @@
4444
set_selected_boost_version,
4545
modernize_boost_slug,
4646
)
47+
from mailing_list import constants
4748
from versions.models import Version, docs_path_to_boost_name
4849

4950
from . import context_processors
5051
from .mixins import V3Mixin, iter_v3_views
52+
from mailing_list.mixins import (
53+
MailingListCardMixin,
54+
get_subscription_state_count_and_email,
55+
)
5156
from .asciidoc import convert_adoc_to_html
5257
from .boostrenderer import (
5358
convert_img_paths,
@@ -133,7 +138,7 @@ class BoostDevelopmentView(CalendarView):
133138
template_name = "boost_development.html"
134139

135140

136-
class CommunityView(V3Mixin, TemplateView):
141+
class CommunityView(MailingListCardMixin, V3Mixin, TemplateView):
137142
template_name = "community.html"
138143
v3_template_name = "v3/community.html"
139144

@@ -497,7 +502,7 @@ def get_v3_context_data(self, **kwargs):
497502
return {"last_updated": "2024-02-17"}
498503

499504

500-
class LearnPageView(V3Mixin, TemplateView):
505+
class LearnPageView(MailingListCardMixin, V3Mixin, TemplateView):
501506
v3_template_name = "v3/learn_page.html"
502507

503508
def get_v3_context_data(self, **kwargs):
@@ -2232,6 +2237,38 @@ def _intro_card(library_name, authors):
22322237
)
22332238
context["demo_library_items"] = demo_library_items
22342239

2240+
# Mailing list subscribe demo
2241+
from mailing_list.models import UserMailingListSubscription
2242+
2243+
_demo_card_list_id = constants.MAILMAN_LISTS[0]
2244+
context["demo_mailman_lists"] = constants.MAILMAN_LISTS
2245+
context["demo_subscribe_url"] = reverse("mailing-list-subscribe")
2246+
context["demo_quick_subscribe_url"] = reverse("mailing-list-quick-subscribe")
2247+
2248+
mailing_list_state = None
2249+
2250+
if self.request.user.is_authenticated:
2251+
mailing_list_state = get_subscription_state_count_and_email(
2252+
self.request.user, constants.MAILMAN_LISTS
2253+
)
2254+
context["demo_subscribed_lists"] = set(
2255+
UserMailingListSubscription.objects.filter(
2256+
user=self.request.user, list_id__in=constants.MAILMAN_LISTS
2257+
).values_list("list_id", flat=True)
2258+
)
2259+
else:
2260+
context["demo_subscribed_lists"] = set()
2261+
2262+
context["demo_mailing_list_card_list_id"] = _demo_card_list_id
2263+
context["demo_mailing_list_card_state"] = (
2264+
mailing_list_state.state if mailing_list_state else None
2265+
)
2266+
context["demo_subscription_count"] = (
2267+
mailing_list_state.count if mailing_list_state else 0
2268+
)
2269+
context["demo_subscribed_lists_email"] = (
2270+
mailing_list_state.email if mailing_list_state else ""
2271+
)
22352272
# V3 paths registry
22362273
v3_paths = [
22372274
{

docker-compose.yml

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -49,43 +49,43 @@ services:
4949
- ../website2022/:/website
5050
stop_signal: SIGKILL
5151

52-
# mailman-core:
53-
# image: maxking/mailman-core
54-
# stop_grace_period: 5s
55-
# ports:
56-
# - "8001:8001" # API
57-
# - "8024:8024" # LMTP - incoming emails
58-
# volumes:
59-
# - ./mailman/core:/opt/mailman/
60-
# networks:
61-
# - backend
62-
# env_file:
63-
# - .env
64-
# depends_on:
65-
# - db
52+
mailman-core:
53+
image: maxking/mailman-core
54+
stop_grace_period: 5s
55+
ports:
56+
- "8001:8001" # API
57+
- "8024:8024" # LMTP - incoming emails
58+
volumes:
59+
- ./mailman/core:/opt/mailman/
60+
networks:
61+
- backend
62+
env_file:
63+
- .env
64+
depends_on:
65+
- db
6666

67-
# mailman-web:
68-
# image: maxking/mailman-web
69-
# entrypoint: /opt/mailman-docker/compose-start.sh
70-
# env_file:
71-
# - .env
72-
# environment:
73-
# - "DOCKER_DIR=/opt/mailman-docker"
74-
# - "PYTHON=python3"
75-
# - "WEB_PORT=8008"
76-
# depends_on:
77-
# - redis
78-
# - db
79-
# stop_signal: SIGKILL
80-
# ports:
81-
# - "8008:8008" # HTTP
82-
# - "8080:8080" # uwsgi
83-
# volumes:
84-
# - .:/code
85-
# - ./mailman/web:/opt/mailman-web-data
86-
# - ./docker:/opt/mailman-docker
87-
# networks:
88-
# - backend
67+
mailman-web:
68+
image: maxking/mailman-web
69+
entrypoint: /opt/mailman-docker/compose-start.sh
70+
env_file:
71+
- .env
72+
environment:
73+
- "DOCKER_DIR=/opt/mailman-docker"
74+
- "PYTHON=python3"
75+
- "WEB_PORT=8008"
76+
depends_on:
77+
- redis
78+
- db
79+
stop_signal: SIGKILL
80+
ports:
81+
- "8008:8008" # HTTP
82+
- "8080:8080" # uwsgi
83+
volumes:
84+
- .:/code
85+
- ./mailman/web:/opt/mailman-web-data
86+
- ./docker:/opt/mailman-docker
87+
networks:
88+
- backend
8989

9090
celery-worker:
9191
build:

env.template

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,9 @@ ALGOLIA_APP_ID=
8686
ALGOLIA_ANALYTICS_API_KEY=
8787

8888
OPENROUTER_API_KEY=
89+
90+
# Used by Django inside Docker — hostname resolves within the container network
91+
MAILMAN_REST_API_URL=http://mailman-core:8001
92+
# Used by scripts/dev-mailman-helpers on the host — maps to the published port
93+
MAILMAN_DEV_API_URL=http://localhost:8001
94+
MAILMAN_LISTS=boost-users.lists.boost.org,boost-announce.lists.boost.org,boost.lists.boost.org

libraries/views.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from core.constants import SLACK_URL
1717
from core.githubhelper import GithubAPIClient
1818
from core.mixins import V3Mixin
19+
from mailing_list.mixins import MailingListCardMixin
1920
from core.mock_data import SharedResources
2021
from news.services import get_latest_post_cards
2122
from versions.exceptions import BoostImportedDataException
@@ -454,7 +455,12 @@ def get_results_by_tier(self):
454455

455456
@method_decorator(csrf_exempt, name="dispatch")
456457
class LibraryDetail(
457-
V3Mixin, VersionAlertMixin, BoostVersionMixin, ContributorMixin, DetailView
458+
MailingListCardMixin,
459+
V3Mixin,
460+
VersionAlertMixin,
461+
BoostVersionMixin,
462+
ContributorMixin,
463+
DetailView,
458464
):
459465
"""Display a single Library in insolation"""
460466

mailing_list/client.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import logging
2+
3+
import requests
4+
from django.conf import settings
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class MailmanAPIError(Exception):
10+
pass
11+
12+
13+
class MailmanClient:
14+
"""Thin wrapper around the Mailman REST API.
15+
16+
Instantiate with defaults (reads from settings) or pass explicit credentials
17+
to talk to a different Mailman instance:
18+
19+
client = MailmanClient()
20+
client = MailmanClient(base_url="http://other:8001", user="u", password="p")
21+
"""
22+
23+
def __init__(self, base_url=None, api_version=None, user=None, password=None):
24+
url = (base_url or settings.MAILMAN_REST_API_URL).rstrip("/")
25+
version = api_version or settings.MAILMAN_REST_API_VERSION
26+
self._base = (
27+
f"{url}/api-proxy/{version}"
28+
if not settings.LOCAL_DEVELOPMENT
29+
else f"{url}/{version}"
30+
)
31+
self._credentials = (
32+
user or settings.MAILMAN_REST_API_USER,
33+
password or settings.MAILMAN_REST_API_PASS,
34+
)
35+
36+
def subscribe(self, email: str, list_id: str) -> None:
37+
"""POST /<version>/members — subscribe an email to a list.
38+
39+
All pre_* flags are True because Django owns the confirmation flow. This is
40+
only called after the user has clicked the Django confirmation link.
41+
"""
42+
url = f"{self._base}/members"
43+
payload = {
44+
"list_id": list_id,
45+
"subscriber": email,
46+
"pre_verified": True,
47+
"pre_confirmed": True,
48+
"pre_approved": True,
49+
}
50+
try:
51+
response = requests.post(
52+
url, data=payload, auth=self._credentials, timeout=10
53+
)
54+
except requests.RequestException as exc:
55+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
56+
57+
if response.status_code == 409:
58+
# Already a member — treat as a no-op.
59+
return
60+
if not response.ok:
61+
raise MailmanAPIError(
62+
f"subscribe failed [{response.status_code}]: {response.text}"
63+
)
64+
65+
def is_confirmed(self, email: str, list_id: str) -> bool:
66+
"""Return True if the email is a confirmed (active) member of the list."""
67+
url = f"{self._base}/lists/{list_id}/member/{email}"
68+
try:
69+
response = requests.get(url, auth=self._credentials, timeout=10)
70+
except requests.RequestException as exc:
71+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
72+
73+
if response.status_code == 404:
74+
return False
75+
if response.ok:
76+
return True
77+
raise MailmanAPIError(
78+
f"member lookup failed [{response.status_code}]: {response.text}"
79+
)
80+
81+
def _discard_pending(self, email: str, list_id: str) -> None:
82+
"""Discard any pending (unconfirmed) subscription request for email on list_id."""
83+
url = f"{self._base}/lists/{list_id}/requests"
84+
try:
85+
response = requests.get(url, auth=self._credentials, timeout=10)
86+
except requests.RequestException as exc:
87+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
88+
89+
if not response.ok:
90+
raise MailmanAPIError(
91+
f"pending requests lookup failed [{response.status_code}]: {response.text}"
92+
)
93+
94+
entries = response.json().get("entries", [])
95+
token = next(
96+
(
97+
e["token"]
98+
for e in entries
99+
if e.get("email") == email and e.get("type") == "subscription"
100+
),
101+
None,
102+
)
103+
if token is None:
104+
return
105+
106+
discard_url = f"{self._base}/lists/{list_id}/requests/{token}"
107+
try:
108+
discard_response = requests.post(
109+
discard_url,
110+
data={"action": "discard"},
111+
auth=self._credentials,
112+
timeout=10,
113+
)
114+
except requests.RequestException as exc:
115+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
116+
117+
if discard_response.status_code == 404:
118+
return
119+
if not discard_response.ok:
120+
raise MailmanAPIError(
121+
f"discard pending failed [{discard_response.status_code}]"
122+
)
123+
124+
def unsubscribe(self, email: str, list_id: str) -> None:
125+
"""DELETE /<version>/members/<id> — remove a subscription."""
126+
url = f"{self._base}/lists/{list_id}/member/{email}"
127+
try:
128+
response = requests.get(url, auth=self._credentials, timeout=10)
129+
except requests.RequestException as exc:
130+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
131+
132+
if response.status_code == 404:
133+
# Not a confirmed member — discard any pending subscription request so
134+
# the user can subscribe again cleanly.
135+
self._discard_pending(email, list_id)
136+
return
137+
if not response.ok:
138+
raise MailmanAPIError(
139+
f"member lookup failed [{response.status_code}]: {response.text}"
140+
)
141+
142+
member_id = response.json().get("member_id")
143+
if not member_id:
144+
raise MailmanAPIError("member lookup returned no member_id")
145+
146+
delete_url = f"{self._base}/members/{member_id}"
147+
try:
148+
del_response = requests.delete(
149+
delete_url, auth=self._credentials, timeout=10
150+
)
151+
except requests.RequestException as exc:
152+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
153+
154+
if del_response.status_code == 404:
155+
return
156+
if not del_response.ok:
157+
raise MailmanAPIError(
158+
f"unsubscribe failed [{del_response.status_code}]: {del_response.text}"
159+
)

0 commit comments

Comments
 (0)