-
Notifications
You must be signed in to change notification settings - Fork 27
Story #2282: Implements Mailman mailing list subscription #2444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
2e512df
docker: enable mailman-core and mailman-web services
herzog0 869ae05
chore: rename MAILMAN_REST_API_PATH to MAILMAN_REST_API_VERSION
herzog0 660c3b8
refactor: use SubscriptionState NamedTuple and queryset counts in sub…
herzog0 d528dcf
refactor: convert Mailman client to a class
herzog0 54e77d1
refactor: instantiate MailmanClient locally instead of module-level
herzog0 6c5482c
fix: update test mocks to patch MailmanClient instead of removed modu…
herzog0 0d0b0f6
fix: build mailman list ids dynamically
herzog0 68b0572
fix: mailman lists with local domain
herzog0 1711b28
fix: tests
herzog0 b130bb5
fix: remove logs
herzog0 89d3ae0
fix: don't ignore discard pending requests
herzog0 6165ea5
fix: narrow field type validation
herzog0 15b37cc
fix: conditionally render success message
herzog0 34892bb
fix: don't log emails even in errors
herzog0 dd5afb8
fix: guard for mailing lists
herzog0 c840a78
fix: rate limiting in tests
herzog0 6679879
fix: add api-proxy to prod urls
herzog0 140a143
fix: use relative URLs for demo subscribe endpoints to avoid mixed co…
herzog0 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
herzog0 marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import logging | ||
|
|
||
| import requests | ||
| from django.conf import settings | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class MailmanAPIError(Exception): | ||
| pass | ||
|
|
||
|
|
||
| class MailmanClient: | ||
| """Thin wrapper around the Mailman REST API. | ||
|
|
||
| Instantiate with defaults (reads from settings) or pass explicit credentials | ||
| to talk to a different Mailman instance: | ||
|
|
||
| client = MailmanClient() | ||
| client = MailmanClient(base_url="http://other:8001", user="u", password="p") | ||
|
herzog0 marked this conversation as resolved.
|
||
| """ | ||
|
|
||
| def __init__(self, base_url=None, api_version=None, user=None, password=None): | ||
| url = (base_url or settings.MAILMAN_REST_API_URL).rstrip("/") | ||
| version = api_version or settings.MAILMAN_REST_API_VERSION | ||
| self._base = ( | ||
| f"{url}/api-proxy/{version}" | ||
| if not settings.LOCAL_DEVELOPMENT | ||
| else f"{url}/{version}" | ||
| ) | ||
| self._credentials = ( | ||
| user or settings.MAILMAN_REST_API_USER, | ||
| password or settings.MAILMAN_REST_API_PASS, | ||
| ) | ||
|
|
||
| def subscribe(self, email: str, list_id: str) -> None: | ||
| """POST /<version>/members — subscribe an email to a list. | ||
|
|
||
| All pre_* flags are True because Django owns the confirmation flow. This is | ||
| only called after the user has clicked the Django confirmation link. | ||
| """ | ||
| url = f"{self._base}/members" | ||
| payload = { | ||
| "list_id": list_id, | ||
| "subscriber": email, | ||
| "pre_verified": True, | ||
| "pre_confirmed": True, | ||
| "pre_approved": True, | ||
| } | ||
| try: | ||
| response = requests.post( | ||
| url, data=payload, auth=self._credentials, timeout=10 | ||
| ) | ||
| except requests.RequestException as exc: | ||
| raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc | ||
|
|
||
| if response.status_code == 409: | ||
| # Already a member — treat as a no-op. | ||
| return | ||
| if not response.ok: | ||
| raise MailmanAPIError( | ||
| f"subscribe failed [{response.status_code}]: {response.text}" | ||
| ) | ||
|
|
||
| def is_confirmed(self, email: str, list_id: str) -> bool: | ||
| """Return True if the email is a confirmed (active) member of the list.""" | ||
| url = f"{self._base}/lists/{list_id}/member/{email}" | ||
| try: | ||
| response = requests.get(url, auth=self._credentials, timeout=10) | ||
| except requests.RequestException as exc: | ||
| raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc | ||
|
|
||
| if response.status_code == 404: | ||
| return False | ||
| if response.ok: | ||
| return True | ||
| raise MailmanAPIError( | ||
| f"member lookup failed [{response.status_code}]: {response.text}" | ||
| ) | ||
|
|
||
| def _discard_pending(self, email: str, list_id: str) -> None: | ||
| """Discard any pending (unconfirmed) subscription request for email on list_id.""" | ||
| url = f"{self._base}/lists/{list_id}/requests" | ||
| try: | ||
| response = requests.get(url, auth=self._credentials, timeout=10) | ||
| except requests.RequestException as exc: | ||
| raise MailmanAPIError(f"Mailman API unreachable: {exc}") from exc | ||
|
|
||
| if not response.ok: | ||
| raise MailmanAPIError( | ||
| f"pending requests lookup failed [{response.status_code}]: {response.text}" | ||
| ) | ||
|
|
||
| entries = response.json().get("entries", []) | ||
| token = next( | ||
| ( | ||
| e["token"] | ||
| for e in entries | ||
| if e.get("email") == email and e.get("type") == "subscription" | ||
| ), | ||
| None, | ||
| ) | ||
| if token is None: | ||
| return | ||
|
|
||
| discard_url = f"{self._base}/lists/{list_id}/requests/{token}" | ||
| try: | ||
| discard_response = requests.post( | ||
| discard_url, | ||
| data={"action": "discard"}, | ||
| auth=self._credentials, | ||
| timeout=10, | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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 /<version>/members/<id> — 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}" | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.