Skip to content

Commit 2c48da6

Browse files
committed
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
1 parent f71f5d2 commit 2c48da6

35 files changed

Lines changed: 2000 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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,17 @@
381381

382382
# Mailman API credentials
383383
MAILMAN_REST_API_URL = env("MAILMAN_REST_API_URL", default="http://localhost:8001")
384+
MAILMAN_REST_API_PATH = env("MAILMAN_REST_API_PATH", default="/3.1")
384385
MAILMAN_REST_API_USER = env("MAILMAN_REST_API_USER", default="restadmin")
385386
MAILMAN_REST_API_PASS = env("MAILMAN_REST_API_PASS", default="restpass")
387+
MAILMAN_LISTS = env.list(
388+
"MAILMAN_LISTS",
389+
default=[
390+
"boost-users.lists.boost.org",
391+
"boost-announce.lists.boost.org",
392+
"boost.lists.boost.org",
393+
],
394+
)
386395

387396
# Fastly API credentials
388397
FASTLY_SERVICE = env("FASTLY_SERVICE", default="empty")

config/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@
269269
LibraryDetail.as_view(redirect_to_docs=True),
270270
name="library-docs-redirect",
271271
),
272+
path("mailing-list/", include("mailing_list.urls")),
272273
path("news/", include("news.urls")),
273274
path("v3/news/add/", V3AllTypesCreateView.as_view(), name="v3-news-create"),
274275
path(

core/views.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646

4747
from . import context_processors
4848
from .mixins import V3Mixin, iter_v3_views
49+
from mailing_list.mixins import (
50+
MailingListCardMixin,
51+
get_subscription_state_count_and_email,
52+
)
4953
from .asciidoc import convert_adoc_to_html
5054
from .boostrenderer import (
5155
convert_img_paths,
@@ -129,7 +133,7 @@ class BoostDevelopmentView(CalendarView):
129133
template_name = "boost_development.html"
130134

131135

132-
class CommunityView(V3Mixin, TemplateView):
136+
class CommunityView(MailingListCardMixin, V3Mixin, TemplateView):
133137
template_name = "community.html"
134138
v3_template_name = "v3/community.html"
135139

@@ -498,7 +502,7 @@ def get_v3_context_data(self, **kwargs):
498502
return {"last_updated": "2024-02-17"}
499503

500504

501-
class LearnPageView(V3Mixin, TemplateView):
505+
class LearnPageView(MailingListCardMixin, V3Mixin, TemplateView):
502506
v3_template_name = "v3/learn_page.html"
503507

504508
def get_v3_context_data(self, **kwargs):
@@ -1463,6 +1467,7 @@ def get_context_data(self, **kwargs):
14631467
from libraries.utils import (
14641468
patch_commit_authors,
14651469
)
1470+
from django.conf import settings as _settings
14661471

14671472
CODE_DEMO_BEAST = """int main()
14681473
{
@@ -2217,6 +2222,42 @@ def get_context_data(self, **kwargs):
22172222
)
22182223
context["demo_library_items"] = demo_library_items
22192224

2225+
# Mailing list subscribe demo
2226+
from mailing_list.models import UserMailingListSubscription
2227+
2228+
_demo_card_list_id = "boost.lists.boost.org"
2229+
context["demo_mailman_lists"] = _settings.MAILMAN_LISTS
2230+
context["demo_subscribe_url"] = self.request.build_absolute_uri(
2231+
reverse("mailing-list-subscribe")
2232+
)
2233+
context["demo_quick_subscribe_url"] = self.request.build_absolute_uri(
2234+
reverse("mailing-list-quick-subscribe")
2235+
)
2236+
2237+
mailing_list_state = None
2238+
2239+
if self.request.user.is_authenticated:
2240+
mailing_list_state = get_subscription_state_count_and_email(
2241+
self.request.user, _settings.MAILMAN_LISTS
2242+
)
2243+
context["demo_subscribed_lists"] = set(
2244+
UserMailingListSubscription.objects.filter(
2245+
user=self.request.user, list_id__in=_settings.MAILMAN_LISTS
2246+
).values_list("list_id", flat=True)
2247+
)
2248+
else:
2249+
context["demo_subscribed_lists"] = set()
2250+
2251+
context["demo_mailing_list_card_list_id"] = _demo_card_list_id
2252+
context["demo_mailing_list_card_state"] = (
2253+
mailing_list_state[0] if mailing_list_state else None
2254+
)
2255+
context["demo_subscription_count"] = (
2256+
mailing_list_state[1] if mailing_list_state else 0
2257+
)
2258+
context["demo_subscribed_lists_email"] = (
2259+
mailing_list_state[2] if mailing_list_state else ""
2260+
)
22202261
# V3 paths registry
22212262
v3_paths = [
22222263
{

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.models import Entry
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: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
def _base_url() -> str:
14+
return settings.MAILMAN_REST_API_URL.rstrip("/") + settings.MAILMAN_REST_API_PATH
15+
16+
17+
def _auth() -> tuple[str, str]:
18+
return (settings.MAILMAN_REST_API_USER, settings.MAILMAN_REST_API_PASS)
19+
20+
21+
def subscribe(email: str, list_id: str) -> None:
22+
"""POST /3.1/members — subscribe an email to a list.
23+
24+
All pre_* flags are True because Django owns the confirmation flow. This is
25+
only called after the user has clicked the Django confirmation link.
26+
"""
27+
url = f"{_base_url()}/members"
28+
payload = {
29+
"list_id": list_id,
30+
"subscriber": email,
31+
"pre_verified": True,
32+
"pre_confirmed": True,
33+
"pre_approved": True,
34+
}
35+
try:
36+
response = requests.post(url, data=payload, auth=_auth(), timeout=10)
37+
except requests.RequestException as exc:
38+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
39+
40+
if response.status_code == 409:
41+
# Already a member — treat as a no-op.
42+
return
43+
if not response.ok:
44+
raise MailmanAPIError(
45+
f"subscribe failed [{response.status_code}]: {response.text}"
46+
)
47+
48+
49+
def is_confirmed(email: str, list_id: str) -> bool:
50+
"""Return True if the email is a confirmed (active) member of the list."""
51+
url = f"{_base_url()}/lists/{list_id}/member/{email}"
52+
try:
53+
response = requests.get(url, auth=_auth(), timeout=10)
54+
except requests.RequestException as exc:
55+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
56+
57+
if response.status_code == 404:
58+
return False
59+
if response.ok:
60+
return True
61+
raise MailmanAPIError(
62+
f"member lookup failed [{response.status_code}]: {response.text}"
63+
)
64+
65+
66+
def _discard_pending(email: str, list_id: str) -> None:
67+
"""Discard any pending (unconfirmed) subscription request for email on list_id."""
68+
url = f"{_base_url()}/lists/{list_id}/requests"
69+
try:
70+
response = requests.get(url, auth=_auth(), timeout=10)
71+
except requests.RequestException as exc:
72+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
73+
74+
if not response.ok:
75+
raise MailmanAPIError(
76+
f"pending requests lookup failed [{response.status_code}]: {response.text}"
77+
)
78+
79+
entries = response.json().get("entries", [])
80+
token = next(
81+
(
82+
e["token"]
83+
for e in entries
84+
if e.get("email") == email and e.get("type") == "subscription"
85+
),
86+
None,
87+
)
88+
if token is None:
89+
return
90+
91+
discard_url = f"{_base_url()}/lists/{list_id}/requests/{token}"
92+
try:
93+
requests.post(discard_url, data={"action": "discard"}, auth=_auth(), timeout=10)
94+
except requests.RequestException as exc:
95+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
96+
97+
98+
def unsubscribe(email: str, list_id: str) -> None:
99+
"""DELETE /3.1/members/<id> — remove a subscription."""
100+
url = f"{_base_url()}/lists/{list_id}/member/{email}"
101+
try:
102+
response = requests.get(url, auth=_auth(), timeout=10)
103+
except requests.RequestException as exc:
104+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
105+
106+
if response.status_code == 404:
107+
# Not a confirmed member — discard any pending subscription request so
108+
# the user can subscribe again cleanly.
109+
_discard_pending(email, list_id)
110+
return
111+
if not response.ok:
112+
raise MailmanAPIError(
113+
f"member lookup failed [{response.status_code}]: {response.text}"
114+
)
115+
116+
member_id = response.json().get("member_id")
117+
if not member_id:
118+
raise MailmanAPIError("member lookup returned no member_id")
119+
120+
delete_url = f"{_base_url()}/members/{member_id}"
121+
try:
122+
del_response = requests.delete(delete_url, auth=_auth(), timeout=10)
123+
except requests.RequestException as exc:
124+
raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc
125+
126+
if del_response.status_code == 404:
127+
return
128+
if not del_response.ok:
129+
raise MailmanAPIError(
130+
f"unsubscribe failed [{del_response.status_code}]: {del_response.text}"
131+
)

mailing_list/constants.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,36 @@
1+
MAILING_LIST_LABELS = {
2+
"boost.lists.boost.org": {
3+
"name": "Boost Developers",
4+
"address": "boost@lists.boost.org",
5+
"description": (
6+
"The primary discussion list for Boost library developers. Topics cover "
7+
"library submission, development, review, and project-wide decisions. "
8+
"Posts from non-subscribers are automatically rejected, and first-time "
9+
"posts are moderated. Please read the discussion policy before posting: "
10+
"https://www.boost.org/doc/user-guide/discussion-policy.html"
11+
),
12+
},
13+
"boost-announce.lists.boost.org": {
14+
"name": "Boost Announcements",
15+
"address": "boost-announce@lists.boost.org",
16+
"description": (
17+
"A low-volume, announce-only list for upcoming Boost formal reviews and "
18+
"new software releases. A good fit if you want to stay informed without "
19+
"following the high-volume developer discussion."
20+
),
21+
},
22+
"boost-users.lists.boost.org": {
23+
"name": "Boost Users",
24+
"address": "boost-users@lists.boost.org",
25+
"description": (
26+
"Discussion list for developers using the Boost C++ libraries. The right "
27+
"place to ask questions, share solutions, and get help integrating Boost "
28+
"into your projects. Please read the discussion policy before posting: "
29+
"https://www.boost.org/doc/user-guide/discussion-policy.html"
30+
),
31+
},
32+
}
33+
134
# we only want boost devel for now, leaving the others in case that changes.
235
ML_STATS_URLS = [
336
"https://lists.boost.org/Archives/boost/{:04}/{:02}/author.php",

0 commit comments

Comments
 (0)