Skip to content

Story #2282: Implements Mailman mailing list subscription#2444

Open
herzog0 wants to merge 6 commits into
developfrom
teo/2282-mailing-list
Open

Story #2282: Implements Mailman mailing list subscription#2444
herzog0 wants to merge 6 commits into
developfrom
teo/2282-mailing-list

Conversation

@herzog0

@herzog0 herzog0 commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Issue: #2282

Summary & Context

Implements the mailing list subscribe/unsubscribe flow backed by the Mailman REST API. Users (authenticated or anonymous) can subscribe to Boost mailing lists via a V3 card component; subscription state is tracked in Django and confirmed via a signed email link before Mailman is called.

Changes

  • Mailman API client (mailing_list/client.py) - thin wrapper around the Mailman REST API exposing subscribe, unsubscribe, is_confirmed, and _discard_pending; all pre_* flags are set to True because Django owns the confirmation step
  • UserMailingListSubscription model + migration - new model tracking per-user subscription state (pending / active) keyed on (user, list_id) with a corresponding migration
  • Three new views (mailing_list/views.py):
    • QuickSubscribeView - single-list subscribe for both authenticated and anonymous users; HTMX requests get an in-place card swap, non-HTMX requests get a PRG redirect back to the originating page with state encoded in query params (ml_state, ml_error, ml_email)
    • SubscribeView - multi-list subscribe/unsubscribe for authenticated users (used in the demo page; will be reused for the profile modal)
    • ConfirmSubscriptionView - handles the signed confirmation link, calls Mailman, and upgrades the DB record to active; renders success/invalid pages
  • MailingListCardMixin (mailing_list/mixins.py) - injects card context into any CBV; reads DB state for authenticated users and falls back to ml_state / ml_error / ml_email query params for the no-JS PRG flow; wired into CommunityView, LearnPageView, ReleaseDetailView, and LibrarySubpageView
  • V3 templates - updated _mailing_list_card.html with state-aware rendering (default / pending / active / error), HTMX swap, and a plain action/method form fallback for no-JS; new confirm_success.html, confirm_invalid.html, _subscribe_result.html, _subscribe_success_card.html, and plain-text confirmation email template
  • V3 CSS - new mailing-list-card.css (spinner, badge, form loading states) and mailing-list-confirm.css (full-page success/error confirmation layout)
  • Docker + env - mailman-core and mailman-web services enabled in docker-compose.yml; MAILMAN_REST_API_URL, MAILMAN_DEV_API_URL, and MAILMAN_LISTS added to env.template
  • Dev helper script (scripts/dev-mailman-helpers) - shell script for seeding local Mailman lists and inspecting subscription state during development

Risks & Considerations

  • The confirmation token uses django.core.signing with a 7-day TTL and a fixed salt embedded in the source. The salt is not a secret (signing is not encryption), but rotating it invalidates all outstanding tokens.
  • QuickSubscribeView is stateless for anonymous users - there is no server-side record until confirmation. If the email is already subscribed, a live Mailman API check is made; if the API is unreachable the duplicate-check is skipped silently and Mailman will return 409 on confirm (handled as a no-op).
  • SubscribeView is currently only wired to the demo page - it will need to be adapted when the multi-selection profile modal is built.
  • mailman-core and mailman-web Docker services now start by default. Teams that don't need Mailman locally can comment them out or use docker compose up web db redis.

Screenshots

image image image image

Peer-review testing steps

  1. Copy env.template values for MAILMAN_REST_API_URL, MAILMAN_DEV_API_URL, and MAILMAN_LISTS into your .env
  2. docker compose up - verify mailman-core starts on port 8001
  3. Run scripts/dev-mailman-helpers to seed the three Boost lists in the local Mailman instance (choose create lists)
  4. Open your Maildev inbox at http://localhost:1080/#/
  5. Log out
  6. Visit /community/ and test the mailing list card as an anonymous user - enter an email and verify a confirmation email is sent to maildev
  7. Copy and paste the confirmation link in another tab - verify the success page shows the correct list name
  8. Go back to the community page, refresh it. You'll notice the card goes back to the default state, because we aren't storing any information about the subscription state for logged out users
  9. Try entering the same email, you should get an error message in the card
  10. Now log in
  11. Navigate to http://localhost:8000/v3/demo/components/#mailing-list-subscribe-live
  12. Write down the same email and uncheck all the boxes. Hit save. This will unsubscribe you from the list (you can fully test subscriptions in multiple lists in this component btw)
  13. Go back to the community page and repeat steps 6 and 7 (while logged in this time)
  14. Right after submitting the form in step 6 you should see the card transition to "pending" state
  15. Copy and paste the confirmation link in another tab - verify the success page shows the correct list name
  16. Go back to the community page, refresh it. You'll notice the card now transitions to the "subscribed" state

No-JS flow (disable JavaScript in DevTools - Debugger tab - "Disable JavaScript")

  1. Repeat steps 5-9 with JS disabled - the form should submit normally, redirect back to the community page, and show the correct card state (pending or error) without any in-place swap
  2. Click the confirmation link - the success page should render as usual
  3. Log in and repeat steps 13-16 with JS disabled - after submitting, the page reloads with the pending card; after confirmation and a manual refresh, the card shows the subscribed state

Self-review Checklist

  • Tag at least one team member from each team to review this PR
  • Link this PR to the related GitHub Project ticket

Frontend

  • UI implementation matches Figma design
  • Tested in light and dark mode
  • Responsive / mobile verified
  • Accessibility checked (keyboard navigation, etc.)
  • Ensure design tokens are used for colors, spacing, typography, etc. – No hardcoded values
  • Test without JavaScript (if applicable)
  • No console errors or warnings

@herzog0 herzog0 requested review from jlchilders11 and julhoang May 18, 2026 15:26
@herzog0 herzog0 marked this pull request as ready for review May 18, 2026 15:27
@herzog0 herzog0 linked an issue May 18, 2026 that may be closed by this pull request
@herzog0 herzog0 force-pushed the teo/2282-mailing-list branch 2 times, most recently from 1a44f32 to 18ed3a0 Compare May 20, 2026 23:47
@herzog0 herzog0 requested review from julioest and ycanales May 21, 2026 00:05
@herzog0 herzog0 force-pushed the teo/2282-mailing-list branch 2 times, most recently from a715613 to 1f60148 Compare May 22, 2026 14:56

@julhoang julhoang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @herzog0 , this is such a fantastic work! Thank you for all of your hard work and thoughtful considerations to put this PR together 🙌 !! There's a few concerns I want to share:

1. Stale pending records

If a user doesn't confirm within 7 days, it seems like we're still retaining the pending record in our DB indefinitely. Should we schedule a Celery task to clean up dead records past the confirmation window?

2. No rate limiting on the anonymous subscription card

It seems like there's currently no rate limit on the subscription card for anonymous users. With malicious intent, someone could use it to fire off a lot of emails (either across many addresses or repeatedly to the same one), which spams real people. IMO the bigger risk is email providers like Gmail will likely flag our sending domain as spam, which affects deliverability for every legitimate email we send (e.g. confirmations, notifications, etc.).

3. Duplicate email across users

I'm not fully clear on how Mailman handles subscription state on its end. Let's consider this scenario:

  • User A (authenticated) tries to subscribe with test@gmail.com → DB now has User A in pending state with that email
  • User B (the real owner, authenticated) subscribes with their actual email test@gmail.com → confirms successfully → DB now has User B in active state with the same email
Image

The screenshot shows our system currently accepts this, though I'm wondering:

  • Does Mailman permit this on its side, or will the second subscription collide? (I tried to test for this but couldn't tell for sure)
  • Should we add a uniqueness check on (list_id, email) to prevent this from happening? 🤔

4. Adding Tests
Can we consider adding tests to ensure we can cover all possible scenarios?


All this being said, I understand this is already a large PR – if you'd prefer to spin any of these out as follow-up tasks, we can certainly do that too!

Comment thread core/views.py Outdated
Comment thread config/settings.py Outdated
Comment thread templates/v3/mailing_list/confirm_invalid.html Outdated
Comment thread templates/v3/mailing_list/confirm_success.html Outdated
Comment thread mailing_list/views.py
Comment thread mailing_list/views.py Outdated
Comment thread mailing_list/views.py
Comment thread templates/v3/mailing_list/confirm_invalid.html Outdated
Comment thread templates/v3/mailing_list/confirm_success.html Outdated
Comment thread templates/v3/includes/_mailing_list_card.html
@herzog0 herzog0 requested a review from julhoang May 26, 2026 15:16
@herzog0

herzog0 commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

Great suggestions @julhoang! Thanks for the review. Everything should be addressed now. Only the rate limit that I thought would be good adding to both types of users (authenticated and anonymous).
Also I have a comment about the error catching above. Let me know your thoughts!

@herzog0 herzog0 force-pushed the teo/2282-mailing-list branch 2 times, most recently from 3b91b88 to b3baaad Compare May 27, 2026 15:06

@julhoang julhoang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work here @herzog0! This mailing list subscription definitely presents a lot of edge cases – I appreciate how thoroughly you've thought through the different user paths and the careful approach you took to handle them. This will make the feature much more robust 🙌

@herzog0 herzog0 force-pushed the teo/2282-mailing-list branch 2 times, most recently from a917f3a to 2c48da6 Compare May 29, 2026 17:05

@jlchilders11 jlchilders11 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few small questions/changes.

Comment thread config/settings.py Outdated
Comment thread mailing_list/mixins.py Outdated
Comment thread mailing_list/mixins.py Outdated
Comment thread mailing_list/client.py
@herzog0 herzog0 requested a review from jlchilders11 June 3, 2026 15:27

@jlchilders11 jlchilders11 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, thanks for those clean ups!

Comment thread mailing_list/client.py
to talk to a different Mailman instance:

client = MailmanClient()
client = MailmanClient(base_url="http://other:8001", user="u", password="p")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent, this is exactly the functionality I was thinking of!

herzog0 added 6 commits June 8, 2026 11:47
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
@herzog0 herzog0 force-pushed the teo/2282-mailing-list branch from a705faa to 6c5482c Compare June 8, 2026 14:48
@sdarwin

sdarwin commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Here is part of a review from Claude. Not certain if it's accurate but worth checking.

The "is it safe" question has a clear answer: mostly yes, but there is a real bug.

Inside SubscribeView.post() (mailing_list/views.py lines 124–204), the unsubscribe branch does this:

views.py
Lines 175-194

        for list_id in to_unsubscribe:
            sub = UserMailingListSubscription.objects.filter(
                user=request.user, list_id=list_id
            ).first()
            if sub and sub.status == SubscriptionStatus.PENDING:
                sub.delete()
                unsubscribed.append(list_id)
                continue
            try:
                MailmanClient().unsubscribe(email, list_id)
                UserMailingListSubscription.objects.filter(
                    user=request.user, list_id=list_id
                ).delete()
                unsubscribed.append(list_id)
            except MailmanAPIError as exc:
                logger.error(
                    "Mailman unsubscribe error for %s/%s: %s", email, list_id, exc
                )
                errors.append(list_id)

Notice it calls MailmanClient().unsubscribe(email, list_id) where email is whatever the user typed into the form right now (line 125: email = request.POST.get("email", "").strip()), not sub.email (the address actually stored on the DB row, which is what they subscribed with).

That means: suppose Alice is logged in, she previously subscribed alice@example.com, and on the manage page she retypes bob@example.com in the email field and unticks the box for the boost-developers list. The code will call Mailman to delete bob@example.com from that list. If Bob happens to be a real member (e.g. an old direct mailman subscriber from years ago), this will silently delete him. That's the kind of "delete a pre-existing user accidentally" thing you were worried about. It's the one place I'd actually call this out as needing a fix:

What is the right thing to do?
The whole point of the fix is "never trust the form-submitted email for a destructive Mailman operation." That principle says: if we don't have a stored sub.email to anchor to, we have nothing safe to unsubscribe, so we should do nothing in that case, not fall back to the POST email. So the actual fix looks more like this:

for list_id in to_unsubscribe:
    sub = UserMailingListSubscription.objects.filter(
        user=request.user, list_id=list_id
    ).first()
    if not sub:
        # Row vanished between computing `current` and now — nothing safe to do.
        continue
    if sub.status == SubscriptionStatus.PENDING:
        sub.delete()
        unsubscribed.append(list_id)
        continue
    try:
        MailmanClient().unsubscribe(sub.email, list_id)
        sub.delete()
        unsubscribed.append(list_id)
    except MailmanAPIError as exc:
        logger.error(
            "Mailman unsubscribe error for %s/%s: %s", sub.email, list_id, exc
        )
        errors.append(list_id)

@sdarwin

sdarwin commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

@coderabbitai full review

@sdarwin

sdarwin commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

I enabled coderabbit on the website-v2 repository. Experimental. The results might be nitpicky and don't always need to be followed.
It did not trigger here so far, but I created a duplicate PR of this one at https://github.com/sdarwin/website-v2/pull/1 see the results over there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Backend Integration] Mailing List Auto Subscription

5 participants